diff --git a/src/editor/canvas/DirectorCanvas.test.tsx b/src/editor/canvas/DirectorCanvas.test.tsx index 6b9542e..fc29929 100644 --- a/src/editor/canvas/DirectorCanvas.test.tsx +++ b/src/editor/canvas/DirectorCanvas.test.tsx @@ -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]"); diff --git a/src/editor/canvas/DirectorCanvas.tsx b/src/editor/canvas/DirectorCanvas.tsx index de2daf6..d9bb08d 100644 --- a/src/editor/canvas/DirectorCanvas.tsx +++ b/src/editor/canvas/DirectorCanvas.tsx @@ -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) ); } diff --git a/src/editor/canvas/SceneRoot.rotation.test.ts b/src/editor/canvas/SceneRoot.rotation.test.ts index 391d2df..1146bec 100644 --- a/src/editor/canvas/SceneRoot.rotation.test.ts +++ b/src/editor/canvas/SceneRoot.rotation.test.ts @@ -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]); +}); diff --git a/src/editor/canvas/SceneRoot.test.tsx b/src/editor/canvas/SceneRoot.test.tsx index a7992a9..5c3105b 100644 --- a/src/editor/canvas/SceneRoot.test.tsx +++ b/src/editor/canvas/SceneRoot.test.tsx @@ -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(); + + // 选中轨迹点后给该点挂拖拽 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(); expect(idleContainer.querySelector('mesh[name="trajectory-placement-plane"]')).not.toBeInTheDocument(); diff --git a/src/editor/canvas/SceneRoot.tsx b/src/editor/canvas/SceneRoot.tsx index b4498f5..3c2ac8e 100644 --- a/src/editor/canvas/SceneRoot.tsx +++ b/src/editor/canvas/SceneRoot.tsx @@ -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 = ( { @@ -571,7 +586,9 @@ function ObjectSceneNode({ ); - 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({ 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(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 ? ( + + ) : ( +
+ ); +} + 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)} />