(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 (
{sampledCurve ? (
@@ -898,6 +937,7 @@ function TrajectoryPath({
return (
{/* 透明大圆盘只为了让小球更好点中(俯视时点地面圆盘比点小球容易) */}
@@ -921,6 +961,16 @@ function TrajectoryPath({
);
})}
+ {/* 选中点挂拖拽 gizmo:拖到哪,角色预览就跟到哪。打点模式 / 播放中 / 无选中点时不挂。 */}
+ {draggable ? (
+
+ ) : null}
);
}
diff --git a/src/editor/canvas/sceneTransform.test.ts b/src/editor/canvas/sceneTransform.test.ts
index c5ab258..e042f65 100644
--- a/src/editor/canvas/sceneTransform.test.ts
+++ b/src/editor/canvas/sceneTransform.test.ts
@@ -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", () => {
diff --git a/src/editor/canvas/sceneTransform.ts b/src/editor/canvas/sceneTransform.ts
index 543ace8..289b8f6 100644
--- a/src/editor/canvas/sceneTransform.ts
+++ b/src/editor/canvas/sceneTransform.ts
@@ -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],
};
}
diff --git a/src/editor/panels/InspectorControls.tsx b/src/editor/panels/InspectorControls.tsx
index c8e8c6b..6958a24 100644
--- a/src/editor/panels/InspectorControls.tsx
+++ b/src/editor/panels/InspectorControls.tsx
@@ -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 (
+
+ {label}°
+ {onReset ? (
+
+ ) : (
+
+ )}
+ onValueChange(event.currentTarget.value)}
+ onPointerCancel={stopRangeDrag}
+ onPointerDown={beginRangeDrag}
+ onPointerUp={stopRangeDrag}
+ />
+ onValueChange(event.currentTarget.value)}
+ onFocus={beginInteraction}
+ />
+
+ );
+}
+
export function InspectorColorField({
label,
colorAriaLabel,
diff --git a/src/editor/panels/ObjectTreePanel.test.tsx b/src/editor/panels/ObjectTreePanel.test.tsx
index 3bd009d..8996e1d 100644
--- a/src/editor/panels/ObjectTreePanel.test.tsx
+++ b/src/editor/panels/ObjectTreePanel.test.tsx
@@ -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();
+
+ // 先进「场景」卡片(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");
+});
diff --git a/src/editor/panels/ObjectTreePanel.tsx b/src/editor/panels/ObjectTreePanel.tsx
index ba6b016..ed8b2e6 100644
--- a/src/editor/panels/ObjectTreePanel.tsx
+++ b/src/editor/panels/ObjectTreePanel.tsx
@@ -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);
}
diff --git a/src/editor/panels/ScenePanel.test.tsx b/src/editor/panels/ScenePanel.test.tsx
index ebf8216..a7e35f5 100644
--- a/src/editor/panels/ScenePanel.test.tsx
+++ b/src/editor/panels/ScenePanel.test.tsx
@@ -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();
+
+ 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({
diff --git a/src/editor/panels/ScenePanel.tsx b/src/editor/panels/ScenePanel.tsx
index 641bb92..4262d6f 100644
--- a/src/editor/panels/ScenePanel.tsx
+++ b/src/editor/panels/ScenePanel.tsx
@@ -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 (
- 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° 一整圈) */}
+ resetSceneRotation(0)}
+ onValueChange={(value) => commitSceneRotation(0, value)}
+ />
+ resetSceneRotation(1)}
+ onValueChange={(value) => commitSceneRotation(1, value)}
+ />
+ resetSceneRotation(2)}
+ onValueChange={(value) => commitSceneRotation(2, value)}
/>
{panoramaAsset ? (
diff --git a/src/editor/panels/TrajectoryPointPanel.test.tsx b/src/editor/panels/TrajectoryPointPanel.test.tsx
index 91d7840..d1bd72c 100644
--- a/src/editor/panels/TrajectoryPointPanel.test.tsx
+++ b/src/editor/panels/TrajectoryPointPanel.test.tsx
@@ -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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ // 直接写入带负号的字符串(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();
diff --git a/src/editor/panels/TrajectoryPointPanel.tsx b/src/editor/panels/TrajectoryPointPanel.tsx
index 2344d48..db04287 100644
--- a/src/editor/panels/TrajectoryPointPanel.tsx
+++ b/src/editor/panels/TrajectoryPointPanel.tsx
@@ -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) */}
- resetRotation(1)}
- axes={[
- {
- axis: "Y",
- ariaLabel: "轨迹点水平角",
- step: "1",
- value: point.rotation[1],
- onChange: (value) => updateRotation(1, value),
- },
- ]}
+ onValueChange={(value) => updateRotation(1, value)}
/>
- resetRotation(0)}
- axes={[
- {
- axis: "X",
- ariaLabel: "轨迹点俯仰角",
- step: "1",
- value: point.rotation[0],
- onChange: (value) => updateRotation(0, value),
- },
- ]}
+ onValueChange={(value) => updateRotation(0, value)}
/>
- resetRotation(2)}
- axes={[
- {
- axis: "Z",
- ariaLabel: "轨迹点横滚角",
- step: "1",
- value: point.rotation[2],
- onChange: (value) => updateRotation(2, value),
- },
- ]}
+ onValueChange={(value) => updateRotation(2, value)}
/>