feat: 旋转单位改为度并支持轨迹点拖拽

统一数据模型中的旋转为度数并在渲染时转弧度,新增 InspectorAngleField 角度控件,为场景与轨迹点提供滑块和重置按钮;选中轨迹点时角色预览到该点并支持拖拽 gizmo 调整位置;机位点击统一走 selectObject 以正确切换右侧属性卡片。
This commit is contained in:
2026-09-17 11:18:58 +08:00
parent e74cf6d28b
commit 0794486857
21 changed files with 659 additions and 99 deletions

View File

@@ -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]");

View File

@@ -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)
);
}

View File

@@ -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]);
});

View File

@@ -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();

View File

@@ -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>
);
}

View File

@@ -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", () => {

View File

@@ -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],
};
}

View File

@@ -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,

View File

@@ -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");
});

View File

@@ -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);
}

View File

@@ -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({

View File

@@ -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 ? (

View File

@@ -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 />);

View File

@@ -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"

View File

@@ -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]);
}
}

View File

@@ -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);

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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]);

View File

@@ -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,
},
})),

View File

@@ -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;