500 lines
16 KiB
TypeScript
500 lines
16 KiB
TypeScript
"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;
|