757 lines
29 KiB
TypeScript
757 lines
29 KiB
TypeScript
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]);
|
||
});
|