fix: 优化Gizmo
This commit is contained in:
@@ -7,7 +7,7 @@ import {
|
||||
shouldRenderViewportGrid,
|
||||
} from "./DirectorCanvas";
|
||||
import { createDefaultDirectorProject } from "../store/directorStore";
|
||||
import { getCameraViewSnapshotFromShot } from "../schema/cameraGeometry";
|
||||
import { getCameraLookRotationFromTarget, getCameraViewSnapshotFromShot } from "../schema/cameraGeometry";
|
||||
import { PerspectiveCamera, Vector3 } from "three";
|
||||
|
||||
function expectTupleToBeCloseTo(
|
||||
@@ -85,7 +85,14 @@ it("starts the director and default camera view from a centered front compositio
|
||||
|
||||
expect(defaultCameraView.fov).toBe(DEFAULT_DIRECTOR_VIEW_SNAPSHOT.fov);
|
||||
expectTupleToBeCloseTo(defaultCameraView.position, defaultCamera.transform.position);
|
||||
expect(defaultCameraView.target).toEqual(DEFAULT_DIRECTOR_VIEW_SNAPSHOT.target);
|
||||
// 机位存的是注视朝向角:默认机位 = 默认导演视角那条视线的角度
|
||||
expectTupleToBeCloseTo(
|
||||
defaultCameraView.lookRotation,
|
||||
getCameraLookRotationFromTarget(
|
||||
DEFAULT_DIRECTOR_VIEW_SNAPSHOT.position,
|
||||
DEFAULT_DIRECTOR_VIEW_SNAPSHOT.target
|
||||
)
|
||||
);
|
||||
expect(defaultCamera.transform.position[0]).toBe(0);
|
||||
expect(defaultCamera.transform.position[1]).toBeGreaterThan(DEFAULT_DIRECTOR_VIEW_SNAPSHOT.target[1]);
|
||||
});
|
||||
|
||||
@@ -17,7 +17,15 @@ import { clearViewportCaptureHandler, setViewportCaptureHandler } from "../io/ca
|
||||
import { setVideoExportContext } from "../io/videoExportBridge";
|
||||
import { buildScreenshotMeta, type ScreenshotResult } from "../io/screenshotExport";
|
||||
import { useDirectorStore, type CameraShotSnapshot } from "../store/directorStore";
|
||||
import { DEFAULT_DIRECTOR_CAMERA_VIEW_SNAPSHOT, getCameraViewSnapshotFromShot } from "../schema/cameraGeometry";
|
||||
import {
|
||||
DEFAULT_DIRECTOR_CAMERA_LOOK_POINT,
|
||||
DEFAULT_DIRECTOR_CAMERA_POSITION,
|
||||
getCameraLookPoint,
|
||||
getCameraRenderQuaternion,
|
||||
getCameraViewSnapshotFromShot,
|
||||
getWorldCameraViewSnapshot,
|
||||
type CameraViewSnapshot,
|
||||
} from "../schema/cameraGeometry";
|
||||
import type { DirectorObject, DirectorTransform, SceneSettings } from "../schema/directorProject";
|
||||
import { degreesRotationToRadians } from "../schema/directorProject";
|
||||
import { getGroundedLabelY } from "../runtime/mannequin/bodyTypes";
|
||||
@@ -35,7 +43,15 @@ import { ViewportBackground } from "./ViewportBackground";
|
||||
import { ViewportToolbar } from "./ViewportToolbar";
|
||||
import { getViewportAspectFrameRect, type ViewportSafeAreaInsets } from "./viewportAspectFrame";
|
||||
|
||||
export const DEFAULT_DIRECTOR_VIEW_SNAPSHOT: CameraShotSnapshot = DEFAULT_DIRECTOR_CAMERA_VIEW_SNAPSHOT;
|
||||
/**
|
||||
* 导演视角的初始位姿:OrbitControls 需要一个轨道中心点,所以这里存的是「注视点」;
|
||||
* 它属于视口状态,不是工程数据 —— 工程里的机位统一用注视朝向角(lookRotation)表示。
|
||||
*/
|
||||
export const DEFAULT_DIRECTOR_VIEW_SNAPSHOT: CameraShotSnapshot = {
|
||||
fov: 50,
|
||||
position: [...DEFAULT_DIRECTOR_CAMERA_POSITION],
|
||||
target: [...DEFAULT_DIRECTOR_CAMERA_LOOK_POINT],
|
||||
};
|
||||
const VIEWPORT_FRAME_PADDING = 40;
|
||||
const VIEWPORT_TOOLBAR_BOTTOM_OFFSET = 40;
|
||||
const DEFAULT_VIEWPORT_TOOLBAR_HEIGHT = 44;
|
||||
@@ -168,6 +184,19 @@ function applySnapshotToCamera(camera: ThreePerspectiveCamera, snapshot: CameraS
|
||||
camera.updateMatrixWorld();
|
||||
}
|
||||
|
||||
/**
|
||||
* 把「机位视角」写到渲染相机上:位置 + 注视朝向角直接合成姿态。
|
||||
* 与导演视角的 applySnapshotToCamera 分开 —— 那边是 OrbitControls 的轨道中心点(世界坐标),
|
||||
* 这边是工程里的注视朝向角,两者不是一回事。
|
||||
*/
|
||||
function applyCameraViewToCamera(camera: ThreePerspectiveCamera, view: CameraViewSnapshot) {
|
||||
camera.fov = view.fov;
|
||||
camera.position.set(...view.position);
|
||||
camera.quaternion.copy(getCameraRenderQuaternion(view.lookRotation));
|
||||
camera.updateProjectionMatrix();
|
||||
camera.updateMatrixWorld();
|
||||
}
|
||||
|
||||
/**
|
||||
* 外部改视角(重置视角 / 坐标控件切轴)时「当场」结算残余惯性并把视角写回相机与 OrbitControls。
|
||||
* 返回是否真的写回去了 —— 机位视角下没有 OrbitControls,只能等切回导演视角时由副作用补写。
|
||||
@@ -472,7 +501,8 @@ function CanvasCaptureBridge({
|
||||
| {
|
||||
id: string;
|
||||
fov: number;
|
||||
target: [number, number, number];
|
||||
position: [number, number, number];
|
||||
lookRotation: [number, number, number];
|
||||
}
|
||||
| undefined;
|
||||
bottomPadding: number;
|
||||
@@ -497,7 +527,8 @@ function CanvasCaptureBridge({
|
||||
}): Promise<ScreenshotResult[]> => {
|
||||
const target = new Vector3(0, 1.2, 0);
|
||||
if (viewMode === "camera" && activeCamera) {
|
||||
target.fromArray(activeCamera.target);
|
||||
// 机位只存注视朝向角,环绕用的中心点按视线前方现算一个
|
||||
target.fromArray(getCameraLookPoint(activeCamera.position, activeCamera.lookRotation));
|
||||
} else if (controlsRef.current?.target) {
|
||||
target.copy(controlsRef.current.target);
|
||||
}
|
||||
@@ -530,24 +561,20 @@ function CanvasCaptureBridge({
|
||||
// 机位面板触发的「当前机位」截图:渲染的是该机位自己的视角,
|
||||
// 而不是编辑器此刻所在的导演视角(导演视角通常更高、更远),
|
||||
// 否则用户在机位面板生成的预览会和切到相机视角看到的不一致。
|
||||
if (source === "camera-panel" && cameraId) {
|
||||
const captureCamera = useDirectorStore
|
||||
.getState()
|
||||
.project.cameras.find((item) => item.id === cameraId);
|
||||
if (captureCamera) {
|
||||
const view = getCameraViewSnapshotFromShot(captureCamera);
|
||||
const savedPosition = workingCamera.position.clone();
|
||||
const savedQuaternion = workingCamera.quaternion.clone();
|
||||
const savedFov = workingCamera.fov;
|
||||
applySnapshotToCamera(workingCamera, view);
|
||||
try {
|
||||
return [snapshot("当前机位", [...view.target])];
|
||||
} finally {
|
||||
workingCamera.position.copy(savedPosition);
|
||||
workingCamera.quaternion.copy(savedQuaternion);
|
||||
workingCamera.fov = savedFov;
|
||||
workingCamera.updateProjectionMatrix();
|
||||
}
|
||||
if (source === "camera-panel" && cameraId && activeCamera) {
|
||||
// activeCamera 已经是世界坐标的机位视角(与切到机位视角一致):
|
||||
// 直接用它渲染,截图预览才和用户在机位视角里看到的画面吻合。
|
||||
const savedPosition = workingCamera.position.clone();
|
||||
const savedQuaternion = workingCamera.quaternion.clone();
|
||||
const savedFov = workingCamera.fov;
|
||||
applyCameraViewToCamera(workingCamera, activeCamera);
|
||||
try {
|
||||
return [snapshot("当前机位", getCameraLookPoint(activeCamera.position, activeCamera.lookRotation))];
|
||||
} finally {
|
||||
workingCamera.position.copy(savedPosition);
|
||||
workingCamera.quaternion.copy(savedQuaternion);
|
||||
workingCamera.fov = savedFov;
|
||||
workingCamera.updateProjectionMatrix();
|
||||
}
|
||||
}
|
||||
return [snapshot(source === "camera-panel" ? "当前机位" : "当前视角")];
|
||||
@@ -765,14 +792,29 @@ export function DirectorCanvas() {
|
||||
const panoramaAsset = assets.find((item) => item.id === panoramaAssetId);
|
||||
const showViewportGrid = shouldRenderViewportGrid(hasPanorama, sceneSettings.snapToGrid);
|
||||
const activeCameraView = activeCamera ? getCameraViewSnapshotFromShot(activeCamera) : undefined;
|
||||
// 机位线框 / 三轴 gizmo 都画在场景组里(场景变换之内),而「机位视角」渲染相机、
|
||||
// 面板数值、截图中心点都该落在世界坐标,所以这里把局部机位换算成世界机位。
|
||||
const activeCameraWorldView = activeCameraView
|
||||
? getWorldCameraViewSnapshot(activeCameraView, sceneSettings)
|
||||
: undefined;
|
||||
/** 截图桥需要的机位视角:在世界机位视角上补一个 id,用来把截图归到对应机位 */
|
||||
const activeCameraCaptureView =
|
||||
activeCamera && activeCameraWorldView ? { id: activeCamera.id, ...activeCameraWorldView } : undefined;
|
||||
const viewportAspectRatio = useDirectorStore((state) => state.viewportAspectRatio);
|
||||
const viewportRuleOfThirdsEnabled = useDirectorStore((state) => state.viewportRuleOfThirdsEnabled);
|
||||
const viewportPanelsCollapsed = useDirectorStore((state) => state.viewportPanelsCollapsed);
|
||||
const trajectoryPlacementObjectId = useDirectorStore((state) => state.trajectoryPlacementObjectId);
|
||||
const setViewMode = useDirectorStore((state) => state.setViewMode);
|
||||
const setViewportRuleOfThirdsEnabled = useDirectorStore((state) => state.setViewportRuleOfThirdsEnabled);
|
||||
const visibleViewportSnapshot =
|
||||
viewMode === "camera" && activeCameraView ? activeCameraView : directorViewSnapshot;
|
||||
// 坐标控件 / 方位切换只认「位置 + 一个中心点」,机位视角下按视线前方现算一个注视点
|
||||
const visibleViewportSnapshot: CameraShotSnapshot =
|
||||
viewMode === "camera" && activeCameraWorldView
|
||||
? {
|
||||
fov: activeCameraWorldView.fov,
|
||||
position: activeCameraWorldView.position,
|
||||
target: getCameraLookPoint(activeCameraWorldView.position, activeCameraWorldView.lookRotation),
|
||||
}
|
||||
: directorViewSnapshot;
|
||||
const viewportSafeAreaInsets: ViewportSafeAreaInsets = viewportPanelsCollapsed
|
||||
? { left: 0, right: 0, top: 0, bottom: 0 }
|
||||
: {
|
||||
@@ -872,6 +914,9 @@ export function DirectorCanvas() {
|
||||
<div
|
||||
className={`director-canvas${trajectoryPlacementObjectId ? " is-placing-trajectory" : ""}`}
|
||||
data-testid="director-canvas"
|
||||
// 屏蔽在画布上右键(右键平移)弹出的浏览器上下文菜单 / 手势:
|
||||
// 相机视角下没有 OrbitControls 接管右键,右键拖拽会直接触发浏览器手势,这里统一拦截。
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
>
|
||||
<Canvas
|
||||
camera={{ position: DEFAULT_DIRECTOR_VIEW_SNAPSHOT.position, fov: DEFAULT_DIRECTOR_VIEW_SNAPSHOT.fov }}
|
||||
@@ -955,16 +1000,23 @@ export function DirectorCanvas() {
|
||||
snapshotSourceRef={viewportSnapshotSourceRef}
|
||||
viewMode={viewMode}
|
||||
/>
|
||||
{viewMode === "camera" && activeCameraView ? (
|
||||
{viewMode === "camera" && activeCameraWorldView ? (
|
||||
<PerspectiveCamera
|
||||
fov={activeCameraView.fov}
|
||||
fov={activeCameraWorldView.fov}
|
||||
makeDefault
|
||||
position={activeCameraView.position}
|
||||
onUpdate={(camera) => camera.lookAt(...activeCameraView.target)}
|
||||
position={activeCameraWorldView.position}
|
||||
quaternion={
|
||||
getCameraRenderQuaternion(activeCameraWorldView.lookRotation).toArray() as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
]
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
<CanvasCaptureBridge
|
||||
activeCamera={activeCamera}
|
||||
activeCamera={activeCameraCaptureView}
|
||||
bottomPadding={aspectOverlayBottomPadding}
|
||||
controlsRef={controlsRef}
|
||||
safeAreaInsets={viewportSafeAreaInsets}
|
||||
|
||||
@@ -44,10 +44,10 @@ it("keeps the viewport camera model upright while facing the shot target", () =>
|
||||
|
||||
function getFrustumMetrics({
|
||||
fov = 50,
|
||||
target = [0, 1.2, 0],
|
||||
lookRotation = [0, 0, 0],
|
||||
}: {
|
||||
fov?: number;
|
||||
target?: [number, number, number];
|
||||
lookRotation?: [number, number, number];
|
||||
}) {
|
||||
const lines = getViewportCameraFrustumLines({
|
||||
id: "cam_1",
|
||||
@@ -59,7 +59,7 @@ function getFrustumMetrics({
|
||||
scale: [1, 1, 1],
|
||||
},
|
||||
targetMode: "manual",
|
||||
target,
|
||||
lookRotation,
|
||||
lastCaptureUrl: null,
|
||||
});
|
||||
const points = lines.flat();
|
||||
@@ -86,10 +86,10 @@ it("uses a 16:9 front viewfinder sized by the camera fov", () => {
|
||||
expect(metrics.maxHalfWidth / metrics.maxHalfHeight).toBeCloseTo(16 / 9);
|
||||
});
|
||||
|
||||
it("keeps the viewport camera viewfinder length fixed when the shot target distance changes", () => {
|
||||
const nearMetrics = getFrustumMetrics({ target: [0, 1.2, 5] });
|
||||
const defaultMetrics = getFrustumMetrics({ target: [0, 1.2, 0] });
|
||||
const farMetrics = getFrustumMetrics({ target: [0, 1.2, -9] });
|
||||
it("keeps the viewport camera viewfinder length fixed when the shot look angles change", () => {
|
||||
const nearMetrics = getFrustumMetrics({ lookRotation: [20, 0, 0] });
|
||||
const defaultMetrics = getFrustumMetrics({ lookRotation: [0, 0, 0] });
|
||||
const farMetrics = getFrustumMetrics({ lookRotation: [-35, 90, 0] });
|
||||
|
||||
expect(defaultMetrics.maxDepth).toBeCloseTo(nearMetrics.maxDepth);
|
||||
expect(farMetrics.maxDepth).toBeCloseTo(defaultMetrics.maxDepth);
|
||||
@@ -210,7 +210,7 @@ it("starts the viewfinder lines from the centered front lens frame", () => {
|
||||
scale: [1, 1, 1],
|
||||
},
|
||||
targetMode: "manual",
|
||||
target: [0, 1.2, 0],
|
||||
lookRotation: [0, 0, 0],
|
||||
lastCaptureUrl: null,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Html, Line, TransformControls, type TransformControlsProps } from "@react-three/drei";
|
||||
import { useLoader, type ThreeEvent } from "@react-three/fiber";
|
||||
import { createPortal, useLoader, useThree, type ThreeEvent } from "@react-three/fiber";
|
||||
import { Suspense, useCallback, useLayoutEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from "react";
|
||||
import {
|
||||
Box3,
|
||||
CatmullRomCurve3,
|
||||
DoubleSide,
|
||||
Euler,
|
||||
Matrix4,
|
||||
Quaternion,
|
||||
Vector3,
|
||||
@@ -23,11 +24,14 @@ import type {
|
||||
import {
|
||||
degreesRotationToRadians,
|
||||
radiansRotationToDegrees,
|
||||
radiansToDegrees,
|
||||
} from "../schema/directorProject";
|
||||
import {
|
||||
CAMERA_LOOK_EULER_ORDER,
|
||||
VIEWPORT_CAMERA_ASPECT,
|
||||
VIEWPORT_CAMERA_FRUSTUM_DEPTH,
|
||||
VIEWPORT_CAMERA_VISUAL_SCALE,
|
||||
getCameraLookQuaternion,
|
||||
} from "../schema/cameraGeometry";
|
||||
import { VIEWPORT_OBJECT_LABEL_VERTICAL_GAP } from "../schema/viewportLabels";
|
||||
import { evaluateTrajectoryMotionAtTime, getTrajectoryPointTimes } from "../schema/trajectoryMotion";
|
||||
@@ -45,6 +49,9 @@ import { HIDE_FROM_VIEWPORT_CAPTURE_KEY } from "./viewportCapture";
|
||||
import { getCrowdAnchorTransform } from "../store/directorStore";
|
||||
|
||||
export { getEffectiveGroundOpacity, getPanoramaRotationRadians } from "./panoramaMath";
|
||||
// 旧名字:线框姿态以前是「位置 + 注视点」算出来的,现在统一由「位置 + 注视朝向角」算,
|
||||
// 这里保留转发,外部(含单测)仍能按注视点取姿态。
|
||||
export { getCameraQuaternionFromTarget as getViewportCameraQuaternion } from "../schema/cameraGeometry";
|
||||
|
||||
const VIEWPORT_CAMERA_LINE = "#A9D8FF";
|
||||
/** 轨迹:路点标记 / 连线的兜底色(默认沿用角色自身颜色),选中点用高亮色 */
|
||||
@@ -54,8 +61,6 @@ const TRAJECTORY_SELECTED_COLOR = "#FFC857";
|
||||
const TRAJECTORY_PLANE_SIZE = 600;
|
||||
const VIEWPORT_CAMERA_LINE_OPACITY = 0.92;
|
||||
const VIEWPORT_CAMERA_HIT_PADDING = 0.06;
|
||||
const VIEWPORT_CAMERA_FORWARD = new Vector3(0, 0, 1);
|
||||
const VIEWPORT_CAMERA_WORLD_UP = new Vector3(0, 1, 0);
|
||||
const VIEWPORT_CAMERA_BODY_CENTER: CameraWirePoint = [0, 0, -0.52 * VIEWPORT_CAMERA_VISUAL_SCALE];
|
||||
const VIEWPORT_CAMERA_BODY_SIZE: CameraWirePoint = [
|
||||
0.4 * VIEWPORT_CAMERA_VISUAL_SCALE,
|
||||
@@ -123,8 +128,12 @@ function ViewportTransformControls({
|
||||
const beginUndoBatch = useDirectorStore((state) => state.beginUndoBatch);
|
||||
const endUndoBatch = useDirectorStore((state) => state.endUndoBatch);
|
||||
const setEditingObjectId = usePlaybackStore((state) => state.setEditingObjectId);
|
||||
// gizmo 必须挂在场景根(而不是场景组)上:场景组带了缩放 / 旋转 / 平移,
|
||||
// 一旦 gizmo 作为它的子节点,手柄尺寸会被场景缩放放大(屏幕上的手柄变大),
|
||||
// 且位置会被场景变换叠加两遍。挂到根上后手柄落在物体真实世界坐标,且尺寸不再随场景缩放变化。
|
||||
const scene = useThree((state) => state.scene);
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<TransformControls
|
||||
ref={setControlsRef}
|
||||
mode={mode}
|
||||
@@ -138,28 +147,11 @@ function ViewportTransformControls({
|
||||
onObjectChange={onObjectChange}
|
||||
translationSnap={translationSnap ?? undefined}
|
||||
userData={{ [HIDE_FROM_VIEWPORT_CAPTURE_KEY]: true }}
|
||||
/>
|
||||
/>,
|
||||
scene
|
||||
);
|
||||
}
|
||||
|
||||
export function getViewportCameraQuaternion(
|
||||
position: [number, number, number],
|
||||
target: [number, number, number]
|
||||
) {
|
||||
const origin = new Vector3(...position);
|
||||
const direction = new Vector3(...target).sub(origin);
|
||||
if (direction.lengthSq() === 0) return new Quaternion();
|
||||
|
||||
const forward = direction.normalize();
|
||||
const up =
|
||||
Math.abs(forward.dot(VIEWPORT_CAMERA_WORLD_UP)) > 0.999
|
||||
? new Vector3(0, 0, 1)
|
||||
: VIEWPORT_CAMERA_WORLD_UP;
|
||||
const matrix = new Matrix4().lookAt(origin, origin.clone().sub(forward), up);
|
||||
|
||||
return new Quaternion().setFromRotationMatrix(matrix);
|
||||
}
|
||||
|
||||
export function getViewportCameraOpaqueDepthRange() {
|
||||
const zValues = getViewportCameraBodyWireframeLines()
|
||||
.filter((line) => line.part !== "lens")
|
||||
@@ -712,10 +704,7 @@ function ViewportCameraRig({
|
||||
const cameraHitArea = useMemo(() => getViewportCameraHitArea(), []);
|
||||
const cameraLabelY = useMemo(() => getViewportCameraLabelY(), []);
|
||||
const frustumLines = useMemo(() => getViewportCameraFrustumLines(camera), [camera]);
|
||||
const cameraQuaternion = useMemo(
|
||||
() => getViewportCameraQuaternion(camera.transform.position, camera.target),
|
||||
[camera.target, camera.transform.position]
|
||||
);
|
||||
const cameraQuaternion = useMemo(() => getCameraLookQuaternion(camera.lookRotation), [camera.lookRotation]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
groupRef.current?.quaternion?.copy?.(cameraQuaternion);
|
||||
@@ -726,17 +715,21 @@ function ViewportCameraRig({
|
||||
if (!group) return;
|
||||
|
||||
const position: [number, number, number] = [group.position.x, group.position.y, group.position.z];
|
||||
const forward = VIEWPORT_CAMERA_FORWARD.clone().applyQuaternion(group.quaternion).normalize();
|
||||
const currentDistance = new Vector3(...camera.target).distanceTo(group.position);
|
||||
const nextTarget = group.position.clone().add(forward.multiplyScalar(Math.max(currentDistance, 0.1)));
|
||||
// 回写的是「水平角 / 俯仰角 / 横滚角」三个度数,按 YXZ 拆,和写入时的合成顺序一致
|
||||
const euler = new Euler().setFromQuaternion(group.quaternion, CAMERA_LOOK_EULER_ORDER);
|
||||
const lookRotation: [number, number, number] = [
|
||||
radiansToDegrees(euler.x),
|
||||
radiansToDegrees(euler.y),
|
||||
radiansToDegrees(euler.z),
|
||||
];
|
||||
|
||||
updateCamera(camera.id, {
|
||||
transform: {
|
||||
position,
|
||||
rotation: [group.rotation.x, group.rotation.y, group.rotation.z],
|
||||
rotation: lookRotation,
|
||||
scale: [group.scale.x, group.scale.y, group.scale.z],
|
||||
},
|
||||
target: [nextTarget.x, nextTarget.y, nextTarget.z],
|
||||
lookRotation,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -751,6 +744,17 @@ function ViewportCameraRig({
|
||||
selectObject(object?.id ?? null);
|
||||
}
|
||||
|
||||
// 相机机身(机身盒 + 镜头 + 片盘)的几何中心在局部坐标约为
|
||||
// (0, 0.225·scale, -0.395·scale),即 group 原点(也就是 TransformControls 三轴手柄
|
||||
// 的锚点)落在了镜头前方、机身偏下的位置。把模型整体平移该中心的相反向量,
|
||||
// 让 group 原点与相机本体中心对齐,这样选中相机时三轴手柄会落在相机身上,
|
||||
// 而不是悬在机位上方/前方很远处。
|
||||
const cameraModelOffset: [number, number, number] = [
|
||||
0,
|
||||
-0.225 * VIEWPORT_CAMERA_VISUAL_SCALE,
|
||||
0.395 * VIEWPORT_CAMERA_VISUAL_SCALE,
|
||||
];
|
||||
|
||||
const node = (
|
||||
<group
|
||||
ref={groupRef}
|
||||
@@ -760,40 +764,42 @@ function ViewportCameraRig({
|
||||
userData={{ [HIDE_FROM_VIEWPORT_CAPTURE_KEY]: true, directorCameraId: camera.id }}
|
||||
onClick={selectCameraFromViewport}
|
||||
>
|
||||
{showLabel ? (
|
||||
<ViewportObjectLabel position={[0, cameraLabelY, 0]}>{camera.name}</ViewportObjectLabel>
|
||||
) : null}
|
||||
<group position={cameraModelOffset}>
|
||||
{showLabel ? (
|
||||
<ViewportObjectLabel position={[0, cameraLabelY, 0]}>{camera.name}</ViewportObjectLabel>
|
||||
) : null}
|
||||
|
||||
<mesh name={`${camera.id}-hit-area`} onClick={selectCameraFromViewport} position={cameraHitArea.position}>
|
||||
<boxGeometry args={cameraHitArea.args} />
|
||||
<meshBasicMaterial depthWrite={false} opacity={0} transparent />
|
||||
</mesh>
|
||||
<mesh name={`${camera.id}-hit-area`} onClick={selectCameraFromViewport} position={cameraHitArea.position}>
|
||||
<boxGeometry args={cameraHitArea.args} />
|
||||
<meshBasicMaterial depthWrite={false} opacity={0} transparent />
|
||||
</mesh>
|
||||
|
||||
{bodyWireframeLines.map((line, index) => (
|
||||
<Line
|
||||
key={`${camera.id}-${line.part}-${index}`}
|
||||
color={VIEWPORT_CAMERA_LINE}
|
||||
lineWidth={1}
|
||||
name={`${camera.id}-${line.part}-${index}`}
|
||||
onClick={selectCameraFromViewport}
|
||||
opacity={VIEWPORT_CAMERA_LINE_OPACITY}
|
||||
points={line.points}
|
||||
transparent
|
||||
/>
|
||||
))}
|
||||
{bodyWireframeLines.map((line, index) => (
|
||||
<Line
|
||||
key={`${camera.id}-${line.part}-${index}`}
|
||||
color={VIEWPORT_CAMERA_LINE}
|
||||
lineWidth={1}
|
||||
name={`${camera.id}-${line.part}-${index}`}
|
||||
onClick={selectCameraFromViewport}
|
||||
opacity={VIEWPORT_CAMERA_LINE_OPACITY}
|
||||
points={line.points}
|
||||
transparent
|
||||
/>
|
||||
))}
|
||||
|
||||
{frustumLines.map((points, index) => (
|
||||
<Line
|
||||
key={`${camera.id}-frustum-${index}`}
|
||||
color={VIEWPORT_CAMERA_LINE}
|
||||
lineWidth={1}
|
||||
name={`${camera.id}-viewfinder-${index}`}
|
||||
onClick={selectCameraFromViewport}
|
||||
opacity={VIEWPORT_CAMERA_LINE_OPACITY}
|
||||
points={points}
|
||||
transparent
|
||||
/>
|
||||
))}
|
||||
{frustumLines.map((points, index) => (
|
||||
<Line
|
||||
key={`${camera.id}-frustum-${index}`}
|
||||
color={VIEWPORT_CAMERA_LINE}
|
||||
lineWidth={1}
|
||||
name={`${camera.id}-viewfinder-${index}`}
|
||||
onClick={selectCameraFromViewport}
|
||||
opacity={VIEWPORT_CAMERA_LINE_OPACITY}
|
||||
points={points}
|
||||
transparent
|
||||
/>
|
||||
))}
|
||||
</group>
|
||||
</group>
|
||||
);
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { clearViewportCaptureHandler, setViewportCaptureHandler } from "../io/ca
|
||||
import { BODY_TYPE_OPTIONS } from "../runtime/mannequin/bodyTypes";
|
||||
import { createInitialDirectorState, useDirectorStore } from "../store/directorStore";
|
||||
import { usePlaybackStore } from "../runtime/playbackStore";
|
||||
import { getCameraRigPositionFromViewSnapshot, getCameraViewSnapshotFromShot } from "../schema/cameraGeometry";
|
||||
import { getCameraLookRotationFromTarget, getCameraRigPositionFromViewSnapshot, getCameraViewSnapshotFromShot } from "../schema/cameraGeometry";
|
||||
import { ViewportToolbar } from "./ViewportToolbar";
|
||||
import { getModelLibraryItems } from "../modelLibrary/modelLibraryCatalog";
|
||||
|
||||
@@ -349,7 +349,8 @@ it("keeps add role and add camera actions available from the viewport capsule",
|
||||
expect(state.project.cameras[1].fov).toBe(64);
|
||||
expect(state.project.cameras[1].transform.position).toEqual(getCameraRigPositionFromViewSnapshot(snapshot));
|
||||
expect(getCameraViewSnapshotFromShot(state.project.cameras[1]).position).toEqual(snapshot.position);
|
||||
expect(state.project.cameras[1].target).toEqual([0, 1, -2]);
|
||||
// 从视口新建的机位:把「视口位置 → 视口注视点」换算成注视朝向角
|
||||
expect(state.project.cameras[1].lookRotation).toEqual(getCameraLookRotationFromTarget(snapshot.position, snapshot.target));
|
||||
});
|
||||
|
||||
it("does not show operation feedback text on the right side of the viewport capsule", async () => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Euler, Matrix4, Quaternion, Vector3 } from "three";
|
||||
import { degreesRotationToRadians, type SceneSettings } from "../schema/directorProject";
|
||||
|
||||
/** 场景组使用的 Three 变换(position / rotation / scale)。 */
|
||||
@@ -24,3 +25,18 @@ export function getSceneGroupTransform(scene: SceneSettings): SceneGroupTransfor
|
||||
scale: [scene.scale, scene.scale, scene.scale],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 SceneSettings 转换成场景组的 THREE 世界矩阵。
|
||||
* 机位线框 / 三轴 gizmo 都画在这个场景组里,世界坐标 = 该矩阵 × 局部坐标;
|
||||
* 「机位位置 X/Y/Z」面板、切到机位视角时的渲染相机都要落在世界空间,
|
||||
* 需要拿这层矩阵把局部坐标换算过去(否则场景一缩放/旋转,xyz 越大越错位)。
|
||||
*/
|
||||
export function getSceneGroupMatrix(scene: SceneSettings): Matrix4 {
|
||||
const transform = getSceneGroupTransform(scene);
|
||||
return new Matrix4().compose(
|
||||
new Vector3(...transform.position),
|
||||
new Quaternion().setFromEuler(new Euler(...transform.rotation)),
|
||||
new Vector3(...transform.scale)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { beforeEach, expect, it, vi } from "vitest";
|
||||
import { Object3D, PerspectiveCamera, Scene, Vector2 } from "three";
|
||||
import { usePlaybackStore } from "../runtime/playbackStore";
|
||||
import { useDirectorStore } from "../store/directorStore";
|
||||
import { getCameraLookRotationFromTarget } from "../schema/cameraGeometry";
|
||||
import { exportVideo } from "./videoExport";
|
||||
|
||||
vi.mock("mp4-muxer", () => ({
|
||||
@@ -125,7 +126,7 @@ function placeCameraShotAt(position: [number, number, number], target: [number,
|
||||
const shot = store.project.cameras[0];
|
||||
store.updateCamera(shot.id, {
|
||||
transform: { position, rotation: [0, 0, 0], scale: [1, 1, 1] },
|
||||
target,
|
||||
lookRotation: getCameraLookRotationFromTarget(position, target),
|
||||
fov,
|
||||
});
|
||||
store.setActiveCamera(shot.id);
|
||||
@@ -172,7 +173,7 @@ it("lets the camera track win over the static shot pose in camera-view exports",
|
||||
// 给它加一条运镜轨道:0s 处机位在 [5, 1, 0],和机位自身的 [0, 3, 9] 明显不同
|
||||
useDirectorStore.getState().addCameraKeyframe(shotId, 0, {
|
||||
position: [5, 1, 0],
|
||||
target: [0, 1, 0],
|
||||
lookRotation: getCameraLookRotationFromTarget([5, 1, 0], [0, 1, 0]),
|
||||
fov: 40,
|
||||
});
|
||||
const bridge = createFakeBridge();
|
||||
|
||||
@@ -6,7 +6,7 @@ import { usePlaybackStore } from "../runtime/playbackStore";
|
||||
import { getVideoExportContext } from "./videoExportBridge";
|
||||
import { hideViewportCaptureHelpers, withViewportCaptureHelpersHidden } from "../canvas/viewportCapture";
|
||||
import { applyActiveCameraViewAtTime, applyTimelineTransformsAtTime } from "../runtime/playbackTransforms";
|
||||
import { getCameraViewSnapshotFromShot, type CameraViewSnapshot } from "../schema/cameraGeometry";
|
||||
import { getCameraRenderQuaternion, getCameraViewSnapshotFromShot, getWorldCameraViewSnapshot, type CameraViewSnapshot } from "../schema/cameraGeometry";
|
||||
import { getViewportAspectRatioValue } from "../schema/viewportAspectRatio";
|
||||
import { normalizeTimelineFps } from "../schema/timeline";
|
||||
|
||||
@@ -66,7 +66,7 @@ function waitNextFrame(): Promise<void> {
|
||||
*/
|
||||
function applyCameraSnapshotToFrameCamera(camera: PerspectiveCamera, view: CameraViewSnapshot) {
|
||||
camera.position.set(view.position[0], view.position[1], view.position[2]);
|
||||
camera.lookAt(view.target[0], view.target[1], view.target[2]);
|
||||
camera.quaternion.copy(getCameraRenderQuaternion(view.lookRotation));
|
||||
if (Math.abs(camera.fov - view.fov) > 0.001) {
|
||||
camera.fov = view.fov;
|
||||
}
|
||||
@@ -189,7 +189,11 @@ async function exportVideoFrameByFrame(view: VideoExportView): Promise<void> {
|
||||
view === "camera"
|
||||
? exportState.project.cameras.find((item) => item.id === exportState.project.activeCameraId) ?? null
|
||||
: null;
|
||||
const activeCameraView = activeCameraShot ? getCameraViewSnapshotFromShot(activeCameraShot) : null;
|
||||
// 机位线框 / 三轴 gizmo 都在场景组里,导出相机也要落在世界坐标(场景变换之内),
|
||||
// 否则场景一缩放 / 旋转,导出画面里机位所在的世界位置和编辑器里看到的对不上。
|
||||
const activeCameraView = activeCameraShot
|
||||
? getWorldCameraViewSnapshot(getCameraViewSnapshotFromShot(activeCameraShot), exportState.project.scene)
|
||||
: null;
|
||||
|
||||
const frameDurationUs = Math.round(1_000_000 / fps);
|
||||
try {
|
||||
|
||||
@@ -3,6 +3,7 @@ import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, vi } from "vitest";
|
||||
import { clearViewportCaptureHandler, setViewportCaptureHandler } from "../io/captureBridge";
|
||||
import { createInitialDirectorState, useDirectorStore } from "../store/directorStore";
|
||||
import { DEFAULT_DIRECTOR_CAMERA_VIEW_SNAPSHOT, getCameraLookRotationFromTarget } from "../schema/cameraGeometry";
|
||||
import { CameraPanel } from "./CameraPanel";
|
||||
|
||||
function seedCameraCapture() {
|
||||
@@ -98,7 +99,9 @@ it("renders the approved camera panel fields", () => {
|
||||
expect(screen.getByLabelText("切换机位")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("机位位置 X")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("注视目标模式")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("注视坐标 X")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("机位水平角")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("机位俯仰角")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("机位横滚角")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("机位 FOV")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -181,7 +184,7 @@ it("renders target mode as the custom dropdown menu", async () => {
|
||||
await user.click(screen.getByLabelText("注视目标模式"));
|
||||
|
||||
const menu = screen.getByRole("listbox", { name: "注视目标模式" });
|
||||
const manualOption = within(menu).getByRole("option", { name: "手动坐标" });
|
||||
const manualOption = within(menu).getByRole("option", { name: "手动角度" });
|
||||
|
||||
expect(menu).toHaveClass("inspector-dropdown-menu");
|
||||
expect(manualOption).toHaveClass("is-selected");
|
||||
@@ -200,7 +203,7 @@ it("lists visible viewport models as camera focus targets and centers on the sel
|
||||
const menu = screen.getByRole("listbox", { name: "注视目标模式" });
|
||||
const modelNames = within(menu).getAllByRole("option").map((option) => option.textContent);
|
||||
|
||||
expect(modelNames).toEqual(["手动坐标", "角色01", "角色02", "立方体"]);
|
||||
expect(modelNames).toEqual(["手动角度", "角色01", "角色02", "立方体"]);
|
||||
expect(within(menu).queryByRole("option", { name: "机位01" })).not.toBeInTheDocument();
|
||||
|
||||
await user.click(within(menu).getByRole("option", { name: "角色02" }));
|
||||
@@ -212,14 +215,18 @@ it("lists visible viewport models as camera focus targets and centers on the sel
|
||||
expect(focusedObject).toBeTruthy();
|
||||
expect(camera?.targetMode).toBe("object");
|
||||
expect(camera?.targetObjectId).toBe(focusedObject?.id);
|
||||
expect(camera?.target).toEqual([-1.25, 0.99, 0]);
|
||||
// 锁定物体 = 把「机位 → 物体」这条视线换算成注视朝向角
|
||||
expect(camera?.lookRotation).toEqual(
|
||||
getCameraLookRotationFromTarget(camera!.transform.position, [-1.25, 0.99, 0])
|
||||
);
|
||||
expect(screen.getByLabelText("注视目标模式")).toHaveTextContent("角色02");
|
||||
expect(screen.getByLabelText("注视坐标 X")).toHaveValue(-1.25);
|
||||
expect(screen.getByLabelText("注视坐标 Y")).toHaveValue(0.99);
|
||||
expect(screen.getByLabelText("注视坐标 Z")).toHaveValue(0);
|
||||
// rotation = [俯仰角 pitch(X), 水平角 yaw(Y), 横滚角 roll(Z)]
|
||||
expect(screen.getByLabelText("机位水平角")).toHaveValue(camera!.lookRotation[1]);
|
||||
expect(screen.getByLabelText("机位俯仰角")).toHaveValue(camera!.lookRotation[0]);
|
||||
expect(screen.getByLabelText("机位横滚角")).toHaveValue(camera!.lookRotation[2]);
|
||||
});
|
||||
|
||||
it("resets the camera position and the look-at target from their own title buttons", async () => {
|
||||
it("resets the camera position and the look angles from their own buttons", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CameraPanel />);
|
||||
|
||||
@@ -227,7 +234,12 @@ it("resets the camera position and the look-at target from their own title butto
|
||||
const seededCamera = readCamera();
|
||||
expect(seededCamera).toBeTruthy();
|
||||
|
||||
["位置", "注视坐标"].forEach((label) => {
|
||||
const positionReset = within(screen.getByRole("group", { name: "位置" })).getByRole("button", { name: "重置位置" });
|
||||
expect(positionReset).toHaveClass("inspector-axis-reset-button");
|
||||
expect(positionReset.querySelector("svg")).toBeInTheDocument();
|
||||
|
||||
// 三个角度各自一行,行首带重置按钮
|
||||
["水平角", "俯仰角", "横滚角"].forEach((label) => {
|
||||
const button = within(screen.getByRole("group", { name: label })).getByRole("button", { name: `重置${label}` });
|
||||
|
||||
expect(button).toHaveClass("inspector-axis-reset-button");
|
||||
@@ -236,24 +248,32 @@ it("resets the camera position and the look-at target from their own title butto
|
||||
|
||||
useDirectorStore.getState().updateCamera("cam_1", {
|
||||
transform: { ...seededCamera!.transform, position: [7, 3, 7] },
|
||||
target: [1, 2, 3],
|
||||
lookRotation: [12, 34, 56],
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "重置位置" }));
|
||||
|
||||
// 机位回到默认机位位置(不是归零:相机压在世界原点会让视线方向退化)
|
||||
expect(readCamera()?.transform.position).toEqual([0, 1.55, 5.4]);
|
||||
// 只重置位置,注视点保持原样
|
||||
expect(readCamera()?.target).toEqual([1, 2, 3]);
|
||||
// 只重置位置,注视朝向保持原样
|
||||
expect(readCamera()?.lookRotation).toEqual([12, 34, 56]);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "重置注视坐标" }));
|
||||
// 单独重置「俯仰角」→ 归零(水平),不带动水平角 / 横滚角
|
||||
await user.click(within(screen.getByRole("group", { name: "俯仰角" })).getByRole("button", { name: "重置俯仰角" }));
|
||||
expect(readCamera()?.lookRotation).toEqual([0, 34, 56]);
|
||||
|
||||
expect(readCamera()?.target).toEqual([0, 1.05, 0]);
|
||||
await user.click(screen.getByRole("button", { name: "重置注视角度" }));
|
||||
|
||||
// 俯仰角重置为水平 0°,水平角 / 横滚角回到默认机位
|
||||
expect(readCamera()?.lookRotation).toEqual([
|
||||
0,
|
||||
DEFAULT_DIRECTOR_CAMERA_VIEW_SNAPSHOT.lookRotation[1],
|
||||
DEFAULT_DIRECTOR_CAMERA_VIEW_SNAPSHOT.lookRotation[2],
|
||||
]);
|
||||
expect(readCamera()?.targetMode).toBe("manual");
|
||||
expect(screen.getByLabelText("注视坐标 Z")).toHaveValue(0);
|
||||
});
|
||||
|
||||
it("updates camera position and target coordinates across all axes", async () => {
|
||||
it("updates camera position and look angles across all axes", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CameraPanel />);
|
||||
|
||||
@@ -261,14 +281,15 @@ it("updates camera position and target coordinates across all axes", async () =>
|
||||
await user.type(screen.getByLabelText("机位位置 Y"), "3.4");
|
||||
await user.clear(screen.getByLabelText("机位位置 Z"));
|
||||
await user.type(screen.getByLabelText("机位位置 Z"), "7.5");
|
||||
await user.clear(screen.getByLabelText("注视坐标 Y"));
|
||||
await user.type(screen.getByLabelText("注视坐标 Y"), "1.8");
|
||||
await user.clear(screen.getByLabelText("注视坐标 Z"));
|
||||
await user.type(screen.getByLabelText("注视坐标 Z"), "2");
|
||||
await user.clear(screen.getByLabelText("机位水平角"));
|
||||
await user.type(screen.getByLabelText("机位水平角"), "210");
|
||||
await user.clear(screen.getByLabelText("机位俯仰角"));
|
||||
await user.type(screen.getByLabelText("机位俯仰角"), "12");
|
||||
|
||||
const camera = useDirectorStore.getState().project.cameras[0];
|
||||
expect(camera.transform.position).toEqual([0, 3.4, 7.5]);
|
||||
expect(camera.target).toEqual([0, 1.8, 2]);
|
||||
// lookRotation = [俯仰角 pitch(X), 水平角 yaw(Y), 横滚角 roll(Z)]
|
||||
expect(camera.lookRotation).toEqual([12, 210, 0]);
|
||||
});
|
||||
|
||||
it("captures the current camera preview from the properties tab and shows it in the screenshots overview", async () => {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Camera, ChevronLeft, ChevronRight, Download, Eye, Images, Send, Trash2, X, ZoomIn, ZoomOut } from "lucide-react";
|
||||
import { Camera, ChevronLeft, ChevronRight, Download, Eye, Images, RotateCcw, Send, Trash2, X, ZoomIn, ZoomOut } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Vector3 } from "three";
|
||||
import {
|
||||
InspectorAngleField,
|
||||
InspectorAxisGroup,
|
||||
InspectorPanel,
|
||||
InspectorRangeNumberField,
|
||||
@@ -12,10 +14,15 @@ import {
|
||||
import { requestViewportCapture } from "../io/captureBridge";
|
||||
import { downloadDataUrl } from "../io/screenshotExport";
|
||||
import { postDirectorDeskCapturesToHost } from "../io/hostBridge";
|
||||
import { DEFAULT_DIRECTOR_CAMERA_VIEW_SNAPSHOT } from "../schema/cameraGeometry";
|
||||
import {
|
||||
DEFAULT_DIRECTOR_CAMERA_VIEW_SNAPSHOT,
|
||||
getCameraLookRotationFromTarget,
|
||||
} from "../schema/cameraGeometry";
|
||||
import { getDirectorObjectFocusTarget, isCameraFocusableObject } from "../schema/cameraTarget";
|
||||
import type { DirectorCameraCapture } from "../schema/directorProject";
|
||||
import { CAMERA_ROTATION_LIMITS, normalizeCameraRotation } from "../schema/directorProject";
|
||||
import { useDirectorStore } from "../store/directorStore";
|
||||
import { getSceneGroupMatrix } from "../canvas/sceneTransform";
|
||||
|
||||
const VIEWER_ZOOM_MIN = 0.25;
|
||||
const VIEWER_ZOOM_MAX = 5;
|
||||
@@ -23,14 +30,22 @@ const VIEWER_ZOOM_STEP = 0.25;
|
||||
/** 可拖动预览窗口的最小尺寸(手动调节时不允许小于该值) */
|
||||
const VIEWER_DIALOG_MIN_WIDTH = 360;
|
||||
const VIEWER_DIALOG_MIN_HEIGHT = 300;
|
||||
/** 机位重置值:默认机位的位置 / 注视点(与新建项目里 1 号机位一致) */
|
||||
/** 机位重置值:默认机位的位置 / 注视朝向角(与新建项目里 1 号机位一致) */
|
||||
const DEFAULT_CAMERA_SHOT_POSITION = DEFAULT_DIRECTOR_CAMERA_VIEW_SNAPSHOT.position;
|
||||
const DEFAULT_CAMERA_SHOT_TARGET = DEFAULT_DIRECTOR_CAMERA_VIEW_SNAPSHOT.target;
|
||||
const DEFAULT_CAMERA_SHOT_LOOK_ROTATION = DEFAULT_DIRECTOR_CAMERA_VIEW_SNAPSHOT.lookRotation;
|
||||
/** 机位「俯仰角」重置为水平 0°(而非默认机位的略微俯视),水平角 / 横滚角仍回到默认机位 */
|
||||
const DEFAULT_CAMERA_PITCH_RESET = 0;
|
||||
|
||||
function replaceAxis(tuple: [number, number, number], axis: 0 | 1 | 2, value: number): [number, number, number] {
|
||||
return tuple.map((item, index) => (index === axis ? value : item)) as [number, number, number];
|
||||
}
|
||||
|
||||
/** 输入为空或非法值时按 0 处理,避免把 NaN 写进工程 */
|
||||
function toSafeNumber(value: string) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
export function CameraPanel() {
|
||||
const [activeTab, setActiveTab] = useState<"properties" | "captures">("properties");
|
||||
const [captureError, setCaptureError] = useState<string | null>(null);
|
||||
@@ -70,6 +85,16 @@ export function CameraPanel() {
|
||||
const setActiveCamera = useDirectorStore((state) => state.setActiveCamera);
|
||||
const addCameraCaptures = useDirectorStore((state) => state.addCameraCaptures);
|
||||
const updateCamera = useDirectorStore((state) => state.updateCamera);
|
||||
// 机位线框 / 三轴 gizmo 都画在场景组里(场景变换之内),手柄落在世界坐标;
|
||||
// 面板里的「机位位置 X/Y/Z」也要显示同一套世界坐标,否则场景一缩放 / 旋转,
|
||||
// 手柄(世界)和数值(局部)就差得越来越多。编辑时把世界值反算回场景局部坐标再存。
|
||||
const scene = useDirectorStore((state) => state.project.scene);
|
||||
const sceneMatrix = useMemo(() => getSceneGroupMatrix(scene), [scene]);
|
||||
const sceneMatrixInverse = useMemo(() => sceneMatrix.clone().invert(), [sceneMatrix]);
|
||||
const localToWorldAxis = (local: [number, number, number], axis: 0 | 1 | 2): number =>
|
||||
Math.round(new Vector3(...local).applyMatrix4(sceneMatrix).toArray()[axis] * 1000) / 1000;
|
||||
const worldToLocal = (world: [number, number, number]): [number, number, number] =>
|
||||
new Vector3(...world).applyMatrix4(sceneMatrixInverse).toArray() as [number, number, number];
|
||||
|
||||
if (!camera) return null;
|
||||
const currentCamera = camera;
|
||||
@@ -393,15 +418,44 @@ export function CameraPanel() {
|
||||
updateCamera(currentCamera.id, {
|
||||
targetMode: "object",
|
||||
targetObjectId: targetObject.id,
|
||||
target: getDirectorObjectFocusTarget(targetObject),
|
||||
// 锁定物体:把「机位 → 物体」这条视线换算成注视朝向角
|
||||
lookRotation: getCameraLookRotationFromTarget(
|
||||
currentCamera.transform.position,
|
||||
getDirectorObjectFocusTarget(targetObject)
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function updateManualTarget(axis: 0 | 1 | 2, value: string) {
|
||||
/** 手改任一角度即退出「锁定物体」,改为手动角度(与轨迹点改朝向角的口径一致) */
|
||||
function updateManualLookRotation(axis: 0 | 1 | 2, value: string) {
|
||||
const nextValue = normalizeCameraRotation(axis, toSafeNumber(value));
|
||||
updateCamera(currentCamera.id, {
|
||||
targetMode: "manual",
|
||||
targetObjectId: null,
|
||||
target: replaceAxis(currentCamera.target, axis, Number(value)),
|
||||
lookRotation: replaceAxis(currentCamera.lookRotation, axis, nextValue),
|
||||
});
|
||||
}
|
||||
|
||||
function resetLookRotation(axis: 0 | 1 | 2) {
|
||||
// 俯仰角(轴 0)重置为水平 0°,水平角 / 横滚角回到默认机位对应角
|
||||
const resetValue = axis === 0 ? DEFAULT_CAMERA_PITCH_RESET : DEFAULT_CAMERA_SHOT_LOOK_ROTATION[axis];
|
||||
updateCamera(currentCamera.id, {
|
||||
targetMode: "manual",
|
||||
targetObjectId: null,
|
||||
lookRotation: replaceAxis(currentCamera.lookRotation, axis, resetValue),
|
||||
});
|
||||
}
|
||||
|
||||
function resetAllLookRotation() {
|
||||
updateCamera(currentCamera.id, {
|
||||
targetMode: "manual",
|
||||
targetObjectId: null,
|
||||
// 俯仰角重置为水平 0°,水平角 / 横滚角回到默认机位
|
||||
lookRotation: [
|
||||
DEFAULT_CAMERA_PITCH_RESET,
|
||||
DEFAULT_CAMERA_SHOT_LOOK_ROTATION[1],
|
||||
DEFAULT_CAMERA_SHOT_LOOK_ROTATION[2],
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -712,38 +766,44 @@ export function CameraPanel() {
|
||||
{
|
||||
axis: "X",
|
||||
ariaLabel: "机位位置 X",
|
||||
value: currentCamera.transform.position[0],
|
||||
onChange: (value) =>
|
||||
value: localToWorldAxis(currentCamera.transform.position, 0),
|
||||
onChange: (value) => {
|
||||
const world = new Vector3(...currentCamera.transform.position)
|
||||
.applyMatrix4(sceneMatrix)
|
||||
.toArray() as [number, number, number];
|
||||
world[0] = toSafeNumber(value);
|
||||
updateCamera(currentCamera.id, {
|
||||
transform: {
|
||||
...currentCamera.transform,
|
||||
position: replaceAxis(currentCamera.transform.position, 0, Number(value)),
|
||||
},
|
||||
}),
|
||||
transform: { ...currentCamera.transform, position: worldToLocal(world) },
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
axis: "Y",
|
||||
ariaLabel: "机位位置 Y",
|
||||
value: currentCamera.transform.position[1],
|
||||
onChange: (value) =>
|
||||
value: localToWorldAxis(currentCamera.transform.position, 1),
|
||||
onChange: (value) => {
|
||||
const world = new Vector3(...currentCamera.transform.position)
|
||||
.applyMatrix4(sceneMatrix)
|
||||
.toArray() as [number, number, number];
|
||||
world[1] = toSafeNumber(value);
|
||||
updateCamera(currentCamera.id, {
|
||||
transform: {
|
||||
...currentCamera.transform,
|
||||
position: replaceAxis(currentCamera.transform.position, 1, Number(value)),
|
||||
},
|
||||
}),
|
||||
transform: { ...currentCamera.transform, position: worldToLocal(world) },
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
axis: "Z",
|
||||
ariaLabel: "机位位置 Z",
|
||||
value: currentCamera.transform.position[2],
|
||||
onChange: (value) =>
|
||||
value: localToWorldAxis(currentCamera.transform.position, 2),
|
||||
onChange: (value) => {
|
||||
const world = new Vector3(...currentCamera.transform.position)
|
||||
.applyMatrix4(sceneMatrix)
|
||||
.toArray() as [number, number, number];
|
||||
world[2] = toSafeNumber(value);
|
||||
updateCamera(currentCamera.id, {
|
||||
transform: {
|
||||
...currentCamera.transform,
|
||||
position: replaceAxis(currentCamera.transform.position, 2, Number(value)),
|
||||
},
|
||||
}),
|
||||
transform: { ...currentCamera.transform, position: worldToLocal(world) },
|
||||
});
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@@ -753,44 +813,53 @@ export function CameraPanel() {
|
||||
value={targetSelectValue}
|
||||
onChange={handleTargetSelection}
|
||||
>
|
||||
<option value="manual">手动坐标</option>
|
||||
<option value="manual">手动角度</option>
|
||||
{focusableObjects.map((item) => (
|
||||
<option key={item.id} value={`object:${item.id}`}>
|
||||
{item.name}
|
||||
</option>
|
||||
))}
|
||||
</InspectorSelectField>
|
||||
<InspectorAxisGroup
|
||||
label="注视坐标"
|
||||
onReset={() =>
|
||||
// 注视点回到默认机位的注视点,并切回手动模式(与手改任一轴的行为一致)
|
||||
updateCamera(currentCamera.id, {
|
||||
targetMode: "manual",
|
||||
targetObjectId: null,
|
||||
target: [...DEFAULT_CAMERA_SHOT_TARGET],
|
||||
})
|
||||
}
|
||||
axes={[
|
||||
{
|
||||
axis: "X",
|
||||
ariaLabel: "注视坐标 X",
|
||||
value: currentCamera.target[0],
|
||||
onChange: (value) => updateManualTarget(0, value),
|
||||
},
|
||||
{
|
||||
axis: "Y",
|
||||
ariaLabel: "注视坐标 Y",
|
||||
value: currentCamera.target[1],
|
||||
onChange: (value) => updateManualTarget(1, value),
|
||||
},
|
||||
{
|
||||
axis: "Z",
|
||||
ariaLabel: "注视坐标 Z",
|
||||
value: currentCamera.target[2],
|
||||
onChange: (value) => updateManualTarget(2, value),
|
||||
},
|
||||
]}
|
||||
{/* 注视朝向:水平角 / 俯仰角 / 横滚角各一行,语义与轨迹点完全一致 */}
|
||||
<InspectorAngleField
|
||||
label="水平角"
|
||||
max={CAMERA_ROTATION_LIMITS[1].max}
|
||||
min={CAMERA_ROTATION_LIMITS[1].min}
|
||||
numberAriaLabel="机位水平角"
|
||||
rangeAriaLabel="机位水平角滑块"
|
||||
value={currentCamera.lookRotation[1]}
|
||||
onReset={() => resetLookRotation(1)}
|
||||
onValueChange={(value) => updateManualLookRotation(1, value)}
|
||||
/>
|
||||
<InspectorAngleField
|
||||
label="俯仰角"
|
||||
max={CAMERA_ROTATION_LIMITS[0].max}
|
||||
min={CAMERA_ROTATION_LIMITS[0].min}
|
||||
numberAriaLabel="机位俯仰角"
|
||||
rangeAriaLabel="机位俯仰角滑块"
|
||||
value={currentCamera.lookRotation[0]}
|
||||
onReset={() => resetLookRotation(0)}
|
||||
onValueChange={(value) => updateManualLookRotation(0, value)}
|
||||
/>
|
||||
<InspectorAngleField
|
||||
label="横滚角"
|
||||
max={CAMERA_ROTATION_LIMITS[2].max}
|
||||
min={CAMERA_ROTATION_LIMITS[2].min}
|
||||
numberAriaLabel="机位横滚角"
|
||||
rangeAriaLabel="机位横滚角滑块"
|
||||
value={currentCamera.lookRotation[2]}
|
||||
onReset={() => resetLookRotation(2)}
|
||||
onValueChange={(value) => updateManualLookRotation(2, value)}
|
||||
/>
|
||||
<button
|
||||
className="camera-capture-clear-all"
|
||||
type="button"
|
||||
aria-label="重置注视角度"
|
||||
onClick={resetAllLookRotation}
|
||||
>
|
||||
<RotateCcw aria-hidden="true" size={14} strokeWidth={1.9} />
|
||||
<span>重置注视角度</span>
|
||||
</button>
|
||||
<InspectorRangeNumberField
|
||||
label="视野角度 (FOV)"
|
||||
rangeAriaLabel="机位 FOV 滑杆"
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
TimelinePanel,
|
||||
} from "./TimelinePanel";
|
||||
import { DEFAULT_TIMELINE_FPS } from "../schema/timeline";
|
||||
import { TrajectoryPointPanel } from "./TrajectoryPointPanel";
|
||||
|
||||
function getDefaultCharacter() {
|
||||
return useDirectorStore.getState().project.objects.find((item) => item.kind === "character")!;
|
||||
@@ -436,6 +437,49 @@ it("keeps the trajectory keyframe selected when clicking the keyframe itself", (
|
||||
expect(keyframe).toHaveClass("is-selected");
|
||||
});
|
||||
|
||||
it("keeps the trajectory point selected when editing it from the trajectory point inspector", () => {
|
||||
const character = useDirectorStore.getState().project.objects.find((item) => item.kind === "character")!;
|
||||
useDirectorStore.getState().addTrajectoryPoint(character.id, [1, 0, 0]);
|
||||
useDirectorStore.getState().addTrajectoryPoint(character.id, [2, 0, 0]);
|
||||
|
||||
render(
|
||||
<>
|
||||
<TimelinePanel />
|
||||
<TrajectoryPointPanel />
|
||||
</>
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "轨迹关键帧 2.5s" }));
|
||||
expect(useDirectorStore.getState().selectedTrajectoryPoint).not.toBeNull();
|
||||
|
||||
const inspector = screen.getByLabelText("轨迹点右侧属性面板");
|
||||
expect(useDirectorStore.getState().selectedTrajectoryPoint).not.toBeNull();
|
||||
|
||||
// 「轨迹点」卡片编辑的就是这个点,卡片内任何点击都不算「点到别处」,
|
||||
// 否则每改一次位置 / 角度,卡片就会被换成角色属性面板。逐一验证卡片内各处:
|
||||
const insideTargets = [
|
||||
// 位置输入框
|
||||
screen.getByLabelText("轨迹点位置 X"),
|
||||
// 角度滑块(拖拽开始)
|
||||
screen.getByLabelText("轨迹点水平角滑块"),
|
||||
// 角度数值框
|
||||
screen.getByLabelText("轨迹点水平角"),
|
||||
// 角度重置按钮
|
||||
screen.getByLabelText("重置水平角"),
|
||||
// 卡片标题栏
|
||||
screen.getByText("轨迹点"),
|
||||
// 卡片内摘要文字
|
||||
screen.getByText(/所属角色:/),
|
||||
// 卡片自身(点击留白 / 内边距区域)
|
||||
inspector,
|
||||
];
|
||||
|
||||
for (const target of insideTargets) {
|
||||
fireEvent.pointerDown(target, { button: 0, pointerId: 1 });
|
||||
expect(useDirectorStore.getState().selectedTrajectoryPoint).not.toBeNull();
|
||||
expect(screen.getByLabelText("轨迹点右侧属性面板")).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it("deletes a selected trajectory point with the Delete key", () => {
|
||||
const character = useDirectorStore.getState().project.objects.find((item) => item.kind === "character")!;
|
||||
useDirectorStore.getState().addTrajectoryPoint(character.id, [1, 0, 0]);
|
||||
@@ -820,7 +864,7 @@ it("gives the camera track a collapse toggle as well", () => {
|
||||
function addCameraKeyframeAt(cameraId: string, time: number) {
|
||||
useDirectorStore.getState().addCameraKeyframe(cameraId, time, {
|
||||
position: [time, 0, 0],
|
||||
target: [0, 0, 0],
|
||||
lookRotation: [0, 0, 0],
|
||||
fov: 50,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -454,8 +454,13 @@ export function TimelinePanel() {
|
||||
if (!selectedTrajectoryPoint) return;
|
||||
|
||||
function clearTrajectoryPointOnOutsidePointerDown(event: PointerEvent) {
|
||||
const target = event.target instanceof Element ? event.target : null;
|
||||
|
||||
// 点在轨迹关键帧自己身上不算「别处」——那一下是选中它 / 拖它改时间
|
||||
if (event.target instanceof Element && event.target.closest(".timeline-keyframe-trajectory")) return;
|
||||
if (target?.closest(".timeline-keyframe-trajectory")) return;
|
||||
// 右侧「轨迹点」属性面板编辑的就是这个点,点它(滑块 / 数值框 / 标题栏)也不算别处,
|
||||
// 否则每改一次位置或角度,选中态就被清掉、面板跳回角色属性卡片。
|
||||
if (target?.closest(".trajectory-point-inspector")) return;
|
||||
|
||||
selectTrajectoryPoint(null, null);
|
||||
|
||||
@@ -507,7 +512,7 @@ export function TimelinePanel() {
|
||||
if (!selectedShot) throw new Error("no selected shot");
|
||||
return {
|
||||
position: [...selectedShot.transform.position] as [number, number, number],
|
||||
target: [...selectedShot.target] as [number, number, number],
|
||||
lookRotation: [...selectedShot.lookRotation] as [number, number, number],
|
||||
fov: selectedShot.fov,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ export function addKeyframeAtPlayhead(): boolean {
|
||||
|
||||
directorStore.addCameraKeyframe(shot.id, playhead, {
|
||||
position: [...shot.transform.position] as [number, number, number],
|
||||
target: [...shot.target] as [number, number, number],
|
||||
lookRotation: [...shot.lookRotation] as [number, number, number],
|
||||
fov: shot.fov,
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { DirectorObject } from "../schema/directorProject";
|
||||
import type { CameraTimelineTrack, TimelineTrack } from "../schema/timeline";
|
||||
import { applyActiveCameraViewAtTime, applyTimelineTransformsAtTime } from "./playbackTransforms";
|
||||
import { computeExportSize } from "../io/videoExport";
|
||||
import { getViewportCameraQuaternion } from "../canvas/SceneRoot";
|
||||
import { getCameraLookQuaternion } from "../schema/cameraGeometry";
|
||||
|
||||
function makeTrajectoryCharacter(): DirectorObject {
|
||||
return {
|
||||
@@ -43,8 +43,8 @@ function makeCameraTrack(): CameraTimelineTrack {
|
||||
return {
|
||||
cameraId: "cam-1",
|
||||
keyframes: [
|
||||
{ id: "ckf-1", time: 0, value: { position: [0, 1, 0], target: [0, 0, 0], fov: 50 } },
|
||||
{ id: "ckf-2", time: 2, value: { position: [2, 1, 0], target: [0, 0, 0], fov: 60 } },
|
||||
{ id: "ckf-1", time: 0, value: { position: [0, 1, 0], lookRotation: [0, 0, 0], fov: 50 } },
|
||||
{ id: "ckf-2", time: 2, value: { position: [2, 1, 0], lookRotation: [0, 90, 0], fov: 60 } },
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -143,9 +143,8 @@ it("applies camera wireframe position and orientation from the camera track", ()
|
||||
applyTimelineTransformsAtTime(scene, [], [makeCameraTrack()], 2, null);
|
||||
|
||||
expect(wire.position.toArray()).toEqual([2, 1, 0]);
|
||||
// 朝向使用 SceneRoot 的 getViewportCameraQuaternion 约定(与预览线框一致)
|
||||
const expectedQuaternion = getViewportCameraQuaternion([2, 1, 0], [0, 0, 0]);
|
||||
expect(wire.quaternion.equals(expectedQuaternion)).toBe(true);
|
||||
// 线框姿态直接由注视朝向角合成(模型 +Z = 镜头方向),与预览线框同一套约定
|
||||
expect(wire.quaternion.equals(getCameraLookQuaternion([0, 90, 0]))).toBe(true);
|
||||
});
|
||||
|
||||
it("overrides the render camera in camera view mode", () => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { DirectorObject } from "../schema/directorProject";
|
||||
import { evaluateCameraTrackAtTime, evaluateTrackAtTime } from "../schema/timeline";
|
||||
import type { CameraTimelineTrack, TimelineTrack } from "../schema/timeline";
|
||||
import { evaluateTrajectoryMotionAtTime, hasTrajectoryMotion } from "../schema/trajectoryMotion";
|
||||
import { getViewportCameraQuaternion } from "../canvas/SceneRoot";
|
||||
import { getCameraLookQuaternion, getCameraRenderQuaternion } from "../schema/cameraGeometry";
|
||||
|
||||
/** 人物轨迹驱动所需的工程上下文:对象列表 + 时间轴时长。 */
|
||||
export interface TimelinePlaybackContext {
|
||||
@@ -16,7 +16,7 @@ export interface TimelinePlaybackContext {
|
||||
* 按 playhead 把物体 / 相机线框的关键帧插值结果直接写到场景中的 Three.js 对象。
|
||||
* SceneRoot 的 group 通过 userData 标记身份:
|
||||
* - directorObjectId → 物体(覆盖 position/rotation/scale)
|
||||
* - directorCameraId → 导演视图相机线框(覆盖 position + 朝向 target)
|
||||
* - directorCameraId → 导演视图相机线框(覆盖 position + 注视朝向角)
|
||||
* 正在被 gizmo 拖拽的对象(editingObjectId)会被跳过,避免覆盖编辑中的值。
|
||||
* 预览(PlaybackTransformSync 的 useFrame)与视频导出(逐帧离线渲染)共用此逻辑,
|
||||
* 保证「所见即所得」。
|
||||
@@ -82,7 +82,8 @@ export function applyTimelineTransformsAtTime(
|
||||
const value = evaluateCameraTrackAtTime(track, playhead);
|
||||
if (value) {
|
||||
object.position.set(value.position[0], value.position[1], value.position[2]);
|
||||
object.quaternion.copy(getViewportCameraQuaternion(value.position, value.target));
|
||||
// 运镜关键帧里的注视朝向是度数,线框姿态直接由三个角合成
|
||||
object.quaternion.copy(getCameraLookQuaternion(value.lookRotation));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -91,7 +92,7 @@ export function applyTimelineTransformsAtTime(
|
||||
|
||||
/**
|
||||
* 机位视图下:若当前激活相机有运镜轨道,用插值结果覆盖渲染相机的
|
||||
* position / lookAt(target) / fov,呈现真正的运镜画面。
|
||||
* position / 注视朝向 / fov,呈现真正的运镜画面。
|
||||
* 返回是否覆盖了相机(导演视角或无轨道时不动)。
|
||||
* 预览(ActiveCameraPlaybackSync 的 useFrame)与视频导出共用此逻辑。
|
||||
*/
|
||||
@@ -121,7 +122,7 @@ export function applyActiveCameraViewAtTime({
|
||||
if (!value) return false;
|
||||
|
||||
camera.position.set(value.position[0], value.position[1], value.position[2]);
|
||||
camera.lookAt(value.target[0], value.target[1], value.target[2]);
|
||||
camera.quaternion.copy(getCameraRenderQuaternion(value.lookRotation));
|
||||
if (Math.abs(camera.fov - value.fov) > 0.001) {
|
||||
camera.fov = value.fov;
|
||||
camera.updateProjectionMatrix();
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { Vector3 } from "three";
|
||||
import { createDefaultDirectorProject } from "../store/directorStore";
|
||||
import {
|
||||
DEFAULT_DIRECTOR_CAMERA_VIEW_SNAPSHOT,
|
||||
VIEWPORT_CAMERA_FRUSTUM_DEPTH,
|
||||
VIEWPORT_CAMERA_FRUSTUM_FRAME_WIDTH,
|
||||
VIEWPORT_CAMERA_VISUAL_SCALE,
|
||||
getCameraLookPoint,
|
||||
getCameraLookRotationFromTarget,
|
||||
getCameraRigPositionFromViewSnapshot,
|
||||
getCameraViewSnapshotFromShot,
|
||||
} from "./cameraGeometry";
|
||||
@@ -12,7 +16,7 @@ it("places the camera viewing point at the rig position", () => {
|
||||
const viewSnapshot = getCameraViewSnapshotFromShot(camera);
|
||||
|
||||
expect(viewSnapshot.position).toEqual(camera.transform.position);
|
||||
expect(viewSnapshot.target).toEqual(camera.target);
|
||||
expect(viewSnapshot.lookRotation).toEqual(camera.lookRotation);
|
||||
expect(viewSnapshot.fov).toBe(camera.fov);
|
||||
});
|
||||
|
||||
@@ -20,13 +24,38 @@ it("returns the rig position from a saved viewport snapshot", () => {
|
||||
const snapshot = {
|
||||
fov: 50,
|
||||
position: [0, 1.62, 3.8] as [number, number, number],
|
||||
target: [0, 1.2, 0] as [number, number, number],
|
||||
lookRotation: [0, 0, 0] as [number, number, number],
|
||||
};
|
||||
const rigPosition = getCameraRigPositionFromViewSnapshot(snapshot);
|
||||
|
||||
expect(rigPosition).toEqual(snapshot.position);
|
||||
});
|
||||
|
||||
it("converts a look-at point into look angles and back to the same direction", () => {
|
||||
const position: [number, number, number] = [0, 1.55, 5.4];
|
||||
const target: [number, number, number] = [2, 0.8, -1];
|
||||
const lookRotation = getCameraLookRotationFromTarget(position, target);
|
||||
const lookPoint = getCameraLookPoint(position, lookRotation, 3);
|
||||
|
||||
// 沿视线取点:方向必须和「机位 → 注视点」一致
|
||||
const expected = new Vector3(...target).sub(new Vector3(...position)).normalize().multiplyScalar(3);
|
||||
const actual = new Vector3(...lookPoint).sub(new Vector3(...position));
|
||||
|
||||
expect(actual.x).toBeCloseTo(expected.x, 5);
|
||||
expect(actual.y).toBeCloseTo(expected.y, 5);
|
||||
expect(actual.z).toBeCloseTo(expected.z, 5);
|
||||
});
|
||||
|
||||
it("reads back the default shot look angles as pitch/yaw/roll in degrees", () => {
|
||||
const [pitch, yaw, roll] = DEFAULT_DIRECTOR_CAMERA_VIEW_SNAPSHOT.lookRotation;
|
||||
|
||||
// 默认机位在 [0, 1.55, 5.4] 看向 [0, 1.05, 0]:略微俯视 + 转向 -Z
|
||||
expect(pitch).toBeGreaterThan(0);
|
||||
expect(pitch).toBeLessThan(10);
|
||||
expect(yaw).toBeCloseTo(180, 5);
|
||||
expect(roll).toBeCloseTo(0, 5);
|
||||
});
|
||||
|
||||
it("scales the viewport camera viewfinder from one visual scale", () => {
|
||||
// 取景框(机身/镜筒/视锥)全部由这一个视觉缩放值推导,改它即可整体缩放。
|
||||
// 0.35 是当前标定值;旧测试里写死的 0.8 已与实现不一致。
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import type { DirectorCameraShot } from "./directorProject";
|
||||
import { Euler, Matrix4, Quaternion, Vector3 } from "three";
|
||||
import {
|
||||
degreesRotationToRadians,
|
||||
radiansToDegrees,
|
||||
type DirectorCameraShot,
|
||||
type SceneSettings,
|
||||
} from "./directorProject";
|
||||
|
||||
export interface CameraViewSnapshot {
|
||||
fov: number;
|
||||
position: [number, number, number];
|
||||
target: [number, number, number];
|
||||
/** 注视朝向(度):[俯仰角 pitch(X), 水平角 yaw(Y), 横滚角 roll(Z)] */
|
||||
lookRotation: [number, number, number];
|
||||
}
|
||||
|
||||
export const VIEWPORT_CAMERA_ASPECT = 16 / 9;
|
||||
@@ -11,20 +18,186 @@ export const VIEWPORT_CAMERA_VISUAL_SCALE = 0.35;
|
||||
export const VIEWPORT_CAMERA_FRUSTUM_DEPTH = 5.2 * VIEWPORT_CAMERA_VISUAL_SCALE;
|
||||
export const VIEWPORT_CAMERA_FRUSTUM_FRAME_WIDTH = 3.2 * VIEWPORT_CAMERA_VISUAL_SCALE;
|
||||
|
||||
/** 机位线框模型的镜头方向是自身 +Z(与角色 / 轨迹点「正面朝 +Z」一致) */
|
||||
export const CAMERA_LOOK_FORWARD = new Vector3(0, 0, 1);
|
||||
/**
|
||||
* 注视朝向三个角的合成顺序:先横滚(绕自身 Z)、再俯仰(绕自身 X)、最后水平(绕世界 Y)。
|
||||
* 用 YXZ 而不是默认的 XYZ,是为了让俯仰 / 横滚始终绕镜头自身的轴 ——
|
||||
* 用 XYZ 时机头转过 180° 后俯仰方向会反过来,调镜头会非常反直觉。
|
||||
*/
|
||||
export const CAMERA_LOOK_EULER_ORDER: "YXZ" = "YXZ";
|
||||
|
||||
const WORLD_UP = new Vector3(0, 1, 0);
|
||||
const WORLD_ORIGIN = new Vector3(0, 0, 0);
|
||||
/** 视线几乎竖直(与世界 up 共线)时换一个参考 up,避免 lookAt 退化 */
|
||||
const STEEP_VIEW_UP = new Vector3(0, 0, 1);
|
||||
/** three 的相机沿自身 -Z 看出去,与线框模型的 +Z 镜头方向差半圈 */
|
||||
const RENDER_CAMERA_FLIP = new Quaternion().setFromAxisAngle(new Vector3(0, 1, 0), Math.PI);
|
||||
/** 需要把朝向角换算成一个世界「注视点」时(截图元数据 / 环绕机位)沿视线取的距离 */
|
||||
export const DEFAULT_CAMERA_LOOK_DISTANCE = 5;
|
||||
|
||||
/** 默认机位:位置 + 它看向的点(导演视角初始位姿与 1 号机位共用) */
|
||||
export const DEFAULT_DIRECTOR_CAMERA_POSITION: [number, number, number] = [0, 1.55, 5.4];
|
||||
export const DEFAULT_DIRECTOR_CAMERA_LOOK_POINT: [number, number, number] = [0, 1.05, 0];
|
||||
|
||||
/**
|
||||
* 由「机位位置 → 世界注视点」反解出注视朝向角。
|
||||
* 锁定物体、从视口新建机位、旧工程迁移都走这里 —— 数据模型里只存角度,不存注视点。
|
||||
*/
|
||||
export function getCameraLookRotationFromTarget(
|
||||
position: [number, number, number],
|
||||
target: [number, number, number]
|
||||
): [number, number, number] {
|
||||
const direction = new Vector3(...target).sub(new Vector3(...position));
|
||||
if (direction.lengthSq() === 0) return [0, 0, 0];
|
||||
|
||||
const forward = direction.normalize();
|
||||
const up = Math.abs(forward.dot(WORLD_UP)) > 0.999 ? STEEP_VIEW_UP : WORLD_UP;
|
||||
// Matrix4.lookAt 的 +Z 指向「eye - target」:这里要 +Z = 镜头方向,所以把注视点放到反方向
|
||||
const matrix = new Matrix4().lookAt(WORLD_ORIGIN, WORLD_ORIGIN.clone().sub(forward), up);
|
||||
const euler = new Euler().setFromQuaternion(new Quaternion().setFromRotationMatrix(matrix), CAMERA_LOOK_EULER_ORDER);
|
||||
|
||||
return [radiansToDegrees(euler.x), radiansToDegrees(euler.y), radiansToDegrees(euler.z)];
|
||||
}
|
||||
|
||||
/** 由「机位位置 → 世界注视点」直接得到线框姿态(+Z 指向注视点) */
|
||||
export function getCameraQuaternionFromTarget(
|
||||
position: [number, number, number],
|
||||
target: [number, number, number]
|
||||
): Quaternion {
|
||||
return getCameraLookQuaternion(getCameraLookRotationFromTarget(position, target));
|
||||
}
|
||||
|
||||
/** 把注视朝向角合成成四元数:结果的 +Z 就是镜头看向的方向(线框模型姿态) */
|
||||
export function getCameraLookQuaternion(rotation: [number, number, number]): Quaternion {
|
||||
return new Quaternion().setFromEuler(
|
||||
new Euler(...degreesRotationToRadians(rotation), CAMERA_LOOK_EULER_ORDER)
|
||||
);
|
||||
}
|
||||
|
||||
/** 渲染相机(three 相机沿自身 -Z 看出去)的姿态:在线框姿态上再绕 Y 转半圈 */
|
||||
export function getCameraRenderQuaternion(rotation: [number, number, number]): Quaternion {
|
||||
const quaternion = getCameraLookQuaternion(rotation).multiply(RENDER_CAMERA_FLIP.clone());
|
||||
|
||||
// q 与 -q 是同一个姿态:统一取 w ≥ 0 的那一个。
|
||||
// 这样同一组角度无论走「角度合成」还是 three 的 lookAt,算出来的四个分量都一致,
|
||||
// 比对 / 导出时不会因为双重覆盖的符号差而看起来像两个不同的朝向。
|
||||
if (quaternion.w >= 0) return quaternion;
|
||||
|
||||
return quaternion.set(-quaternion.x, -quaternion.y, -quaternion.z, -quaternion.w);
|
||||
}
|
||||
|
||||
/**
|
||||
* 视线前方 distance 处的世界点。
|
||||
* 角度模型本身不需要注视点,但截图元数据、四方位环绕这些「绕着一个点转」的场景仍要一个落点。
|
||||
*/
|
||||
export function getCameraLookPoint(
|
||||
position: [number, number, number],
|
||||
rotation: [number, number, number],
|
||||
distance: number = DEFAULT_CAMERA_LOOK_DISTANCE
|
||||
): [number, number, number] {
|
||||
const forward = CAMERA_LOOK_FORWARD.clone().applyQuaternion(getCameraLookQuaternion(rotation));
|
||||
const point = new Vector3(...position).add(forward.multiplyScalar(Math.max(distance, 0.0001)));
|
||||
|
||||
return [point.x, point.y, point.z];
|
||||
}
|
||||
|
||||
const _slerpFrom = new Quaternion();
|
||||
const _slerpTo = new Quaternion();
|
||||
const _slerpResult = new Quaternion();
|
||||
|
||||
/**
|
||||
* 两帧注视朝向角之间做四元数插值:与轨迹点的朝向插值一致,
|
||||
* 逐轴线性插值会在角度跨圈时绕远路,转出来的镜头会甩一下。
|
||||
*/
|
||||
export function slerpCameraLookRotation(
|
||||
a: [number, number, number],
|
||||
b: [number, number, number],
|
||||
t: number
|
||||
): [number, number, number] {
|
||||
if (a[0] === b[0] && a[1] === b[1] && a[2] === b[2]) return [a[0], a[1], a[2]];
|
||||
|
||||
_slerpFrom.setFromEuler(new Euler(...degreesRotationToRadians(a), CAMERA_LOOK_EULER_ORDER));
|
||||
_slerpTo.setFromEuler(new Euler(...degreesRotationToRadians(b), CAMERA_LOOK_EULER_ORDER));
|
||||
_slerpResult.slerpQuaternions(_slerpFrom, _slerpTo, t);
|
||||
|
||||
const euler = new Euler().setFromQuaternion(_slerpResult, CAMERA_LOOK_EULER_ORDER);
|
||||
return [radiansToDegrees(euler.x), radiansToDegrees(euler.y), radiansToDegrees(euler.z)];
|
||||
}
|
||||
|
||||
export const DEFAULT_DIRECTOR_CAMERA_VIEW_SNAPSHOT: CameraViewSnapshot = {
|
||||
fov: 50,
|
||||
position: [0, 1.55, 5.4],
|
||||
target: [0, 1.05, 0],
|
||||
position: [...DEFAULT_DIRECTOR_CAMERA_POSITION],
|
||||
lookRotation: getCameraLookRotationFromTarget(DEFAULT_DIRECTOR_CAMERA_POSITION, DEFAULT_DIRECTOR_CAMERA_LOOK_POINT),
|
||||
};
|
||||
|
||||
export function getCameraViewSnapshotFromShot(camera: DirectorCameraShot): CameraViewSnapshot {
|
||||
return {
|
||||
fov: camera.fov,
|
||||
position: [...camera.transform.position],
|
||||
target: camera.target,
|
||||
lookRotation: [...camera.lookRotation],
|
||||
};
|
||||
}
|
||||
|
||||
export function getCameraRigPositionFromViewSnapshot(snapshot: CameraViewSnapshot): [number, number, number] {
|
||||
/**
|
||||
* 把「场景内局部坐标的机位视角」换算成「世界坐标的机位视角」。
|
||||
*
|
||||
* 机位线框模型和三轴 gizmo 都画在场景组(sceneGroup)里,所以它们的世界位置 / 朝向是
|
||||
* 场景变换(缩放 / 旋转 / 平移)乘到局部坐标 / 朝向上的结果。但「机位位置 X/Y/Z」面板、
|
||||
* 切到机位视角时真正生效的渲染相机、以及截图中心点和方位控件都要落在同一套世界坐标里,
|
||||
* 否则场景一缩放 / 旋转,xyz 越大、世界坐标和局部数值就差得越多。
|
||||
*
|
||||
* 位置:worldPos = sceneMatrix × localPos
|
||||
* 朝向:worldQuat = sceneQuat × localLookQuat(再拆回 YXZ 三个角)
|
||||
*/
|
||||
export function getWorldCameraViewSnapshot(
|
||||
view: CameraViewSnapshot,
|
||||
scene: SceneSettings
|
||||
): CameraViewSnapshot {
|
||||
const rotationRad = degreesRotationToRadians(scene.rotation);
|
||||
const sceneMatrix = new Matrix4().compose(
|
||||
new Vector3(...scene.position),
|
||||
new Quaternion().setFromEuler(new Euler(...rotationRad)),
|
||||
new Vector3(scene.scale, scene.scale, scene.scale)
|
||||
);
|
||||
const worldPosition = new Vector3(...view.position).applyMatrix4(sceneMatrix);
|
||||
|
||||
const sceneQuat = new Quaternion().setFromEuler(new Euler(...rotationRad));
|
||||
const worldLookQuat = sceneQuat.multiply(getCameraLookQuaternion(view.lookRotation));
|
||||
const worldEuler = new Euler().setFromQuaternion(worldLookQuat, CAMERA_LOOK_EULER_ORDER);
|
||||
const worldLookRotation: [number, number, number] = [
|
||||
radiansToDegrees(worldEuler.x),
|
||||
radiansToDegrees(worldEuler.y),
|
||||
radiansToDegrees(worldEuler.z),
|
||||
];
|
||||
|
||||
return {
|
||||
fov: view.fov,
|
||||
position: [worldPosition.x, worldPosition.y, worldPosition.z],
|
||||
lookRotation: worldLookRotation,
|
||||
};
|
||||
}
|
||||
|
||||
export function getCameraRigPositionFromViewSnapshot(snapshot: {
|
||||
position: [number, number, number];
|
||||
}): [number, number, number] {
|
||||
return [...snapshot.position];
|
||||
}
|
||||
|
||||
/**
|
||||
* 旧工程迁移:早期版本用世界坐标 target 表示注视,读进来时换算成注视朝向角。
|
||||
* 已经是角度的机位(lookRotation 存在)原样返回。
|
||||
*/
|
||||
export function migrateLegacyCameraShot(camera: DirectorCameraShot): DirectorCameraShot {
|
||||
if (Array.isArray(camera.lookRotation)) return camera;
|
||||
|
||||
const legacyTarget = (camera as unknown as { target?: unknown }).target;
|
||||
const position = camera.transform?.position;
|
||||
|
||||
return {
|
||||
...camera,
|
||||
lookRotation: Array.isArray(legacyTarget) && position
|
||||
? getCameraLookRotationFromTarget(position, legacyTarget as [number, number, number])
|
||||
: [...DEFAULT_DIRECTOR_CAMERA_VIEW_SNAPSHOT.lookRotation],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -129,17 +129,26 @@ export interface DirectorTrajectoryPointRef {
|
||||
}
|
||||
|
||||
/**
|
||||
* 轨迹点朝向角的取值范围(度),下标与 DirectorTrajectoryPoint.rotation 一致:
|
||||
* - 0 俯仰角 pitch(X):-90 – 90(趴着 ↔ 躺着,物理限位,不是整圈)
|
||||
* - 1 水平角 yaw(Y):0 – 360(绕身体竖直轴转一整圈)
|
||||
* - 2 横滚角 roll(Z):-180 – 180(绕身体前后轴侧倾一整圈)
|
||||
* 朝向角的取值范围(度),下标与 rotation 一致:
|
||||
* - 0 俯仰角 pitch(X):-90 – 90(趴着 ↔ 躺着 / 镜头俯视 ↔ 仰视,物理限位,不是整圈)
|
||||
* - 1 水平角 yaw(Y):0 – 360(绕竖直轴转一整圈)
|
||||
* - 2 横滚角 roll(Z):-180 – 180(绕前后轴侧倾一整圈)
|
||||
*
|
||||
* 轨迹点(DirectorTrajectoryPoint.rotation)与机位注视(DirectorCameraShot.lookRotation)
|
||||
* 共用这一套语义,保证「角色朝向」和「镜头朝向」在面板上的读数是同一套语言。
|
||||
*/
|
||||
export const TRAJECTORY_ROTATION_LIMITS: Record<0 | 1 | 2, { min: number; max: number }> = {
|
||||
export const ORIENTATION_ROTATION_LIMITS: Record<0 | 1 | 2, { min: number; max: number }> = {
|
||||
0: { min: -90, max: 90 },
|
||||
1: { min: 0, max: 360 },
|
||||
2: { min: -180, max: 180 },
|
||||
};
|
||||
|
||||
/** 轨迹点朝向角的取值范围(度):通用朝向角语义的别名 */
|
||||
export const TRAJECTORY_ROTATION_LIMITS = ORIENTATION_ROTATION_LIMITS;
|
||||
|
||||
/** 机位注视朝向的取值范围(度):与轨迹点完全一致 */
|
||||
export const CAMERA_ROTATION_LIMITS = ORIENTATION_ROTATION_LIMITS;
|
||||
|
||||
export const DEGREES_TO_RADIANS = Math.PI / 180;
|
||||
export const RADIANS_TO_DEGREES = 180 / Math.PI;
|
||||
|
||||
@@ -183,18 +192,24 @@ export function wrapAngleIntoRange(value: number, min: number, max: number): num
|
||||
* - 水平角 / 横滚角是整圈,越界按模收敛(360° ≡ 0°、181° ≡ -179°),端点保留。
|
||||
* - 非法值按 0 处理。
|
||||
*/
|
||||
export function normalizeTrajectoryRotation(axis: 0 | 1 | 2, value: number): number {
|
||||
export function normalizeOrientationRotation(axis: 0 | 1 | 2, value: number): number {
|
||||
if (!Number.isFinite(value)) return 0;
|
||||
|
||||
if (axis === 0) {
|
||||
const { min, max } = TRAJECTORY_ROTATION_LIMITS[0];
|
||||
const { min, max } = ORIENTATION_ROTATION_LIMITS[0];
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
const { min, max } = TRAJECTORY_ROTATION_LIMITS[axis];
|
||||
const { min, max } = ORIENTATION_ROTATION_LIMITS[axis];
|
||||
return wrapAngleIntoRange(value, min, max);
|
||||
}
|
||||
|
||||
/** 轨迹点朝向角归一化(沿用通用朝向角规则,保留原名便于按语义调用) */
|
||||
export const normalizeTrajectoryRotation = normalizeOrientationRotation;
|
||||
|
||||
/** 机位注视朝向角归一化(与轨迹点同一套规则:俯仰角夹紧、水平角 / 横滚角按整圈取模) */
|
||||
export const normalizeCameraRotation = normalizeOrientationRotation;
|
||||
|
||||
/** 角色 / 道具 / 群众变换旋转的取值范围(度):每个轴都是 0–360° 一整圈。 */
|
||||
export const TRANSFORM_ROTATION_LIMITS = { min: 0, max: 360 } as const;
|
||||
|
||||
@@ -254,7 +269,17 @@ export interface DirectorCameraShot {
|
||||
transform: DirectorTransform;
|
||||
targetMode: "manual" | "object";
|
||||
targetObjectId?: string | null;
|
||||
target: [number, number, number];
|
||||
/**
|
||||
* 机位的注视朝向(度),顺序与 DirectorTrajectoryPoint.rotation 完全一致:
|
||||
* [俯仰角 pitch(X), 水平角 yaw(Y), 横滚角 roll(Z)]。
|
||||
* 语义(镜头朝 +Z、机顶朝 +Y):
|
||||
* - 俯仰角 +90° = 垂直俯视(镜头朝下)、-90° = 垂直仰视(镜头朝上)
|
||||
* - 水平角 0–360° 是一整圈(360° ≡ 0°)
|
||||
* - 横滚角 -180° – 180° 是一整圈(180° ≡ -180°)
|
||||
* 合成顺序是 YXZ(先横滚、再俯仰、最后水平),保证俯仰 / 横滚始终绕镜头自身轴,
|
||||
* 详细换算见 cameraGeometry。
|
||||
*/
|
||||
lookRotation: [number, number, number];
|
||||
lastCaptureUrl?: string | null;
|
||||
captures?: DirectorCameraCapture[];
|
||||
}
|
||||
|
||||
@@ -30,8 +30,8 @@ import {
|
||||
|
||||
const V0 = { position: [0, 0, 0] as [number, number, number], rotation: [0, 0, 0] as [number, number, number], scale: [1, 1, 1] as [number, number, number] };
|
||||
const V1 = { position: [10, 0, 0] as [number, number, number], rotation: [0, 0, 0] as [number, number, number], scale: [1, 1, 1] as [number, number, number] };
|
||||
const CAM_A = { position: [0, 0, 0] as [number, number, number], target: [0, 0, 0] as [number, number, number], fov: 50 };
|
||||
const CAM_B = { position: [10, 0, 0] as [number, number, number], target: [0, 0, 0] as [number, number, number], fov: 70 };
|
||||
const CAM_A = { position: [0, 0, 0] as [number, number, number], lookRotation: [0, 0, 0] as [number, number, number], fov: 50 };
|
||||
const CAM_B = { position: [10, 0, 0] as [number, number, number], lookRotation: [0, 90, 0] as [number, number, number], fov: 70 };
|
||||
|
||||
it("creates a default empty timeline with both track kinds and playback settings", () => {
|
||||
expect(createDefaultTimeline()).toEqual({
|
||||
@@ -64,9 +64,16 @@ it("returns null for an object track with no keyframes", () => {
|
||||
});
|
||||
|
||||
// ---------- 相机插值 ----------
|
||||
it("lerps camera position/target/fov linearly between two keyframes", () => {
|
||||
it("lerps camera position/fov linearly and slerps the look angles between two keyframes", () => {
|
||||
const track = { cameraId: "cam_1", keyframes: [{ id: "k1", time: 0, value: CAM_A }, { id: "k2", time: 2, value: CAM_B }] };
|
||||
expect(evaluateCameraTrackAtTime(track, 1)).toEqual({ position: [5, 0, 0], target: [0, 0, 0], fov: 60 });
|
||||
const value = evaluateCameraTrackAtTime(track, 1);
|
||||
|
||||
expect(value?.position).toEqual([5, 0, 0]);
|
||||
expect(value?.fov).toBe(60);
|
||||
// 水平角 0° → 90° 的中点(四元数插值,走最短弧)
|
||||
expect(value?.lookRotation[0]).toBeCloseTo(0, 5);
|
||||
expect(value?.lookRotation[1]).toBeCloseTo(45, 5);
|
||||
expect(value?.lookRotation[2]).toBeCloseTo(0, 5);
|
||||
});
|
||||
|
||||
it("returns null for a camera track with no keyframes", () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Euler, Quaternion } from "three";
|
||||
import { degreesToRadians, radiansToDegrees } from "./directorProject";
|
||||
import { slerpCameraLookRotation } from "./cameraGeometry";
|
||||
|
||||
export type TransformTuple = [number, number, number];
|
||||
|
||||
@@ -23,7 +24,8 @@ export interface TimelineTrack {
|
||||
// ===== 相机关键帧 =====
|
||||
export interface CameraKeyframeValue {
|
||||
position: TransformTuple;
|
||||
target: TransformTuple;
|
||||
/** 注视朝向(度):[俯仰角 pitch(X), 水平角 yaw(Y), 横滚角 roll(Z)],与机位的 lookRotation 同一套语义 */
|
||||
lookRotation: TransformTuple;
|
||||
fov: number;
|
||||
}
|
||||
export interface CameraTimelineKeyframe {
|
||||
@@ -177,7 +179,8 @@ export function evaluateTrackAtTime(track: TimelineTrack, time: number): Keyfram
|
||||
function lerpCameraValue(a: CameraKeyframeValue, b: CameraKeyframeValue, t: number): CameraKeyframeValue {
|
||||
return {
|
||||
position: lerpTuple(a.position, b.position, t),
|
||||
target: lerpTuple(a.target, b.target, t),
|
||||
// 注视朝向是角度,走四元数插值(与轨迹点一致),位置 / FOV 仍按线性插值
|
||||
lookRotation: slerpCameraLookRotation(a.lookRotation, b.lookRotation, t),
|
||||
fov: lerp(a.fov, b.fov, t),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterEach, beforeEach, vi } from "vitest";
|
||||
import { createDefaultDirectorProject, createInitialDirectorState, useDirectorStore } from "./directorStore";
|
||||
import { selectRightPanelKind } from "./directorSelectors";
|
||||
import { getCameraRigPositionFromViewSnapshot } from "../schema/cameraGeometry";
|
||||
import { getCameraLookRotationFromTarget, getCameraRigPositionFromViewSnapshot } from "../schema/cameraGeometry";
|
||||
import { DEFAULT_GROUND_COLOR } from "../schema/directorProject";
|
||||
|
||||
function createMemoryStorage(): Storage {
|
||||
@@ -295,7 +295,7 @@ it("deletes the linked camera track when a camera object is removed", () => {
|
||||
.project.objects.find((item) => item.kind === "camera" && item.linkedCameraId === "cam_1")!;
|
||||
useDirectorStore.getState().addCameraKeyframe("cam_1", 1, {
|
||||
position: [1, 1, 1],
|
||||
target: [0, 0, 0],
|
||||
lookRotation: [0, 0, 0],
|
||||
fov: 50,
|
||||
});
|
||||
expect(useDirectorStore.getState().project.timeline.cameraTracks).toHaveLength(1);
|
||||
@@ -409,15 +409,17 @@ it("adds a new camera from the current viewport snapshot", () => {
|
||||
const state = useDirectorStore.getState();
|
||||
const addedCamera = state.project.cameras[state.project.cameras.length - 1];
|
||||
const addedObject = state.project.objects.find((item) => item.linkedCameraId === addedCamera?.id);
|
||||
const rigPosition = getCameraRigPositionFromViewSnapshot({
|
||||
const snapshot = {
|
||||
fov: 62,
|
||||
position: [4, 3, 2],
|
||||
target: [0.5, 1.1, -2],
|
||||
});
|
||||
position: [4, 3, 2] as [number, number, number],
|
||||
target: [0.5, 1.1, -2] as [number, number, number],
|
||||
};
|
||||
const rigPosition = getCameraRigPositionFromViewSnapshot(snapshot);
|
||||
|
||||
expect(addedCamera?.fov).toBe(62);
|
||||
expect(addedCamera?.transform.position).toEqual(rigPosition);
|
||||
expect(addedCamera?.target).toEqual([0.5, 1.1, -2]);
|
||||
// 视口快照是「位置 + 注视点」,落到工程里变成注视朝向角
|
||||
expect(addedCamera?.lookRotation).toEqual(getCameraLookRotationFromTarget(snapshot.position, snapshot.target));
|
||||
expect(addedObject?.transform.position).toEqual(rigPosition);
|
||||
expect(state.project.activeCameraId).toBe(addedCamera?.id);
|
||||
expect(state.selectedObjectId).toBe(addedObject?.id);
|
||||
@@ -433,7 +435,7 @@ it("keeps object-focused cameras centered when the target model moves", () => {
|
||||
useDirectorStore.getState().updateCamera("cam_1", {
|
||||
targetMode: "object",
|
||||
targetObjectId: targetObject!.id,
|
||||
target: [-1.725, 0.5, 1.15],
|
||||
lookRotation: [0, 0, 0],
|
||||
});
|
||||
useDirectorStore.getState().updateObjectTransform(targetObject!.id, { position: [2, 0, -3] });
|
||||
|
||||
@@ -441,7 +443,10 @@ it("keeps object-focused cameras centered when the target model moves", () => {
|
||||
|
||||
expect(camera.targetMode).toBe("object");
|
||||
expect(camera.targetObjectId).toBe(targetObject!.id);
|
||||
expect(camera.target).toEqual([2, 0.5, -3]);
|
||||
// 锁定物体:物体挪了就把「机位 → 物体」的视线重新换算成注视朝向角
|
||||
expect(camera.lookRotation).toEqual(
|
||||
getCameraLookRotationFromTarget(camera.transform.position, [2, 0.5, -3])
|
||||
);
|
||||
});
|
||||
|
||||
it("appends camera captures with sequential camera-shot names", () => {
|
||||
@@ -912,7 +917,7 @@ it("addCameraKeyframe inserts a keyframe on the camera's track", () => {
|
||||
const cameraId = useDirectorStore.getState().project.cameras[0].id;
|
||||
|
||||
const kfId = useDirectorStore.getState().addCameraKeyframe(cameraId, 1, {
|
||||
position: [3, 3, 3], target: [0, 1, 0], fov: 40,
|
||||
position: [3, 3, 3], lookRotation: [10, 20, 30], fov: 40,
|
||||
});
|
||||
|
||||
expect(typeof kfId).toBe("string");
|
||||
@@ -921,17 +926,17 @@ it("addCameraKeyframe inserts a keyframe on the camera's track", () => {
|
||||
expect(track?.keyframes[0].value.fov).toBe(40);
|
||||
});
|
||||
|
||||
it("applyCameraKeyframeToShot writes position/target/fov onto the camera shot", () => {
|
||||
it("applyCameraKeyframeToShot writes position/look rotation/fov onto the camera shot", () => {
|
||||
useDirectorStore.getState().replaceProject(createDefaultDirectorProject() as never);
|
||||
const cameraId = useDirectorStore.getState().project.cameras[0].id;
|
||||
|
||||
useDirectorStore.getState().applyCameraKeyframeToShot(cameraId, {
|
||||
position: [8, 2, 9], target: [1, 1, 1], fov: 33,
|
||||
position: [8, 2, 9], lookRotation: [5, 180, 15], fov: 33,
|
||||
});
|
||||
|
||||
const shot = useDirectorStore.getState().project.cameras.find((c) => c.id === cameraId);
|
||||
expect(shot?.transform.position).toEqual([8, 2, 9]);
|
||||
expect(shot?.target).toEqual([1, 1, 1]);
|
||||
expect(shot?.lookRotation).toEqual([5, 180, 15]);
|
||||
expect(shot?.fov).toBe(33);
|
||||
});
|
||||
|
||||
@@ -939,7 +944,7 @@ it("removeCameraKeyframe drops the empty camera track", () => {
|
||||
useDirectorStore.getState().replaceProject(createDefaultDirectorProject() as never);
|
||||
const cameraId = useDirectorStore.getState().project.cameras[0].id;
|
||||
const kfId = useDirectorStore.getState().addCameraKeyframe(cameraId, 1, {
|
||||
position: [0, 0, 0], target: [0, 0, 0], fov: 50,
|
||||
position: [0, 0, 0], lookRotation: [0, 0, 0], fov: 50,
|
||||
});
|
||||
|
||||
useDirectorStore.getState().removeCameraKeyframe(cameraId, kfId);
|
||||
@@ -1006,7 +1011,7 @@ it("clearAllKeyframes empties both object and camera tracks", () => {
|
||||
position: [0, 0, 0], rotation: [0, 0, 0], scale: [1, 1, 1],
|
||||
});
|
||||
useDirectorStore.getState().addCameraKeyframe(cameraId, 1, {
|
||||
position: [0, 0, 0], target: [0, 0, 0], fov: 50,
|
||||
position: [0, 0, 0], lookRotation: [0, 0, 0], fov: 50,
|
||||
});
|
||||
|
||||
useDirectorStore.getState().clearAllKeyframes();
|
||||
|
||||
@@ -30,6 +30,8 @@ import { DEFAULT_CHARACTER_BODY_TYPE, normalizeBodyType } from "../runtime/manne
|
||||
import {
|
||||
DEFAULT_DIRECTOR_CAMERA_VIEW_SNAPSHOT,
|
||||
getCameraRigPositionFromViewSnapshot,
|
||||
getCameraLookRotationFromTarget,
|
||||
migrateLegacyCameraShot,
|
||||
} from "../schema/cameraGeometry";
|
||||
import type { ViewportAspectRatio } from "../schema/viewportAspectRatio";
|
||||
import {
|
||||
@@ -215,7 +217,7 @@ export interface DirectorActions {
|
||||
cameraId: string,
|
||||
patch: Partial<DirectorCameraShot> & {
|
||||
transform?: DirectorTransform;
|
||||
target?: [number, number, number];
|
||||
lookRotation?: [number, number, number];
|
||||
}
|
||||
) => void;
|
||||
// 物体关键帧
|
||||
@@ -517,8 +519,57 @@ function withSceneDefaults(project: DirectorProject): DirectorProject {
|
||||
return { ...project, scene: nextScene };
|
||||
}
|
||||
|
||||
/**
|
||||
* 旧工程里机位的注视是一个世界坐标 target:统一换算成注视朝向角
|
||||
* (水平角 / 俯仰角 / 横滚角),运镜关键帧同理。
|
||||
*/
|
||||
function withCameraLookRotations(project: DirectorProject): DirectorProject {
|
||||
const cameras = project.cameras ?? [];
|
||||
const cameraTracks = project.timeline?.cameraTracks ?? [];
|
||||
const legacyCamera = cameras.some((camera) => Boolean(camera) && !Array.isArray(camera.lookRotation));
|
||||
const legacyKeyframe = cameraTracks.some((track) =>
|
||||
(track.keyframes ?? []).some((keyframe) => Boolean(keyframe) && !Array.isArray(keyframe.value?.lookRotation))
|
||||
);
|
||||
|
||||
if (!legacyCamera && !legacyKeyframe) return project;
|
||||
|
||||
return {
|
||||
...project,
|
||||
cameras: cameras.map((camera) => (camera ? migrateLegacyCameraShot(camera) : camera)),
|
||||
timeline: legacyKeyframe
|
||||
? {
|
||||
...project.timeline,
|
||||
cameraTracks: cameraTracks.map((track) => ({
|
||||
...track,
|
||||
keyframes: (track.keyframes ?? []).map((keyframe) => ({
|
||||
...keyframe,
|
||||
value: migrateLegacyCameraKeyframeValue(keyframe.value),
|
||||
})),
|
||||
})),
|
||||
}
|
||||
: project.timeline,
|
||||
};
|
||||
}
|
||||
|
||||
function migrateLegacyCameraKeyframeValue(value: {
|
||||
position: [number, number, number];
|
||||
fov: number;
|
||||
lookRotation?: [number, number, number];
|
||||
target?: [number, number, number];
|
||||
}): CameraKeyframeValue {
|
||||
return {
|
||||
position: value.position,
|
||||
fov: value.fov,
|
||||
lookRotation: Array.isArray(value.lookRotation)
|
||||
? value.lookRotation
|
||||
: Array.isArray(value.target)
|
||||
? getCameraLookRotationFromTarget(value.position, value.target)
|
||||
: [...DEFAULT_DIRECTOR_CAMERA_VIEW_SNAPSHOT.lookRotation],
|
||||
};
|
||||
}
|
||||
|
||||
function migrateDirectorProject(project: DirectorProject): DirectorProject {
|
||||
const withTimelineProject = withSceneDefaults(withTimeline(project));
|
||||
const withTimelineProject = withSceneDefaults(withCameraLookRotations(withTimeline(project)));
|
||||
return {
|
||||
...withTimelineProject,
|
||||
objects: withTimelineProject.objects.map((object) => {
|
||||
@@ -676,7 +727,7 @@ export function createDefaultDirectorProject({
|
||||
fov: DEFAULT_DIRECTOR_CAMERA_VIEW_SNAPSHOT.fov,
|
||||
transform: createTransform(getCameraRigPositionFromViewSnapshot(DEFAULT_DIRECTOR_CAMERA_VIEW_SNAPSHOT)),
|
||||
targetMode: "manual",
|
||||
target: DEFAULT_DIRECTOR_CAMERA_VIEW_SNAPSHOT.target,
|
||||
lookRotation: [...DEFAULT_DIRECTOR_CAMERA_VIEW_SNAPSHOT.lookRotation],
|
||||
lastCaptureUrl: null,
|
||||
captures: [],
|
||||
};
|
||||
@@ -928,7 +979,11 @@ function refreshCamerasFocusedOnObject(cameras: DirectorCameraShot[], object: Di
|
||||
camera.targetMode === "object" && camera.targetObjectId === object.id
|
||||
? {
|
||||
...camera,
|
||||
target: getDirectorObjectFocusTarget(object),
|
||||
// 锁定物体:把「机位 → 物体」这条视线换算成注视朝向角存下来
|
||||
lookRotation: getCameraLookRotationFromTarget(
|
||||
camera.transform.position,
|
||||
getDirectorObjectFocusTarget(object)
|
||||
),
|
||||
}
|
||||
: camera
|
||||
);
|
||||
@@ -960,7 +1015,10 @@ function refreshCamerasFocusedOnObjects(
|
||||
|
||||
return {
|
||||
...camera,
|
||||
target: getDirectorObjectFocusTarget(targetObject),
|
||||
lookRotation: getCameraLookRotationFromTarget(
|
||||
camera.transform.position,
|
||||
getDirectorObjectFocusTarget(targetObject)
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -1298,8 +1356,8 @@ function pasteClipboardEntries(state: DirectorRuntimeState): DirectorRuntimeStat
|
||||
id: nextCameraId,
|
||||
name: formatSceneItemName("机位", cameraIndex),
|
||||
transform: applyOffsetToTransform(entry.camera.transform, offset),
|
||||
target:
|
||||
entry.camera.targetMode === "manual" ? applyPositionOffset(entry.camera.target, offset) : entry.camera.target,
|
||||
// 注视是朝向角,平移机位不会改变它(锁物体的机位稍后按新位置重算)
|
||||
lookRotation: [...entry.camera.lookRotation],
|
||||
targetObjectId: targetObjectId ?? entry.camera.targetObjectId ?? null,
|
||||
captures: [],
|
||||
lastCaptureUrl: null,
|
||||
@@ -1354,7 +1412,10 @@ function pasteClipboardEntries(state: DirectorRuntimeState): DirectorRuntimeStat
|
||||
return {
|
||||
...camera,
|
||||
targetObjectId: mappedTargetObjectId,
|
||||
target: getDirectorObjectFocusTarget(targetObject),
|
||||
lookRotation: getCameraLookRotationFromTarget(
|
||||
camera.transform.position,
|
||||
getDirectorObjectFocusTarget(targetObject)
|
||||
),
|
||||
};
|
||||
});
|
||||
const lastPastedObject = pastedObjectIds.length
|
||||
@@ -2348,7 +2409,9 @@ export const useDirectorStore = create<DirectorStore>((set, get) => {
|
||||
fov: snapshot?.fov ?? 50,
|
||||
transform,
|
||||
targetMode: "manual",
|
||||
target: snapshot?.target ?? [0, 1.2, 0],
|
||||
lookRotation: snapshot
|
||||
? getCameraLookRotationFromTarget(snapshot.position, snapshot.target)
|
||||
: [...DEFAULT_DIRECTOR_CAMERA_VIEW_SNAPSHOT.lookRotation],
|
||||
lastCaptureUrl: null,
|
||||
captures: [],
|
||||
};
|
||||
@@ -2643,7 +2706,7 @@ export const useDirectorStore = create<DirectorStore>((set, get) => {
|
||||
...item,
|
||||
...patch,
|
||||
transform: patch.transform ?? item.transform,
|
||||
target: patch.target ?? item.target,
|
||||
lookRotation: patch.lookRotation ?? item.lookRotation,
|
||||
}
|
||||
: item
|
||||
),
|
||||
@@ -2744,12 +2807,12 @@ export const useDirectorStore = create<DirectorStore>((set, get) => {
|
||||
},
|
||||
})),
|
||||
applyCameraKeyframeToShot: (cameraId, value) => {
|
||||
// 只改 position/target/fov,rotation/scale 保留 shot 原值(相机朝向由 target 决定)
|
||||
// 只改 position/注视朝向/fov,scale 保留 shot 原值
|
||||
const camera = get().project.cameras.find((item) => item.id === cameraId);
|
||||
if (!camera) return;
|
||||
get().updateCamera(cameraId, {
|
||||
fov: value.fov,
|
||||
target: value.target,
|
||||
lookRotation: value.lookRotation,
|
||||
transform: { ...camera.transform, position: value.position },
|
||||
});
|
||||
},
|
||||
|
||||
@@ -444,6 +444,8 @@ textarea:focus-visible {
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
/* 屏蔽触摸板 / 触控的浏览器手势(双指、右边缘滑动等),避免右键或触控操作被浏览器当成手势吞掉 */
|
||||
touch-action: none;
|
||||
background: rgb(var(--field-rgb));
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user