feat: 旋转单位改为度并支持轨迹点拖拽
统一数据模型中的旋转为度数并在渲染时转弧度,新增 InspectorAngleField 角度控件,为场景与轨迹点提供滑块和重置按钮;选中轨迹点时角色预览到该点并支持拖拽 gizmo 调整位置;机位点击统一走 selectObject 以正确切换右侧属性卡片。
This commit is contained in:
@@ -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],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -525,6 +525,103 @@ export function InspectorRangeNumberField({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 角度一行:标签(带 °)+ 重置按钮 + 滑块 + 数值框。
|
||||
* 滑块和数值框共用同一份 value / onChange,滑块负责快速拖拽(负值也能直观拖出来),
|
||||
* 数值框负责精确输入。
|
||||
*/
|
||||
export function InspectorAngleField({
|
||||
label,
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
step = 1,
|
||||
numberAriaLabel,
|
||||
rangeAriaLabel,
|
||||
resetLabel,
|
||||
onValueChange,
|
||||
onReset,
|
||||
}: {
|
||||
label: string;
|
||||
value: FieldValue;
|
||||
min: number;
|
||||
max: number;
|
||||
step?: number;
|
||||
/** 数值输入框的 aria-label(如「轨迹点水平角」) */
|
||||
numberAriaLabel: string;
|
||||
/** 滑块的 aria-label(如「轨迹点水平角滑块」) */
|
||||
rangeAriaLabel: string;
|
||||
resetLabel?: string;
|
||||
onValueChange: (value: string) => void;
|
||||
onReset?: () => void;
|
||||
}) {
|
||||
const rangeDragCleanupRef = useRef<(() => void) | null>(null);
|
||||
const { beginInteraction, endInteraction } = useUndoBatchInteraction();
|
||||
|
||||
useEffect(() => () => rangeDragCleanupRef.current?.(), []);
|
||||
|
||||
function stopRangeDrag() {
|
||||
window.removeEventListener("pointerup", stopRangeDrag);
|
||||
window.removeEventListener("pointercancel", stopRangeDrag);
|
||||
rangeDragCleanupRef.current = null;
|
||||
endInteraction();
|
||||
}
|
||||
|
||||
function beginRangeDrag() {
|
||||
rangeDragCleanupRef.current?.();
|
||||
beginInteraction();
|
||||
window.addEventListener("pointerup", stopRangeDrag);
|
||||
window.addEventListener("pointercancel", stopRangeDrag);
|
||||
rangeDragCleanupRef.current = stopRangeDrag;
|
||||
}
|
||||
|
||||
const resolvedResetLabel = resetLabel ?? `重置${label}`;
|
||||
|
||||
return (
|
||||
<div className="inspector-field inspector-angle-field" role="group" aria-label={label}>
|
||||
<span className="inspector-field-label inspector-angle-label">{label}°</span>
|
||||
{onReset ? (
|
||||
<button
|
||||
aria-label={resolvedResetLabel}
|
||||
className="inspector-axis-reset-button"
|
||||
title={resolvedResetLabel}
|
||||
type="button"
|
||||
onClick={onReset}
|
||||
>
|
||||
<RotateCcw aria-hidden="true" size={14} strokeWidth={1.9} />
|
||||
</button>
|
||||
) : (
|
||||
<span aria-hidden="true" className="inspector-angle-reset-spacer" />
|
||||
)}
|
||||
<input
|
||||
aria-label={rangeAriaLabel}
|
||||
className="inspector-angle-range"
|
||||
max={max}
|
||||
min={min}
|
||||
step={step}
|
||||
type="range"
|
||||
value={value}
|
||||
onChange={(event) => onValueChange(event.currentTarget.value)}
|
||||
onPointerCancel={stopRangeDrag}
|
||||
onPointerDown={beginRangeDrag}
|
||||
onPointerUp={stopRangeDrag}
|
||||
/>
|
||||
<input
|
||||
aria-label={numberAriaLabel}
|
||||
className="inspector-angle-value"
|
||||
max={max}
|
||||
min={min}
|
||||
step={step}
|
||||
type="number"
|
||||
value={value}
|
||||
onBlur={endInteraction}
|
||||
onChange={(event) => onValueChange(event.currentTarget.value)}
|
||||
onFocus={beginInteraction}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function InspectorColorField({
|
||||
label,
|
||||
colorAriaLabel,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach } from "vitest";
|
||||
import { createInitialDirectorState, useDirectorStore } from "../store/directorStore";
|
||||
import { selectRightPanelKind } from "../store/directorSelectors";
|
||||
import { ObjectTreePanel } from "./ObjectTreePanel";
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -516,3 +517,19 @@ it("switches the active camera when users select a camera row", async () => {
|
||||
expect(useDirectorStore.getState().project.activeCameraId).toBe("cam_1");
|
||||
expect(useDirectorStore.getState().selectedObjectId).toBe("cam_object_1");
|
||||
});
|
||||
|
||||
it("switches the right inspector to the camera card when a camera row is clicked after the scene card", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ObjectTreePanel />);
|
||||
|
||||
// 先进「场景」卡片(directorInspectorMode = "scene")
|
||||
await user.click(screen.getByRole("treeitem", { name: "3D场景" }));
|
||||
expect(selectRightPanelKind(useDirectorStore.getState())).toBe("scene");
|
||||
|
||||
// 再点机位:右侧应切到摄像机属性卡片,而不是继续停在场景卡片
|
||||
await user.click(screen.getByRole("button", { name: "机位01" }));
|
||||
|
||||
expect(selectRightPanelKind(useDirectorStore.getState())).toBe("camera");
|
||||
expect(useDirectorStore.getState().directorInspectorMode).toBe("auto");
|
||||
expect(useDirectorStore.getState().selectedObjectId).toBe("cam_object_1");
|
||||
});
|
||||
|
||||
@@ -85,7 +85,6 @@ export function ObjectTreePanel() {
|
||||
const selectObject = useDirectorStore((state) => state.selectObject);
|
||||
const selectCrowd = useDirectorStore((state) => state.selectCrowd);
|
||||
const toggleObjectSelection = useDirectorStore((state) => state.toggleObjectSelection);
|
||||
const setActiveCamera = useDirectorStore((state) => state.setActiveCamera);
|
||||
const toggleObjectVisible = useDirectorStore((state) => state.toggleObjectVisible);
|
||||
const toggleObjectLocked = useDirectorStore((state) => state.toggleObjectLocked);
|
||||
const deleteSelectedObject = useDirectorStore((state) => state.deleteSelectedObject);
|
||||
@@ -318,10 +317,8 @@ export function ObjectTreePanel() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.object?.kind === "camera" && item.object.linkedCameraId) {
|
||||
setActiveCamera(item.object.linkedCameraId);
|
||||
return;
|
||||
}
|
||||
// 机位与其它对象一样走 selectObject:会选中该对象、把右侧卡片状态复位为 auto,
|
||||
// 并在对象是摄像机时自动激活它的 linkedCameraId,右侧随即切到摄像机属性卡片。
|
||||
selectObject(item.id);
|
||||
}
|
||||
|
||||
|
||||
@@ -97,6 +97,32 @@ it("updates scene transform, panorama, and ground controls", async () => {
|
||||
expect(scene.groundHeight).toBe(1.2);
|
||||
});
|
||||
|
||||
it("gives each scene rotation axis its own slider and reset button", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ScenePanel />);
|
||||
|
||||
const ySlider = screen.getByLabelText("场景旋转 Y滑块");
|
||||
const zSlider = screen.getByLabelText("场景旋转 Z滑块");
|
||||
|
||||
// 每个轴一行:滑杆 + 数值框共用同一份值,范围是 0–360° 一整圈
|
||||
expect(screen.getByLabelText("场景旋转 X滑块")).toHaveAttribute("min", "0");
|
||||
expect(screen.getByLabelText("场景旋转 X滑块")).toHaveAttribute("max", "360");
|
||||
expect(ySlider).toBeInTheDocument();
|
||||
expect(zSlider).toBeInTheDocument();
|
||||
|
||||
// 拖滑杆写值(0–360° 整圈),数值框同步显示同一份值
|
||||
fireEvent.change(ySlider, { target: { value: "120" } });
|
||||
fireEvent.change(zSlider, { target: { value: "210" } });
|
||||
expect(useDirectorStore.getState().project.scene.rotation).toEqual([0, 120, 210]);
|
||||
expect(screen.getByLabelText("场景旋转 Y")).toHaveValue(120);
|
||||
expect(screen.getByLabelText("场景旋转 Z")).toHaveValue(210);
|
||||
|
||||
// 每个轴有独立的重置按钮,只重置自己那一轴,其它轴不受影响
|
||||
await user.click(screen.getByLabelText("重置旋转 Y"));
|
||||
expect(useDirectorStore.getState().project.scene.rotation).toEqual([0, 0, 210]);
|
||||
expect(screen.getByLabelText("场景旋转 Y")).toHaveValue(0);
|
||||
});
|
||||
|
||||
it("renders a connected panorama as a compact thumbnail card with the file name overlay", () => {
|
||||
const initialState = createInitialDirectorState();
|
||||
useDirectorStore.setState({
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState, type ChangeEvent } from "react";
|
||||
import { ImageOff } from "lucide-react";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import {
|
||||
InspectorAngleField,
|
||||
InspectorAxisGroup,
|
||||
InspectorColorField,
|
||||
InspectorPanel,
|
||||
@@ -19,6 +20,9 @@ const SCENE_SCALE_MIN = 0.1;
|
||||
const SCENE_SCALE_MAX = 3;
|
||||
const GROUND_HEIGHT_MIN = -5;
|
||||
const GROUND_HEIGHT_MAX = 5;
|
||||
/** 场景旋转每个轴都是 0–360° 一整圈(与数据模型 / 渲染归一化保持一致) */
|
||||
const SCENE_ROTATION_MIN = 0;
|
||||
const SCENE_ROTATION_MAX = 360;
|
||||
|
||||
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];
|
||||
@@ -28,6 +32,12 @@ function clampNumber(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
/** 角度输入为空或非法值时按 0 处理,避免把 NaN 写进工程 */
|
||||
function toSafeNumber(value: string) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
export function ScenePanel() {
|
||||
const scene = useDirectorStore((state) => state.project.scene);
|
||||
const assets = useDirectorStore((state) => state.project.assets);
|
||||
@@ -101,6 +111,15 @@ export function ScenePanel() {
|
||||
setGroundHeightDraft(String(nextHeight));
|
||||
}
|
||||
|
||||
/** 场景旋转:归一化(0–360° 整圈)由 store 统一处理,这里只写单个轴 */
|
||||
function commitSceneRotation(axis: 0 | 1 | 2, value: string) {
|
||||
updateScene({ rotation: replaceAxis(scene.rotation, axis, toSafeNumber(value)) });
|
||||
}
|
||||
|
||||
function resetSceneRotation(axis: 0 | 1 | 2) {
|
||||
updateScene({ rotation: replaceAxis(scene.rotation, axis, 0) });
|
||||
}
|
||||
|
||||
return (
|
||||
<InspectorPanel title="3D场景" ariaLabel="3D场景右侧属性面板" className="scene-inspector">
|
||||
<InspectorRangeNumberField
|
||||
@@ -150,31 +169,36 @@ export function ScenePanel() {
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<InspectorAxisGroup
|
||||
label="场景旋转"
|
||||
axes={[
|
||||
{
|
||||
axis: "X",
|
||||
ariaLabel: "场景旋转 X",
|
||||
step: "1",
|
||||
value: scene.rotation[0],
|
||||
onChange: (value) => updateScene({ rotation: replaceAxis(scene.rotation, 0, Number(value)) }),
|
||||
},
|
||||
{
|
||||
axis: "Y",
|
||||
ariaLabel: "场景旋转 Y",
|
||||
step: "1",
|
||||
value: scene.rotation[1],
|
||||
onChange: (value) => updateScene({ rotation: replaceAxis(scene.rotation, 1, Number(value)) }),
|
||||
},
|
||||
{
|
||||
axis: "Z",
|
||||
ariaLabel: "场景旋转 Z",
|
||||
step: "1",
|
||||
value: scene.rotation[2],
|
||||
onChange: (value) => updateScene({ rotation: replaceAxis(scene.rotation, 2, Number(value)) }),
|
||||
},
|
||||
]}
|
||||
{/* 场景旋转:X / Y / Z 各自一行,行首带单独重置按钮,滑块 + 数值框共用同一份值(0–360° 一整圈) */}
|
||||
<InspectorAngleField
|
||||
label="旋转 X"
|
||||
min={SCENE_ROTATION_MIN}
|
||||
max={SCENE_ROTATION_MAX}
|
||||
numberAriaLabel="场景旋转 X"
|
||||
rangeAriaLabel="场景旋转 X滑块"
|
||||
value={scene.rotation[0]}
|
||||
onReset={() => resetSceneRotation(0)}
|
||||
onValueChange={(value) => commitSceneRotation(0, value)}
|
||||
/>
|
||||
<InspectorAngleField
|
||||
label="旋转 Y"
|
||||
min={SCENE_ROTATION_MIN}
|
||||
max={SCENE_ROTATION_MAX}
|
||||
numberAriaLabel="场景旋转 Y"
|
||||
rangeAriaLabel="场景旋转 Y滑块"
|
||||
value={scene.rotation[1]}
|
||||
onReset={() => resetSceneRotation(1)}
|
||||
onValueChange={(value) => commitSceneRotation(1, value)}
|
||||
/>
|
||||
<InspectorAngleField
|
||||
label="旋转 Z"
|
||||
min={SCENE_ROTATION_MIN}
|
||||
max={SCENE_ROTATION_MAX}
|
||||
numberAriaLabel="场景旋转 Z"
|
||||
rangeAriaLabel="场景旋转 Z滑块"
|
||||
value={scene.rotation[2]}
|
||||
onReset={() => resetSceneRotation(2)}
|
||||
onValueChange={(value) => commitSceneRotation(2, value)}
|
||||
/>
|
||||
<InspectorSection title="全景背景">
|
||||
{panoramaAsset ? (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach } from "vitest";
|
||||
import { createInitialDirectorState, useDirectorStore } from "../store/directorStore";
|
||||
@@ -73,6 +73,59 @@ it("gives each angle row its own reset button", async () => {
|
||||
expect(readPoints()[0]?.rotation).toEqual([0, 0, 0]);
|
||||
});
|
||||
|
||||
it("exposes angle sliders constrained to their configured ranges", () => {
|
||||
render(<TrajectoryPointPanel />);
|
||||
|
||||
const cases: Array<[string, string, string]> = [
|
||||
["轨迹点水平角滑块", "0", "360"],
|
||||
["轨迹点俯仰角滑块", "-90", "90"],
|
||||
["轨迹点横滚角滑块", "-180", "180"],
|
||||
];
|
||||
|
||||
cases.forEach(([label, min, max]) => {
|
||||
const slider = screen.getByLabelText(label);
|
||||
|
||||
expect(slider).toHaveAttribute("type", "range");
|
||||
expect(slider).toHaveAttribute("min", min);
|
||||
expect(slider).toHaveAttribute("max", max);
|
||||
});
|
||||
});
|
||||
|
||||
it("updates the angle from the slider", () => {
|
||||
render(<TrajectoryPointPanel />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("轨迹点水平角滑块"), { target: { value: "180" } });
|
||||
|
||||
expect(readPoints()[0]?.rotation[1]).toBe(180);
|
||||
});
|
||||
|
||||
it("normalizes typed angles into their ranges (pitch clamps, yaw/roll wrap as full circles)", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TrajectoryPointPanel />);
|
||||
|
||||
await user.clear(screen.getByLabelText("轨迹点水平角"));
|
||||
await user.type(screen.getByLabelText("轨迹点水平角"), "400");
|
||||
await user.clear(screen.getByLabelText("轨迹点俯仰角"));
|
||||
await user.type(screen.getByLabelText("轨迹点俯仰角"), "120");
|
||||
await user.clear(screen.getByLabelText("轨迹点横滚角"));
|
||||
await user.type(screen.getByLabelText("轨迹点横滚角"), "200");
|
||||
|
||||
// rotation = [俯仰角 pitch(X), 水平角 yaw(Y), 横滚角 roll(Z)]
|
||||
// 俯仰角越界夹到 90;水平角整圈 400° ≡ 40°;横滚角整圈 200° ≡ -160°
|
||||
expect(readPoints()[0]?.rotation).toEqual([90, 40, -160]);
|
||||
});
|
||||
|
||||
it("wraps negative horizontal/roll angles around the full circle", () => {
|
||||
render(<TrajectoryPointPanel />);
|
||||
|
||||
// 直接写入带负号的字符串(userEvent 无法在受控 number 输入框里键入负号)
|
||||
fireEvent.change(screen.getByLabelText("轨迹点水平角"), { target: { value: "-20" } });
|
||||
fireEvent.change(screen.getByLabelText("轨迹点横滚角"), { target: { value: "-200" } });
|
||||
|
||||
// 水平角 -20° ≡ 340°;横滚角 -200° ≡ 160°
|
||||
expect(readPoints()[0]?.rotation).toEqual([0, 340, 160]);
|
||||
});
|
||||
|
||||
it("deletes the point from the card", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TrajectoryPointPanel />);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { InspectorAxisGroup, InspectorPanel } from "./InspectorControls";
|
||||
import { InspectorAngleField, InspectorAxisGroup, InspectorPanel } from "./InspectorControls";
|
||||
import { useDirectorStore } from "../store/directorStore";
|
||||
import { TRAJECTORY_ROTATION_LIMITS, normalizeTrajectoryRotation } from "../schema/directorProject";
|
||||
|
||||
/** 角度 / 位置输入为空或非法值时按 0 处理,避免把 NaN 写进工程 */
|
||||
function toSafeNumber(value: string) {
|
||||
@@ -20,7 +21,8 @@ function replaceAxis(
|
||||
* 轨迹点属性卡片:选中角色身上的某个轨迹点时显示在右侧。
|
||||
*
|
||||
* - 位置:x / y / z 三个轴都能调(画布打点时只取 x / z、y 固定 0,之后可以再抬升)
|
||||
* - 朝向:水平角(绕 Y)/ 俯仰角(绕 X)/ 横滚角(绕 Z),每个角度一行,行尾各带重置按钮
|
||||
* - 朝向:水平角(绕 Y,0–360,整圈)/ 俯仰角(绕 X,+90° 趴着、-90° 躺着)/ 横滚角(绕 Z,-180–180,整圈),
|
||||
* 每个角度一行,行首带重置按钮,行内是「滑块 + 数值框」共用同一份值
|
||||
* - 底部:删除这个轨迹点
|
||||
*/
|
||||
export function TrajectoryPointPanel() {
|
||||
@@ -39,7 +41,8 @@ export function TrajectoryPointPanel() {
|
||||
|
||||
function updateRotation(axis: 0 | 1 | 2, value: string) {
|
||||
updateTrajectoryPoint(owner!.id, point!.id, {
|
||||
rotation: replaceAxis(point!.rotation, axis, toSafeNumber(value)),
|
||||
// 角度归一化:俯仰角夹到 [-90,90],水平角/横滚角按整圈取模收敛
|
||||
rotation: replaceAxis(point!.rotation, axis, normalizeTrajectoryRotation(axis, toSafeNumber(value))),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -88,45 +91,36 @@ export function TrajectoryPointPanel() {
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{/* 朝向:三个角度各自一行,行尾分别带重置按钮(重置为 0) */}
|
||||
<InspectorAxisGroup
|
||||
{/* 朝向:三个角度各自一行,行首带重置按钮(重置为 0),滑块 + 数值框共用同一份值 */}
|
||||
<InspectorAngleField
|
||||
label="水平角"
|
||||
max={TRAJECTORY_ROTATION_LIMITS[1].max}
|
||||
min={TRAJECTORY_ROTATION_LIMITS[1].min}
|
||||
numberAriaLabel="轨迹点水平角"
|
||||
rangeAriaLabel="轨迹点水平角滑块"
|
||||
value={point.rotation[1]}
|
||||
onReset={() => resetRotation(1)}
|
||||
axes={[
|
||||
{
|
||||
axis: "Y",
|
||||
ariaLabel: "轨迹点水平角",
|
||||
step: "1",
|
||||
value: point.rotation[1],
|
||||
onChange: (value) => updateRotation(1, value),
|
||||
},
|
||||
]}
|
||||
onValueChange={(value) => updateRotation(1, value)}
|
||||
/>
|
||||
<InspectorAxisGroup
|
||||
<InspectorAngleField
|
||||
label="俯仰角"
|
||||
max={TRAJECTORY_ROTATION_LIMITS[0].max}
|
||||
min={TRAJECTORY_ROTATION_LIMITS[0].min}
|
||||
numberAriaLabel="轨迹点俯仰角"
|
||||
rangeAriaLabel="轨迹点俯仰角滑块"
|
||||
value={point.rotation[0]}
|
||||
onReset={() => resetRotation(0)}
|
||||
axes={[
|
||||
{
|
||||
axis: "X",
|
||||
ariaLabel: "轨迹点俯仰角",
|
||||
step: "1",
|
||||
value: point.rotation[0],
|
||||
onChange: (value) => updateRotation(0, value),
|
||||
},
|
||||
]}
|
||||
onValueChange={(value) => updateRotation(0, value)}
|
||||
/>
|
||||
<InspectorAxisGroup
|
||||
<InspectorAngleField
|
||||
label="横滚角"
|
||||
max={TRAJECTORY_ROTATION_LIMITS[2].max}
|
||||
min={TRAJECTORY_ROTATION_LIMITS[2].min}
|
||||
numberAriaLabel="轨迹点横滚角"
|
||||
rangeAriaLabel="轨迹点横滚角滑块"
|
||||
value={point.rotation[2]}
|
||||
onReset={() => resetRotation(2)}
|
||||
axes={[
|
||||
{
|
||||
axis: "Z",
|
||||
ariaLabel: "轨迹点横滚角",
|
||||
step: "1",
|
||||
value: point.rotation[2],
|
||||
onChange: (value) => updateRotation(2, value),
|
||||
},
|
||||
]}
|
||||
onValueChange={(value) => updateRotation(2, value)}
|
||||
/>
|
||||
<button
|
||||
className="trajectory-point-delete"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Object3D, PerspectiveCamera } from "three";
|
||||
import { degreesRotationToRadians } from "../schema/directorProject";
|
||||
import { evaluateCameraTrackAtTime, evaluateTrackAtTime } from "../schema/timeline";
|
||||
import type { CameraTimelineTrack, TimelineTrack } from "../schema/timeline";
|
||||
import { getViewportCameraQuaternion } from "../canvas/SceneRoot";
|
||||
@@ -32,7 +33,8 @@ export function applyTimelineTransformsAtTime(
|
||||
const value = evaluateTrackAtTime(track, playhead);
|
||||
if (value) {
|
||||
object.position.set(value.position[0], value.position[1], value.position[2]);
|
||||
object.rotation.set(value.rotation[0], value.rotation[1], value.rotation[2]);
|
||||
// 关键帧里的旋转是度数,Three.js 的 rotation 需要弧度
|
||||
object.rotation.set(...degreesRotationToRadians(value.rotation));
|
||||
object.scale.set(value.scale[0], value.scale[1], value.scale[2]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Euler, Vector3 } from "three";
|
||||
import { getGroundedLabelY } from "../runtime/mannequin/bodyTypes";
|
||||
import { getUE4GroundedLabelY } from "../runtime/ue4Mannequin/ue4MannequinRig";
|
||||
import type { DirectorObject, GeometryPrimitiveType } from "./directorProject";
|
||||
import { degreesRotationToRadians, type DirectorObject, type GeometryPrimitiveType } from "./directorProject";
|
||||
|
||||
const IMPORTED_MODEL_FOCUS_OFFSET_Y = 1;
|
||||
|
||||
@@ -47,7 +47,8 @@ export function getDirectorObjectFocusTarget(object: DirectorObject): [number, n
|
||||
const [scaleX, scaleY, scaleZ] = object.transform.scale;
|
||||
const offset = new Vector3(0, getDirectorObjectFocusOffsetY(object), 0)
|
||||
.multiply(new Vector3(scaleX, scaleY, scaleZ))
|
||||
.applyEuler(new Euler(...object.transform.rotation));
|
||||
// 物体旋转存的是度数,Euler 需要弧度
|
||||
.applyEuler(new Euler(...degreesRotationToRadians(object.transform.rotation)));
|
||||
const target = new Vector3(...object.transform.position).add(offset);
|
||||
|
||||
return roundTuple(target);
|
||||
|
||||
@@ -95,6 +95,10 @@ export interface DirectorAssetRef {
|
||||
* 用户在画布上双击地面打点,新建时定位只取 x / z(y 固定为 0),所以默认轨迹是贴地平面的;
|
||||
* rotation 是该路点处角色的朝向,单位是度,顺序与 DirectorTransform 一致:
|
||||
* [俯仰角 pitch(X), 水平角 yaw(Y), 横滚角 roll(Z)]。
|
||||
* 语义(角色正面朝 +Z、头顶朝 +Y):
|
||||
* - 俯仰角 +90° = 趴着(脸朝下)、-90° = 躺着(脸朝上)
|
||||
* - 水平角 0–360° 是一整圈(360° ≡ 0°)
|
||||
* - 横滚角 -180° – 180° 是一整圈(180° ≡ -180°)
|
||||
*/
|
||||
export interface DirectorTrajectoryPoint {
|
||||
id: string;
|
||||
@@ -108,6 +112,88 @@ export interface DirectorTrajectoryPointRef {
|
||||
pointId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 轨迹点朝向角的取值范围(度),下标与 DirectorTrajectoryPoint.rotation 一致:
|
||||
* - 0 俯仰角 pitch(X):-90 – 90(趴着 ↔ 躺着,物理限位,不是整圈)
|
||||
* - 1 水平角 yaw(Y):0 – 360(绕身体竖直轴转一整圈)
|
||||
* - 2 横滚角 roll(Z):-180 – 180(绕身体前后轴侧倾一整圈)
|
||||
*/
|
||||
export const TRAJECTORY_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 DEGREES_TO_RADIANS = Math.PI / 180;
|
||||
export const RADIANS_TO_DEGREES = 180 / Math.PI;
|
||||
|
||||
export function degreesToRadians(degrees: number): number {
|
||||
return degrees * DEGREES_TO_RADIANS;
|
||||
}
|
||||
|
||||
export function radiansToDegrees(radians: number): number {
|
||||
return radians * RADIANS_TO_DEGREES;
|
||||
}
|
||||
|
||||
export function degreesRotationToRadians(rotation: [number, number, number]): [number, number, number] {
|
||||
return [degreesToRadians(rotation[0]), degreesToRadians(rotation[1]), degreesToRadians(rotation[2])];
|
||||
}
|
||||
|
||||
export function radiansRotationToDegrees(rotation: [number, number, number]): [number, number, number] {
|
||||
return [radiansToDegrees(rotation[0]), radiansToDegrees(rotation[1]), radiansToDegrees(rotation[2])];
|
||||
}
|
||||
|
||||
/**
|
||||
* 把角度按整圈收敛到 [min, max];端点保留,避免滑块拖到尽头又跳回另一端:
|
||||
* - 区间内的值原样返回;
|
||||
* - 大于 max 按 (value - min) % (max - min) + min 绕回;
|
||||
* - 小于 min 按 max - (min - value) % (max - min) 绕回。
|
||||
* 非法值(NaN / 空)按 0 处理(0 落在区间内时)。
|
||||
*/
|
||||
export function wrapAngleIntoRange(value: number, min: number, max: number): number {
|
||||
if (!Number.isFinite(value)) return min <= 0 && max >= 0 ? 0 : min;
|
||||
|
||||
const span = max - min;
|
||||
|
||||
if (value > max) return ((value - min) % span) + min;
|
||||
if (value < min) return max - ((min - value) % span);
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把轨迹点朝向角归一化到合法区间:
|
||||
* - 俯仰角(轴 0)是物理限位 [-90, 90],越界直接夹(趴着 / 躺着是端点,不是整圈)。
|
||||
* - 水平角 / 横滚角是整圈,越界按模收敛(360° ≡ 0°、181° ≡ -179°),端点保留。
|
||||
* - 非法值按 0 处理。
|
||||
*/
|
||||
export function normalizeTrajectoryRotation(axis: 0 | 1 | 2, value: number): number {
|
||||
if (!Number.isFinite(value)) return 0;
|
||||
|
||||
if (axis === 0) {
|
||||
const { min, max } = TRAJECTORY_ROTATION_LIMITS[0];
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
const { min, max } = TRAJECTORY_ROTATION_LIMITS[axis];
|
||||
return wrapAngleIntoRange(value, min, max);
|
||||
}
|
||||
|
||||
/** 角色 / 道具 / 群众变换旋转的取值范围(度):每个轴都是 0–360° 一整圈。 */
|
||||
export const TRANSFORM_ROTATION_LIMITS = { min: 0, max: 360 } as const;
|
||||
|
||||
/**
|
||||
* 把对象变换旋转归一化为「每个轴 0–360° 一整圈」:越界按模绕回,端点保留。
|
||||
* 对象旋转是通用欧拉角,不区分俯仰/横滚语义;与轨迹点一致,存入的是度数。
|
||||
*/
|
||||
export function normalizeTransformRotation(rotation: [number, number, number]): [number, number, number] {
|
||||
return [
|
||||
wrapAngleIntoRange(rotation[0], TRANSFORM_ROTATION_LIMITS.min, TRANSFORM_ROTATION_LIMITS.max),
|
||||
wrapAngleIntoRange(rotation[1], TRANSFORM_ROTATION_LIMITS.min, TRANSFORM_ROTATION_LIMITS.max),
|
||||
wrapAngleIntoRange(rotation[2], TRANSFORM_ROTATION_LIMITS.min, TRANSFORM_ROTATION_LIMITS.max),
|
||||
];
|
||||
}
|
||||
|
||||
export interface DirectorObject {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Euler, Quaternion } from "three";
|
||||
import { degreesToRadians, radiansToDegrees } from "./directorProject";
|
||||
|
||||
export type TransformTuple = [number, number, number];
|
||||
|
||||
@@ -100,11 +101,12 @@ const _tmpEuler = new Euler();
|
||||
|
||||
function slerpRotation(a: TransformTuple, b: TransformTuple, t: number): TransformTuple {
|
||||
if (a[0] === b[0] && a[1] === b[1] && a[2] === b[2]) return [a[0], a[1], a[2]];
|
||||
_qa.setFromEuler(_tmpEuler.set(a[0], a[1], a[2]));
|
||||
_qb.setFromEuler(_tmpEuler.set(b[0], b[1], b[2]));
|
||||
// 关键帧里存的是度数;四元数插值要弧度,插完再转回度数,保持数据模型单位一致
|
||||
_qa.setFromEuler(_tmpEuler.set(degreesToRadians(a[0]), degreesToRadians(a[1]), degreesToRadians(a[2])));
|
||||
_qb.setFromEuler(_tmpEuler.set(degreesToRadians(b[0]), degreesToRadians(b[1]), degreesToRadians(b[2])));
|
||||
_qr.slerpQuaternions(_qa, _qb, t);
|
||||
_tmpEuler.setFromQuaternion(_qr);
|
||||
return [_tmpEuler.x, _tmpEuler.y, _tmpEuler.z];
|
||||
return [radiansToDegrees(_tmpEuler.x), radiansToDegrees(_tmpEuler.y), radiansToDegrees(_tmpEuler.z)];
|
||||
}
|
||||
|
||||
function lerpTransformValue(a: KeyframeTransformValue, b: KeyframeTransformValue, t: number): KeyframeTransformValue {
|
||||
|
||||
@@ -708,6 +708,37 @@ it("updates and removes a trajectory point for the owning character", () => {
|
||||
expect(selectRightPanelKind(useDirectorStore.getState())).toBe("character");
|
||||
});
|
||||
|
||||
it("normalizes trajectory point angles (pitch clamps, yaw/roll wrap as full circles)", () => {
|
||||
useDirectorStore.getState().addTrajectoryPoint("char_default_a", [1, 0, 2]);
|
||||
const pointId = useDirectorStore.getState().selectedTrajectoryPoint?.pointId;
|
||||
expect(pointId).toBeTruthy();
|
||||
|
||||
// 俯仰角越界夹到 90;水平角 -20° ≡ 340°;横滚角 300° ≡ -60°
|
||||
useDirectorStore.getState().updateTrajectoryPoint("char_default_a", pointId!, {
|
||||
rotation: [200, -20, 300],
|
||||
});
|
||||
|
||||
expect(readTrajectoryPoints("char_default_a")[0]?.rotation).toEqual([90, 340, -60]);
|
||||
});
|
||||
|
||||
it("normalizes object transform rotation into a single 0–360° circle", () => {
|
||||
useDirectorStore.getState().updateObjectTransform("char_default_a", {
|
||||
rotation: [720, 450, -90],
|
||||
});
|
||||
|
||||
// 角色卡片里的旋转每个轴按 0–360° 整圈收敛:720° → 0°、450° → 90°、-90° → 270°
|
||||
expect(
|
||||
useDirectorStore.getState().project.objects.find((item) => item.id === "char_default_a")?.transform.rotation
|
||||
).toEqual([0, 90, 270]);
|
||||
});
|
||||
|
||||
it("normalizes scene rotation into a single 0–360° circle", () => {
|
||||
useDirectorStore.getState().updateScene({ rotation: [720, 450, -90] });
|
||||
|
||||
// 3D 场景卡片里的场景旋转每个轴按 0–360° 整圈收敛:720° → 0°、450° → 90°、-90° → 270°
|
||||
expect(useDirectorStore.getState().project.scene.rotation).toEqual([0, 90, 270]);
|
||||
});
|
||||
|
||||
it("clears the trajectory point selection when another object is selected", () => {
|
||||
useDirectorStore.getState().addTrajectoryPoint("char_default_a", [1, 0, 1]);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { create } from "zustand";
|
||||
import { MANNEQUIN_POSE_PRESETS } from "../presets/mannequinPosePresets";
|
||||
import { GEOMETRY_PRIMITIVE_OPTIONS } from "../schema/directorProject";
|
||||
import { GEOMETRY_PRIMITIVE_OPTIONS, normalizeTrajectoryRotation, normalizeTransformRotation } from "../schema/directorProject";
|
||||
import type {
|
||||
DirectorAssetRef,
|
||||
DirectorAssetSource,
|
||||
@@ -940,7 +940,8 @@ function applyCrowdTransformPatch(
|
||||
}
|
||||
|
||||
const nextPosition = patch.position ?? anchor.position;
|
||||
const nextRotation = patch.rotation ?? anchor.rotation;
|
||||
// 旋转一律存度数,且按 0–360° 一整圈收敛(避免转很多圈)
|
||||
const nextRotation = patch.rotation ? normalizeTransformRotation(patch.rotation) : anchor.rotation;
|
||||
const nextScale = patch.scale ?? anchor.scale;
|
||||
const deltaRotation: [number, number, number] = [
|
||||
nextRotation[0] - anchor.rotation[0],
|
||||
@@ -1598,9 +1599,16 @@ export const useDirectorStore = create<DirectorStore>((set, get) => {
|
||||
point.id === pointId
|
||||
? {
|
||||
...point,
|
||||
// 直接采用传入的精确值(含小数),不再收敛
|
||||
// 位置直接采用传入的精确值(含小数),不再收敛
|
||||
position: patch.position ? patch.position : point.position,
|
||||
rotation: patch.rotation ? patch.rotation : point.rotation,
|
||||
// 朝向角归一化:俯仰角夹到 [-90,90],水平角/横滚角按整圈取模收敛
|
||||
rotation: patch.rotation
|
||||
? ([
|
||||
normalizeTrajectoryRotation(0, patch.rotation[0]),
|
||||
normalizeTrajectoryRotation(1, patch.rotation[1]),
|
||||
normalizeTrajectoryRotation(2, patch.rotation[2]),
|
||||
] as [number, number, number])
|
||||
: point.rotation,
|
||||
}
|
||||
: point
|
||||
),
|
||||
@@ -1644,6 +1652,8 @@ export const useDirectorStore = create<DirectorStore>((set, get) => {
|
||||
scene: {
|
||||
...state.project.scene,
|
||||
...patch,
|
||||
// 场景旋转一律存度数,且按 0–360° 一整圈收敛(避免转很多圈)
|
||||
...(patch.rotation ? { rotation: normalizeTransformRotation(patch.rotation) } : {}),
|
||||
},
|
||||
},
|
||||
})),
|
||||
@@ -1703,10 +1713,12 @@ export const useDirectorStore = create<DirectorStore>((set, get) => {
|
||||
updateObjectTransform: (id, patch) =>
|
||||
commitMutation((state) => {
|
||||
const currentObject = state.project.objects.find((item) => item.id === id);
|
||||
// 旋转一律存度数,且按 0–360° 一整圈收敛(避免转很多圈)
|
||||
const nextRotation = patch.rotation ? normalizeTransformRotation(patch.rotation) : currentObject?.transform.rotation;
|
||||
const nextTransform = currentObject
|
||||
? {
|
||||
position: patch.position ?? currentObject.transform.position,
|
||||
rotation: patch.rotation ?? currentObject.transform.rotation,
|
||||
rotation: nextRotation ?? currentObject.transform.rotation,
|
||||
scale: patch.scale ?? currentObject.transform.scale,
|
||||
}
|
||||
: null;
|
||||
@@ -1720,7 +1732,7 @@ export const useDirectorStore = create<DirectorStore>((set, get) => {
|
||||
...item,
|
||||
transform: {
|
||||
position: patch.position ?? item.transform.position,
|
||||
rotation: patch.rotation ?? item.transform.rotation,
|
||||
rotation: nextRotation ?? item.transform.rotation,
|
||||
scale: patch.scale ?? item.transform.scale,
|
||||
},
|
||||
})),
|
||||
|
||||
@@ -1108,6 +1108,98 @@ textarea:focus-visible {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 轨迹点角度行:标签(带 °)+ 重置 + 滑块 + 数值框 */
|
||||
.inspector-angle-field {
|
||||
display: grid;
|
||||
grid-template-columns: 52px 22px minmax(0, 1fr) 52px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: var(--right-sidebar-content-width);
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.inspector-angle-label {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.inspector-angle-reset-spacer {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.inspector-angle-range {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 14px;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.inspector-angle-range::-webkit-slider-runnable-track {
|
||||
height: 4px;
|
||||
border-radius: 20px;
|
||||
background: rgb(var(--border-rgb) / 0.72);
|
||||
}
|
||||
|
||||
.inspector-angle-range::-webkit-slider-thumb {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin-top: -5px;
|
||||
appearance: none;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: rgb(var(--text-rgb));
|
||||
box-shadow: 0 1px 2px rgb(0 0 0 / 0.35);
|
||||
}
|
||||
|
||||
.inspector-angle-range::-moz-range-track {
|
||||
height: 4px;
|
||||
border: 0;
|
||||
border-radius: 20px;
|
||||
background: rgb(var(--border-rgb) / 0.72);
|
||||
}
|
||||
|
||||
.inspector-angle-range::-moz-range-thumb {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: rgb(var(--text-rgb));
|
||||
box-shadow: 0 1px 2px rgb(0 0 0 / 0.35);
|
||||
}
|
||||
|
||||
.inspector-angle-value {
|
||||
width: 52px;
|
||||
height: 34px;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
padding: 0 6px;
|
||||
color: rgb(var(--text-rgb));
|
||||
background: rgb(var(--field-rgb));
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 17px;
|
||||
text-align: center;
|
||||
outline: none;
|
||||
appearance: textfield;
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
.inspector-angle-value:focus,
|
||||
.inspector-angle-value:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 1px rgb(var(--accent-rgb) / 0.45);
|
||||
}
|
||||
|
||||
.inspector-angle-value::-webkit-outer-spin-button,
|
||||
.inspector-angle-value::-webkit-inner-spin-button {
|
||||
margin: 0;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.inspector-color-row {
|
||||
display: grid;
|
||||
grid-template-columns: 34px 214px;
|
||||
|
||||
Reference in New Issue
Block a user