feat: 增加轨迹

This commit is contained in:
2026-09-17 09:09:06 +08:00
parent b9fd289caf
commit e74cf6d28b
42 changed files with 4010 additions and 570 deletions

View File

@@ -1,9 +1,11 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { Box3, Vector3 } from "three";
import { Box3, DoubleSide, Vector3 } from "three";
import { afterEach, beforeEach, vi } from "vitest";
import { VIEWPORT_CAMERA_VISUAL_SCALE } from "../schema/cameraGeometry";
import { createInitialDirectorState, useDirectorStore } from "../store/directorStore";
import { getImportedModelNormalization, SceneRoot } from "./SceneRoot";
import { getImportedModelNormalization, getTrajectoryGroundPoint, SceneRoot } from "./SceneRoot";
import { AXIS_COLORS } from "./sceneAxes";
import { GROUND_GRID_ELEVATION, GROUND_PLANE_SIZE } from "./sceneGround";
const mockCharacterModelShouldSuspend = vi.hoisted(() => ({ current: false }));
@@ -52,6 +54,7 @@ vi.mock("@react-three/drei", async () => {
onClick,
points,
transparent,
userData,
}: {
color?: string;
lineWidth?: number;
@@ -60,10 +63,12 @@ vi.mock("@react-three/drei", async () => {
opacity?: number;
points?: Array<[number, number, number]>;
transparent?: boolean;
userData?: Record<string, unknown>;
}) => (
<div
data-clickable={onClick ? "true" : "false"}
data-color={color}
data-hide-from-capture={userData?.hideFromViewportCapture ? "true" : "false"}
data-line-width={lineWidth}
data-name={name}
data-opacity={opacity}
@@ -266,6 +271,81 @@ it("uses the requested dark ground surface color", () => {
expect(groundMaterial?.outerHTML.toLowerCase()).toContain("polygonoffsetunits=\"1\"");
});
it("keeps the ground visible when the view drops below the ground plane", () => {
const { container } = render(<SceneRoot />);
const groundMaterial = container.querySelector('meshbasicmaterial[color="#303640"]');
// 单面渲染时,视角落到地面以下会因背面剔除整块消失
expect(groundMaterial).toHaveAttribute("side", String(DoubleSide));
});
it("sizes the ground plane with the shared footprint constant used by the viewport grid", () => {
const { container } = render(<SceneRoot />);
const groundGeometry = container.querySelector("planegeometry");
expect(groundGeometry).toHaveAttribute("args", `${GROUND_PLANE_SIZE},${GROUND_PLANE_SIZE}`);
});
it("draws X and Z ground axes straight through the origin with the matching gizmo colors", () => {
const { container } = render(<SceneRoot />);
const xAxis = container.querySelector('[data-name="ground-axis-x"]');
const zAxis = container.querySelector('[data-name="ground-axis-z"]');
expect(xAxis).toHaveAttribute("data-color", AXIS_COLORS.x);
expect(zAxis).toHaveAttribute("data-color", AXIS_COLORS.z);
// 两端对称于原点 → 从地面一边贯通到另一边(不是只画负半轴)
expect(JSON.parse(xAxis?.getAttribute("data-points") ?? "[]")).toEqual([
[-GROUND_PLANE_SIZE / 2, GROUND_GRID_ELEVATION, 0],
[GROUND_PLANE_SIZE / 2, GROUND_GRID_ELEVATION, 0],
]);
expect(JSON.parse(zAxis?.getAttribute("data-points") ?? "[]")).toEqual([
[0, GROUND_GRID_ELEVATION, -GROUND_PLANE_SIZE / 2],
[0, GROUND_GRID_ELEVATION, GROUND_PLANE_SIZE / 2],
]);
// 和网格一样属于编辑器辅助元素,不参与截图 / 视频导出
expect(xAxis).toHaveAttribute("data-hide-from-capture", "true");
expect(zAxis).toHaveAttribute("data-hide-from-capture", "true");
});
it("hides the ground axes together with the ground toggle", () => {
useDirectorStore.setState({
...useDirectorStore.getState(),
project: {
...useDirectorStore.getState().project,
scene: { ...useDirectorStore.getState().project.scene, showGround: false },
},
});
const { container } = render(<SceneRoot />);
expect(container.querySelector('[data-name="ground-axis-x"]')).not.toBeInTheDocument();
expect(container.querySelector('[data-name="ground-axis-z"]')).not.toBeInTheDocument();
});
it("keeps the ground plane inside the shared scene transform group", () => {
useDirectorStore.setState({
...useDirectorStore.getState(),
project: {
...useDirectorStore.getState().project,
scene: {
...useDirectorStore.getState().project.scene,
scale: 2,
position: [1, 3, -2],
rotation: [0, 0.5, 0],
groundHeight: 1.5,
},
},
});
const { container } = render(<SceneRoot />);
const groundPlane = container.querySelector('meshbasicmaterial[color="#303640"]')?.closest("group");
// 地面所在的场景组必须和视口网格用同一套变换,否则会分成两层不同高度的地面
expect(groundPlane).toHaveAttribute("position", "1,3,-2");
expect(groundPlane).toHaveAttribute("rotation", "0,0.5,0");
expect(groundPlane).toHaveAttribute("scale", "2,2,2");
});
it("renders added geometry primitives as light blue-white models", () => {
useDirectorStore.getState().addGeometryPrimitive("sphere");
@@ -563,3 +643,56 @@ it("uses the built-in UE4 mannequin rig for default generated characters", () =>
expect(screen.getByTestId("mock-character-model")).toHaveAttribute("data-rig-type", "ue4-mannequin");
});
it("renders solid trajectory spheres and a smooth curve through the waypoints", () => {
useDirectorStore.getState().addTrajectoryPoint("char_default_a", [1, 0, 2]);
useDirectorStore.getState().addTrajectoryPoint("char_default_a", [3, 0, 4]);
const { container } = render(<SceneRoot />);
const curve = container.querySelector('[data-name="trajectory-curve-char_default_a"]');
expect(curve).toBeInTheDocument();
// 和网格 / 坐标轴一样属于编辑器辅助元素
expect(curve).toHaveAttribute("data-hide-from-capture", "true");
const sampled = JSON.parse(curve?.getAttribute("data-points") ?? "[]");
// 平滑曲线(Catmull-Rom 样条)从「角色位置」出发、再穿过每个路点:
// 起点 = 角色(char_default_a 在原点,抬到网格高度)→ 第一个路点 (1,_,2) → 最后一个路点 (3,_,4)
// 端点(t=0 / t=1)被 getPoints 精确采样到控制点;中段控制点未必被等距采样精确命中,故用宽松容差
expect(sampled[0][0]).toBeCloseTo(0, 6);
expect(sampled[0][1]).toBeCloseTo(GROUND_GRID_ELEVATION, 6);
expect(sampled[0][2]).toBeCloseTo(0, 6);
expect(sampled[sampled.length - 1][0]).toBeCloseTo(3, 6);
expect(sampled[sampled.length - 1][1]).toBeCloseTo(GROUND_GRID_ELEVATION, 6);
expect(sampled[sampled.length - 1][2]).toBeCloseTo(4, 6);
// 曲线确实经过第一个路点(样条中段某处接近 (1,_,2))
const passesFirstWaypoint = sampled.some(
(p: number[]) => Math.abs(p[0] - 1) < 0.1 && Math.abs(p[2] - 2) < 0.1
);
expect(passesFirstWaypoint).toBe(true);
expect(sampled.length).toBeGreaterThan(2);
// 两个实心小球(路点标记)
expect(container.querySelectorAll('mesh[name^="trajectory-point-"]')).toHaveLength(2);
});
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();
useDirectorStore.getState().startTrajectoryPlacement("char_default_a");
const { container } = render(<SceneRoot />);
const plane = container.querySelector('mesh[name="trajectory-placement-plane"]');
expect(plane).toBeInTheDocument();
expect(plane?.querySelector("meshbasicmaterial")).toHaveAttribute("opacity", "0");
});
it("getTrajectoryGroundPoint keeps the Z axis instead of collapsing it onto the X axis", () => {
// 现在落点用的是「场景组局部坐标」:点击地面得到 (x, 高度, z),Z 就是真实的地面纵轴深度
expect(getTrajectoryGroundPoint(new Vector3(3, 0, 5))).toEqual([3, 0, 5]);
expect(getTrajectoryGroundPoint(new Vector3(-2, 2, -4))).toEqual([-2, 0, -4]);
// 若错误地丢弃 Z(比如写成 [x, 0, 0]),所有点会塌到 X 轴
expect(getTrajectoryGroundPoint(new Vector3(3, 0, 5))).not.toEqual([3, 0, 0]);
});