Files
ttstd-3d-director/src/editor/canvas/SceneRoot.test.tsx

757 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { readFileSync } from "node:fs";
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 { usePlaybackStore } from "../runtime/playbackStore";
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 }));
vi.mock("@react-three/drei", async () => {
const actual = await vi.importActual<typeof import("@react-three/drei")>("@react-three/drei");
return {
...actual,
Html: ({
center,
children,
distanceFactor,
pointerEvents,
position,
sprite,
transform,
zIndexRange,
}: {
center?: boolean;
children?: React.ReactNode;
distanceFactor?: number;
pointerEvents?: string;
position?: [number, number, number];
sprite?: boolean;
transform?: boolean;
zIndexRange?: [number, number];
}) => (
<div
data-center={center ? "true" : "false"}
data-distance-factor={distanceFactor}
data-pointer-events={pointerEvents}
data-position={JSON.stringify(position)}
data-sprite={sprite ? "true" : "false"}
data-testid="html-label"
data-transform={transform ? "true" : "false"}
data-z-index-range={JSON.stringify(zIndexRange)}
>
{children}
</div>
),
Line: ({
color,
lineWidth,
name,
opacity,
onClick,
points,
transparent,
userData,
}: {
color?: string;
lineWidth?: number;
name?: string;
onClick?: React.MouseEventHandler<HTMLDivElement>;
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}
data-point-count={points?.length}
data-points={JSON.stringify(points)}
data-testid="camera-line"
data-transparent={transparent ? "true" : "false"}
onClick={onClick}
/>
),
useProgress: () => ({ progress: 0 }),
Bounds: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
TransformControls: ({
children,
mode,
object,
translationSnap,
userData,
}: {
children?: React.ReactNode;
mode?: string;
object?: { current: unknown };
translationSnap?: number | null;
userData?: Record<string, unknown>;
}) => (
<div
data-has-object={object ? "true" : "false"}
data-hide-from-capture={userData?.hideFromViewportCapture ? "true" : "false"}
data-mode={mode}
data-translation-snap={translationSnap == null ? "null" : String(translationSnap)}
data-testid="transform-controls"
>
{children}
</div>
),
};
});
vi.mock("../runtime/PrimitiveMannequin", () => ({
PrimitiveMannequin: ({
bodyType,
color,
rigState,
}: {
bodyType?: string;
color?: string;
rigState?: { rigType?: string };
}) => (
<div
data-body-type={bodyType}
data-color={color}
data-rig-type={rigState?.rigType}
data-testid="mock-primitive-mannequin"
/>
),
}));
vi.mock("../runtime/CharacterModel", async () => {
const React = await vi.importActual<typeof import("react")>("react");
return {
CharacterModel: ({
bodyType,
color,
onLabelAnchorYChange,
rigState,
}: {
bodyType?: string;
color?: string;
onLabelAnchorYChange?: (anchorY: number) => void;
rigState?: { rigType?: string };
}) => {
if (mockCharacterModelShouldSuspend.current) {
throw new Promise(() => undefined);
}
const labelAnchorY = bodyType === "chibi" ? 0.92 : 2.04;
React.useLayoutEffect(() => {
onLabelAnchorYChange?.(labelAnchorY);
}, [labelAnchorY, onLabelAnchorYChange]);
return (
<div data-body-type={bodyType} data-color={color} data-rig-type={rigState?.rigType} data-testid="mock-character-model" />
);
},
};
});
beforeEach(() => {
vi.spyOn(console, "error").mockImplementation(() => {});
mockCharacterModelShouldSuspend.current = false;
usePlaybackStore.setState({ isPlaying: false, playhead: 0, duration: 5, editingObjectId: null });
const base = createInitialDirectorState();
useDirectorStore.setState({
...useDirectorStore.getState(),
...base,
project: {
...base.project,
panoramaAssetId: "asset_panorama_1",
assets: [
{
id: "asset_panorama_1",
kind: "panorama",
sourceType: "image",
fileName: "studio-360.jpg",
url: "blob:studio-360",
},
],
},
});
});
afterEach(() => {
vi.restoreAllMocks();
});
function getUniquePoints(points: Array<[number, number, number]>) {
return Array.from(new Map(points.map((point) => [point.join(","), point])).values());
}
it("renders role labels while panorama background is managed by the viewport layer", () => {
render(<SceneRoot />);
expect(screen.queryByTestId("panorama-sphere")).not.toBeInTheDocument();
expect(screen.getByText("角色01")).toBeInTheDocument();
});
it("does not flash the procedural mannequin while the default UE4 role model is loading", () => {
mockCharacterModelShouldSuspend.current = true;
render(<SceneRoot />);
expect(screen.queryByTestId("mock-character-model")).not.toBeInTheDocument();
expect(screen.queryByTestId("mock-primitive-mannequin")).not.toBeInTheDocument();
expect(screen.getByText("角色01")).toBeInTheDocument();
expect(screen.getByText("机位01")).toBeInTheDocument();
});
it("renders role labels as centered 3D billboards that scale with viewport distance", () => {
render(<SceneRoot />);
const label = screen.getByText("角色01").closest('[data-testid="html-label"]');
expect(label).toHaveAttribute("data-position", "[0,2.04,0]");
expect(label).toHaveAttribute("data-center", "true");
expect(label).toHaveAttribute("data-transform", "true");
expect(label).toHaveAttribute("data-sprite", "true");
expect(label).toHaveAttribute("data-pointer-events", "none");
expect(label).toHaveAttribute("data-distance-factor", "3");
expect(label).toHaveAttribute("data-z-index-range", "[0,1]");
});
it("updates short role labels from the measured character model height", async () => {
useDirectorStore.getState().updateCharacterBodyType("char_default_a", "chibi");
render(<SceneRoot />);
const label = screen.getByText("角色01").closest('[data-testid="html-label"]');
await waitFor(() => {
expect(label).toHaveAttribute("data-position", "[0,0.92,0]");
});
});
it("passes distinct character colors into every viewport character model", () => {
useDirectorStore.getState().addPresetCharacter("female");
useDirectorStore.getState().addPresetCharacter("teen");
render(<SceneRoot />);
const characterColors = screen.getAllByTestId("mock-character-model").map((model) => model.dataset.color);
expect(characterColors).toEqual(["#4F8EF7", "#E0524D", "#E91E63"]);
expect(new Set(characterColors).size).toBe(characterColors.length);
});
it("renders viewport camera labels with the same 3D label behavior as role labels", () => {
render(<SceneRoot />);
const label = screen.getByText("机位01").closest('[data-testid="html-label"]');
const position = JSON.parse(label?.getAttribute("data-position") ?? "[]") as [number, number, number];
expect(position[0]).toBeCloseTo(0);
expect(position[1]).toBeCloseTo(0.65 * VIEWPORT_CAMERA_VISUAL_SCALE + 0.18);
expect(position[2]).toBeCloseTo(0);
expect(label).toHaveAttribute("data-center", "true");
expect(label).toHaveAttribute("data-transform", "true");
expect(label).toHaveAttribute("data-sprite", "true");
expect(label).toHaveAttribute("data-pointer-events", "none");
expect(label).toHaveAttribute("data-distance-factor", "3");
expect(label).toHaveAttribute("data-z-index-range", "[0,1]");
});
it("uses the requested dark ground surface color", () => {
const { container } = render(<SceneRoot />);
const groundMaterial = container.querySelector('meshbasicmaterial[color="#303640"]');
expect(groundMaterial).toBeInTheDocument();
expect(groundMaterial?.outerHTML.toLowerCase()).toContain("polygonoffsetfactor=\"1\"");
expect(groundMaterial?.outerHTML.toLowerCase()).toContain("polygonoffsetunits=\"1\"");
});
it("paints the ground with the ground color from the scene settings", () => {
useDirectorStore.getState().updateScene({ groundColor: "#204060" });
const { container } = render(<SceneRoot />);
// 地面颜色和天空颜色一样是工程设置:面板上改了颜色,视口里的地面材质就得跟着变
expect(container.querySelector('meshbasicmaterial[color="#204060"]')).toBeInTheDocument();
expect(container.querySelector('meshbasicmaterial[color="#303640"]')).not.toBeInTheDocument();
});
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, 90, 0],
groundHeight: 1.5,
},
},
});
const { container } = render(<SceneRoot />);
const groundPlane = container.querySelector('meshbasicmaterial[color="#303640"]')?.closest("group");
// 地面所在的场景组必须和视口网格用同一套变换,否则会分成两层不同高度的地面
expect(groundPlane).toHaveAttribute("position", "1,3,-2");
// 场景旋转存的是度数,写进 Three.js group 前会转成弧度(90° → π/2)
expect(groundPlane).toHaveAttribute("rotation", degreesRotationToRadians([0, 90, 0]).join(","));
expect(groundPlane).toHaveAttribute("scale", "2,2,2");
});
it("renders added geometry primitives as light blue-white models", () => {
useDirectorStore.getState().addGeometryPrimitive("sphere");
const { container } = render(<SceneRoot />);
expect(container.querySelector('mesh[name="geometry-sphere"]')).toBeInTheDocument();
expect(container.querySelector("spheregeometry")).toBeInTheDocument();
expect(container.querySelector('meshstandardmaterial[color="#d7e7ff"]')).toBeInTheDocument();
});
it("does not render name labels above geometry primitives", () => {
useDirectorStore.getState().addGeometryPrimitive("sphere");
render(<SceneRoot />);
expect(screen.getByText("角色01")).toBeInTheDocument();
expect(screen.getByText("机位01")).toBeInTheDocument();
expect(screen.queryByText("球体")).not.toBeInTheDocument();
});
it("grounds the torus geometry primitive on the floor", () => {
useDirectorStore.getState().addGeometryPrimitive("torus");
const { container } = render(<SceneRoot />);
const torus = container.querySelector('mesh[name="geometry-torus"]');
const geometry = torus?.querySelector("torusgeometry");
const position = String(torus?.getAttribute("position") ?? "")
.split(",")
.map(Number) as [number, number, number];
const [, tubeRadius] = String(geometry?.getAttribute("args") ?? "")
.split(",")
.map(Number);
expect(torus).toBeInTheDocument();
expect(position[1] - tubeRadius).toBeCloseTo(0);
});
it("normalizes imported model bounds to a director-desk friendly size on the ground", () => {
const normalization = getImportedModelNormalization(
new Box3(new Vector3(2, -3, -4), new Vector3(12, 2, 2)),
2
);
expect(normalization.scale).toBeCloseTo(0.2);
expect(normalization.position[0]).toBeCloseTo(-1.4);
expect(normalization.position[1]).toBeCloseTo(0.6);
expect(normalization.position[2]).toBeCloseTo(0.2);
});
it("wraps each imported model in its own loading boundary so the rest of the scene stays mounted", () => {
const base = createInitialDirectorState();
useDirectorStore.setState({
...useDirectorStore.getState(),
...base,
project: {
...base.project,
assets: [
{
id: "asset_model_1",
kind: "prop",
sourceType: "model",
fileName: "microwave_low.fbx",
url: "blob:microwave",
},
],
objects: [
...base.project.objects,
{
id: "obj_model_1",
name: "微波炉",
kind: "prop",
visible: true,
locked: false,
assetRefId: "asset_model_1",
transform: {
position: [0, 0, 0],
rotation: [0, 0, 0],
scale: [1, 1, 1],
},
},
],
},
});
render(<SceneRoot />);
expect(screen.getByText("角色01")).toBeInTheDocument();
expect(screen.getByText("机位01")).toBeInTheDocument();
});
it("keeps imported model normalization neutral for empty bounds", () => {
const normalization = getImportedModelNormalization(new Box3(), 2);
expect(normalization.scale).toBe(1);
expect(normalization.position).toEqual([0, 0, 0]);
});
it("renders the viewport camera as a reference-style blue wireframe model and viewfinder", () => {
const { container } = render(<SceneRoot />);
expect(screen.getByText("机位01")).toBeInTheDocument();
expect(container.querySelector('meshstandardmaterial[color="#D88900"]')).not.toBeInTheDocument();
expect(container.querySelector('meshstandardmaterial[color="#05070A"]')).not.toBeInTheDocument();
expect(container.querySelector('meshstandardmaterial[color="#FF2B19"]')).not.toBeInTheDocument();
const blueLines = screen.getAllByTestId("camera-line").filter((line) => line.dataset.color === "#A9D8FF");
const bodyLines = blueLines.filter((line) => line.dataset.name?.includes("-body-"));
const lensLines = blueLines.filter((line) => line.dataset.name?.includes("-lens-"));
const reelLines = blueLines.filter((line) => line.dataset.name?.includes("-reel-"));
const viewfinderLines = blueLines.filter((line) => line.dataset.name?.includes("-viewfinder-"));
const modelLines = [...bodyLines, ...lensLines, ...reelLines];
const allBodyPoints = bodyLines.flatMap((line) => JSON.parse(line.dataset.points ?? "[]") as Array<[number, number, number]>);
const bodyWidth = Math.max(...allBodyPoints.map((point) => point[0])) - Math.min(...allBodyPoints.map((point) => point[0]));
const bodyHeight = Math.max(...allBodyPoints.map((point) => point[1])) - Math.min(...allBodyPoints.map((point) => point[1]));
const bodyDepth = Math.max(...allBodyPoints.map((point) => point[2])) - Math.min(...allBodyPoints.map((point) => point[2]));
const closedLensRings = lensLines.filter((line) => {
const points = JSON.parse(line.dataset.points ?? "[]") as Array<[number, number, number]>;
const firstPoint = points[0];
const lastPoint = points[points.length - 1];
return (
points.length === 5 &&
firstPoint?.[0] === lastPoint?.[0] &&
firstPoint?.[1] === lastPoint?.[1] &&
firstPoint?.[2] === lastPoint?.[2]
);
});
expect(modelLines).toHaveLength(20);
expect(bodyWidth).toBeCloseTo(0.4 * VIEWPORT_CAMERA_VISUAL_SCALE);
expect(bodyHeight).toBeCloseTo(0.4 * VIEWPORT_CAMERA_VISUAL_SCALE);
expect(bodyDepth / bodyWidth).toBeGreaterThan(2.4);
expect(lensLines).toHaveLength(6);
expect(reelLines).toHaveLength(2);
expect(reelLines.every((line) => Number(line.dataset.pointCount) > 20)).toBe(true);
expect(closedLensRings).toHaveLength(2);
expect(viewfinderLines).toHaveLength(8);
expect(new Set(blueLines.map((line) => line.dataset.color))).toEqual(new Set(["#A9D8FF"]));
expect(new Set(blueLines.map((line) => line.dataset.opacity))).toEqual(new Set(["0.92"]));
expect(new Set(blueLines.map((line) => line.dataset.lineWidth))).toEqual(new Set(["1"]));
expect(modelLines.every((line) => line.dataset.clickable === "true")).toBe(true);
expect(viewfinderLines[0]).toHaveAttribute("data-transparent", "true");
expect(viewfinderLines[0]).toHaveAttribute("data-point-count", "2");
const firstLinePoints = JSON.parse(viewfinderLines[0].dataset.points ?? "[]") as Array<[number, number, number]>;
const lensPoints = lensLines.flatMap((line) => JSON.parse(line.dataset.points ?? "[]") as Array<[number, number, number]>);
const lensMaxZ = Math.max(...lensPoints.map((point) => point[2]));
const frontFramePoints = getUniquePoints(lensPoints.filter((point) => point[2] === lensMaxZ));
const lensFrontCenter = frontFramePoints
.reduce(
(acc, point) => [acc[0] + point[0], acc[1] + point[1], acc[2] + point[2]] as [number, number, number],
[0, 0, 0] as [number, number, number]
)
.map((value) => Number((value / frontFramePoints.length).toFixed(6))) as [number, number, number];
expect(firstLinePoints[0]?.[0]).toBeCloseTo(lensFrontCenter[0]);
expect(firstLinePoints[0]?.[1]).toBeCloseTo(lensFrontCenter[1]);
expect(firstLinePoints[0]?.[2]).toBeCloseTo(lensFrontCenter[2]);
expect(firstLinePoints[1]?.[2]).toBeGreaterThan(firstLinePoints[0]?.[2] ?? 0);
});
it("selects the viewport camera when users click a camera model line", () => {
render(<SceneRoot />);
const bodyLine = screen.getAllByTestId("camera-line").find((line) => line.dataset.name?.includes("-body-"));
expect(bodyLine).toHaveAttribute("data-clickable", "true");
fireEvent.click(bodyLine!);
expect(useDirectorStore.getState().selectedObjectId).toBe("cam_object_1");
});
it("selects the viewport camera from the enlarged wireframe hit area", () => {
const { container } = render(<SceneRoot />);
const hitArea = container.querySelector('mesh[name="cam_1-hit-area"]');
expect(hitArea).toBeInTheDocument();
fireEvent.click(hitArea!);
expect(useDirectorStore.getState().selectedObjectId).toBe("cam_object_1");
});
it("shows transform controls when a viewport camera is selected", () => {
useDirectorStore.setState({
...useDirectorStore.getState(),
selectedObjectId: "cam_object_1",
transformMode: "rotate",
});
render(<SceneRoot />);
expect(screen.getByTestId("transform-controls")).toHaveAttribute("data-mode", "rotate");
expect(screen.getByTestId("transform-controls")).toHaveAttribute("data-has-object", "true");
expect(screen.getByTestId("transform-controls")).toHaveAttribute("data-hide-from-capture", "true");
});
it("hides role labels when the scene toggle is disabled", () => {
useDirectorStore.setState({
...useDirectorStore.getState(),
project: {
...useDirectorStore.getState().project,
scene: {
...useDirectorStore.getState().project.scene,
showLabels: false,
},
},
});
render(<SceneRoot />);
expect(screen.queryByText("角色01")).not.toBeInTheDocument();
});
it("shows transform controls around the selected character in the active tool mode", () => {
useDirectorStore.setState({
...useDirectorStore.getState(),
selectedObjectId: "char_default_a",
transformMode: "rotate",
});
render(<SceneRoot />);
expect(screen.getByTestId("transform-controls")).toHaveAttribute("data-mode", "rotate");
expect(screen.getByTestId("transform-controls")).toHaveAttribute("data-has-object", "true");
expect(screen.getByTestId("transform-controls")).toHaveAttribute("data-hide-from-capture", "true");
expect(screen.getByTestId("transform-controls")).toHaveAttribute("data-translation-snap", "null");
});
it("shows transform controls around selected models while in camera view", () => {
useDirectorStore.setState({
...useDirectorStore.getState(),
viewMode: "camera",
selectedObjectId: "char_default_a",
transformMode: "scale",
});
render(<SceneRoot />);
expect(screen.getByTestId("transform-controls")).toHaveAttribute("data-mode", "scale");
expect(screen.getByTestId("transform-controls")).toHaveAttribute("data-has-object", "true");
expect(screen.getByTestId("transform-controls")).toHaveAttribute("data-translation-snap", "null");
});
it("selects the whole crowd group and shows one transform control when users click a crowd member", () => {
useDirectorStore.getState().addCrowdCharacters({ rows: 2, columns: 2, spacing: 1.2 });
render(<SceneRoot />);
fireEvent.click(screen.getAllByTestId("mock-character-model")[1]!);
expect(useDirectorStore.getState().selectedCrowdId).toBe("crowd_1");
expect(useDirectorStore.getState().selectedObjectIds).toHaveLength(4);
expect(screen.getAllByTestId("transform-controls")).toHaveLength(1);
});
it("passes world-grid snapping into viewport transform controls while translating selected objects", () => {
useDirectorStore.setState({
...useDirectorStore.getState(),
selectedObjectId: "char_default_a",
transformMode: "translate",
project: {
...useDirectorStore.getState().project,
scene: {
...useDirectorStore.getState().project.scene,
snapToGrid: true,
},
},
});
render(<SceneRoot />);
expect(screen.getByTestId("transform-controls")).toHaveAttribute("data-mode", "translate");
expect(screen.getByTestId("transform-controls")).toHaveAttribute("data-translation-snap", "1");
});
it("passes character body type to the procedural mannequin", () => {
const state = createInitialDirectorState();
const character = state.project.objects.find((item) => item.kind === "character");
expect(character).toBeTruthy();
useDirectorStore.setState({
...state,
project: {
...state.project,
objects: state.project.objects.map((item) =>
item.id === character!.id ? { ...item, bodyType: "chibi" } : item
),
},
});
render(<SceneRoot />);
expect(screen.getByTestId("mock-character-model")).toHaveAttribute("data-body-type", "chibi");
});
it("uses the built-in UE4 mannequin rig for default generated characters", () => {
render(<SceneRoot />);
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("keeps the trajectory marker material writing depth so an opaque ground cannot hide it", () => {
// 这条约束落在材质参数上(jsdom 里 <mesh> / <meshBasicMaterial> 只是普通 DOM 节点,
// depthWrite 这种 unknown 布尔属性不会落到 DOM 上,跑不出来),所以按源码契约断言。
const source = readFileSync("src/editor/canvas/SceneRoot.tsx", "utf8");
const markerMaterialLine = source
.split("\n")
.find((line) => line.includes("<meshBasicMaterial color={selected ? TRAJECTORY_SELECTED_COLOR : color}"));
expect(markerMaterialLine).toBeDefined();
// 地面是 transparent 材质:即使透明度为 1 也走透明通道、排在所有不透明物体之后渲染。
// 小球一旦不写深度(depthWrite={false}),地面会整片盖住它 ——
// 表现就是「地面透明度为 1 时看不到轨迹点」。
expect(markerMaterialLine).not.toContain("depthWrite");
});
it("puts a walking character at its trajectory sample for the current playhead", () => {
useDirectorStore.getState().addTrajectoryPoint("char_default_a", [8, 0, 0]);
// 单点轨迹:0s 在角色起始位置,时间轴末尾(5s)到达该轨迹点
usePlaybackStore.setState({ isPlaying: false, playhead: 5, duration: 5, editingObjectId: null });
render(<SceneRoot />);
const characterGroup = screen.getByTestId("mock-character-model").closest("group");
expect(characterGroup).toHaveAttribute("position", "8,0,0");
});
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();
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]);
});