Files
ttstd-3d-director/src/editor/canvas/ViewportToolbar.tsx
2026-09-18 00:52:28 +08:00

977 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
useEffect,
useLayoutEffect,
useRef,
useState,
type CSSProperties,
type ChangeEvent,
type MutableRefObject,
} from "react";
import {
Box,
Boxes,
Camera,
ChevronRight,
Circle,
Expand,
Grid2X2,
Grid3X3,
Image,
ImagePlus,
Move3D,
Plus,
Ratio,
Rotate3D,
Route,
Scale3D,
Square,
Trash2,
UserPlus,
Video,
X,
type LucideIcon,
} from "lucide-react";
import { requestViewportCapture } from "../io/captureBridge";
import { importPanoramaFile } from "../runtime/panoramaCommand";
import { readLocalModelFile } from "../loaders/localModelImport";
import {
getModelLibraryItems,
MODEL_LIBRARY_CATEGORIES,
type ModelLibraryCategoryId,
type ModelLibraryItem,
} from "../modelLibrary/modelLibraryCatalog";
import {
VIEWPORT_ASPECT_RATIO_OPTIONS,
type ViewportAspectRatio,
} from "../schema/viewportAspectRatio";
import { BODY_TYPE_OPTIONS, type CharacterBodyType } from "../runtime/mannequin/bodyTypes";
import { GEOMETRY_PRIMITIVE_OPTIONS, type GeometryPrimitiveType } from "../schema/directorProject";
import {
useDirectorStore,
type CameraShotSnapshot,
type CrowdCharactersInput,
type TransformMode,
} from "../store/directorStore";
type ToolbarAction = {
label: string;
icon: LucideIcon;
mode?: TransformMode;
/** 非变换模式的自定义切换态(如「录制」) */
pressed?: boolean;
disabled?: boolean;
title?: string;
className?: string;
onClick: () => void;
};
const DEFAULT_VIEWPORT_TOOLBAR_HEIGHT = 46;
const VIEWPORT_TOOLBAR_BOTTOM_OFFSET = 40;
const DEFAULT_CROWD_ROWS = 3;
const DEFAULT_CROWD_COLUMNS = 3;
const DEFAULT_CROWD_SPACING = 1.2;
const MIN_CROWD_GRID_SIZE = 1;
const MAX_CROWD_GRID_SIZE = 12;
const MIN_CROWD_SPACING = 0.1;
const MAX_CROWD_SPACING = 10;
function clampCrowdGridSize(value: number) {
if (!Number.isFinite(value)) return MIN_CROWD_GRID_SIZE;
return Math.min(MAX_CROWD_GRID_SIZE, Math.max(MIN_CROWD_GRID_SIZE, Math.round(value)));
}
function clampCrowdSpacing(value: number) {
if (!Number.isFinite(value)) return DEFAULT_CROWD_SPACING;
return Math.min(MAX_CROWD_SPACING, Math.max(MIN_CROWD_SPACING, Number(value.toFixed(2))));
}
function waitForNextAnimationFrame() {
return new Promise<void>((resolve) => {
requestAnimationFrame(() => resolve());
});
}
export function ViewportToolbar({
getViewportCameraSnapshot,
toolbarContainerRef,
}: {
getViewportCameraSnapshot?: () => CameraShotSnapshot;
toolbarContainerRef?: MutableRefObject<HTMLDivElement | null>;
}) {
const toolbarRef = useRef<HTMLDivElement | null>(null);
const aspectRatioPanelRef = useRef<HTMLDivElement | null>(null);
const characterTriggerRef = useRef<HTMLButtonElement | null>(null);
const geometryTriggerRef = useRef<HTMLButtonElement | null>(null);
const crowdTriggerRef = useRef<HTMLButtonElement | null>(null);
const modelLibraryTriggerRef = useRef<HTMLButtonElement | null>(null);
const characterMenuRef = useRef<HTMLDivElement | null>(null);
const geometryMenuRef = useRef<HTMLDivElement | null>(null);
const crowdPanelRef = useRef<HTMLDivElement | null>(null);
const modelLibraryPanelRef = useRef<HTMLDivElement | null>(null);
const sceneLocalModelInputRef = useRef<HTMLInputElement | null>(null);
const libraryLocalModelInputRef = useRef<HTMLInputElement | null>(null);
const panoramaInputRef = useRef<HTMLInputElement | null>(null);
const [characterMenuOpen, setCharacterMenuOpen] = useState(false);
const [geometryMenuOpen, setGeometryMenuOpen] = useState(false);
const [crowdPanelOpen, setCrowdPanelOpen] = useState(false);
const [modelLibraryOpen, setModelLibraryOpen] = useState(false);
const [aspectRatioPanelOpen, setAspectRatioPanelOpen] = useState(false);
const [toolbarHeight, setToolbarHeight] = useState(DEFAULT_VIEWPORT_TOOLBAR_HEIGHT);
const [characterMenuStyle, setCharacterMenuStyle] = useState<CSSProperties>({});
const [geometryMenuStyle, setGeometryMenuStyle] = useState<CSSProperties>({});
const [crowdPanelStyle, setCrowdPanelStyle] = useState<CSSProperties>({});
const [modelLibraryPanelStyle, setModelLibraryPanelStyle] = useState<CSSProperties>({});
const [crowdBodyType, setCrowdBodyType] = useState<CharacterBodyType>(BODY_TYPE_OPTIONS[0]?.bodyType ?? "mannequin");
const [crowdRows, setCrowdRows] = useState(String(DEFAULT_CROWD_ROWS));
const [crowdColumns, setCrowdColumns] = useState(String(DEFAULT_CROWD_COLUMNS));
const [crowdSpacing, setCrowdSpacing] = useState(String(DEFAULT_CROWD_SPACING));
const [activeModelLibraryCategoryId, setActiveModelLibraryCategoryId] =
useState<ModelLibraryCategoryId>("convenience");
const addImportedAsset = useDirectorStore((state) => state.addImportedAsset);
const addObjectFromAsset = useDirectorStore((state) => state.addObjectFromAsset);
const removeImportedAsset = useDirectorStore((state) => state.removeImportedAsset);
const assets = useDirectorStore((state) => state.project.assets);
const addPresetCharacter = useDirectorStore((state) => state.addPresetCharacter);
const addCrowdCharacters = useDirectorStore((state) => state.addCrowdCharacters);
const addGeometryPrimitive = useDirectorStore((state) => state.addGeometryPrimitive);
const addCameraShot = useDirectorStore((state) => state.addCameraShot);
const addCameraCaptures = useDirectorStore((state) => state.addCameraCaptures);
const activeCameraId = useDirectorStore((state) => state.project.activeCameraId);
const viewMode = useDirectorStore((state) => state.viewMode);
const transformMode = useDirectorStore((state) => state.transformMode);
const viewportAspectRatio = useDirectorStore((state) => state.viewportAspectRatio);
const setViewMode = useDirectorStore((state) => state.setViewMode);
const setTransformMode = useDirectorStore((state) => state.setTransformMode);
const setViewportAspectRatio = useDirectorStore((state) => state.setViewportAspectRatio);
const toggleViewportPanelsCollapsed = useDirectorStore((state) => state.toggleViewportPanelsCollapsed);
const project = useDirectorStore((state) => state.project);
const selectedObjectId = useDirectorStore((state) => state.selectedObjectId);
const timelineVisible = useDirectorStore((state) => state.timelineVisible);
const toggleTimelineVisible = useDirectorStore((state) => state.toggleTimelineVisible);
const trajectoryPlacementObjectId = useDirectorStore((state) => state.trajectoryPlacementObjectId);
const startTrajectoryPlacement = useDirectorStore((state) => state.startTrajectoryPlacement);
const stopTrajectoryPlacement = useDirectorStore((state) => state.stopTrajectoryPlacement);
// 轨迹打点目标:只有角色带运动轨迹(画布打点模式只接受角色)
const selectedObject = project.objects.find((item) => item.id === selectedObjectId) ?? null;
const selectedCharacter = selectedObject?.kind === "character" ? selectedObject : null;
const canPlaceTrajectory = Boolean(selectedCharacter);
const trajectoryOwnerName = selectedCharacter?.name ?? "";
const isPlacingTrajectory =
Boolean(selectedCharacter) && trajectoryPlacementObjectId === selectedCharacter?.id;
// 打点模式下按 Esc 退出
useEffect(() => {
if (!trajectoryPlacementObjectId) return undefined;
function handleKeyDown(event: KeyboardEvent) {
if (event.key !== "Escape") return;
stopTrajectoryPlacement();
}
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [stopTrajectoryPlacement, trajectoryPlacementObjectId]);
useEffect(() => {
if (!characterMenuOpen && !crowdPanelOpen && !modelLibraryOpen && !aspectRatioPanelOpen) return;
function closeMenusOnOutsidePointerDown(event: PointerEvent) {
if (event.target instanceof Node && toolbarRef.current?.contains(event.target)) return;
if (event.target instanceof Node && characterMenuRef.current?.contains(event.target)) return;
if (event.target instanceof Node && geometryMenuRef.current?.contains(event.target)) return;
if (event.target instanceof Node && crowdPanelRef.current?.contains(event.target)) return;
if (event.target instanceof Node && modelLibraryPanelRef.current?.contains(event.target)) return;
if (event.target instanceof Node && aspectRatioPanelRef.current?.contains(event.target)) return;
if (event.target instanceof Node && sceneLocalModelInputRef.current?.contains(event.target)) return;
if (event.target instanceof Node && libraryLocalModelInputRef.current?.contains(event.target)) return;
if (event.target instanceof Node && panoramaInputRef.current?.contains(event.target)) return;
setCharacterMenuOpen(false);
setGeometryMenuOpen(false);
setCrowdPanelOpen(false);
setModelLibraryOpen(false);
setAspectRatioPanelOpen(false);
}
document.addEventListener("pointerdown", closeMenusOnOutsidePointerDown);
return () => {
document.removeEventListener("pointerdown", closeMenusOnOutsidePointerDown);
};
}, [aspectRatioPanelOpen, characterMenuOpen, crowdPanelOpen, modelLibraryOpen]);
useLayoutEffect(() => {
const element = toolbarRef.current;
if (!element) return;
const updateHeight = () => {
const nextHeight = Math.max(element.offsetHeight, DEFAULT_VIEWPORT_TOOLBAR_HEIGHT);
setToolbarHeight((currentHeight) => (currentHeight === nextHeight ? currentHeight : nextHeight));
};
updateHeight();
if (typeof ResizeObserver === "undefined") {
window.addEventListener("resize", updateHeight);
return () => {
window.removeEventListener("resize", updateHeight);
};
}
const resizeObserver = new ResizeObserver(updateHeight);
resizeObserver.observe(element);
window.addEventListener("resize", updateHeight);
return () => {
resizeObserver.disconnect();
window.removeEventListener("resize", updateHeight);
};
}, []);
useLayoutEffect(() => {
const toolbarElement = toolbarRef.current;
const frameElement = toolbarElement?.parentElement;
if (!toolbarElement || !frameElement) return;
const updateFloatingPositions = () => {
const frameRect = frameElement.getBoundingClientRect();
if (characterMenuOpen && characterTriggerRef.current) {
const triggerRect = characterTriggerRef.current.getBoundingClientRect();
setCharacterMenuStyle({
left: `${triggerRect.left - frameRect.left + triggerRect.width / 2}px`,
bottom: `${frameRect.bottom - triggerRect.top + 8}px`,
});
}
if (geometryMenuOpen && geometryTriggerRef.current) {
const triggerRect = geometryTriggerRef.current.getBoundingClientRect();
setGeometryMenuStyle({
left: `${triggerRect.right - frameRect.left + 8}px`,
bottom: `${frameRect.bottom - triggerRect.bottom}px`,
});
}
if (crowdPanelOpen && crowdTriggerRef.current) {
const triggerRect = crowdTriggerRef.current.getBoundingClientRect();
setCrowdPanelStyle({
left: `${triggerRect.right - frameRect.left + 8}px`,
bottom: `${frameRect.bottom - triggerRect.bottom}px`,
});
}
if (modelLibraryOpen) {
const toolbarRect = toolbarElement.getBoundingClientRect();
setModelLibraryPanelStyle({
left: `${toolbarRect.left - frameRect.left + toolbarRect.width / 2}px`,
bottom: `${frameRect.bottom - toolbarRect.top + 10}px`,
});
}
};
updateFloatingPositions();
if (typeof ResizeObserver === "undefined") {
window.addEventListener("resize", updateFloatingPositions);
return () => {
window.removeEventListener("resize", updateFloatingPositions);
};
}
const resizeObserver = new ResizeObserver(updateFloatingPositions);
resizeObserver.observe(frameElement);
resizeObserver.observe(toolbarElement);
if (characterTriggerRef.current) {
resizeObserver.observe(characterTriggerRef.current);
}
if (geometryTriggerRef.current) {
resizeObserver.observe(geometryTriggerRef.current);
}
if (crowdTriggerRef.current) {
resizeObserver.observe(crowdTriggerRef.current);
}
if (modelLibraryTriggerRef.current) {
resizeObserver.observe(modelLibraryTriggerRef.current);
}
window.addEventListener("resize", updateFloatingPositions);
return () => {
resizeObserver.disconnect();
window.removeEventListener("resize", updateFloatingPositions);
};
}, [characterMenuOpen, crowdPanelOpen, geometryMenuOpen, modelLibraryOpen]);
async function handleLocalModelChange(
event: ChangeEvent<HTMLInputElement>,
addToScene: boolean
) {
const input = event.currentTarget;
const files = Array.from(input.files ?? []);
if (!files.length) return;
try {
for (const file of files) {
const result = await readLocalModelFile(file);
addImportedAsset({
kind: "prop",
...result,
addToScene,
assetSource: "local",
});
}
} catch {
// The toolbar keeps file actions quiet; detailed import feedback lives in the side panel.
} finally {
input.value = "";
}
}
async function handlePanoramaChange(event: ChangeEvent<HTMLInputElement>) {
const input = event.currentTarget;
const file = input.files?.[0];
if (!file) return;
try {
// 与场景面板「未连接全景图」占位卡共用同一实现
await importPanoramaFile(file);
} finally {
input.value = "";
}
}
async function handleCapture(preset: "current" | "four" | "twelve") {
try {
// 复用当前选中的机位承接截图,避免每次点截图都新增一个机位(四方位 / 十二方位尤其明显)
const targetCameraId = activeCameraId ?? addCameraShot(getViewportCameraSnapshot?.());
setViewMode("camera");
await waitForNextAnimationFrame();
const results = await requestViewportCapture({
preset,
source: "camera-panel",
cameraId: targetCameraId,
});
addCameraCaptures(targetCameraId, results.map((result) => result.dataUrl));
} catch {
// Keep the capsule toolbar icon-only and free of transient status text.
}
}
function selectTransformMode(mode: TransformMode) {
setTransformMode(mode);
}
function toggleCharacterMenu() {
setCharacterMenuOpen((isOpen) => !isOpen);
setGeometryMenuOpen(false);
setCrowdPanelOpen(false);
setModelLibraryOpen(false);
setAspectRatioPanelOpen(false);
}
function addCharacterWithBodyType(bodyType: CharacterBodyType) {
addPresetCharacter(bodyType);
setCharacterMenuOpen(false);
setGeometryMenuOpen(false);
setCrowdPanelOpen(false);
}
function addGeometryWithType(geometryType: GeometryPrimitiveType) {
addGeometryPrimitive(geometryType);
setCharacterMenuOpen(false);
setGeometryMenuOpen(false);
setCrowdPanelOpen(false);
}
function openCrowdPanel() {
setCrowdPanelOpen(true);
setGeometryMenuOpen(false);
}
function closeCrowdPanel() {
setCrowdPanelOpen(false);
}
function getCrowdInputValue(): CrowdCharactersInput {
return {
bodyType: crowdBodyType,
rows: clampCrowdGridSize(Number(crowdRows)),
columns: clampCrowdGridSize(Number(crowdColumns)),
spacing: clampCrowdSpacing(Number(crowdSpacing)),
};
}
function applyCrowdValueDrafts(input: CrowdCharactersInput) {
setCrowdRows(String(input.rows));
setCrowdColumns(String(input.columns));
setCrowdSpacing(String(input.spacing));
}
function addCrowd() {
const nextInput = getCrowdInputValue();
applyCrowdValueDrafts(nextInput);
addCrowdCharacters(nextInput);
setCharacterMenuOpen(false);
setGeometryMenuOpen(false);
setCrowdPanelOpen(false);
}
function toggleModelLibrary() {
setModelLibraryOpen((isOpen) => !isOpen);
setCharacterMenuOpen(false);
setGeometryMenuOpen(false);
setCrowdPanelOpen(false);
setAspectRatioPanelOpen(false);
}
function addModelLibraryItem(item: ModelLibraryItem) {
addImportedAsset({
kind: "prop",
assetSource: "library",
fileName: item.fileName,
name: item.name,
url: item.url,
});
setModelLibraryOpen(false);
}
async function handleMyModelsImport() {
libraryLocalModelInputRef.current?.click();
}
const myModelLibraryItems: ModelLibraryItem[] = assets
.filter((asset) => asset.sourceType === "model" && asset.assetSource === "local")
.map(
(asset) =>
({
categoryId: "my-models",
fileName: asset.fileName,
id: asset.id,
name: asset.name ?? asset.fileName.replace(/\.(fbx|obj)$/i, ""),
thumbUrl: undefined,
url: asset.url,
}) satisfies ModelLibraryItem
);
function addCameraFromViewport() {
const snapshot = getViewportCameraSnapshot?.();
addCameraShot(snapshot);
}
function toggleAspectRatioPanel() {
setAspectRatioPanelOpen((isOpen) => !isOpen);
setCharacterMenuOpen(false);
setGeometryMenuOpen(false);
setCrowdPanelOpen(false);
setModelLibraryOpen(false);
}
function selectAspectRatio(ratio: ViewportAspectRatio) {
setViewportAspectRatio(ratio);
setAspectRatioPanelOpen(false);
}
/** 添加轨迹:进入 / 退出「画布打点」模式(Esc 也能退出) */
function toggleTrajectoryPlacement() {
if (isPlacingTrajectory) {
stopTrajectoryPlacement();
return;
}
if (selectedCharacter) {
startTrajectoryPlacement(selectedCharacter.id);
}
}
/** 录制:显示 / 隐藏悬浮时间轴(录制面板) */
function toggleRecording() {
toggleTimelineVisible();
}
const actions: ToolbarAction[] = [
{ label: "移动", icon: Move3D, mode: "translate", onClick: () => selectTransformMode("translate") },
{ label: "旋转", icon: Rotate3D, mode: "rotate", onClick: () => selectTransformMode("rotate") },
{ label: "缩放", icon: Scale3D, mode: "scale", onClick: () => selectTransformMode("scale") },
{ label: "导入全景图", icon: ImagePlus, onClick: () => panoramaInputRef.current?.click() },
{
label: "导入本地模型",
icon: Box,
onClick: () => {
sceneLocalModelInputRef.current?.click();
},
},
{ label: "模型库", icon: Boxes, onClick: toggleModelLibrary },
{ label: "添加机位", icon: Video, onClick: addCameraFromViewport },
{ 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 },
// 打点 / 录制放在最右侧
{
label: "添加轨迹",
icon: Route,
disabled: !canPlaceTrajectory,
pressed: isPlacingTrajectory,
title: canPlaceTrajectory
? isPlacingTrajectory
? `退出打点(Esc 也可):此时画布不响应选取,双击地面给「${trajectoryOwnerName}」再落一个点`
: `进入打点:之后双击画布即可给「${trajectoryOwnerName}」落一个贴地点的轨迹点`
: "请先选中一个角色,再为它添加轨迹",
onClick: toggleTrajectoryPlacement,
},
{
label: "录制",
icon: timelineVisible ? Square : Circle,
pressed: timelineVisible,
className: timelineVisible ? "is-recording" : undefined,
title: timelineVisible ? "隐藏时间轴 · 录制中按 K 打关键帧" : "显示时间轴(录制)",
onClick: toggleRecording,
},
];
function renderActionButton(action: ToolbarAction) {
const Icon = action.icon;
const active = action.mode ? transformMode === action.mode : Boolean(action.pressed);
const isToggle = Boolean(action.mode) || action.pressed !== undefined;
const buttonClassName = [
"ui-icon-button",
"viewport-toolbar-button",
active ? "is-active" : "",
action.className ?? "",
]
.filter(Boolean)
.join(" ");
return (
<button
key={action.label}
aria-label={action.label}
aria-pressed={isToggle ? active : undefined}
className={buttonClassName}
disabled={action.disabled}
title={action.title}
type="button"
onClick={action.onClick}
>
<Icon aria-hidden="true" size={17} strokeWidth={1.9} />
<span className="viewport-toolbar-label">{action.label}</span>
</button>
);
}
const modelLibraryItems = getModelLibraryItems();
const activeModelLibraryItems =
activeModelLibraryCategoryId === "my-models"
? myModelLibraryItems
: modelLibraryItems.filter((item) => item.categoryId === activeModelLibraryCategoryId);
const crowdInputValue = getCrowdInputValue();
const crowdTotalCount = crowdInputValue.rows * crowdInputValue.columns;
function setToolbarElement(element: HTMLDivElement | null) {
toolbarRef.current = element;
if (toolbarContainerRef) {
toolbarContainerRef.current = element;
}
}
const aspectRatioPanelStyle = {
"--viewport-toolbar-height": `${toolbarHeight}px`,
} as CSSProperties;
return (
<>
<div className="viewport-toolbar" role="group" aria-label="3D视口快捷工具" ref={setToolbarElement}>
{actions.slice(0, 3).map(renderActionButton)}
<div className="viewport-toolbar-menu-wrap">
<button
aria-expanded={characterMenuOpen}
aria-label="添加角色"
className="ui-icon-button viewport-toolbar-button"
ref={characterTriggerRef}
type="button"
onClick={toggleCharacterMenu}
>
<UserPlus aria-hidden="true" size={17} strokeWidth={1.9} />
<span className="viewport-toolbar-label">添加角色</span>
</button>
</div>
{actions.slice(3).map((action) => {
if (action.label !== "模型库") {
return renderActionButton(action);
}
const Icon = action.icon;
return (
<button
key={action.label}
aria-label={action.label}
className="ui-icon-button viewport-toolbar-button"
ref={modelLibraryTriggerRef}
type="button"
onClick={action.onClick}
>
<Icon aria-hidden="true" size={17} strokeWidth={1.9} />
<span className="viewport-toolbar-label">{action.label}</span>
</button>
);
})}
</div>
{characterMenuOpen ? (
<div
ref={characterMenuRef}
className="viewport-toolbar-menu"
role="menu"
aria-label="选择角色体型"
style={characterMenuStyle}
>
{BODY_TYPE_OPTIONS.map((option) => (
<button
key={option.bodyType}
role="menuitem"
type="button"
onClick={() => addCharacterWithBodyType(option.bodyType)}
onMouseEnter={() => {
setGeometryMenuOpen(false);
setCrowdPanelOpen(false);
}}
>
{option.label}
</button>
))}
<div
className="viewport-toolbar-submenu-wrap"
onMouseEnter={openCrowdPanel}
>
<button
ref={crowdTriggerRef}
aria-expanded={crowdPanelOpen}
aria-haspopup="dialog"
className="viewport-toolbar-menu-subtrigger"
role="menuitem"
type="button"
onFocus={openCrowdPanel}
onMouseEnter={openCrowdPanel}
>
<span>群众 (3x3)</span>
<ChevronRight aria-hidden="true" size={14} strokeWidth={1.8} />
</button>
</div>
<div
className="viewport-toolbar-submenu-wrap"
onMouseEnter={() => {
setGeometryMenuOpen(true);
setCrowdPanelOpen(false);
}}
>
<button
ref={geometryTriggerRef}
aria-expanded={geometryMenuOpen}
aria-haspopup="menu"
className="viewport-toolbar-menu-subtrigger"
role="menuitem"
type="button"
onMouseEnter={() => {
setGeometryMenuOpen(true);
setCrowdPanelOpen(false);
}}
>
<span>几何模型</span>
<ChevronRight aria-hidden="true" size={14} strokeWidth={1.8} />
</button>
</div>
</div>
) : null}
{crowdPanelOpen ? (
<div
ref={crowdPanelRef}
className="viewport-toolbar-crowd-panel"
role="dialog"
aria-label="添加群众阵列"
style={crowdPanelStyle}
>
<div className="viewport-toolbar-crowd-panel-header">
<h2 className="viewport-toolbar-crowd-panel-title">添加群众阵列</h2>
<span className="viewport-toolbar-crowd-panel-count">共{crowdTotalCount}人</span>
</div>
<div className="viewport-toolbar-crowd-grid">
<label className="viewport-toolbar-crowd-field">
<span>行数</span>
<input
className="ui-field"
aria-label="群众行数"
inputMode="numeric"
type="number"
min={MIN_CROWD_GRID_SIZE}
max={MAX_CROWD_GRID_SIZE}
value={crowdRows}
onChange={(event) => setCrowdRows(event.currentTarget.value)}
/>
</label>
<span className="viewport-toolbar-crowd-separator" aria-hidden="true">
×
</span>
<label className="viewport-toolbar-crowd-field">
<span>列数</span>
<input
className="ui-field"
aria-label="群众列数"
inputMode="numeric"
type="number"
min={MIN_CROWD_GRID_SIZE}
max={MAX_CROWD_GRID_SIZE}
value={crowdColumns}
onChange={(event) => setCrowdColumns(event.currentTarget.value)}
/>
</label>
<label className="viewport-toolbar-crowd-field viewport-toolbar-crowd-field-spacing">
<span>间距</span>
<input
className="ui-field"
aria-label="群众间距"
inputMode="decimal"
type="number"
min={MIN_CROWD_SPACING}
max={MAX_CROWD_SPACING}
step="0.1"
value={crowdSpacing}
onChange={(event) => setCrowdSpacing(event.currentTarget.value)}
/>
</label>
</div>
<div className="viewport-toolbar-crowd-actions">
<button className="viewport-toolbar-crowd-cancel camera-capture-clear-all" type="button" onClick={closeCrowdPanel}>
取消
</button>
<button
aria-label="添加群众"
className="viewport-toolbar-crowd-confirm camera-capture-send-all"
type="button"
onClick={addCrowd}
>
添加
</button>
</div>
</div>
) : null}
{geometryMenuOpen ? (
<div
ref={geometryMenuRef}
className="viewport-toolbar-submenu"
role="menu"
aria-label="选择几何模型"
style={geometryMenuStyle}
>
{GEOMETRY_PRIMITIVE_OPTIONS.map((option) => (
<button
key={option.type}
role="menuitem"
type="button"
onClick={() => addGeometryWithType(option.type)}
>
{option.label}
</button>
))}
</div>
) : null}
{modelLibraryOpen ? (
<div
ref={modelLibraryPanelRef}
className="model-library-panel"
role="dialog"
aria-label="模型库"
style={modelLibraryPanelStyle}
>
<div className="model-library-header">
<h2 className="model-library-title">模型库</h2>
<button
aria-label="关闭模型库"
className="top-bar-action-button model-library-close-button"
type="button"
onClick={() => setModelLibraryOpen(false)}
>
<X aria-hidden="true" size={16} strokeWidth={1.8} />
</button>
</div>
<div className="model-library-tabs" role="tablist" aria-label="模型分类">
{MODEL_LIBRARY_CATEGORIES.map((category) => {
const active = category.id === activeModelLibraryCategoryId;
return (
<button
key={category.id}
aria-selected={active}
className={`model-library-tab${active ? " is-active" : ""}`}
role="tab"
type="button"
onClick={() => setActiveModelLibraryCategoryId(category.id)}
>
{category.label}
</button>
);
})}
</div>
{activeModelLibraryCategoryId === "my-models" && activeModelLibraryItems.length === 0 ? (
<div className="model-library-empty-state object-search-empty-state" role="status" aria-label="暂无任何模型">
<span className="object-search-empty-icon" data-testid="my-models-empty-icon">
<Boxes aria-hidden="true" size={16} strokeWidth={1.8} />
</span>
<span>暂无任何模型</span>
<button className="top-bar-action-button model-library-empty-action" type="button" onClick={() => void handleMyModelsImport()}>
本地导入
</button>
</div>
) : (
<div className="model-library-grid" role="list" aria-label="模型列表">
{activeModelLibraryItems.map((item) => (
activeModelLibraryCategoryId === "my-models" ? (
<div key={item.id} className="model-library-card-wrap">
<button
aria-label={`添加模型 ${item.name}`}
className="model-library-card"
type="button"
onClick={() => {
addObjectFromAsset(item.id);
setModelLibraryOpen(false);
}}
>
<span className="model-library-thumb" aria-hidden="true">
{item.thumbUrl ? (
<img
alt=""
aria-hidden="true"
className="model-library-thumb-image"
loading="lazy"
src={item.thumbUrl}
/>
) : (
<Boxes size={24} strokeWidth={1.6} />
)}
</span>
<span className="model-library-name">{item.name}</span>
</button>
<button
aria-label={`删除模型 ${item.name}`}
className="model-library-card-delete"
type="button"
onClick={() => {
removeImportedAsset(item.id);
}}
>
<Trash2 aria-hidden="true" size={14} strokeWidth={1.9} />
</button>
</div>
) : (
<button
key={item.id}
aria-label={`添加模型 ${item.name}`}
className="model-library-card"
type="button"
onClick={() => {
addModelLibraryItem(item);
}}
>
<span className="model-library-thumb" aria-hidden="true">
{item.thumbUrl ? (
<img
alt=""
aria-hidden="true"
className="model-library-thumb-image"
loading="lazy"
src={item.thumbUrl}
/>
) : (
<Boxes size={24} strokeWidth={1.6} />
)}
</span>
<span className="model-library-name">{item.name}</span>
</button>
)
))}
{activeModelLibraryCategoryId === "my-models" ? (
<button
aria-label="本地导入"
className="model-library-card model-library-import-card"
type="button"
onClick={() => void handleMyModelsImport()}
>
<span className="model-library-thumb model-library-thumb-import" aria-hidden="true">
<Plus size={28} strokeWidth={1.8} />
</span>
<span className="model-library-name">本地导入</span>
</button>
) : null}
</div>
)}
</div>
) : null}
{aspectRatioPanelOpen ? (
<div
ref={aspectRatioPanelRef}
className="viewport-aspect-panel"
role="dialog"
aria-label="比例"
style={aspectRatioPanelStyle}
>
<h2 className="viewport-aspect-panel-title">比例</h2>
<div className="viewport-aspect-panel-grid" role="group" aria-label="画幅比例选项">
{VIEWPORT_ASPECT_RATIO_OPTIONS.map((option) => {
const active = option.id === viewportAspectRatio;
const frameClassName = `viewport-aspect-option-frame viewport-aspect-option-frame-${option.id.replace(":", "-")}`;
return (
<button
key={option.id}
aria-pressed={active}
className={`viewport-aspect-option${active ? " is-active" : ""}`}
type="button"
onClick={() => selectAspectRatio(option.id)}
>
<span className={frameClassName} aria-hidden="true" />
<span className="viewport-aspect-option-label">{option.label}</span>
</button>
);
})}
</div>
</div>
) : null}
<input
ref={panoramaInputRef}
aria-hidden="true"
className="hidden-file-input"
tabIndex={-1}
accept=".jpg,.jpeg,.png,.webp"
type="file"
onChange={(event) => void handlePanoramaChange(event)}
/>
<input
ref={sceneLocalModelInputRef}
aria-hidden="true"
className="hidden-file-input"
data-testid="scene-local-model-input"
tabIndex={-1}
accept=".fbx,.obj"
type="file"
onChange={(event) => void handleLocalModelChange(event, true)}
/>
<input
ref={libraryLocalModelInputRef}
aria-hidden="true"
className="hidden-file-input"
data-testid="library-local-model-input"
tabIndex={-1}
accept=".fbx,.obj"
multiple
type="file"
onChange={(event) => void handleLocalModelChange(event, false)}
/>
</>
);
}