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("@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]; }) => (
{children}
), Line: ({ color, lineWidth, name, opacity, onClick, points, transparent, userData, }: { color?: string; lineWidth?: number; name?: string; onClick?: React.MouseEventHandler; opacity?: number; points?: Array<[number, number, number]>; transparent?: boolean; userData?: Record; }) => (
), 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; }) => (
{children}
), }; }); vi.mock("../runtime/PrimitiveMannequin", () => ({ PrimitiveMannequin: ({ bodyType, color, rigState, }: { bodyType?: string; color?: string; rigState?: { rigType?: string }; }) => (
), })); vi.mock("../runtime/CharacterModel", async () => { const React = await vi.importActual("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 (
); }, }; }); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); // 地面颜色和天空颜色一样是工程设置:面板上改了颜色,视口里的地面材质就得跟着变 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); expect(screen.getByTestId("mock-character-model")).toHaveAttribute("data-body-type", "chibi"); }); it("uses the built-in UE4 mannequin rig for default generated characters", () => { render(); 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(); 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 里 / 只是普通 DOM 节点, // depthWrite 这种 unknown 布尔属性不会落到 DOM 上,跑不出来),所以按源码契约断言。 const source = readFileSync("src/editor/canvas/SceneRoot.tsx", "utf8"); const markerMaterialLine = source .split("\n") .find((line) => line.includes(" { useDirectorStore.getState().addTrajectoryPoint("char_default_a", [8, 0, 0]); // 单点轨迹:0s 在角色起始位置,时间轴末尾(5s)到达该轨迹点 usePlaybackStore.setState({ isPlaying: false, playhead: 5, duration: 5, editingObjectId: null }); render(); 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(); // 选中轨迹点后给该点挂拖拽 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(); useDirectorStore.getState().startTrajectoryPlacement("char_default_a"); const { container } = render(); 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]); });