feat: 增加轨迹
This commit is contained in:
@@ -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]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user