feat: 旋转单位改为度并支持轨迹点拖拽
统一数据模型中的旋转为度数并在渲染时转弧度,新增 InspectorAngleField 角度控件,为场景与轨迹点提供滑块和重置按钮;选中轨迹点时角色预览到该点并支持拖拽 gizmo 调整位置;机位点击统一走 selectObject 以正确切换右侧属性卡片。
This commit is contained in:
1 parent
e74cf6d28b
commit
0794486857
21 files changed
+659
-99
No files matched your search
@@ -10,6 +10,7 @@ import {
|
||||
} from "./DirectorCanvas";
|
||||
import { GROUND_PLANE_SIZE } from "./sceneGround";
|
||||
import { getViewportAspectFrameRect } from "./viewportAspectFrame";
|
||||
import { degreesRotationToRadians } from "../schema/directorProject";
|
||||
|
||||
const mockCameraPositionSet = vi.hoisted(() => vi.fn());
|
||||
const mockCameraLookAt = vi.hoisted(() => vi.fn());
|
||||
@@ -370,7 +371,7 @@ it("shares the scene transform with the ground plane so the grid never becomes a
|
||||
...useDirectorStore.getState().project.scene,
|
||||
scale: 2,
|
||||
position: [1, 3, -2],
|
||||
rotation: [0, 0.5, 0],
|
||||
rotation: [0, 90, 0],
|
||||
groundHeight: 1.5,
|
||||
},
|
||||
},
|
||||
@@ -384,7 +385,8 @@ it("shares the scene transform with the ground plane so the grid never becomes a
|
||||
// 网格与地面同在场景组内:共享场景平移/旋转/缩放,不会各自留在不同坐标空间
|
||||
expect(sceneGroup).not.toBeNull();
|
||||
expect(sceneGroup).toHaveAttribute("position", "1,3,-2");
|
||||
expect(sceneGroup).toHaveAttribute("rotation", "0,0.5,0");
|
||||
// 场景旋转存的是度数,写进 Three.js group 前会转成弧度(90° → π/2)
|
||||
expect(sceneGroup).toHaveAttribute("rotation", degreesRotationToRadians([0, 90, 0]).join(","));
|
||||
expect(sceneGroup).toHaveAttribute("scale", "2,2,2");
|
||||
// 网格自身的 y 相对场景原点,仍是「地面高度 + 微小抬升」
|
||||
expect(grid).toHaveAttribute("data-position", "[0,1.502,0]");
|
||||
|
||||
@@ -19,6 +19,7 @@ import { buildScreenshotMeta, type ScreenshotResult } from "../io/screenshotExpo
|
||||
import { useDirectorStore, type CameraShotSnapshot } from "../store/directorStore";
|
||||
import { DEFAULT_DIRECTOR_CAMERA_VIEW_SNAPSHOT, getCameraViewSnapshotFromShot } from "../schema/cameraGeometry";
|
||||
import type { DirectorObject, DirectorTransform, SceneSettings } from "../schema/directorProject";
|
||||
import { degreesRotationToRadians } from "../schema/directorProject";
|
||||
import { getGroundedLabelY } from "../runtime/mannequin/bodyTypes";
|
||||
import { getUE4GroundedLabelY } from "../runtime/ue4Mannequin/ue4MannequinRig";
|
||||
import { SceneRoot } from "./SceneRoot";
|
||||
@@ -209,7 +210,8 @@ function applySnapshotToRelativeGizmoCamera(camera: ThreePerspectiveCamera, snap
|
||||
function createTransformMatrix(transform: DirectorTransform) {
|
||||
return new Matrix4().compose(
|
||||
new Vector3(...transform.position),
|
||||
new Quaternion().setFromEuler(new Euler(...transform.rotation)),
|
||||
// 物体旋转存的是度数,Euler 需要弧度(与 SceneRoot 的 group 保持一致)
|
||||
new Quaternion().setFromEuler(new Euler(...degreesRotationToRadians(transform.rotation))),
|
||||
new Vector3(...transform.scale)
|
||||
);
|
||||
}
|
||||
@@ -217,7 +219,8 @@ function createTransformMatrix(transform: DirectorTransform) {
|
||||
function createSceneMatrix(scene: SceneSettings) {
|
||||
return new Matrix4().compose(
|
||||
new Vector3(...scene.position),
|
||||
new Quaternion().setFromEuler(new Euler(...scene.rotation)),
|
||||
// 场景旋转存的是度数,Euler 需要弧度(与 getSceneGroupTransform 保持一致)
|
||||
new Quaternion().setFromEuler(new Euler(...degreesRotationToRadians(scene.rotation))),
|
||||
new Vector3(scene.scale, scene.scale, scene.scale)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Vector3 } from "three";
|
||||
import { Euler, Vector3 } from "three";
|
||||
import {
|
||||
getEffectiveGroundOpacity,
|
||||
getPanoramaRotationRadians,
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
getViewportCameraQuaternion,
|
||||
} from "./SceneRoot";
|
||||
import { VIEWPORT_CAMERA_VISUAL_SCALE } from "../schema/cameraGeometry";
|
||||
import { degreesRotationToRadians, normalizeTransformRotation } from "../schema/directorProject";
|
||||
|
||||
it("aligns the panorama front view to the image center by default", () => {
|
||||
expect(getPanoramaRotationRadians(0)).toBeCloseTo(Math.PI / 2);
|
||||
@@ -239,3 +240,49 @@ it("expands the invisible viewport camera hit area around the full line model",
|
||||
expect(hitZ - hitDepth / 2).toBeLessThanOrEqual(minZ - 0.04);
|
||||
expect(hitZ + hitDepth / 2).toBeGreaterThanOrEqual(maxZ + 0.04);
|
||||
});
|
||||
|
||||
const degToRad = (degrees: number) => (degrees * Math.PI) / 180;
|
||||
|
||||
it("maps trajectory pitch so +90° is prone (face down) and -90° is supine (face up)", () => {
|
||||
// 角色正面朝 +Z(模型脸在 +Z),俯仰角绕 X 轴。
|
||||
const front = new Vector3(0, 0, 1);
|
||||
|
||||
const atProne = front.clone().applyEuler(new Euler(degToRad(90), 0, 0));
|
||||
const atSupine = front.clone().applyEuler(new Euler(degToRad(-90), 0, 0));
|
||||
|
||||
// +90° → 脸朝下(趴着);-90° → 脸朝上(躺着)
|
||||
expect(atProne.y).toBeLessThan(0);
|
||||
expect(atSupine.y).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("treats horizontal and roll angles as full circles (360° periodic)", () => {
|
||||
const front = new Vector3(0, 0, 1);
|
||||
const up = new Vector3(0, 1, 0);
|
||||
|
||||
// 水平角 360° 与 0° 朝向一致(整圈)
|
||||
const yawZero = front.clone().applyEuler(new Euler(0, 0, 0));
|
||||
const yawFull = front.clone().applyEuler(new Euler(0, degToRad(360), 0));
|
||||
expect(yawFull.x).toBeCloseTo(yawZero.x);
|
||||
expect(yawFull.z).toBeCloseTo(yawZero.z);
|
||||
|
||||
// 横滚角 180° 与 -180° 朝向一致(整圈)
|
||||
const rollPos = up.clone().applyEuler(new Euler(0, 0, degToRad(180)));
|
||||
const rollNeg = up.clone().applyEuler(new Euler(0, 0, degToRad(-180)));
|
||||
expect(rollPos.y).toBeCloseTo(rollNeg.y);
|
||||
});
|
||||
|
||||
it("converts stored degrees to radians when applied to the 3D group (360° = one turn, not ~57 turns)", () => {
|
||||
// 数据模型里旋转是度数;直接当 Three.js 的弧度属性用会把 360° 当成 360 弧度 ≈ 57 圈。
|
||||
const applied = degreesRotationToRadians([90, 360, -180]);
|
||||
|
||||
expect(applied[0]).toBeCloseTo(Math.PI / 2);
|
||||
expect(applied[1]).toBeCloseTo(2 * Math.PI); // 水平角 360° → 恰好一整圈(2π 弧度)
|
||||
expect(applied[2]).toBeCloseTo(-Math.PI);
|
||||
});
|
||||
|
||||
it("normalizes object transform rotation into a single 0–360° circle", () => {
|
||||
// 角色 / 道具 / 群众卡片里的旋转每个轴都是 0–360° 一整圈
|
||||
expect(normalizeTransformRotation([720, 450, -90])).toEqual([0, 90, 270]);
|
||||
expect(normalizeTransformRotation([0, 0, 0])).toEqual([0, 0, 0]);
|
||||
expect(normalizeTransformRotation([361, -1, 180])).toEqual([1, 359, 180]);
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { Box3, DoubleSide, Vector3 } from "three";
|
||||
import { afterEach, beforeEach, vi } from "vitest";
|
||||
import { VIEWPORT_CAMERA_VISUAL_SCALE } from "../schema/cameraGeometry";
|
||||
import { degreesRotationToRadians } from "../schema/directorProject";
|
||||
import { createInitialDirectorState, useDirectorStore } from "../store/directorStore";
|
||||
import { getImportedModelNormalization, getTrajectoryGroundPoint, SceneRoot } from "./SceneRoot";
|
||||
import { AXIS_COLORS } from "./sceneAxes";
|
||||
@@ -331,7 +332,7 @@ it("keeps the ground plane inside the shared scene transform group", () => {
|
||||
...useDirectorStore.getState().project.scene,
|
||||
scale: 2,
|
||||
position: [1, 3, -2],
|
||||
rotation: [0, 0.5, 0],
|
||||
rotation: [0, 90, 0],
|
||||
groundHeight: 1.5,
|
||||
},
|
||||
},
|
||||
@@ -342,7 +343,8 @@ it("keeps the ground plane inside the shared scene transform group", () => {
|
||||
|
||||
// 地面所在的场景组必须和视口网格用同一套变换,否则会分成两层不同高度的地面
|
||||
expect(groundPlane).toHaveAttribute("position", "1,3,-2");
|
||||
expect(groundPlane).toHaveAttribute("rotation", "0,0.5,0");
|
||||
// 场景旋转存的是度数,写进 Three.js group 前会转成弧度(90° → π/2)
|
||||
expect(groundPlane).toHaveAttribute("rotation", degreesRotationToRadians([0, 90, 0]).join(","));
|
||||
expect(groundPlane).toHaveAttribute("scale", "2,2,2");
|
||||
});
|
||||
|
||||
@@ -676,6 +678,23 @@ it("renders solid trajectory spheres and a smooth curve through the waypoints",
|
||||
expect(container.querySelectorAll('mesh[name^="trajectory-point-"]')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("shows a translate gizmo on the selected trajectory point so it can be dragged", () => {
|
||||
useDirectorStore.getState().addTrajectoryPoint("char_default_a", [1, 0, 2]);
|
||||
const pointId = useDirectorStore
|
||||
.getState()
|
||||
.project.objects.find((item) => item.id === "char_default_a")!
|
||||
.trajectoryPoints![0].id;
|
||||
useDirectorStore.getState().selectTrajectoryPoint("char_default_a", pointId);
|
||||
|
||||
render(<SceneRoot />);
|
||||
|
||||
// 选中轨迹点后给该点挂拖拽 gizmo(角色自身的变换 gizmo 此时被抑制,故只有一个)
|
||||
const gizmo = screen.getByTestId("transform-controls");
|
||||
expect(gizmo).toHaveAttribute("data-mode", "translate");
|
||||
expect(gizmo).toHaveAttribute("data-has-object", "true");
|
||||
expect(gizmo).toHaveAttribute("data-hide-from-capture", "true");
|
||||
});
|
||||
|
||||
it("mounts an invisible ground plane only while placing trajectory points", () => {
|
||||
const { container: idleContainer } = render(<SceneRoot />);
|
||||
expect(idleContainer.querySelector('mesh[name="trajectory-placement-plane"]')).not.toBeInTheDocument();
|
||||
|
||||
@@ -20,6 +20,10 @@ import type {
|
||||
DirectorObject,
|
||||
GeometryPrimitiveType,
|
||||
} from "../schema/directorProject";
|
||||
import {
|
||||
degreesRotationToRadians,
|
||||
radiansRotationToDegrees,
|
||||
} from "../schema/directorProject";
|
||||
import {
|
||||
VIEWPORT_CAMERA_ASPECT,
|
||||
VIEWPORT_CAMERA_FRUSTUM_DEPTH,
|
||||
@@ -494,6 +498,14 @@ function ObjectSceneNode({
|
||||
const updateObjectTransform = useDirectorStore((state) => state.updateObjectTransform);
|
||||
const isPlaying = usePlaybackStore((state) => state.isPlaying);
|
||||
const motionTime = usePlaybackStore((state) => state.playhead);
|
||||
// 当前选中的轨迹点若属于本角色,则把角色「预览」到该路点的位置与朝向上,
|
||||
// 让用户直接看到角色在某个轨迹点的姿态(水平角/俯仰角/横滚角)。
|
||||
// 播放时角色要沿路径动画,故预览只在非播放时生效(拖拽 gizmo 也已排除播放态)。
|
||||
const selectedTrajectoryPoint = useDirectorStore((state) => state.selectedTrajectoryPoint);
|
||||
const activeTrajectoryPoint =
|
||||
!isPlaying && selectedTrajectoryPoint?.objectId === item.id
|
||||
? item.trajectoryPoints?.find((point) => point.id === selectedTrajectoryPoint.pointId)
|
||||
: undefined;
|
||||
const isImportedModel = asset?.sourceType === "model";
|
||||
const characterLabelKey = `${item.id}:${item.bodyType ?? ""}:${item.characterRig?.rigType ?? ""}`;
|
||||
const fallbackCharacterLabelY =
|
||||
@@ -526,9 +538,10 @@ function ObjectSceneNode({
|
||||
const group = groupRef.current;
|
||||
if (!group) return;
|
||||
|
||||
// group.rotation 是弧度,存入数据模型前要转成度数(数据模型里旋转一律用度数)
|
||||
updateObjectTransform(item.id, {
|
||||
position: [group.position.x, group.position.y, group.position.z],
|
||||
rotation: [group.rotation.x, group.rotation.y, group.rotation.z],
|
||||
rotation: radiansRotationToDegrees([group.rotation.x, group.rotation.y, group.rotation.z]),
|
||||
scale: [group.scale.x, group.scale.y, group.scale.z],
|
||||
});
|
||||
}
|
||||
@@ -536,8 +549,10 @@ function ObjectSceneNode({
|
||||
const node = (
|
||||
<group
|
||||
ref={groupRef}
|
||||
position={item.transform.position}
|
||||
rotation={item.transform.rotation}
|
||||
position={activeTrajectoryPoint ? activeTrajectoryPoint.position : item.transform.position}
|
||||
// 数据模型里旋转是度数,应用到 Three.js 的 rotation 属性(弧度)前要转换,
|
||||
// 否则 360° 会被当成 360 弧度 ≈ 57 圈。
|
||||
rotation={degreesRotationToRadians(activeTrajectoryPoint ? activeTrajectoryPoint.rotation : item.transform.rotation)}
|
||||
scale={item.transform.scale}
|
||||
userData={{ directorObjectId: item.id }}
|
||||
onClick={(event) => {
|
||||
@@ -571,7 +586,9 @@ function ObjectSceneNode({
|
||||
</group>
|
||||
);
|
||||
|
||||
const showGizmo = selected && transformable && !isPlaying;
|
||||
// 选中了本角色的某个轨迹点时,角色被预览到该路点上,此时不再显示角色自身的变换 gizmo,
|
||||
// 避免和轨迹点的拖拽 gizmo 重叠;拖拽由轨迹点 gizmo 负责。
|
||||
const showGizmo = selected && transformable && !isPlaying && !activeTrajectoryPoint;
|
||||
|
||||
if (!showGizmo) return node;
|
||||
|
||||
@@ -612,12 +629,13 @@ function CrowdTransformRig({
|
||||
const group = groupRef.current;
|
||||
if (!group) return;
|
||||
|
||||
// group.rotation 是弧度,存入数据模型前要转成度数
|
||||
updateCrowdTransform(crowdId, {
|
||||
position: [group.position.x, group.position.y, group.position.z],
|
||||
rotation: [group.rotation.x, group.rotation.y, group.rotation.z],
|
||||
rotation: radiansRotationToDegrees([group.rotation.x, group.rotation.y, group.rotation.z]),
|
||||
scale: [group.scale.x, group.scale.y, group.scale.z],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!selected || !transformable || !crowdAnchor) return null;
|
||||
|
||||
@@ -626,7 +644,7 @@ function CrowdTransformRig({
|
||||
<group
|
||||
ref={groupRef}
|
||||
position={crowdAnchor.position}
|
||||
rotation={crowdAnchor.rotation}
|
||||
rotation={degreesRotationToRadians(crowdAnchor.rotation)}
|
||||
scale={crowdAnchor.scale}
|
||||
/>
|
||||
<ViewportTransformControls
|
||||
@@ -847,8 +865,14 @@ function TrajectoryPath({
|
||||
interactive: boolean;
|
||||
}) {
|
||||
const selectTrajectoryPoint = useDirectorStore((state) => state.selectTrajectoryPoint);
|
||||
const updateTrajectoryPoint = useDirectorStore((state) => state.updateTrajectoryPoint);
|
||||
const isPlaying = usePlaybackStore((state) => state.isPlaying);
|
||||
const translationSnap = useDirectorStore((state) => (state.project.scene.snapToGrid ? 1 : null));
|
||||
const selectedGroupRef = useRef<Group>(null!);
|
||||
const points = owner.trajectoryPoints ?? [];
|
||||
const color = owner.color ?? TRAJECTORY_FALLBACK_COLOR;
|
||||
// 只有在「可交互(非打点模式)且非播放中」且确实有选中点时,才给选中点挂拖拽 gizmo
|
||||
const draggable = interactive && !isPlaying && Boolean(selectedPointId);
|
||||
|
||||
/** 平滑曲线采样点:从「角色自身位置」出发,依次穿过每个路点(Catmull-Rom 样条,
|
||||
* centripetal 型避免路径自交打环)。这样轨迹是从角色身上延伸出来的,而不是凭空
|
||||
@@ -881,6 +905,21 @@ function TrajectoryPath({
|
||||
selectTrajectoryPoint(owner.id, pointId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 拖拽过程中实时把 gizmo 移动后的坐标写回选中的轨迹点:
|
||||
* 路点组在场景组局部坐标里抬高了 GROUND_GRID_ELEVATION(小球贴地用),
|
||||
* 这里把抬升减回去,存回的是轨迹语义坐标(y 默认 0 = 贴地)。
|
||||
* 写回后「角色预览」会跟着同一份坐标实时移动,所见即所得。
|
||||
*/
|
||||
function commitSelectedPoint() {
|
||||
const group = selectedGroupRef.current;
|
||||
if (!group || !selectedPointId) return;
|
||||
|
||||
updateTrajectoryPoint(owner.id, selectedPointId, {
|
||||
position: [group.position.x, group.position.y - GROUND_GRID_ELEVATION, group.position.z],
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<group name={`trajectory-${owner.id}`} userData={{ [HIDE_FROM_VIEWPORT_CAPTURE_KEY]: true }}>
|
||||
{sampledCurve ? (
|
||||
@@ -898,6 +937,7 @@ function TrajectoryPath({
|
||||
return (
|
||||
<group
|
||||
key={point.id}
|
||||
ref={selected ? selectedGroupRef : undefined}
|
||||
position={[point.position[0], point.position[1] + GROUND_GRID_ELEVATION, point.position[2]]}
|
||||
>
|
||||
{/* 透明大圆盘只为了让小球更好点中(俯视时点地面圆盘比点小球容易) */}
|
||||
@@ -921,6 +961,16 @@ function TrajectoryPath({
|
||||
</group>
|
||||
);
|
||||
})}
|
||||
{/* 选中点挂拖拽 gizmo:拖到哪,角色预览就跟到哪。打点模式 / 播放中 / 无选中点时不挂。 */}
|
||||
{draggable ? (
|
||||
<ViewportTransformControls
|
||||
mode="translate"
|
||||
object={selectedGroupRef}
|
||||
onObjectChange={commitSelectedPoint}
|
||||
translationSnap={translationSnap}
|
||||
editingId={owner.id}
|
||||
/>
|
||||
) : null}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,12 +27,14 @@ it("maps the scene scale onto all three axes", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("passes the scene position and rotation through unchanged", () => {
|
||||
expect(getSceneGroupTransform(createScene({ position: [1, 3, -2], rotation: [0, 0.5, 0] }))).toEqual({
|
||||
position: [1, 3, -2],
|
||||
rotation: [0, 0.5, 0],
|
||||
scale: [1, 1, 1],
|
||||
});
|
||||
it("converts the scene rotation from degrees to radians for the Three.js group", () => {
|
||||
const transform = getSceneGroupTransform(createScene({ position: [1, 3, -2], rotation: [0, 90, 180] }));
|
||||
|
||||
expect(transform.position).toEqual([1, 3, -2]);
|
||||
expect(transform.rotation[0]).toBeCloseTo(0);
|
||||
expect(transform.rotation[1]).toBeCloseTo(Math.PI / 2);
|
||||
expect(transform.rotation[2]).toBeCloseTo(Math.PI);
|
||||
expect(transform.scale).toEqual([1, 1, 1]);
|
||||
});
|
||||
|
||||
it("keeps the ground plane and the viewport grid on the same world height", () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { SceneSettings } from "../schema/directorProject";
|
||||
import { degreesRotationToRadians, type SceneSettings } from "../schema/directorProject";
|
||||
|
||||
/** 场景组使用的 Three 变换(position / rotation / scale)。 */
|
||||
export interface SceneGroupTransform {
|
||||
@@ -13,11 +13,14 @@ export interface SceneGroupTransform {
|
||||
* 「场景内容」(地面、网格、角色、道具…)必须共用这一套变换,否则会出现
|
||||
* 「两层地面高度不一样」这类错位:地面在场景组内、网格在场景组外时,
|
||||
* 一旦改过场景缩放 / 平移 / 旋转,两者的世界高度就会分叉。
|
||||
*
|
||||
* scene.rotation 存的是度数,Three.js group 的 rotation 属性是弧度,这里统一转换,
|
||||
* 否则 360° 会被当成 360 弧度 ≈ 57 圈。
|
||||
*/
|
||||
export function getSceneGroupTransform(scene: SceneSettings): SceneGroupTransform {
|
||||
return {
|
||||
position: scene.position,
|
||||
rotation: scene.rotation,
|
||||
rotation: degreesRotationToRadians(scene.rotation),
|
||||
scale: [scene.scale, scene.scale, scene.scale],
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user