fix: 优化空格播放和截图角色标签对不上
This commit is contained in:
@@ -380,7 +380,9 @@ it("shares the scene transform with the ground plane so the grid never becomes a
|
||||
render(<App />);
|
||||
|
||||
const grid = screen.getByTestId("viewport-grid");
|
||||
const sceneGroup = grid.closest("group");
|
||||
// 网格被包在「导出时隐藏」的 group 里,再外层才是与地面共享变换的场景 group
|
||||
const gridWrapper = grid.closest("group");
|
||||
const sceneGroup = gridWrapper?.parentElement?.closest("group");
|
||||
|
||||
// 网格与地面同在场景组内:共享场景平移/旋转/缩放,不会各自留在不同坐标空间
|
||||
expect(sceneGroup).not.toBeNull();
|
||||
|
||||
@@ -26,6 +26,7 @@ import { SceneRoot } from "./SceneRoot";
|
||||
import { AXIS_COLORS } from "./sceneAxes";
|
||||
import { GROUND_GRID_ELEVATION, GROUND_PLANE_SIZE, GROUND_SURFACE_SIDE } from "./sceneGround";
|
||||
import { getSceneGroupTransform } from "./sceneTransform";
|
||||
import { HIDE_FROM_VIEWPORT_CAPTURE_KEY, withViewportCaptureHelpersHidden } from "./viewportCapture";
|
||||
import { PlaybackFrameDriver } from "../runtime/PlaybackFrameDriver";
|
||||
import { ActiveCameraPlaybackSync } from "../runtime/ActiveCameraPlaybackSync";
|
||||
import { PlaybackTransformSync } from "./PlaybackTransformSync";
|
||||
@@ -69,7 +70,6 @@ const RIGHT_PANEL_WIDTH = 300;
|
||||
const GIZMO_EDGE_PADDING = 20;
|
||||
/** 悬浮卡片比面板自身宽度多占的留白(左/右各 --floating-inset,再加同宽间距) */
|
||||
const FLOATING_PANEL_GAP = 32;
|
||||
const HIDE_FROM_VIEWPORT_CAPTURE_KEY = "hideFromViewportCapture";
|
||||
const CAPTURE_LABEL_FONT_SIZE = 12;
|
||||
const CAPTURE_LABEL_HORIZONTAL_PADDING = 10;
|
||||
const CAPTURE_LABEL_VERTICAL_PADDING = 6;
|
||||
@@ -245,7 +245,7 @@ function getCharacterCaptureLabelY(item: DirectorObject) {
|
||||
: getGroundedLabelY(item.bodyType);
|
||||
}
|
||||
|
||||
function getViewportCaptureLabels() {
|
||||
export function getViewportCaptureLabels(captureScene?: Object3D) {
|
||||
const {
|
||||
project: { objects, scene },
|
||||
} = useDirectorStore.getState();
|
||||
@@ -253,14 +253,15 @@ function getViewportCaptureLabels() {
|
||||
if (!scene.showLabels) return [];
|
||||
|
||||
const sceneMatrix = createSceneMatrix(scene);
|
||||
// 角色在场景里的真实世界坐标可能已被播放头 / 人物轨迹挪动,先刷新一遍矩阵,保证读到的位置是「当前这帧」的
|
||||
if (captureScene && typeof captureScene.updateMatrixWorld === "function") captureScene.updateMatrixWorld(true);
|
||||
|
||||
return objects
|
||||
.filter((item) => item.kind === "character" && item.visible)
|
||||
.map((item): ViewportCaptureLabel => {
|
||||
const objectMatrix = createTransformMatrix(item.transform);
|
||||
const worldPosition = new Vector3(0, getCharacterCaptureLabelY(item), 0)
|
||||
.applyMatrix4(objectMatrix)
|
||||
.applyMatrix4(sceneMatrix);
|
||||
// 优先用角色在场景里的真实世界坐标(播放头移动 / 人物轨迹会让它偏离静态 transform),
|
||||
// 这样截图上标签才贴着角色头部,而不是落在它摆放时的 0 点坐标。
|
||||
const worldPosition = findCharacterWorldPosition(captureScene, item) ?? computeCharacterLabelWorldPosition(item, sceneMatrix);
|
||||
|
||||
return {
|
||||
text: item.name,
|
||||
@@ -269,6 +270,28 @@ function getViewportCaptureLabels() {
|
||||
});
|
||||
}
|
||||
|
||||
function computeCharacterLabelWorldPosition(item: DirectorObject, sceneMatrix: Matrix4): Vector3 {
|
||||
const objectMatrix = createTransformMatrix(item.transform);
|
||||
return new Vector3(0, getCharacterCaptureLabelY(item), 0)
|
||||
.applyMatrix4(objectMatrix)
|
||||
.applyMatrix4(sceneMatrix);
|
||||
}
|
||||
|
||||
/** 去场景里找这个角色的根 group(userData.directorObjectId === item.id),取它当前的世界坐标 + 标签高度 */
|
||||
function findCharacterWorldPosition(captureScene: Object3D | undefined, item: DirectorObject): Vector3 | null {
|
||||
if (!captureScene) return null;
|
||||
|
||||
let found: Object3D | undefined;
|
||||
captureScene.traverse((object) => {
|
||||
if (!found && object.userData?.directorObjectId === item.id) found = object;
|
||||
});
|
||||
if (!found) return null;
|
||||
|
||||
const position = found.getWorldPosition(new Vector3());
|
||||
position.y += getCharacterCaptureLabelY(item);
|
||||
return position;
|
||||
}
|
||||
|
||||
function getCssRgbVariable(name: string, fallback: string) {
|
||||
if (typeof window === "undefined") return fallback;
|
||||
|
||||
@@ -434,25 +457,6 @@ function captureViewportCanvas(
|
||||
return cropCanvas.toDataURL("image/png");
|
||||
}
|
||||
|
||||
function withViewportCaptureHelpersHidden(scene: Object3D, render: () => void) {
|
||||
const hiddenObjects: Array<{ object: Object3D; visible: boolean }> = [];
|
||||
|
||||
scene.traverse((object) => {
|
||||
if (object.userData?.[HIDE_FROM_VIEWPORT_CAPTURE_KEY]) {
|
||||
hiddenObjects.push({ object, visible: object.visible });
|
||||
object.visible = false;
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
render();
|
||||
} finally {
|
||||
hiddenObjects.forEach(({ object, visible }) => {
|
||||
object.visible = visible;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function CanvasCaptureBridge({
|
||||
activeCamera,
|
||||
bottomPadding,
|
||||
@@ -507,7 +511,7 @@ function CanvasCaptureBridge({
|
||||
label,
|
||||
dataUrl: captureViewportCanvas(gl.domElement, viewportAspectRatio, bottomPadding, safeAreaInsets, {
|
||||
camera: workingCamera,
|
||||
labels: getViewportCaptureLabels(),
|
||||
labels: getViewportCaptureLabels(scene),
|
||||
}),
|
||||
meta: buildScreenshotMeta({
|
||||
mode: viewMode,
|
||||
@@ -844,15 +848,19 @@ export function DirectorCanvas() {
|
||||
// 网格和地面一样属于「场景内容」:共用同一套场景变换 + 同一尺寸,
|
||||
// 变换不同会让两者错位,尺寸不同会让边缘不重合 —— 两种都会看成"两层地面"。
|
||||
<group {...getSceneGroupTransform(sceneSettings)}>
|
||||
<Grid
|
||||
args={[GROUND_PLANE_SIZE, GROUND_PLANE_SIZE]}
|
||||
cellThickness={0}
|
||||
fadeDistance={80}
|
||||
position={[0, sceneSettings.groundHeight + GROUND_GRID_ELEVATION, 0]}
|
||||
sectionColor="#2A4065"
|
||||
side={GROUND_SURFACE_SIDE}
|
||||
userData={{ [HIDE_FROM_VIEWPORT_CAPTURE_KEY]: true }}
|
||||
/>
|
||||
{/* 导出时(截图 / 视频)是否把网格藏起来由 captureShowGrid 决定:
|
||||
默认 false → 打上 hideFromViewportCapture,导出前统一藏起;勾选后不标记,网格留在画面里。
|
||||
用一层 group 承载标记,避免依赖 <Grid> 自身是否转发 userData。 */}
|
||||
<group userData={{ [HIDE_FROM_VIEWPORT_CAPTURE_KEY]: !sceneSettings.captureShowGrid }}>
|
||||
<Grid
|
||||
args={[GROUND_PLANE_SIZE, GROUND_PLANE_SIZE]}
|
||||
cellThickness={0}
|
||||
fadeDistance={80}
|
||||
position={[0, sceneSettings.groundHeight + GROUND_GRID_ELEVATION, 0]}
|
||||
sectionColor="#2A4065"
|
||||
side={GROUND_SURFACE_SIDE}
|
||||
/>
|
||||
</group>
|
||||
</group>
|
||||
) : null}
|
||||
{viewMode === "director" ? (
|
||||
@@ -916,11 +924,6 @@ export function DirectorCanvas() {
|
||||
</Suspense>
|
||||
</Canvas>
|
||||
</div>
|
||||
{trajectoryPlacementObjectId ? (
|
||||
<div className="viewport-placement-hint" role="status" aria-label="轨迹打点提示">
|
||||
双击画布放置轨迹点 · Esc 或再次点击「添加轨迹」退出
|
||||
</div>
|
||||
) : null}
|
||||
<ViewportAspectOverlay
|
||||
bottomPadding={aspectOverlayBottomPadding}
|
||||
onToggleRuleOfThirds={setViewportRuleOfThirdsEnabled}
|
||||
@@ -938,6 +941,12 @@ export function DirectorCanvas() {
|
||||
重置视角
|
||||
</button>
|
||||
</div>
|
||||
{/* 轨迹打点提示:独立浮层,显示在快捷工具栏「上方」,不嵌进工具栏里 */}
|
||||
{trajectoryPlacementObjectId ? (
|
||||
<div className="viewport-placement-hint" role="status" aria-label="轨迹打点提示">
|
||||
双击画布放置轨迹点 · Esc 或再次点击「添加轨迹」退出
|
||||
</div>
|
||||
) : null}
|
||||
<ViewportToolbar getViewportCameraSnapshot={getViewportCameraSnapshot} toolbarContainerRef={toolbarRef} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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";
|
||||
@@ -274,6 +275,16 @@ it("uses the requested dark ground surface color", () => {
|
||||
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"]');
|
||||
@@ -680,6 +691,21 @@ it("renders solid trajectory spheres and a smooth curve through the waypoints",
|
||||
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)到达该轨迹点
|
||||
|
||||
@@ -41,6 +41,7 @@ import { getEffectiveGroundOpacity } from "./panoramaMath";
|
||||
import { getGroundAxisLines } from "./sceneAxes";
|
||||
import { GROUND_GRID_ELEVATION, GROUND_PLANE_SIZE, GROUND_SURFACE_SIDE } from "./sceneGround";
|
||||
import { getSceneGroupTransform } from "./sceneTransform";
|
||||
import { HIDE_FROM_VIEWPORT_CAPTURE_KEY } from "./viewportCapture";
|
||||
import { getCrowdAnchorTransform } from "../store/directorStore";
|
||||
|
||||
export { getEffectiveGroundOpacity, getPanoramaRotationRadians } from "./panoramaMath";
|
||||
@@ -55,7 +56,6 @@ const VIEWPORT_CAMERA_LINE_OPACITY = 0.92;
|
||||
const VIEWPORT_CAMERA_HIT_PADDING = 0.06;
|
||||
const VIEWPORT_CAMERA_FORWARD = new Vector3(0, 0, 1);
|
||||
const VIEWPORT_CAMERA_WORLD_UP = new Vector3(0, 1, 0);
|
||||
const HIDE_FROM_VIEWPORT_CAPTURE_KEY = "hideFromViewportCapture";
|
||||
const VIEWPORT_CAMERA_BODY_CENTER: CameraWirePoint = [0, 0, -0.52 * VIEWPORT_CAMERA_VISUAL_SCALE];
|
||||
const VIEWPORT_CAMERA_BODY_SIZE: CameraWirePoint = [
|
||||
0.4 * VIEWPORT_CAMERA_VISUAL_SCALE,
|
||||
@@ -967,14 +967,17 @@ function TrajectoryPath({
|
||||
<circleGeometry args={[0.3, 24]} />
|
||||
<meshBasicMaterial depthWrite={false} opacity={0} transparent />
|
||||
</mesh>
|
||||
{/* 实心小球:选中用高亮色,否则用角色色 */}
|
||||
{/* 实心小球:选中用高亮色,否则用角色色。
|
||||
这里必须写深度(用材质默认值 true,别改成 false):地面是 transparent 材质,
|
||||
即使透明度为 1 也走透明通道、排在所有不透明物体之后渲染;
|
||||
小球一旦不写深度,地面就会整片盖住它 —— 表现就是「地面不透明时看不到轨迹点」。 */}
|
||||
<mesh
|
||||
name={`trajectory-point-${point.id}`}
|
||||
userData={{ [HIDE_FROM_VIEWPORT_CAPTURE_KEY]: true }}
|
||||
onClick={interactive ? (event) => selectPoint(point.id, event) : undefined}
|
||||
>
|
||||
<sphereGeometry args={[0.1, 18, 14]} />
|
||||
<meshBasicMaterial color={selected ? TRAJECTORY_SELECTED_COLOR : color} depthWrite={false} />
|
||||
<meshBasicMaterial color={selected ? TRAJECTORY_SELECTED_COLOR : color} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
@@ -1073,7 +1076,7 @@ export function SceneRoot() {
|
||||
<mesh position={[0, scene.groundHeight, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<planeGeometry args={[GROUND_PLANE_SIZE, GROUND_PLANE_SIZE]} />
|
||||
<meshBasicMaterial
|
||||
color="#303640"
|
||||
color={scene.groundColor}
|
||||
opacity={getEffectiveGroundOpacity(scene.groundOpacity, Boolean(panoramaAsset))}
|
||||
polygonOffset
|
||||
polygonOffsetFactor={1}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, it } from "vitest";
|
||||
import type { SceneSettings } from "../schema/directorProject";
|
||||
import { DEFAULT_GROUND_COLOR, type SceneSettings } from "../schema/directorProject";
|
||||
import { getSceneGroupTransform } from "./sceneTransform";
|
||||
|
||||
function createScene(patch: Partial<SceneSettings> = {}): SceneSettings {
|
||||
@@ -11,8 +11,10 @@ function createScene(patch: Partial<SceneSettings> = {}): SceneSettings {
|
||||
panoramaYaw: 0,
|
||||
panoramaRadius: 60,
|
||||
showLabels: true,
|
||||
captureShowGrid: false,
|
||||
snapToGrid: false,
|
||||
showGround: true,
|
||||
groundColor: DEFAULT_GROUND_COLOR,
|
||||
groundOpacity: 0.4,
|
||||
groundHeight: 0,
|
||||
...patch,
|
||||
|
||||
56
src/editor/canvas/viewportCapture.test.ts
Normal file
56
src/editor/canvas/viewportCapture.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { Group, Object3D } from "three";
|
||||
import {
|
||||
HIDE_FROM_VIEWPORT_CAPTURE_KEY,
|
||||
hideViewportCaptureHelpers,
|
||||
withViewportCaptureHelpersHidden,
|
||||
} from "./viewportCapture";
|
||||
|
||||
function makeFlaggedObject() {
|
||||
const object = new Object3D();
|
||||
object.userData[HIDE_FROM_VIEWPORT_CAPTURE_KEY] = true;
|
||||
return object;
|
||||
}
|
||||
|
||||
it("hides every flagged helper and restores the original visibility afterwards", () => {
|
||||
const root = new Group();
|
||||
const flagged = makeFlaggedObject();
|
||||
const nestedFlagged = makeFlaggedObject();
|
||||
const kept = new Object3D();
|
||||
const nestedKept = new Object3D();
|
||||
flagged.add(nestedFlagged, nestedKept);
|
||||
root.add(flagged, kept);
|
||||
// 先藏起来一个,验证 restore 还原的是「进入前的可见性」而不是无脑设 true
|
||||
flagged.visible = false;
|
||||
|
||||
const restore = hideViewportCaptureHelpers(root);
|
||||
|
||||
expect(flagged.visible).toBe(false); // 本来就隐藏,hider 不该改它
|
||||
expect(nestedFlagged.visible).toBe(false);
|
||||
expect(kept.visible).toBe(true);
|
||||
expect(nestedKept.visible).toBe(true);
|
||||
|
||||
restore();
|
||||
|
||||
expect(flagged.visible).toBe(false); // 还原回 hider 进入前的状态
|
||||
expect(nestedFlagged.visible).toBe(true);
|
||||
expect(kept.visible).toBe(true);
|
||||
expect(nestedKept.visible).toBe(true);
|
||||
});
|
||||
|
||||
it("hides helpers only for the render call and always restores, even when render throws", () => {
|
||||
const root = new Group();
|
||||
const flagged = makeFlaggedObject();
|
||||
root.add(flagged);
|
||||
const seenDuringRender: boolean[] = [];
|
||||
|
||||
expect(() =>
|
||||
withViewportCaptureHelpersHidden(root, () => {
|
||||
seenDuringRender.push(flagged.visible);
|
||||
throw new Error("render blew up");
|
||||
})
|
||||
).toThrow("render blew up");
|
||||
|
||||
expect(seenDuringRender).toEqual([false]);
|
||||
expect(flagged.visible).toBe(true);
|
||||
});
|
||||
44
src/editor/canvas/viewportCapture.ts
Normal file
44
src/editor/canvas/viewportCapture.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { Object3D } from "three";
|
||||
|
||||
/**
|
||||
* 截图 / 视频导出时要从画面里去掉的编辑器辅助元素。
|
||||
*
|
||||
* 约定:凡是「只在编辑器里给用户看」的东西 —— 视口网格、地面 X/Z 坐标轴、人物轨迹与路点、
|
||||
* 机位线框与拾取盒、拖拽 gizmo(那三条红绿蓝轴)、名字标签……都在 userData 里打上这个标记。
|
||||
*
|
||||
* 截图与视频导出必须在渲染前统一把它们隐藏、渲完立刻恢复,并且**共用这一份实现**:
|
||||
* 两边各写一套时,很容易出现「截图干净、导出的视频里却带着 gizmo 红绿蓝轴」这类不一致。
|
||||
*/
|
||||
export const HIDE_FROM_VIEWPORT_CAPTURE_KEY = "hideFromViewportCapture";
|
||||
|
||||
/**
|
||||
* 隐藏场景里所有带标记的辅助对象,返回「恢复原样」的函数。
|
||||
*
|
||||
* 适合需要跨多帧保持隐藏的场合(如实时录屏兜底路径,整段录制期间都不能露出来)。
|
||||
*/
|
||||
export function hideViewportCaptureHelpers(scene: Object3D): () => void {
|
||||
const hiddenObjects: Array<{ object: Object3D; visible: boolean }> = [];
|
||||
|
||||
scene.traverse((object) => {
|
||||
if (object.userData?.[HIDE_FROM_VIEWPORT_CAPTURE_KEY]) {
|
||||
hiddenObjects.push({ object, visible: object.visible });
|
||||
object.visible = false;
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
hiddenObjects.forEach(({ object, visible }) => {
|
||||
object.visible = visible;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/** 藏起辅助元素后渲染一帧;无论渲染成功还是抛错,都把可见性恢复原状。 */
|
||||
export function withViewportCaptureHelpersHidden(scene: Object3D, render: () => void) {
|
||||
const restore = hideViewportCaptureHelpers(scene);
|
||||
try {
|
||||
render();
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
}
|
||||
68
src/editor/canvas/viewportCaptureLabels.test.ts
Normal file
68
src/editor/canvas/viewportCaptureLabels.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { Group, Scene } from "three";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import type { DirectorObject } from "../schema/directorProject";
|
||||
import { createDefaultDirectorProject, useDirectorStore } from "../store/directorStore";
|
||||
import { getViewportCaptureLabels } from "./DirectorCanvas";
|
||||
|
||||
describe("getViewportCaptureLabels", () => {
|
||||
beforeEach(() => {
|
||||
useDirectorStore.getState().replaceProject(createDefaultDirectorProject());
|
||||
});
|
||||
|
||||
it("places each label at the character's real scene position, not its static transform (0,0,0)", () => {
|
||||
const project = createDefaultDirectorProject();
|
||||
const characters = project.objects.filter((o): o is DirectorObject => o.kind === "character");
|
||||
expect(characters.length).toBeGreaterThan(0);
|
||||
|
||||
// 把所有角色静态 transform 摆到原点,但在场景里把它们挪到别处 ——
|
||||
// 截图标签必须贴着「场景里真实的位置」,而不是落回 0 点坐标。
|
||||
const scene = new Scene();
|
||||
const nameToPosition = new Map<string, [number, number, number]>();
|
||||
characters.forEach((item, index) => {
|
||||
const group = new Group();
|
||||
group.userData.directorObjectId = item.id;
|
||||
const position: [number, number, number] = [10 + index, 0.5, 20 + index];
|
||||
group.position.set(position[0], position[1], position[2]);
|
||||
scene.add(group);
|
||||
nameToPosition.set(item.name, position);
|
||||
|
||||
item.transform.position = [0, 0, 0];
|
||||
});
|
||||
|
||||
useDirectorStore.getState().replaceProject(project);
|
||||
|
||||
const labels = getViewportCaptureLabels(scene);
|
||||
expect(labels.length).toBe(characters.length);
|
||||
|
||||
for (const label of labels) {
|
||||
const expected = nameToPosition.get(label.text);
|
||||
expect(expected).toBeTruthy();
|
||||
// 用 x / z 验证:若误用静态 transform(原点),这里会是 0
|
||||
expect(label.worldPosition.x).toBeCloseTo(expected![0], 5);
|
||||
expect(label.worldPosition.z).toBeCloseTo(expected![2], 5);
|
||||
// y 在角色头部高度(基础 y + 标签高度),应高于场景里摆放的 y
|
||||
expect(label.worldPosition.y).toBeGreaterThan(expected![1]);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns nothing when scene.showLabels is off", () => {
|
||||
const project = createDefaultDirectorProject();
|
||||
project.scene.showLabels = false;
|
||||
useDirectorStore.getState().replaceProject(project);
|
||||
|
||||
expect(getViewportCaptureLabels(new Scene())).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("captureShowGrid setting", () => {
|
||||
beforeEach(() => {
|
||||
useDirectorStore.getState().replaceProject(createDefaultDirectorProject());
|
||||
});
|
||||
|
||||
it("defaults to false (grid hidden during capture) and toggles", () => {
|
||||
expect(useDirectorStore.getState().project.scene.captureShowGrid).toBe(false);
|
||||
|
||||
useDirectorStore.getState().updateScene({ captureShowGrid: true });
|
||||
expect(useDirectorStore.getState().project.scene.captureShowGrid).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user