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((resolve) => { requestAnimationFrame(() => resolve()); }); } export function ViewportToolbar({ getViewportCameraSnapshot, toolbarContainerRef, }: { getViewportCameraSnapshot?: () => CameraShotSnapshot; toolbarContainerRef?: MutableRefObject; }) { const toolbarRef = useRef(null); const aspectRatioPanelRef = useRef(null); const characterTriggerRef = useRef(null); const geometryTriggerRef = useRef(null); const crowdTriggerRef = useRef(null); const modelLibraryTriggerRef = useRef(null); const characterMenuRef = useRef(null); const geometryMenuRef = useRef(null); const crowdPanelRef = useRef(null); const modelLibraryPanelRef = useRef(null); const sceneLocalModelInputRef = useRef(null); const libraryLocalModelInputRef = useRef(null); const panoramaInputRef = useRef(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({}); const [geometryMenuStyle, setGeometryMenuStyle] = useState({}); const [crowdPanelStyle, setCrowdPanelStyle] = useState({}); const [modelLibraryPanelStyle, setModelLibraryPanelStyle] = useState({}); const [crowdBodyType, setCrowdBodyType] = useState(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("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, 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) { 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 ( ); } 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 ( <>
{actions.slice(0, 3).map(renderActionButton)}
{actions.slice(3).map((action) => { if (action.label !== "模型库") { return renderActionButton(action); } const Icon = action.icon; return ( ); })}
{characterMenuOpen ? (
{BODY_TYPE_OPTIONS.map((option) => ( ))}
{ setGeometryMenuOpen(true); setCrowdPanelOpen(false); }} >
) : null} {crowdPanelOpen ? (

添加群众阵列

共{crowdTotalCount}人
) : null} {geometryMenuOpen ? (
{GEOMETRY_PRIMITIVE_OPTIONS.map((option) => ( ))}
) : null} {modelLibraryOpen ? (

模型库

{MODEL_LIBRARY_CATEGORIES.map((category) => { const active = category.id === activeModelLibraryCategoryId; return ( ); })}
{activeModelLibraryCategoryId === "my-models" && activeModelLibraryItems.length === 0 ? (
暂无任何模型
) : (
{activeModelLibraryItems.map((item) => ( activeModelLibraryCategoryId === "my-models" ? (
) : ( ) ))} {activeModelLibraryCategoryId === "my-models" ? ( ) : null}
)}
) : null} {aspectRatioPanelOpen ? (

比例

{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 ( ); })}
) : null} void handlePanoramaChange(event)} /> void handleLocalModelChange(event, true)} /> void handleLocalModelChange(event, false)} /> ); }