lovart3/components/angle-controller-3d.tsx

500 lines
16 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useRef, useState, useCallback, useMemo, Suspense, useEffect } from "react";
import { Canvas, useFrame, useThree } from "@react-three/fiber";
import { OrbitControls, PerspectiveCamera, Html, useHelper } from "@react-three/drei";
import type { OrbitControls as OrbitControlsImpl } from "three-stdlib";
import * as THREE from "three";
import { cn } from "@/lib/utils";
// 角度配置常量
const AZIMUTH_OPTIONS = [
{ angle: 0, label: "正前方", value: "front" },
{ angle: 45, label: "前右", value: "front-right" },
{ angle: 90, label: "右侧", value: "right" },
{ angle: 135, label: "后右", value: "back-right" },
{ angle: 180, label: "正后方", value: "back" },
{ angle: 225, label: "后左", value: "back-left" },
{ angle: 270, label: "左侧", value: "left" },
{ angle: 315, label: "前左", value: "front-left" },
];
const ELEVATION_OPTIONS = [
{ angle: -30, label: "仰视", value: "low" },
{ angle: 0, label: "平视", value: "eye-level" },
{ angle: 30, label: "俯视", value: "elevated" },
{ angle: 60, label: "高角度", value: "high" },
];
const DISTANCE_OPTIONS = [
{ factor: 0.6, label: "特写", value: "close" },
{ factor: 1.0, label: "中景", value: "medium" },
{ factor: 1.8, label: "远景", value: "wide" },
];
interface AngleController3DProps {
onChange?: (angles: {
azimuth: string;
elevation: string;
distance: string;
azimuthDegree: number;
elevationDegree: number;
distanceFactor: number;
}) => void;
initialAzimuth?: number;
initialElevation?: number;
initialDistance?: number;
showObject?: boolean;
className?: string;
}
// 3D 场景中的相机指示器 - 简约现代风格
function CameraIndicator({ position }: { position: THREE.Vector3 }) {
// 计算相机朝向中心的旋转
const lookAtCenter = useMemo(() => {
const quaternion = new THREE.Quaternion();
const up = new THREE.Vector3(0, 1, 0);
const matrix = new THREE.Matrix4();
matrix.lookAt(position, new THREE.Vector3(0, 0, 0), up);
quaternion.setFromRotationMatrix(matrix);
return quaternion;
}, [position]);
return (
<group position={position} quaternion={lookAtCenter}>
{/* 相机主体 - 哑光黑/深灰 */}
<mesh castShadow>
<boxGeometry args={[0.28, 0.2, 0.18]} />
<meshStandardMaterial color="#334155" metalness={0.2} roughness={0.8} />
</mesh>
{/* 相机顶部 */}
<mesh position={[0, 0.12, -0.02]} castShadow>
<boxGeometry args={[0.12, 0.06, 0.1]} />
<meshStandardMaterial color="#1e293b" metalness={0.2} roughness={0.8} />
</mesh>
{/* 镜头筒 - 金属质感 */}
<mesh position={[0, 0, 0.14]} rotation={[Math.PI / 2, 0, 0]} castShadow>
<cylinderGeometry args={[0.08, 0.1, 0.12, 24]} />
<meshStandardMaterial color="#475569" metalness={0.6} roughness={0.3} />
</mesh>
{/* 镜头玻璃 - 蓝色反光 */}
<mesh position={[0, 0, 0.21]}>
<circleGeometry args={[0.07, 24]} />
<meshStandardMaterial
color="#3b82f6"
metalness={0.9}
roughness={0.1}
transparent
opacity={0.9}
/>
</mesh>
{/* 视野指示 - 极简线条 */}
<group position={[0, 0, 0.2]}>
<line>
<bufferGeometry>
<bufferAttribute
attach="attributes-position"
count={5}
args={[
new Float32Array([
-0.3, -0.2, 1.0, // 左下
0.3, -0.2, 1.0, // 右下
0.3, 0.2, 1.0, // 右上
-0.3, 0.2, 1.0, // 左上
-0.3, -0.2, 1.0, // 闭合
]),
3
]}
/>
</bufferGeometry>
<lineBasicMaterial color="#3b82f6" opacity={0.3} transparent />
</line>
{/* 连接线 */}
{[
[-0.3, -0.2, 1.0], [0.3, -0.2, 1.0], [0.3, 0.2, 1.0], [-0.3, 0.2, 1.0]
].map((point, i) => (
<line key={i}>
<bufferGeometry>
<bufferAttribute
attach="attributes-position"
count={2}
args={[
new Float32Array([0, 0, 0, ...point]),
3
]}
/>
</bufferGeometry>
<lineBasicMaterial color="#3b82f6" opacity={0.15} transparent />
</line>
))}
</group>
</group>
);
}
// 方位角刻度圆环 - 更加隐形
function AzimuthRing() {
return (
<group>
{/* 圆环 */}
<mesh rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[1.98, 2.02, 64]} />
<meshBasicMaterial color="#94a3b8" opacity={0.2} transparent side={THREE.DoubleSide} />
</mesh>
{/* 方位角标签 */}
{AZIMUTH_OPTIONS.map((opt) => {
const rad = (opt.angle * Math.PI) / 180;
const x = Math.sin(rad) * 2.3;
const z = Math.cos(rad) * 2.3;
return (
<Html key={opt.value} position={[x, 0, z]} center>
<div className="text-[10px] font-medium text-muted-foreground whitespace-nowrap select-none pointer-events-none bg-background/50 px-1.5 py-0.5 rounded-full border shadow-sm backdrop-blur-[2px]">
{opt.label}
</div>
</Html>
);
})}
</group>
);
}
// 目标物体 - 极简风格
function TargetObject() {
return (
<group>
{/* 极简几何体代表目标 */}
<mesh position={[0, 0.25, 0]}>
<boxGeometry args={[0.5, 0.5, 0.5]} />
<meshStandardMaterial color="#e2e8f0" metalness={0.1} roughness={0.8} />
</mesh>
{/* 底部阴影 */}
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.01, 0]}>
<circleGeometry args={[0.6, 32]} />
<meshBasicMaterial color="#000000" opacity={0.1} transparent />
</mesh>
{/* 网格地面 */}
<gridHelper args={[4, 8, 0xe2e8f0, 0xf1f5f9]} position={[0, 0, 0]} />
</group>
);
}
// 3D 控制场景
function Scene({
azimuth,
elevation,
distance,
onAngleChange,
showObject,
}: {
azimuth: number;
elevation: number;
distance: number;
onAngleChange: (azimuth: number, elevation: number) => void;
showObject: boolean;
}) {
const controlsRef = useRef<OrbitControlsImpl>(null);
const ghostCameraRef = useRef<THREE.PerspectiveCamera>(null);
const mainCameraRef = useRef<THREE.PerspectiveCamera>(null);
// 计算目标拍摄位置(CameraIndicator的位置)
const targetPosition = useMemo(() => {
const azimuthRad = (azimuth * Math.PI) / 180;
const elevationRad = (elevation * Math.PI) / 180;
const radius = 2 * distance;
const x = radius * Math.cos(elevationRad) * Math.sin(azimuthRad);
const y = radius * Math.sin(elevationRad);
const z = radius * Math.cos(elevationRad) * Math.cos(azimuthRad);
return new THREE.Vector3(x, y, z);
}, [azimuth, elevation, distance]);
// 同步 Ghost Camera 位置到 state
// 当 state 改变时,更新 Ghost Camera 位置
useEffect(() => {
if (ghostCameraRef.current) {
ghostCameraRef.current.position.copy(targetPosition);
ghostCameraRef.current.lookAt(0, 0, 0);
}
}, [targetPosition]);
// 监听控制器变化 (用户拖拽)
const handleControlsChange = useCallback(() => {
if (ghostCameraRef.current) {
const camera = ghostCameraRef.current;
const spherical = new THREE.Spherical();
spherical.setFromVector3(camera.position);
// 将球面坐标转换为我们的角度系统
let newAzimuth = THREE.MathUtils.radToDeg(spherical.theta);
if (newAzimuth < 0) newAzimuth += 360;
const newElevation = 90 - THREE.MathUtils.radToDeg(spherical.phi);
// 只有当角度变化超过阈值时才触发更新,避免浮点数抖动
if (Math.abs(newAzimuth - azimuth) > 0.5 || Math.abs(newElevation - elevation) > 0.5) {
onAngleChange(Math.round(newAzimuth), Math.round(newElevation));
}
}
}, [onAngleChange, azimuth, elevation]);
return (
<>
{/* 主相机:上帝视角,固定位置 */}
<PerspectiveCamera
makeDefault
ref={mainCameraRef}
position={[3.5, 2.5, 3.5]}
fov={45}
onUpdate={(c) => c.lookAt(0, 0, 0)}
/>
{/* 幽灵相机:用于 OrbitControls 交互,不可见 */}
<PerspectiveCamera ref={ghostCameraRef} position={targetPosition} />
{/* 控制器绑定到幽灵相机 */}
<OrbitControls
ref={controlsRef}
camera={ghostCameraRef.current as any}
enablePan={false}
enableZoom={false}
minPolarAngle={Math.PI / 6}
maxPolarAngle={(Math.PI * 5) / 6}
onChange={handleControlsChange}
/>
{/* 灯光 */}
<ambientLight intensity={0.5} />
<directionalLight position={[5, 5, 5]} intensity={1} castShadow />
<pointLight position={[-5, 5, -5]} intensity={0.5} />
{/* 场景内容 */}
{showObject && <TargetObject />}
<AzimuthRing />
{/* 拍摄相机模型(在目标位置渲染) */}
<CameraIndicator position={targetPosition} />
{/* 坐标轴辅助 */}
<axesHelper args={[0.5]} />
</>
);
}
// 主组件
export function AngleController3D({
onChange,
initialAzimuth = 45,
initialElevation = 0,
initialDistance = 1.0,
showObject = true,
className,
}: AngleController3DProps) {
const [azimuth, setAzimuth] = useState(initialAzimuth);
const [elevation, setElevation] = useState(initialElevation);
const [distance, setDistance] = useState(initialDistance);
// 将角度映射到最近的预设值
const getClosestAzimuth = useCallback((deg: number) => {
let minDiff = 360;
let closest = AZIMUTH_OPTIONS[0];
for (const opt of AZIMUTH_OPTIONS) {
const diff = Math.min(Math.abs(deg - opt.angle), 360 - Math.abs(deg - opt.angle));
if (diff < minDiff) {
minDiff = diff;
closest = opt;
}
}
return closest;
}, []);
const getClosestElevation = useCallback((deg: number) => {
let minDiff = 180;
let closest = ELEVATION_OPTIONS[0];
for (const opt of ELEVATION_OPTIONS) {
const diff = Math.abs(deg - opt.angle);
if (diff < minDiff) {
minDiff = diff;
closest = opt;
}
}
return closest;
}, []);
const getClosestDistance = useCallback((factor: number) => {
let minDiff = 10;
let closest = DISTANCE_OPTIONS[0];
for (const opt of DISTANCE_OPTIONS) {
const diff = Math.abs(factor - opt.factor);
if (diff < minDiff) {
minDiff = diff;
closest = opt;
}
}
return closest;
}, []);
// 处理角度变化
const handleAngleChange = useCallback(
(newAzimuth: number, newElevation: number) => {
setAzimuth(newAzimuth);
setElevation(newElevation);
const closestAzimuth = getClosestAzimuth(newAzimuth);
const closestElevation = getClosestElevation(newElevation);
const closestDistance = getClosestDistance(distance);
onChange?.({
azimuth: closestAzimuth.value,
elevation: closestElevation.value,
distance: closestDistance.value,
azimuthDegree: newAzimuth,
elevationDegree: newElevation,
distanceFactor: distance,
});
},
[distance, getClosestAzimuth, getClosestElevation, getClosestDistance, onChange]
);
// 处理距离变化
const handleDistanceChange = useCallback(
(newDistance: number) => {
setDistance(newDistance);
const closestAzimuth = getClosestAzimuth(azimuth);
const closestElevation = getClosestElevation(elevation);
const closestDistance = getClosestDistance(newDistance);
onChange?.({
azimuth: closestAzimuth.value,
elevation: closestElevation.value,
distance: closestDistance.value,
azimuthDegree: azimuth,
elevationDegree: elevation,
distanceFactor: newDistance,
});
},
[azimuth, elevation, getClosestAzimuth, getClosestElevation, getClosestDistance, onChange]
);
// 快速选择预设角度
const handlePresetClick = useCallback(
(presetAzimuth: number, presetElevation: number) => {
setAzimuth(presetAzimuth);
setElevation(presetElevation);
const closestAzimuth = getClosestAzimuth(presetAzimuth);
const closestElevation = getClosestElevation(presetElevation);
const closestDistance = getClosestDistance(distance);
onChange?.({
azimuth: closestAzimuth.value,
elevation: closestElevation.value,
distance: closestDistance.value,
azimuthDegree: presetAzimuth,
elevationDegree: presetElevation,
distanceFactor: distance,
});
},
[distance, getClosestAzimuth, getClosestElevation, getClosestDistance, onChange]
);
const currentAzimuth = getClosestAzimuth(azimuth);
const currentElevation = getClosestElevation(elevation);
const currentDistance = getClosestDistance(distance);
return (
<div className={cn("flex flex-col gap-4", className)}>
{/* 3D 视图 */}
<div className="relative aspect-square w-full max-w-[400px] mx-auto rounded-xl overflow-hidden bg-gradient-to-b from-slate-100 to-white border border-slate-200 shadow-sm dark:from-slate-900 dark:to-slate-950 dark:border-slate-800">
<Canvas>
<Suspense fallback={null}>
<Scene
azimuth={azimuth}
elevation={elevation}
distance={distance}
onAngleChange={handleAngleChange}
showObject={showObject}
/>
</Suspense>
</Canvas>
{/* 当前角度显示 - 悬浮卡片风格 */}
<div className="absolute bottom-3 left-3 right-3 flex justify-between text-xs font-medium text-foreground/80 bg-background/80 backdrop-blur-md rounded-lg px-3 py-2 border shadow-sm select-none pointer-events-none">
<span>方位: {currentAzimuth.label} ({azimuth}°)</span>
<span>仰角: {currentElevation.label} ({elevation}°)</span>
</div>
{/* 操作提示 */}
<div className="absolute top-3 left-3 text-[10px] font-medium text-muted-foreground bg-background/50 backdrop-blur-sm rounded-full px-2 py-1 border border-border/50 select-none pointer-events-none">
🖱️ 拖拽旋转相机位置
</div>
</div>
{/* 快速预设按钮 */}
<div className="space-y-3">
<div className="text-sm font-medium text-foreground">快速选择</div>
<div className="grid grid-cols-4 gap-2">
{[
{ label: "正前", azimuth: 0, elevation: 0 },
{ label: "前3/4", azimuth: 45, elevation: 0 },
{ label: "侧面", azimuth: 90, elevation: 0 },
{ label: "后3/4", azimuth: 135, elevation: 0 },
{ label: "正后", azimuth: 180, elevation: 0 },
{ label: "俯视", azimuth: 0, elevation: 60 },
{ label: "仰视", azimuth: 0, elevation: -30 },
{ label: "鸟瞰", azimuth: 45, elevation: 60 },
].map((preset) => (
<button
type="button"
key={preset.label}
onClick={() => handlePresetClick(preset.azimuth, preset.elevation)}
className={cn(
"px-2 py-1.5 text-xs rounded-md border transition-all duration-200 font-medium",
currentAzimuth.angle === preset.azimuth &&
currentElevation.angle === preset.elevation
? "bg-primary text-primary-foreground border-primary shadow-sm"
: "bg-background hover:bg-accent hover:text-accent-foreground border-border text-muted-foreground"
)}
>
{preset.label}
</button>
))}
</div>
</div>
{/* 距离控制 */}
<div className="space-y-2">
<div className="text-sm font-medium text-foreground">镜头距离</div>
<div className="flex p-1 bg-muted rounded-lg">
{DISTANCE_OPTIONS.map((opt) => (
<button
type="button"
key={opt.value}
onClick={() => handleDistanceChange(opt.factor)}
className={cn(
"flex-1 px-3 py-1.5 text-xs rounded-md transition-all duration-200 font-medium",
currentDistance.value === opt.value
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
>
{opt.label}
</button>
))}
</div>
</div>
</div>
);
}
export default AngleController3D;