-
{label}
+
+ {label}
+ {onReset ? (
+
+ ) : null}
+
{axes.map((control) => (
diff --git a/src/editor/panels/ObjectTreePanel.test.tsx b/src/editor/panels/ObjectTreePanel.test.tsx
index 387bffb..3bd009d 100644
--- a/src/editor/panels/ObjectTreePanel.test.tsx
+++ b/src/editor/panels/ObjectTreePanel.test.tsx
@@ -48,7 +48,7 @@ it("hides empty left panel groups and keeps the approved group order", () => {
const groups = screen.getAllByRole("group").map((group) => group.getAttribute("aria-label"));
- expect(groups).toEqual(["角色分组", "摄像机分组"]);
+ expect(groups).toEqual(["场景分组", "角色分组", "摄像机分组"]);
expect(screen.queryByRole("group", { name: "群众分组" })).not.toBeInTheDocument();
expect(screen.queryByRole("group", { name: "几何体分组" })).not.toBeInTheDocument();
});
@@ -66,6 +66,7 @@ it("shows crowd arrays in a dedicated crowd group using grouped labels like crow
render();
expect(screen.getAllByRole("group").map((group) => group.getAttribute("aria-label"))).toEqual([
+ "场景分组",
"角色分组",
"群众分组",
"摄像机分组",
@@ -170,6 +171,7 @@ it("shows geometry groups when prop objects exist and gives each row the matchin
render();
expect(screen.getAllByRole("group").map((group) => group.getAttribute("aria-label"))).toEqual([
+ "场景分组",
"角色分组",
"几何体分组",
"摄像机分组",
@@ -230,6 +232,7 @@ it("shows imported local and library models in a separate my models group below
render();
expect(screen.getAllByRole("group").map((group) => group.getAttribute("aria-label"))).toEqual([
+ "场景分组",
"角色分组",
"几何体分组",
"我的模型分组",
@@ -342,13 +345,100 @@ it("deletes all selected rows when users press the keyboard delete key", async (
expect(useDirectorStore.getState().selectedObjectIds).toEqual([]);
});
-it("does not render a delete icon and deletes the selected object with the keyboard delete key", async () => {
+it("puts the scene row above the character group and opens the 3D scene inspector", async () => {
+ const user = userEvent.setup();
+ useDirectorStore.getState().selectObject("char_default_a");
+ render();
+
+ const sceneRow = screen.getByRole("treeitem", { name: "3D场景" });
+ expect(sceneRow).toHaveAttribute("aria-selected", "false");
+ // 场景行排在角色分组之前
+ expect(sceneRow.compareDocumentPosition(screen.getByRole("treeitem", { name: "角色01" }))).toBe(
+ Node.DOCUMENT_POSITION_FOLLOWING
+ );
+
+ await user.click(sceneRow);
+
+ const state = useDirectorStore.getState();
+ expect(state.directorInspectorMode).toBe("scene");
+ expect(state.selectedObjectId).toBeNull();
+ expect(state.selectedObjectIds).toEqual([]);
+ expect(screen.getByRole("treeitem", { name: "3D场景" })).toHaveAttribute("aria-selected", "true");
+});
+
+it("switches back to the director view when the scene row is clicked from the camera view", async () => {
+ const user = userEvent.setup();
+ useDirectorStore.getState().setViewMode("camera");
+ render();
+
+ await user.click(screen.getByRole("treeitem", { name: "3D场景" }));
+
+ // 机位视角下不切回导演视角的话,右侧仍会显示摄像机面板
+ expect(useDirectorStore.getState().viewMode).toBe("director");
+ expect(useDirectorStore.getState().directorInspectorMode).toBe("scene");
+});
+
+it("deletes one row from its own delete button without touching the current selection", async () => {
+ const user = userEvent.setup();
+ useDirectorStore.getState().addPresetCharacter("female");
+ render();
+
+ await user.click(screen.getByRole("button", { name: "角色01" }));
+ await user.click(screen.getByRole("button", { name: "删除 角色02" }));
+
+ expect(screen.queryByRole("button", { name: "角色02" })).not.toBeInTheDocument();
+ // 删别人不会丢掉当前选中
+ expect(screen.getByRole("treeitem", { name: "角色01" })).toHaveAttribute("aria-selected", "true");
+ expect(useDirectorStore.getState().selectedObjectId).toBe("char_default_a");
+});
+
+it("deletes a camera row together with its camera shot", async () => {
+ const user = userEvent.setup();
+ useDirectorStore.getState().addCameraShot();
+ render();
+
+ expect(useDirectorStore.getState().project.cameras).toHaveLength(2);
+
+ await user.click(screen.getByRole("button", { name: "删除 机位02" }));
+
+ expect(screen.queryByRole("button", { name: "机位02" })).not.toBeInTheDocument();
+ expect(useDirectorStore.getState().project.cameras.map((item) => item.id)).toEqual(["cam_1"]);
+ expect(useDirectorStore.getState().project.objects.some((item) => item.name === "机位02")).toBe(false);
+});
+
+it("deletes a whole crowd array from the crowd row delete button", async () => {
+ const user = userEvent.setup();
+ useDirectorStore.getState().addCrowdCharacters({ rows: 2, columns: 2, spacing: 1.2 });
+ render();
+
+ await user.click(screen.getByRole("button", { name: "删除 群众(2x2)" }));
+
+ expect(screen.queryByRole("group", { name: "群众分组" })).not.toBeInTheDocument();
+ expect(useDirectorStore.getState().project.objects.filter((item) => item.crowdId)).toHaveLength(0);
+});
+
+it("keeps the per-row delete buttons from also selecting the row", async () => {
+ const user = userEvent.setup();
+ useDirectorStore.getState().addPresetCharacter("female");
+ render();
+
+ await user.click(screen.getByRole("button", { name: "删除 角色02" }));
+
+ expect(useDirectorStore.getState().selectedObjectId).toBeNull();
+ expect(useDirectorStore.getState().selectedObjectIds).toEqual([]);
+});
+
+it("deletes the selected object with the keyboard delete key", async () => {
const user = userEvent.setup();
useDirectorStore.getState().addPresetCharacter("female");
render();
await user.click(screen.getByRole("button", { name: "角色02" }));
+ // 每行一个删除按钮,不再有面板级的「删除选中内容」
+ expect(
+ within(screen.getByRole("treeitem", { name: "角色02" })).getByRole("button", { name: "删除 角色02" })
+ ).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "删除选中内容" })).not.toBeInTheDocument();
await user.keyboard("{Delete}");
@@ -358,6 +448,62 @@ it("does not render a delete icon and deletes the selected object with the keybo
expect(useDirectorStore.getState().project.objects.some((item) => item.name === "角色02")).toBe(false);
});
+it("lists trajectory points under the owning character and selects one", async () => {
+ const user = userEvent.setup();
+ useDirectorStore.getState().selectObject("char_default_a");
+ useDirectorStore.getState().addTrajectoryPoint("char_default_a", [1, 0, 2]);
+ useDirectorStore.getState().addTrajectoryPoint("char_default_a", [3, 0, 4]);
+
+ render();
+
+ const trajectoryList = screen.getByLabelText("角色01轨迹");
+ expect(trajectoryList).toHaveClass("object-trajectory-list");
+ // 单行「轨迹」父行(1 个按钮)+ 其下每个轨迹点一行(选中 + 删除各 1 个按钮)
+ expect(within(trajectoryList).getAllByRole("button")).toHaveLength(5);
+
+ // 默认展开:两个轨迹点都可见
+ expect(screen.getByLabelText("角色01 轨迹点 1")).toBeInTheDocument();
+ expect(screen.getByLabelText("角色01 轨迹点 2")).toBeInTheDocument();
+
+ await user.click(screen.getByLabelText("角色01 轨迹点 2"));
+
+ const state = useDirectorStore.getState();
+ const points = state.project.objects.find((item) => item.id === "char_default_a")?.trajectoryPoints ?? [];
+
+ expect(state.selectedTrajectoryPoint?.objectId).toBe("char_default_a");
+ expect(state.selectedTrajectoryPoint?.pointId).toBe(points[1]?.id);
+});
+
+it("collapses the trajectory sub-list when its parent row is clicked", async () => {
+ const user = userEvent.setup();
+ useDirectorStore.getState().addTrajectoryPoint("char_default_a", [1, 0, 2]);
+ useDirectorStore.getState().addTrajectoryPoint("char_default_a", [3, 0, 4]);
+
+ render();
+
+ expect(screen.getByLabelText("角色01 轨迹点 2")).toBeInTheDocument();
+
+ await user.click(screen.getByLabelText("角色01 轨迹"));
+
+ // 收起后,轨迹点子列表不再渲染(父行仍在)
+ expect(screen.queryByLabelText("角色01 轨迹点 2")).not.toBeInTheDocument();
+ expect(screen.getByLabelText("角色01 轨迹")).toHaveAttribute("aria-expanded", "false");
+});
+
+it("deletes a trajectory point from the character sub list", async () => {
+ const user = userEvent.setup();
+ useDirectorStore.getState().addTrajectoryPoint("char_default_a", [1, 0, 2]);
+
+ render();
+
+ await user.click(screen.getByLabelText("删除 角色01 轨迹点 1"));
+
+ const character = useDirectorStore.getState().project.objects.find((item) => item.id === "char_default_a");
+
+ expect(character?.trajectoryPoints).toHaveLength(0);
+ expect(screen.queryByLabelText("角色01轨迹")).not.toBeInTheDocument();
+});
+
it("switches the active camera when users select a camera row", async () => {
const user = userEvent.setup();
useDirectorStore.getState().addCameraShot();
diff --git a/src/editor/panels/ObjectTreePanel.tsx b/src/editor/panels/ObjectTreePanel.tsx
index afda2d2..ba6b016 100644
--- a/src/editor/panels/ObjectTreePanel.tsx
+++ b/src/editor/panels/ObjectTreePanel.tsx
@@ -1,5 +1,21 @@
import { useEffect, useMemo, useState, type MouseEvent } from "react";
-import { Box, Camera, ChevronDown, ChevronRight, Eye, EyeOff, Lock, Search, Unlock, User, Users } from "lucide-react";
+import {
+ Box,
+ Camera,
+ ChevronDown,
+ ChevronRight,
+ Eye,
+ EyeOff,
+ Globe,
+ Lock,
+ MapPin,
+ Route,
+ Search,
+ Trash2,
+ Unlock,
+ User,
+ Users,
+} from "lucide-react";
import type { DirectorObject, DirectorObjectKind } from "../schema/directorProject";
import { useDirectorStore } from "../store/directorStore";
@@ -19,7 +35,7 @@ type SceneTreeItem = {
previewChildren?: SceneTreePreviewItem[];
};
-type ObjectTreeIconKind = "character" | "crowd" | "geometry" | "model" | "camera";
+type ObjectTreeIconKind = "scene" | "character" | "crowd" | "geometry" | "model" | "camera";
const GROUP_LABELS: Array<{
key: string;
@@ -37,6 +53,7 @@ function ObjectKindIcon({ icon }: { icon: ObjectTreeIconKind }) {
return (
+ {icon === "scene" ? : null}
{icon === "camera" ? : null}
{icon === "crowd" ? : null}
{icon === "geometry" || icon === "model" ? : null}
@@ -54,6 +71,12 @@ function isEditableKeyboardTarget(target: EventTarget | null) {
export function ObjectTreePanel() {
const [query, setQuery] = useState("");
const [expandedCrowdIds, setExpandedCrowdIds] = useState([]);
+ /** 被用户手动收起的轨迹子列表角色 id;默认(不在列表里)就是展开的,所以新增的轨迹点会自动显示 */
+ const [collapsedTrajectoryIds, setCollapsedTrajectoryIds] = useState([]);
+ const trajectoryExpanded = useMemo(
+ () => (id: string) => !collapsedTrajectoryIds.includes(id),
+ [collapsedTrajectoryIds]
+ );
const assets = useDirectorStore((state) => state.project.assets);
const objects = useDirectorStore((state) => state.project.objects);
const selectedObjectId = useDirectorStore((state) => state.selectedObjectId);
@@ -66,6 +89,15 @@ export function ObjectTreePanel() {
const toggleObjectVisible = useDirectorStore((state) => state.toggleObjectVisible);
const toggleObjectLocked = useDirectorStore((state) => state.toggleObjectLocked);
const deleteSelectedObject = useDirectorStore((state) => state.deleteSelectedObject);
+ const deleteObject = useDirectorStore((state) => state.deleteObject);
+ const deleteCrowd = useDirectorStore((state) => state.deleteCrowd);
+ const openSceneInspector = useDirectorStore((state) => state.openSceneInspector);
+ const viewMode = useDirectorStore((state) => state.viewMode);
+ const directorInspectorMode = useDirectorStore((state) => state.directorInspectorMode);
+ const setViewMode = useDirectorStore((state) => state.setViewMode);
+ const selectedTrajectoryPoint = useDirectorStore((state) => state.selectedTrajectoryPoint);
+ const selectTrajectoryPoint = useDirectorStore((state) => state.selectTrajectoryPoint);
+ const removeTrajectoryPoint = useDirectorStore((state) => state.removeTrajectoryPoint);
useEffect(() => {
function handleKeyDown(event: KeyboardEvent) {
@@ -86,6 +118,8 @@ export function ObjectTreePanel() {
};
}, [deleteSelectedObject]);
+ // 「场景」行是否选中:判定条件与 selectRightPanelKind 选出 scene 面板的条件保持一致
+ const sceneSelected = viewMode === "director" && directorInspectorMode === "scene";
const assetsById = useMemo(() => new Map(assets.map((asset) => [asset.id, asset])), [assets]);
const isModelBackedObject = (object: DirectorObject | undefined) => {
if (!object?.assetRefId) return false;
@@ -165,6 +199,12 @@ export function ObjectTreePanel() {
setExpandedCrowdIds((current) => current.filter((crowdId) => crowdIds.has(crowdId)));
}, [groupedItems.crowd]);
+ useEffect(() => {
+ // 角色被删后,它的轨迹收起态一并回收
+ const characterIds = new Set(groupedItems.characters.map((item) => item.id));
+ setCollapsedTrajectoryIds((current) => current.filter((id) => characterIds.has(id)));
+ }, [groupedItems.characters]);
+
const filteredGroups = GROUP_LABELS.map((group) => {
const itemsByGroup =
group.key === "characters"
@@ -200,6 +240,22 @@ export function ObjectTreePanel() {
}).filter((group) => group.items.length > 0);
const hasEmptySearchResult = query.trim().length > 0 && filteredGroups.length === 0;
+ /** 打开 3D 场景属性面板;机位视角下先切回导演视角,否则右侧会继续显示摄像机面板 */
+ function selectScene() {
+ if (viewMode !== "director") setViewMode("director");
+ openSceneInspector();
+ }
+
+ /** 删除场景树的一行:群众行删整组,其余按对象 id 删单个(级联清理交给 store) */
+ function deleteTreeItem(item: SceneTreeItem) {
+ if (item.crowdId) {
+ deleteCrowd(item.crowdId);
+ return;
+ }
+
+ deleteObject(item.id);
+ }
+
function selectTreeItem(item: SceneTreeItem, event: MouseEvent) {
if (item.crowdId) {
const selectedIds = getSelectedIds();
@@ -303,6 +359,28 @@ export function ObjectTreePanel() {
) : (
+ {/* 场景本身作为一个固定条目放在最上面:点它打开右侧的 3D 场景属性面板 */}
+
{filteredGroups.map((group) => (
{group.title}
@@ -383,6 +461,18 @@ export function ObjectTreePanel() {
>
) : null}
+ {/* 删除:群众行整组删除,其余按对象删除(不改变当前选中以外的状态) */}
+
{item.crowdId && expanded && item.previewChildren?.length ? (
@@ -405,6 +495,95 @@ export function ObjectTreePanel() {
))}
) : null}
+ {/* 角色名下:每个角色只有一条轨迹 → 先占一行「轨迹」,下面才是它的轨迹点 */}
+ {item.object?.trajectoryPoints?.length ? (
+
+ -
+
+ setCollapsedTrajectoryIds((current) =>
+ current.includes(item.id)
+ ? current.filter((id) => id !== item.id)
+ : [...current, item.id]
+ )
+ }
+ >
+
+
+
+
+
+ {trajectoryExpanded(item.id) ? (
+
+ {item.object.trajectoryPoints.map((point, index) => {
+ const pointSelected =
+ selectedTrajectoryPoint?.objectId === item.id &&
+ selectedTrajectoryPoint.pointId === point.id;
+ const pointLabel = `轨迹点 ${index + 1}`;
+
+ return (
+ -
+
{
+ event.stopPropagation();
+ selectTrajectoryPoint(item.id, point.id);
+ }}
+ >
+
+
+
+
+
+
+
+ );
+ })}
+
+ ) : null}
+
+
+ ) : null}
);
})}
diff --git a/src/editor/panels/PropPanel.test.tsx b/src/editor/panels/PropPanel.test.tsx
index 7658e4a..e0f1cf7 100644
--- a/src/editor/panels/PropPanel.test.tsx
+++ b/src/editor/panels/PropPanel.test.tsx
@@ -1,4 +1,4 @@
-import { render, screen } from "@testing-library/react";
+import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach } from "vitest";
import { createInitialDirectorState, useDirectorStore } from "../store/directorStore";
@@ -54,6 +54,39 @@ it("renders the prop inspector fields for imported models", () => {
expect(screen.getByLabelText("模型颜色 HEX")).toBeInTheDocument();
});
+it("resets each model axis group from its own title button", async () => {
+ const user = userEvent.setup();
+ useDirectorStore.getState().updateObjectTransform("prop_model_1", {
+ position: [3, 4, 5],
+ rotation: [10, 20, 30],
+ scale: [1.5, 2, 3],
+ });
+
+ render(
);
+
+ ["位置", "旋转", "缩放"].forEach((label) => {
+ const button = within(screen.getByRole("group", { name: label })).getByRole("button", { name: `重置${label}` });
+
+ expect(button).toHaveClass("inspector-axis-reset-button");
+ expect(button.querySelector("svg")).toBeInTheDocument();
+ });
+
+ const readProp = () => useDirectorStore.getState().project.objects.find((item) => item.id === "prop_model_1");
+
+ await user.click(screen.getByRole("button", { name: "重置位置" }));
+ expect(readProp()?.transform.position).toEqual([0, 0, 0]);
+ expect(readProp()?.transform.rotation).toEqual([10, 20, 30]);
+ expect(readProp()?.transform.scale).toEqual([1.5, 2, 3]);
+
+ await user.click(screen.getByRole("button", { name: "重置旋转" }));
+ expect(readProp()?.transform.rotation).toEqual([0, 0, 0]);
+ expect(readProp()?.transform.scale).toEqual([1.5, 2, 3]);
+
+ await user.click(screen.getByRole("button", { name: "重置缩放" }));
+ expect(readProp()?.transform.scale).toEqual([1, 1, 1]);
+ expect(screen.getByLabelText("模型缩放 Z")).toHaveValue(1);
+});
+
it("updates the selected prop name, uniform scale, and color", async () => {
const user = userEvent.setup();
render(
);
diff --git a/src/editor/panels/PropPanel.tsx b/src/editor/panels/PropPanel.tsx
index 062f2fc..645bce0 100644
--- a/src/editor/panels/PropPanel.tsx
+++ b/src/editor/panels/PropPanel.tsx
@@ -5,6 +5,7 @@ import {
InspectorRangeNumberField,
InspectorTextField,
} from "./InspectorControls";
+import { DEFAULT_DIRECTOR_TRANSFORM } from "../schema/directorProject";
import { useDirectorStore } from "../store/directorStore";
function replaceAxis(tuple: [number, number, number], axis: 0 | 1 | 2, value: number): [number, number, number] {
@@ -38,6 +39,7 @@ export function PropPanel() {
updateObjectName(prop.id, value)} />
updateObjectTransform(prop.id, { position: DEFAULT_DIRECTOR_TRANSFORM.position })}
axes={[
{
axis: "X",
@@ -61,6 +63,7 @@ export function PropPanel() {
/>
updateObjectTransform(prop.id, { rotation: DEFAULT_DIRECTOR_TRANSFORM.rotation })}
axes={[
{
axis: "X",
@@ -84,6 +87,7 @@ export function PropPanel() {
/>
updateObjectTransform(prop.id, { scale: DEFAULT_DIRECTOR_TRANSFORM.scale })}
axes={[
{
axis: "X",
diff --git a/src/editor/panels/RightPanel.tsx b/src/editor/panels/RightPanel.tsx
index 880e873..0da8ddd 100644
--- a/src/editor/panels/RightPanel.tsx
+++ b/src/editor/panels/RightPanel.tsx
@@ -4,10 +4,12 @@ import { CameraPanel } from "./CameraPanel";
import { CharacterPanel } from "./CharacterPanel";
import { PropPanel } from "./PropPanel";
import { ScenePanel } from "./ScenePanel";
+import { TrajectoryPointPanel } from "./TrajectoryPointPanel";
export function RightPanel() {
const panelKind = useDirectorStore(selectRightPanelKind);
+ if (panelKind === "trajectoryPoint") return ;
if (panelKind === "character") return ;
if (panelKind === "prop") return ;
if (panelKind === "camera") return ;
diff --git a/src/editor/panels/ScenePanel.test.tsx b/src/editor/panels/ScenePanel.test.tsx
index e053bbe..ebf8216 100644
--- a/src/editor/panels/ScenePanel.test.tsx
+++ b/src/editor/panels/ScenePanel.test.tsx
@@ -1,6 +1,6 @@
-import { fireEvent, render, screen } from "@testing-library/react";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
-import { beforeEach } from "vitest";
+import { afterEach, beforeEach, vi } from "vitest";
import { createInitialDirectorState, useDirectorStore } from "../store/directorStore";
import { ScenePanel } from "./ScenePanel";
@@ -9,6 +9,26 @@ beforeEach(() => {
...useDirectorStore.getState(),
...createInitialDirectorState(),
});
+ // 全景图导入要经过 canvas 适配:jsdom 里没有真实实现,这里补最小可用的桩
+ vi.stubGlobal(
+ "createImageBitmap",
+ vi.fn(async () => ({
+ width: 4096,
+ height: 2048,
+ close: vi.fn(),
+ }))
+ );
+ vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(
+ () => ({ drawImage: vi.fn(), fillRect: vi.fn() }) as unknown as CanvasRenderingContext2D
+ );
+ vi.spyOn(HTMLCanvasElement.prototype, "toDataURL").mockImplementation(
+ () => "data:image/jpeg;base64,panorama-adapted"
+ );
+});
+
+afterEach(() => {
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
});
it("uses the provided right inspector layout for scene properties", () => {
@@ -142,17 +162,50 @@ it("removes the connected panorama when the delete icon is clicked", async () =>
expect(useDirectorStore.getState().project.panoramaAssetId).toBeNull();
expect(useDirectorStore.getState().project.assets).toHaveLength(0);
- expect(screen.getByLabelText("全景图连接状态")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "未连接全景图" })).toBeInTheDocument();
});
it("renders the disconnected panorama state as a fixed-size dark card", () => {
render();
- const panoramaStatus = screen.getByLabelText("全景图连接状态");
+ const panoramaStatus = screen.getByRole("button", { name: "未连接全景图" });
expect(panoramaStatus).toHaveClass("panorama-empty-card");
expect(screen.getByTestId("panorama-empty-icon")).toBeInTheDocument();
expect(panoramaStatus).toHaveTextContent("未连接全景图");
+ expect(panoramaStatus).toHaveAttribute("title", "导入全景图");
+});
+
+it("opens the panorama file picker when the disconnected card is clicked", async () => {
+ const user = userEvent.setup();
+ render();
+
+ const input = screen.getByTestId("scene-panel-panorama-input") as HTMLInputElement;
+ const clickSpy = vi.spyOn(input, "click");
+
+ await user.click(screen.getByRole("button", { name: "未连接全景图" }));
+
+ expect(clickSpy).toHaveBeenCalledTimes(1);
+ expect(input).toHaveAttribute("accept", ".jpg,.jpeg,.png,.webp");
+});
+
+it("imports the picked file as the panorama background from the disconnected card", async () => {
+ const user = userEvent.setup();
+ render();
+
+ const file = new File(["image"], "studio-360.jpg", { type: "image/jpeg" });
+ await user.upload(screen.getByTestId("scene-panel-panorama-input") as HTMLInputElement, file);
+
+ await waitFor(() => {
+ expect(useDirectorStore.getState().project.assets[0]?.fileName).toBe("studio-360.jpg");
+ });
+
+ const state = useDirectorStore.getState();
+ // 和快捷栏「导入全景图」一样:读文件 → 适配投影 → 作为全景资产入库并设为当前全景
+ expect(state.project.panoramaAssetId).toBe(state.project.assets[0]?.id);
+ expect(state.project.assets[0]?.projectionMode).toBe("equirectangular");
+ // 导入成功后占位卡换成缩略图卡
+ expect(screen.getByLabelText("全景图缩略图卡片")).toBeInTheDocument();
});
it("updates panorama radius from both slider and numeric input", async () => {
diff --git a/src/editor/panels/ScenePanel.tsx b/src/editor/panels/ScenePanel.tsx
index 68d7a13..641bb92 100644
--- a/src/editor/panels/ScenePanel.tsx
+++ b/src/editor/panels/ScenePanel.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useState } from "react";
+import { useEffect, useRef, useState, type ChangeEvent } from "react";
import { ImageOff } from "lucide-react";
import { Trash2 } from "lucide-react";
import {
@@ -8,6 +8,7 @@ import {
InspectorRangeNumberField,
InspectorSection,
} from "./InspectorControls";
+import { importPanoramaFile } from "../runtime/panoramaCommand";
import { useDirectorStore } from "../store/directorStore";
const PANORAMA_RADIUS_MIN = 10;
@@ -37,9 +38,23 @@ export function ScenePanel() {
const [panoramaYawDraft, setPanoramaYawDraft] = useState(String(scene.panoramaYaw));
const [panoramaRadiusDraft, setPanoramaRadiusDraft] = useState(String(scene.panoramaRadius));
const [groundHeightDraft, setGroundHeightDraft] = useState(String(scene.groundHeight));
+ const panoramaInputRef = useRef(null);
const panoramaAsset = assets.find((item) => item.id === panoramaAssetId);
const clampedPanoramaRadius = clampNumber(scene.panoramaRadius, PANORAMA_RADIUS_MIN, PANORAMA_RADIUS_MAX);
+ /** 「未连接全景图」占位卡选好文件后导入:与快捷栏「导入全景图」同一实现 */
+ async function handlePanoramaChange(event: ChangeEvent) {
+ const input = event.currentTarget;
+ const file = input.files?.[0];
+ if (!file) return;
+
+ try {
+ await importPanoramaFile(file);
+ } finally {
+ input.value = "";
+ }
+ }
+
useEffect(() => {
setSceneScaleDraft(String(scene.scale));
}, [scene.scale]);
@@ -176,13 +191,29 @@ export function ScenePanel() {
{panoramaAsset.fileName}
) : (
-