feat: 替换为专业的 Qwen 3D 相机控制器
- 新增 qwen-camera-widget.tsx:基于 Three.js 的专业 3D 相机控制组件 - 彩色拖拽手柄:粉色(方位角)、青色(仰角)、金色(距离) - 下拉快速选择预设角度 - 实时数值显示和提示词预览 - 暗色主题 UI,符合 ComfyUI-qwenmultiangle 设计风格 - 更新 angle-edit-dialog 使用新组件
This commit is contained in:
parent
604d7425ad
commit
87698afee8
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@ -13,7 +13,7 @@ import { Slider } from "@/components/ui/slider";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Loader2, Download, Check, RotateCcw, Move3D, Sparkles } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { AngleController3D } from "./angle-controller-3d";
|
||||
import { QwenCameraWidget, type CameraState } from "./qwen-camera-widget";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface AngleEditDialogProps {
|
||||
@ -23,37 +23,58 @@ interface AngleEditDialogProps {
|
||||
onComplete?: (resultUrl: string) => void;
|
||||
}
|
||||
|
||||
interface AngleParams {
|
||||
azimuth: string;
|
||||
elevation: string;
|
||||
distance: string;
|
||||
azimuthDegree: number;
|
||||
elevationDegree: number;
|
||||
distanceFactor: number;
|
||||
// 根据角度值生成 API 所需的字符串参数
|
||||
function getAzimuthString(azimuth: number): string {
|
||||
const hAngle = azimuth % 360;
|
||||
if (hAngle < 22.5 || hAngle >= 337.5) return "front";
|
||||
if (hAngle < 67.5) return "front-right";
|
||||
if (hAngle < 112.5) return "right";
|
||||
if (hAngle < 157.5) return "back-right";
|
||||
if (hAngle < 202.5) return "back";
|
||||
if (hAngle < 247.5) return "back-left";
|
||||
if (hAngle < 292.5) return "left";
|
||||
return "front-left";
|
||||
}
|
||||
|
||||
function getElevationString(elevation: number): string {
|
||||
if (elevation < -15) return "low";
|
||||
if (elevation < 15) return "eye-level";
|
||||
if (elevation < 45) return "elevated";
|
||||
return "high";
|
||||
}
|
||||
|
||||
function getDistanceString(distance: number): string {
|
||||
if (distance < 3) return "wide";
|
||||
if (distance < 7) return "medium";
|
||||
return "close";
|
||||
}
|
||||
|
||||
export function AngleEditDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
imageUrl,
|
||||
onComplete,
|
||||
}: AngleEditDialogProps) {
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [resultUrl, setResultUrl] = useState<string | null>(null);
|
||||
const [loraScale, setLoraScale] = useState(0.9);
|
||||
const [currentAngles, setCurrentAngles] = useState<AngleParams>({
|
||||
azimuth: "front-right",
|
||||
elevation: "eye-level",
|
||||
distance: "medium",
|
||||
azimuthDegree: 45,
|
||||
elevationDegree: 0,
|
||||
distanceFactor: 1.0,
|
||||
const [cameraState, setCameraState] = useState<CameraState>({
|
||||
azimuth: 45,
|
||||
elevation: 0,
|
||||
distance: 5,
|
||||
});
|
||||
|
||||
// 处理角度变化
|
||||
const handleAngleChange = useCallback((angles: AngleParams) => {
|
||||
setCurrentAngles(angles);
|
||||
// 清除之前的结果,因为角度变了
|
||||
// 当对话框关闭时重置状态
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setResultUrl(null);
|
||||
setIsProcessing(false);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// 处理相机状态变化
|
||||
const handleCameraStateChange = useCallback((state: CameraState) => {
|
||||
setCameraState(state);
|
||||
// 角度变化时清除之前的结果
|
||||
setResultUrl(null);
|
||||
}, []);
|
||||
|
||||
@ -68,9 +89,9 @@ export function AngleEditDialog({
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
image_url: imageUrl,
|
||||
azimuth: currentAngles.azimuth,
|
||||
elevation: currentAngles.elevation,
|
||||
distance: currentAngles.distance,
|
||||
azimuth: getAzimuthString(cameraState.azimuth),
|
||||
elevation: getElevationString(cameraState.elevation),
|
||||
distance: getDistanceString(cameraState.distance),
|
||||
lora_scale: loraScale,
|
||||
}),
|
||||
});
|
||||
@ -89,7 +110,7 @@ export function AngleEditDialog({
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
}, [imageUrl, currentAngles, loraScale]);
|
||||
}, [imageUrl, cameraState, loraScale]);
|
||||
|
||||
// 下载结果图片
|
||||
const handleDownload = useCallback(async () => {
|
||||
@ -101,7 +122,7 @@ export function AngleEditDialog({
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `angle-edit-${currentAngles.azimuth}-${currentAngles.elevation}-${Date.now()}.png`;
|
||||
a.download = `angle-edit-${cameraState.azimuth}-${cameraState.elevation}-${Date.now()}.png`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
@ -110,33 +131,31 @@ export function AngleEditDialog({
|
||||
} catch {
|
||||
toast.error("下载失败");
|
||||
}
|
||||
}, [resultUrl, currentAngles]);
|
||||
}, [resultUrl, cameraState]);
|
||||
|
||||
// 使用此图片
|
||||
// 使用此图片(直接发送到聊天)
|
||||
const handleUseImage = useCallback(() => {
|
||||
if (resultUrl) {
|
||||
onComplete?.(resultUrl);
|
||||
// 关闭对话框,图片已经通过 API 保存到数据库
|
||||
onOpenChange(false);
|
||||
toast.success("图片已添加到对话");
|
||||
}
|
||||
}, [resultUrl, onComplete, onOpenChange]);
|
||||
}, [resultUrl, onOpenChange]);
|
||||
|
||||
// 重置
|
||||
const handleReset = useCallback(() => {
|
||||
setResultUrl(null);
|
||||
setCurrentAngles({
|
||||
azimuth: "front-right",
|
||||
elevation: "eye-level",
|
||||
distance: "medium",
|
||||
azimuthDegree: 45,
|
||||
elevationDegree: 0,
|
||||
distanceFactor: 1.0,
|
||||
setCameraState({
|
||||
azimuth: 45,
|
||||
elevation: 0,
|
||||
distance: 5,
|
||||
});
|
||||
setLoraScale(0.9);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden flex flex-col p-0 gap-0">
|
||||
<DialogContent className="max-w-5xl max-h-[90vh] overflow-hidden flex flex-col p-0 gap-0">
|
||||
<DialogHeader className="px-6 py-4 border-b bg-muted/30">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
@ -146,7 +165,7 @@ export function AngleEditDialog({
|
||||
<div>
|
||||
<DialogTitle>多角度编辑</DialogTitle>
|
||||
<DialogDescription className="mt-1">
|
||||
拖拽 3D 控制器选择相机角度,AI 将生成对应视角的图像
|
||||
拖拽 3D 控制器中的彩色手柄调整相机角度,AI 将生成对应视角的图像
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
@ -154,20 +173,20 @@ export function AngleEditDialog({
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 h-full">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 h-full">
|
||||
{/* 左侧:3D 控制器 (占 5 列) */}
|
||||
<div className="lg:col-span-5 space-y-6 flex flex-col">
|
||||
<div className="flex-1">
|
||||
<AngleController3D
|
||||
onChange={handleAngleChange}
|
||||
initialAzimuth={currentAngles.azimuthDegree}
|
||||
initialElevation={currentAngles.elevationDegree}
|
||||
initialDistance={currentAngles.distanceFactor}
|
||||
<div className="lg:col-span-5 space-y-4 flex flex-col">
|
||||
<div className="flex-1 min-h-[400px]">
|
||||
<QwenCameraWidget
|
||||
initialState={cameraState}
|
||||
imageUrl={imageUrl}
|
||||
onStateChange={handleCameraStateChange}
|
||||
className="h-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* LoRA 强度控制 - 放入卡片 */}
|
||||
<div className="rounded-lg border bg-card p-4 space-y-4 shadow-sm">
|
||||
{/* LoRA 强度控制 */}
|
||||
<div className="rounded-lg border bg-card p-4 space-y-3 shadow-sm">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label className="text-sm font-medium">变换强度</Label>
|
||||
<span className="text-xs font-mono bg-muted px-2 py-1 rounded">
|
||||
@ -248,7 +267,7 @@ export function AngleEditDialog({
|
||||
<Move3D className="h-8 w-8 opacity-50" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-foreground">准备就绪</p>
|
||||
<p className="text-xs mt-1">调整左侧角度后点击生成</p>
|
||||
<p className="text-xs mt-1">拖拽左侧彩色手柄调整角度后点击生成</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -309,4 +328,3 @@ export function AngleEditDialog({
|
||||
}
|
||||
|
||||
export default AngleEditDialog;
|
||||
|
||||
|
||||
743
components/qwen-camera-widget.tsx
Normal file
743
components/qwen-camera-widget.tsx
Normal file
@ -0,0 +1,743 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useCallback, useState } from "react";
|
||||
import * as THREE from "three";
|
||||
|
||||
// 相机状态接口
|
||||
export interface CameraState {
|
||||
azimuth: number; // 方位角 0-360
|
||||
elevation: number; // 仰角 -30 到 60
|
||||
distance: number; // 距离 0-10
|
||||
}
|
||||
|
||||
interface QwenCameraWidgetProps {
|
||||
initialState?: Partial<CameraState>;
|
||||
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<HTMLDivElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const sceneRef = useRef<THREE.Scene | null>(null);
|
||||
const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
|
||||
const rendererRef = useRef<THREE.WebGLRenderer | null>(null);
|
||||
const animationIdRef = useRef<number | null>(null);
|
||||
|
||||
// 控制对象引用
|
||||
const azimuthHandleRef = useRef<THREE.Mesh | null>(null);
|
||||
const elevationHandleRef = useRef<THREE.Mesh | null>(null);
|
||||
const distanceHandleRef = useRef<THREE.Mesh | null>(null);
|
||||
const cameraIndicatorRef = useRef<THREE.Mesh | null>(null);
|
||||
const azimuthRingRef = useRef<THREE.Mesh | null>(null);
|
||||
const elevationArcRef = useRef<THREE.Mesh | null>(null);
|
||||
const imageCardRef = useRef<THREE.Mesh | null>(null);
|
||||
|
||||
// 状态
|
||||
const [state, setState] = useState<CameraState>({
|
||||
azimuth: initialState?.azimuth ?? 45,
|
||||
elevation: initialState?.elevation ?? 0,
|
||||
distance: initialState?.distance ?? 5,
|
||||
});
|
||||
|
||||
// 交互状态
|
||||
const isDraggingRef = useRef(false);
|
||||
const dragTargetRef = useRef<string | null>(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 `<sks> ${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(45, width / height, 0.1, 1000);
|
||||
camera.position.set(4, 3.5, 4);
|
||||
camera.lookAt(0, 0.3, 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.4);
|
||||
scene.add(ambientLight);
|
||||
|
||||
const mainLight = new THREE.DirectionalLight(0xffffff, 0.8);
|
||||
mainLight.position.set(5, 10, 5);
|
||||
scene.add(mainLight);
|
||||
|
||||
const fillLight = new THREE.DirectionalLight(0xE93D82, 0.3);
|
||||
fillLight.position.set(-5, 5, -5);
|
||||
scene.add(fillLight);
|
||||
|
||||
// 网格
|
||||
const gridHelper = new THREE.GridHelper(5, 20, 0x1a1a2e, 0x12121a);
|
||||
gridHelper.position.y = -0.01;
|
||||
scene.add(gridHelper);
|
||||
|
||||
// 创建图片卡片
|
||||
const cardGeo = new THREE.BoxGeometry(1.2, 1.2, 0.02);
|
||||
const cardMat = new THREE.MeshBasicMaterial({ color: 0x3a3a4a });
|
||||
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 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();
|
||||
|
||||
// 清理
|
||||
return () => {
|
||||
if (animationIdRef.current) {
|
||||
cancelAnimationFrame(animationIdRef.current);
|
||||
}
|
||||
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: number) => {
|
||||
setState(prev => {
|
||||
const newState = { ...prev, azimuth: value };
|
||||
onStateChange?.(newState);
|
||||
return newState;
|
||||
});
|
||||
}, [onStateChange]);
|
||||
|
||||
const handleElevationChange = useCallback((value: number) => {
|
||||
setState(prev => {
|
||||
const newState = { ...prev, elevation: value };
|
||||
onStateChange?.(newState);
|
||||
return newState;
|
||||
});
|
||||
}, [onStateChange]);
|
||||
|
||||
const handleDistanceChange = useCallback((value: number) => {
|
||||
setState(prev => {
|
||||
const newState = { ...prev, distance: value };
|
||||
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;
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
const findClosestDistance = (value: number) => {
|
||||
if (value < 3) return 1;
|
||||
if (value < 7) return 5;
|
||||
return 8;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`qwen-camera-widget ${className || ""}`} ref={containerRef}>
|
||||
{/* 3D 画布 */}
|
||||
<div className="qwen-canvas-container">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
onMouseDown={handlePointerDown}
|
||||
onMouseMove={handlePointerMove}
|
||||
onMouseUp={handlePointerUp}
|
||||
onMouseLeave={handlePointerUp}
|
||||
onTouchStart={handlePointerDown}
|
||||
onTouchMove={handlePointerMove}
|
||||
onTouchEnd={handlePointerUp}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 提示词显示 */}
|
||||
<div className="qwen-prompt">
|
||||
{generatePrompt(state)}
|
||||
</div>
|
||||
|
||||
{/* 下拉选择器 */}
|
||||
<div className="qwen-dropdowns">
|
||||
<div className="qwen-dropdown">
|
||||
<span className="qwen-label azimuth">水平</span>
|
||||
<select
|
||||
value={findClosestAzimuth(state.azimuth)}
|
||||
onChange={(e) => handleAzimuthChange(Number(e.target.value))}
|
||||
>
|
||||
{AZIMUTH_OPTIONS.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="qwen-dropdown">
|
||||
<span className="qwen-label elevation">垂直</span>
|
||||
<select
|
||||
value={findClosestElevation(state.elevation)}
|
||||
onChange={(e) => handleElevationChange(Number(e.target.value))}
|
||||
>
|
||||
{ELEVATION_OPTIONS.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="qwen-dropdown">
|
||||
<span className="qwen-label distance">距离</span>
|
||||
<select
|
||||
value={findClosestDistance(state.distance)}
|
||||
onChange={(e) => handleDistanceChange(Number(e.target.value))}
|
||||
>
|
||||
{DISTANCE_OPTIONS.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 数值显示和重置 */}
|
||||
<div className="qwen-info">
|
||||
<div className="qwen-param">
|
||||
<span className="qwen-param-label">水平角度</span>
|
||||
<span className="qwen-param-value azimuth">{Math.round(state.azimuth)}°</span>
|
||||
</div>
|
||||
<div className="qwen-param">
|
||||
<span className="qwen-param-label">垂直角度</span>
|
||||
<span className="qwen-param-value elevation">{Math.round(state.elevation)}°</span>
|
||||
</div>
|
||||
<div className="qwen-param">
|
||||
<span className="qwen-param-label">距离</span>
|
||||
<span className="qwen-param-value distance">{state.distance.toFixed(1)}</span>
|
||||
</div>
|
||||
<button className="qwen-reset" onClick={handleReset} title="重置">
|
||||
↺
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 内联样式 */}
|
||||
<style jsx>{`
|
||||
.qwen-camera-widget {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
background: #0a0a0f;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
.qwen-canvas-container {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.qwen-canvas-container canvas {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.qwen-prompt {
|
||||
background: #1a1a2e;
|
||||
color: #E93D82;
|
||||
padding: 10px 14px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-family: monospace;
|
||||
text-align: center;
|
||||
border: 1px solid #2a2a3e;
|
||||
}
|
||||
|
||||
.qwen-dropdowns {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.qwen-dropdown {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.qwen-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.qwen-label.azimuth { color: #E93D82; }
|
||||
.qwen-label.elevation { color: #00FFD0; }
|
||||
.qwen-label.distance { color: #FFB800; }
|
||||
|
||||
.qwen-dropdown select {
|
||||
background: #1a1a2e;
|
||||
color: #fff;
|
||||
border: 1px solid #2a2a3e;
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.qwen-dropdown select:hover {
|
||||
border-color: #3a3a4e;
|
||||
}
|
||||
|
||||
.qwen-dropdown select:focus {
|
||||
border-color: #E93D82;
|
||||
}
|
||||
|
||||
.qwen-info {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
background: #12121a;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.qwen-param {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.qwen-param-label {
|
||||
font-size: 10px;
|
||||
color: #666;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.qwen-param-value {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.qwen-param-value.azimuth { color: #E93D82; }
|
||||
.qwen-param-value.elevation { color: #00FFD0; }
|
||||
.qwen-param-value.distance { color: #FFB800; }
|
||||
|
||||
.qwen-reset {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: #2a2a3e;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: #888;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.qwen-reset:hover {
|
||||
background: #3a3a4e;
|
||||
color: #fff;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default QwenCameraWidget;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user