feat: 优化时间轴
This commit is contained in:
@@ -159,30 +159,39 @@ vi.mock("@react-three/drei", async () => {
|
||||
Grid: ({
|
||||
args,
|
||||
cellColor,
|
||||
cellSize,
|
||||
cellThickness,
|
||||
fadeDistance,
|
||||
infiniteGrid,
|
||||
position,
|
||||
sectionColor,
|
||||
sectionSize,
|
||||
sectionThickness,
|
||||
side,
|
||||
}: {
|
||||
args?: [number, number];
|
||||
cellColor?: string;
|
||||
cellSize?: number;
|
||||
cellThickness?: number;
|
||||
fadeDistance?: number;
|
||||
infiniteGrid?: boolean;
|
||||
position?: [number, number, number];
|
||||
sectionColor?: string;
|
||||
sectionSize?: number;
|
||||
sectionThickness?: number;
|
||||
side?: number;
|
||||
}) => (
|
||||
<div
|
||||
data-args={JSON.stringify(args)}
|
||||
data-cell-color={cellColor}
|
||||
data-cell-size={String(cellSize)}
|
||||
data-cell-thickness={String(cellThickness)}
|
||||
data-fade-distance={String(fadeDistance)}
|
||||
data-infinite-grid={String(Boolean(infiniteGrid))}
|
||||
data-position={JSON.stringify(position)}
|
||||
data-section-color={sectionColor}
|
||||
data-section-size={String(sectionSize)}
|
||||
data-section-thickness={String(sectionThickness)}
|
||||
data-side={String(side)}
|
||||
data-testid="viewport-grid"
|
||||
/>
|
||||
@@ -292,13 +301,19 @@ 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 on the same footprint as the ground", () => {
|
||||
it("renders a fine viewport grid with thicker, darker major lines every 5 cells on the same footprint as the ground", () => {
|
||||
render(<App />);
|
||||
|
||||
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-cell-size", "1");
|
||||
expect(grid).toHaveAttribute("data-section-size", "5");
|
||||
// 每 5 格一条更粗的分割线
|
||||
expect(grid).toHaveAttribute("data-section-thickness", "1.4");
|
||||
// 细分割线比主分割线更细
|
||||
expect(grid).toHaveAttribute("data-cell-thickness", "0.5");
|
||||
// 主分割线颜色更浅(中性灰,突出 RGB 坐标轴)
|
||||
expect(grid).toHaveAttribute("data-section-color", "#6b7280");
|
||||
expect(grid).toHaveAttribute("data-fade-distance", "80");
|
||||
// 网格不能是"无限网格":默认尺寸(1×1 × (1+fadeDistance))比地面小得多,
|
||||
// 边缘不重合就会被看成另一层地面。这里必须和地面同尺寸。
|
||||
@@ -450,7 +465,7 @@ it("renders the native 3D viewport gizmo in an overlay canvas above the aspect m
|
||||
expect(screen.getByTestId("native-gizmo-helper")).toHaveAttribute("data-margin", "[0,0]");
|
||||
expect(screen.getByTestId("native-gizmo-viewport")).toHaveAttribute(
|
||||
"data-axis-colors",
|
||||
"[\"#E56C5B\",\"#6CDB7A\",\"#7AA7FF\"]"
|
||||
"[\"#FF4D4F\",\"#52C41A\",\"#1890FF\"]"
|
||||
);
|
||||
expect(screen.getByTestId("native-gizmo-viewport")).toHaveAttribute("data-disabled", "true");
|
||||
expect(screen.getByTestId("native-gizmo-viewport")).toHaveAttribute("data-scale", "25");
|
||||
|
||||
@@ -24,7 +24,7 @@ import { getGroundedLabelY } from "../runtime/mannequin/bodyTypes";
|
||||
import { getUE4GroundedLabelY } from "../runtime/ue4Mannequin/ue4MannequinRig";
|
||||
import { SceneRoot } from "./SceneRoot";
|
||||
import { AXIS_COLORS } from "./sceneAxes";
|
||||
import { GROUND_GRID_ELEVATION, GROUND_PLANE_SIZE, GROUND_SURFACE_SIDE } from "./sceneGround";
|
||||
import { GROUND_GRID_ELEVATION, GROUND_PLANE_SIZE, GROUND_SURFACE_SIDE, GRID_CELL_SIZE, GRID_SECTION_CELLS } from "./sceneGround";
|
||||
import { getSceneGroupTransform } from "./sceneTransform";
|
||||
import { HIDE_FROM_VIEWPORT_CAPTURE_KEY, withViewportCaptureHelpersHidden } from "./viewportCapture";
|
||||
import { PlaybackFrameDriver } from "../runtime/PlaybackFrameDriver";
|
||||
@@ -74,6 +74,8 @@ const CAPTURE_LABEL_FONT_SIZE = 12;
|
||||
const CAPTURE_LABEL_HORIZONTAL_PADDING = 10;
|
||||
const CAPTURE_LABEL_VERTICAL_PADDING = 6;
|
||||
const CAPTURE_LABEL_BORDER_RADIUS = 999;
|
||||
/** 截图标签相对角色头顶的额外抬高量,让标签浮在头部上方、不遮住模型 */
|
||||
const CAPTURE_LABEL_Y_OFFSET = 0.4;
|
||||
const CAPTURE_LABEL_PANEL_RGB_FALLBACK = "26 26 26";
|
||||
const CAPTURE_LABEL_TEXT_RGB_FALLBACK = "255 255 255";
|
||||
const GIZMO_AXIS_HIT_TARGETS: Array<{
|
||||
@@ -240,9 +242,10 @@ function createSceneMatrix(scene: SceneSettings) {
|
||||
}
|
||||
|
||||
function getCharacterCaptureLabelY(item: DirectorObject) {
|
||||
return item.characterRig?.rigType === "ue4-mannequin"
|
||||
const base = item.characterRig?.rigType === "ue4-mannequin"
|
||||
? getUE4GroundedLabelY(item.bodyType)
|
||||
: getGroundedLabelY(item.bodyType);
|
||||
return base + CAPTURE_LABEL_Y_OFFSET;
|
||||
}
|
||||
|
||||
export function getViewportCaptureLabels(captureScene?: Object3D) {
|
||||
@@ -503,7 +506,7 @@ function CanvasCaptureBridge({
|
||||
const originalQuaternion = workingCamera.quaternion.clone();
|
||||
const originalFov = workingCamera.fov;
|
||||
|
||||
const snapshot = (label: string) => {
|
||||
const snapshot = (label: string, metaTarget?: [number, number, number]) => {
|
||||
withViewportCaptureHelpersHidden(scene, () => {
|
||||
gl.render(scene, workingCamera);
|
||||
});
|
||||
@@ -518,12 +521,35 @@ function CanvasCaptureBridge({
|
||||
cameraId: cameraId ?? (viewMode === "camera" ? activeCamera?.id ?? null : null),
|
||||
fov: workingCamera.fov,
|
||||
position: [workingCamera.position.x, workingCamera.position.y, workingCamera.position.z],
|
||||
target: [target.x, target.y, target.z],
|
||||
target: metaTarget ?? [target.x, target.y, target.z],
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
if (preset === "current") {
|
||||
// 机位面板触发的「当前机位」截图:渲染的是该机位自己的视角,
|
||||
// 而不是编辑器此刻所在的导演视角(导演视角通常更高、更远),
|
||||
// 否则用户在机位面板生成的预览会和切到相机视角看到的不一致。
|
||||
if (source === "camera-panel" && cameraId) {
|
||||
const captureCamera = useDirectorStore
|
||||
.getState()
|
||||
.project.cameras.find((item) => item.id === cameraId);
|
||||
if (captureCamera) {
|
||||
const view = getCameraViewSnapshotFromShot(captureCamera);
|
||||
const savedPosition = workingCamera.position.clone();
|
||||
const savedQuaternion = workingCamera.quaternion.clone();
|
||||
const savedFov = workingCamera.fov;
|
||||
applySnapshotToCamera(workingCamera, view);
|
||||
try {
|
||||
return [snapshot("当前机位", [...view.target])];
|
||||
} finally {
|
||||
workingCamera.position.copy(savedPosition);
|
||||
workingCamera.quaternion.copy(savedQuaternion);
|
||||
workingCamera.fov = savedFov;
|
||||
workingCamera.updateProjectionMatrix();
|
||||
}
|
||||
}
|
||||
}
|
||||
return [snapshot(source === "camera-panel" ? "当前机位" : "当前视角")];
|
||||
}
|
||||
|
||||
@@ -561,6 +587,32 @@ function CanvasCaptureBridge({
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 机位视角下屏蔽 Ctrl/Cmd + 滚轮,避免浏览器把整页放大缩小。
|
||||
* 相机模式下没有 OrbitControls 接管滚轮,浏览器默认的 ctrl+滚轮缩放页面会直接触发,
|
||||
* 监听 canvas 的 wheel 事件(非 passive)并在按下 ctrl/meta 时 preventDefault 即可拦截。 */
|
||||
function ViewportWheelGuard({ enabled }: { enabled: boolean }) {
|
||||
const { gl } = useThree();
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
const canvas = gl.domElement;
|
||||
if (typeof canvas?.addEventListener !== "function") {
|
||||
return;
|
||||
}
|
||||
const handleWheel = (event: WheelEvent) => {
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
canvas.addEventListener("wheel", handleWheel, { passive: false });
|
||||
return () => canvas.removeEventListener("wheel", handleWheel);
|
||||
}, [enabled, gl]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 把渲染三件套注册给视频导出逻辑(逐帧离线渲染用),模式仿 CanvasCaptureBridge */
|
||||
function VideoExportBridge() {
|
||||
const { camera, gl, scene } = useThree();
|
||||
@@ -854,10 +906,14 @@ export function DirectorCanvas() {
|
||||
<group userData={{ [HIDE_FROM_VIEWPORT_CAPTURE_KEY]: !sceneSettings.captureShowGrid }}>
|
||||
<Grid
|
||||
args={[GROUND_PLANE_SIZE, GROUND_PLANE_SIZE]}
|
||||
cellThickness={0}
|
||||
cellSize={GRID_CELL_SIZE}
|
||||
sectionSize={GRID_CELL_SIZE * GRID_SECTION_CELLS}
|
||||
cellThickness={0.5}
|
||||
sectionThickness={1.4}
|
||||
cellColor="#33373f"
|
||||
sectionColor="#6b7280"
|
||||
fadeDistance={80}
|
||||
position={[0, sceneSettings.groundHeight + GROUND_GRID_ELEVATION, 0]}
|
||||
sectionColor="#2A4065"
|
||||
side={GROUND_SURFACE_SIDE}
|
||||
/>
|
||||
</group>
|
||||
@@ -915,6 +971,7 @@ export function DirectorCanvas() {
|
||||
viewportAspectRatio={viewportAspectRatio}
|
||||
viewMode={viewMode}
|
||||
/>
|
||||
<ViewportWheelGuard enabled={viewMode === "camera"} />
|
||||
<PlaybackFrameDriver />
|
||||
<VideoExportBridge />
|
||||
<PlaybackTransformSync />
|
||||
|
||||
@@ -128,7 +128,7 @@ it("uses the fullscreen button to collapse the side panels instead of entering b
|
||||
);
|
||||
});
|
||||
|
||||
it("creates a new camera before storing viewport capsule screenshots from director view", async () => {
|
||||
it("stores viewport capsule screenshots on the active camera without creating a new one from director view", async () => {
|
||||
const user = userEvent.setup();
|
||||
const anchorClick = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
|
||||
const snapshot = {
|
||||
@@ -142,7 +142,7 @@ it("creates a new camera before storing viewport capsule screenshots from direct
|
||||
dataUrl: "data:image/png;base64,current-camera",
|
||||
meta: {
|
||||
mode: "camera" as const,
|
||||
cameraId: "cam_2",
|
||||
cameraId: "cam_1",
|
||||
fov: 64,
|
||||
position: [3, 2, 1] as [number, number, number],
|
||||
target: [0, 1, -2] as [number, number, number],
|
||||
@@ -156,30 +156,25 @@ it("creates a new camera before storing viewport capsule screenshots from direct
|
||||
await user.click(screen.getByRole("button", { name: "当前视角截图" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(handler).toHaveBeenCalledWith({ preset: "current", source: "camera-panel", cameraId: "cam_2" });
|
||||
expect(handler).toHaveBeenCalledWith({ preset: "current", source: "camera-panel", cameraId: "cam_1" });
|
||||
});
|
||||
|
||||
const state = useDirectorStore.getState();
|
||||
const originalCamera = state.project.cameras[0];
|
||||
const newCamera = state.project.cameras[1];
|
||||
const activeCamera = state.project.cameras[0];
|
||||
|
||||
expect(anchorClick).not.toHaveBeenCalled();
|
||||
expect(state.viewMode).toBe("camera");
|
||||
expect(state.project.activeCameraId).toBe("cam_2");
|
||||
expect(state.selectedObjectId).toBe("cam_object_2");
|
||||
expect(originalCamera?.captures).toEqual([]);
|
||||
expect(newCamera?.fov).toBe(64);
|
||||
expect(newCamera?.transform.position).toEqual(getCameraRigPositionFromViewSnapshot(snapshot));
|
||||
expect(getCameraViewSnapshotFromShot(newCamera)).toEqual(snapshot);
|
||||
expect(newCamera?.captures).toEqual([
|
||||
expect(state.project.cameras).toHaveLength(1);
|
||||
expect(state.project.activeCameraId).toBe("cam_1");
|
||||
expect(activeCamera?.captures).toEqual([
|
||||
{
|
||||
id: "cam_2-capture-01",
|
||||
id: "cam_1-capture-01",
|
||||
index: 1,
|
||||
name: "机位02-截图01",
|
||||
name: "机位01-截图01",
|
||||
dataUrl: "data:image/png;base64,current-camera",
|
||||
},
|
||||
]);
|
||||
expect(newCamera?.lastCaptureUrl).toBe("data:image/png;base64,current-camera");
|
||||
expect(activeCamera?.lastCaptureUrl).toBe("data:image/png;base64,current-camera");
|
||||
});
|
||||
|
||||
it("stores viewport capsule screenshots in the current camera while already in camera view", async () => {
|
||||
|
||||
@@ -345,8 +345,8 @@ export function ViewportToolbar({
|
||||
|
||||
async function handleCapture(preset: "current" | "four" | "twelve") {
|
||||
try {
|
||||
const targetCameraId =
|
||||
viewMode === "director" ? addCameraShot(getViewportCameraSnapshot?.()) : activeCameraId;
|
||||
// 复用当前选中的机位承接截图,避免每次点截图都新增一个机位(四方位 / 十二方位尤其明显)
|
||||
const targetCameraId = activeCameraId ?? addCameraShot(getViewportCameraSnapshot?.());
|
||||
|
||||
setViewMode("camera");
|
||||
await waitForNextAnimationFrame();
|
||||
@@ -507,8 +507,8 @@ export function ViewportToolbar({
|
||||
},
|
||||
{ label: "模型库", icon: Boxes, onClick: toggleModelLibrary },
|
||||
{ label: "添加机位", icon: Video, onClick: addCameraFromViewport },
|
||||
{ label: "当前视角截图", icon: Camera, onClick: () => void handleCapture("current") },
|
||||
{ label: "选择画幅比例", icon: Ratio, onClick: toggleAspectRatioPanel },
|
||||
{ label: "当前视角截图", icon: Camera, onClick: () => void handleCapture("current") },
|
||||
{ label: "四方位截图", icon: Grid2X2, onClick: () => void handleCapture("four") },
|
||||
{ label: "十二方位截图", icon: Grid3X3, onClick: () => void handleCapture("twelve") },
|
||||
{ label: "全屏", icon: Expand, onClick: toggleViewportPanelsCollapsed },
|
||||
|
||||
@@ -3,7 +3,7 @@ import { AXIS_COLORS, getGroundAxisLines, GROUND_AXIS_LENGTH } from "./sceneAxes
|
||||
import { GROUND_PLANE_SIZE } from "./sceneGround";
|
||||
|
||||
it("keeps the axis color convention shared with the viewport gizmo", () => {
|
||||
expect(AXIS_COLORS).toEqual({ x: "#E56C5B", y: "#6CDB7A", z: "#7AA7FF" });
|
||||
expect(AXIS_COLORS).toEqual({ x: "#FF4D4F", y: "#52C41A", z: "#1890FF" });
|
||||
});
|
||||
|
||||
it("stretches each ground axis to both edges of the ground", () => {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { GROUND_PLANE_SIZE } from "./sceneGround";
|
||||
|
||||
/**
|
||||
* 轴向颜色约定:X 红 / Y 绿 / Z 蓝。
|
||||
* 轴向颜色约定:X 红 / Y 绿 / Z 蓝(标准 RGB)。
|
||||
* 视口右上角的坐标控件(全景球)和地面坐标轴共用这一份,避免两处颜色漂移。
|
||||
*/
|
||||
export const AXIS_COLORS = {
|
||||
x: "#E56C5B",
|
||||
y: "#6CDB7A",
|
||||
z: "#7AA7FF",
|
||||
x: "#FF4D4F",
|
||||
y: "#52C41A",
|
||||
z: "#1890FF",
|
||||
} as const;
|
||||
|
||||
/** 地面坐标轴的单侧长度:从原点铺到地面边缘,两侧合起来正好贯穿整块地面。 */
|
||||
|
||||
@@ -12,6 +12,12 @@ import { DoubleSide } from "three";
|
||||
*/
|
||||
export const GROUND_PLANE_SIZE = 100;
|
||||
|
||||
/** 视口网格单格尺寸(世界单位)。以网格中心为起点向四周铺开。 */
|
||||
export const GRID_CELL_SIZE = 1;
|
||||
|
||||
/** 每隔多少格绘制一条更粗、更深的分割线(主分割线)。 */
|
||||
export const GRID_SECTION_CELLS = 5;
|
||||
|
||||
/** 网格相对地面平面的抬升,避免两者共面时 z-fighting。 */
|
||||
export const GROUND_GRID_ELEVATION = 0.002;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user