feat: 增加轨迹

This commit is contained in:
tongtong committed 2026-09-17 09:09:06 +08:00
1 parent b9fd289caf
commit e74cf6d28b
42 files changed
+4010 -570

No files matched your search

+141 -14
View File
@@ -1,8 +1,14 @@
import { forwardRef } from "react";
import { fireEvent, render, screen, within } from "@testing-library/react";
import { afterEach, beforeEach, vi } from "vitest";
import { PerspectiveCamera, Vector3 } from "three";
import { DoubleSide, PerspectiveCamera, Vector3 } from "three";
import { clearViewportCaptureHandler, requestViewportCapture } from "../io/captureBridge";
import {
DEFAULT_DIRECTOR_VIEW_SNAPSHOT,
getViewportGizmoHitButtonStyle,
VIEWPORT_WHEEL_ZOOM_SPEED,
} from "./DirectorCanvas";
import { GROUND_PLANE_SIZE } from "./sceneGround";
import { getViewportAspectFrameRect } from "./viewportAspectFrame";
const mockCameraPositionSet = vi.hoisted(() => vi.fn());
@@ -150,27 +156,33 @@ vi.mock("@react-three/drei", async () => {
return {
...actual,
Grid: ({
args,
cellColor,
cellThickness,
fadeDistance,
infiniteGrid,
position,
sectionColor,
side,
}: {
args?: [number, number];
cellColor?: string;
cellThickness?: number;
fadeDistance?: number;
infiniteGrid?: boolean;
position?: [number, number, number];
sectionColor?: string;
side?: number;
}) => (
<div
data-args={JSON.stringify(args)}
data-cell-color={cellColor}
data-cell-thickness={String(cellThickness)}
data-fade-distance={String(fadeDistance)}
data-infinite-grid={String(infiniteGrid)}
data-infinite-grid={String(Boolean(infiniteGrid))}
data-position={JSON.stringify(position)}
data-section-color={sectionColor}
data-side={String(side)}
data-testid="viewport-grid"
/>
),
@@ -204,9 +216,33 @@ vi.mock("@react-three/drei", async () => {
type="button"
/>
),
OrbitControls: forwardRef(({ enabled }: { enabled?: boolean }) => (
<div data-enabled={String(enabled)} data-testid="orbit-controls" />
)),
OrbitControls: forwardRef(
({
enabled,
onChange,
zoomSpeed,
}: {
enabled?: boolean;
onChange?: (event: unknown) => void;
zoomSpeed?: number;
}) => (
<div data-enabled={String(enabled)} data-testid="orbit-controls" data-zoom-speed={String(zoomSpeed)}>
{/* 模拟用户直接操作控件(滚轮缩放 / 拖拽)时控件抛出的 change 事件载荷 */}
<button
aria-label="模拟操作视角控件"
type="button"
onClick={() =>
onChange?.({
target: {
object: { fov: 50, position: { x: 9, y: 2.2, z: 0 } },
target: { x: 0, y: 1.05, z: 0 },
},
})
}
/>
</div>
)
),
PerspectiveCamera: () => null,
Html: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
Line: () => null,
@@ -255,14 +291,57 @@ it("does not render a full-viewport transform drag layer over the 3D viewport",
expect(screen.queryByRole("application", { name: "3D视口缩放拖拽层" })).not.toBeInTheDocument();
});
it("renders only the dark major viewport grid lines", () => {
it("renders only the dark major viewport grid lines on the same footprint as the ground", () => {
render(<App />);
expect(screen.getByTestId("viewport-grid")).toHaveAttribute("data-cell-thickness", "0");
expect(screen.getByTestId("viewport-grid")).toHaveAttribute("data-position", "[0,0.002,0]");
expect(screen.getByTestId("viewport-grid")).toHaveAttribute("data-section-color", "#2A4065");
expect(screen.getByTestId("viewport-grid")).toHaveAttribute("data-fade-distance", "80");
expect(screen.getByTestId("viewport-grid")).toHaveAttribute("data-infinite-grid", "true");
const grid = screen.getByTestId("viewport-grid");
expect(grid).toHaveAttribute("data-cell-thickness", "0");
expect(grid).toHaveAttribute("data-position", "[0,0.002,0]");
expect(grid).toHaveAttribute("data-section-color", "#2A4065");
expect(grid).toHaveAttribute("data-fade-distance", "80");
// 网格不能是"无限网格":默认尺寸(1×1 × (1+fadeDistance))比地面小得多,
// 边缘不重合就会被看成另一层地面。这里必须和地面同尺寸。
expect(grid).toHaveAttribute("data-infinite-grid", "false");
expect(grid).toHaveAttribute("data-args", `[${GROUND_PLANE_SIZE},${GROUND_PLANE_SIZE}]`);
});
it("does not bounce the camera back while the user is driving OrbitControls", () => {
render(<App />);
mockCameraPositionSet.mockClear();
// 用户滚轮 / 拖拽时控件每帧都会抛 change;模拟其中一次
fireEvent.click(screen.getByRole("button", { name: "模拟操作视角控件" }));
// 相机归 OrbitControls 逐帧驱动,不能再把回调里那份(上一帧的)快照写回去,
// 否则缩放时会出现"缩小时突然又放大一点"的抖动
expect(mockCameraPositionSet).not.toHaveBeenCalled();
// 快照本身仍照常同步给 UI:坐标控件命中按钮跟着这次视角变化重算
const expectedStyle = getViewportGizmoHitButtonStyle(
{ fov: 50, position: [9, 2.2, 0], target: [0, 1.05, 0] },
[1, 0, 0]
);
expect(screen.getByRole("button", { name: "切换到 X 正向视图" })).toHaveStyle({
left: expectedStyle.left,
top: expectedStyle.top,
});
});
it("speeds up wheel zoom beyond the three.js default so scrolling feels responsive", () => {
render(<App />);
const controls = screen.getByTestId("orbit-controls");
const zoomSpeed = Number(controls.getAttribute("data-zoom-speed"));
expect(zoomSpeed).toBe(VIEWPORT_WHEEL_ZOOM_SPEED);
// three 的 zoomSpeed 默认 1(每格滚轮只推拉约 5%),必须大于它才是"变快"
expect(zoomSpeed).toBeGreaterThan(1);
});
it("keeps the viewport grid visible when the view drops below the ground plane", () => {
render(<App />);
// drei 的 Grid 默认 BackSide:视角转到地面以下时会因背面剔除整个消失
expect(screen.getByTestId("viewport-grid")).toHaveAttribute("data-side", String(DoubleSide));
});
it("keeps the viewport grid slightly above the configured ground plane", () => {
@@ -282,6 +361,35 @@ it("keeps the viewport grid slightly above the configured ground plane", () => {
expect(screen.getByTestId("viewport-grid")).toHaveAttribute("data-position", "[0,1.502,0]");
});
it("shares the scene transform with the ground plane so the grid never becomes a second floor", () => {
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,
},
},
});
render(<App />);
const grid = screen.getByTestId("viewport-grid");
const sceneGroup = grid.closest("group");
// 网格与地面同在场景组内:共享场景平移/旋转/缩放,不会各自留在不同坐标空间
expect(sceneGroup).not.toBeNull();
expect(sceneGroup).toHaveAttribute("position", "1,3,-2");
expect(sceneGroup).toHaveAttribute("rotation", "0,0.5,0");
expect(sceneGroup).toHaveAttribute("scale", "2,2,2");
// 网格自身的 y 相对场景原点,仍是「地面高度 + 微小抬升」
expect(grid).toHaveAttribute("data-position", "[0,1.502,0]");
});
it("opens the scene inspector when users click empty 3D viewport space", () => {
useDirectorStore.setState({
...useDirectorStore.getState(),
@@ -351,11 +459,29 @@ it("offsets the native viewport gizmo inward when overlay side panels are open",
const gizmo = screen.getByLabelText("3D视口原生坐标控件");
// 右侧悬浮卡片宽 300px,内缩 16px,再加 16px 间距 → 坐标控件贴在其左侧
expect(gizmo).toHaveStyle({
right: "320px",
right: "332px",
});
});
it("resets the director view from the button below the native gizmo", () => {
useDirectorStore.getState().setViewMode("camera");
render(<App />);
const resetButton = screen.getByRole("button", { name: "重置视角" });
expect(resetButton).toHaveClass("viewport-view-reset-button");
// 文字按钮:不再用图标 + 悬停提示
expect(resetButton).toHaveTextContent("重置视角");
expect(resetButton.querySelector("svg")).not.toBeInTheDocument();
mockCameraPositionSet.mockClear();
fireEvent.click(resetButton);
expect(useDirectorStore.getState().viewMode).toBe("director");
expect(mockCameraPositionSet).toHaveBeenCalledWith(...DEFAULT_DIRECTOR_VIEW_SNAPSHOT.position);
});
it("syncs native viewport gizmo axis clicks back to the main director view", () => {
render(<App />);
mockCameraPositionSet.mockClear();
@@ -544,9 +670,10 @@ it("captures screenshots from the same safe-area frame shown by the aspect overl
source: "capture-panel",
});
// 悬浮卡片左右等宽:300 + 16 + 16,与 DirectorCanvas 的安全区保持一致
const expectedFrame = getViewportAspectFrameRect("16:9", 1000, 700, 124, {
left: 220,
right: 300,
left: 332,
right: 332,
top: 0,
bottom: 0,
});