#include "main_window/MainWindow.h" #include "dialogs/AboutWindow.h" #include "dialogs/CancelableTaskDialog.h" #include "dialogs/EntityFinalizeDialog.h" #include "dialogs/BlackholeResolveDialog.h" #include "dialogs/InpaintPreviewDialog.h" #include "editor/EditorCanvas.h" #include "editor/EntityCutoutUtils.h" #include "dialogs/ImageCropDialog.h" #include "core/domain/EntityIntro.h" #include "core/net/ModelServerClient.h" #include "widgets/ToolOptionPopup.h" #include "params/ParamControls.h" #include "props/BackgroundPropertySection.h" #include "props/BlackholePropertySection.h" #include "props/EntityPropertySection.h" #include "props/ToolPropertySection.h" #include "props/CameraPropertySection.h" #include "props/HotspotPropertySection.h" #include "timeline/TimelineWidget.h" #include "timeline/TimelineEditorModel.h" #include "dialogs/FrameAnimationDialog.h" #include "dialogs/AnimationSchemeSettingsDialog.h" #include "dialogs/EntityIntroPopup.h" #include "library/ResourceLibraryDock.h" #include "core/library/ResourceLibraryProvider.h" #include "core/library/OnlineResourceLibraryProvider.h" #include "core/eval/ProjectEvaluator.h" #include "core/image/ImageDecodeConfig.h" #include "core/image/ImageFileLoader.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include namespace { /// 列宽小于此值时自动隐藏右侧两 dock(需小于下方列最小宽度,拖窄时才可能触发) constexpr int kRightDockAutoHideBelow = 64; /// 无内容或内容极窄时的下限;实际最小宽度取 max(本值, 属性栈/项目树 minimumSizeHint)。 constexpr int kRightDockColumnFallbackMinWidth = 168; constexpr int kRightDockColumnStartupWidth = 220; /// 启动时垂直分割高度:项目树较矮、属性区较高 constexpr int kProjectTreeDockStartupHeight = 170; constexpr int kPropertiesDockStartupHeight = 380; constexpr char kMimeHotspotAnimationJson[] = "application/x-hfut-hotspot-animation+json"; [[nodiscard]] QString firstAnimationSchemeIdNonNoneWs(const core::Project& proj) { for (const auto& a : proj.animations()) { if (a.id != QStringLiteral("none")) return a.id; } return {}; } [[nodiscard]] QVector> nonNoneAnimationIdLabelPairsWs(const core::Project& proj) { QVector> out; for (const auto& a : proj.animations()) { if (a.id == QStringLiteral("none")) { continue; } out.push_back(qMakePair(a.id, a.name.isEmpty() ? a.id : a.name)); } return out; } [[nodiscard]] QString nextPresentationHotspotIdWs(const QVector& hs) { QSet used; for (const auto& h : hs) { used.insert(h.id); } int n = static_cast(hs.size()) + 1; for (int guard = 0; guard < 100000; ++guard, ++n) { const QString c = QStringLiteral("hotspot-%1").arg(n); if (!used.contains(c)) return c; } return QStringLiteral("hotspot-1"); } void polishCompactToolButton(QToolButton* b, int px = 40) { if (!b) return; b->setFixedSize(px, px); b->setFocusPolicy(Qt::NoFocus); b->setAutoRaise(true); b->setIconSize(QSize(std::max(12, px - 16), std::max(12, px - 16))); } void setToolButtonIconOrText(QToolButton* b, const QString& themeName, const QString& text) { if (!b) return; const QIcon ic = QIcon::fromTheme(themeName); if (!ic.isNull()) { b->setIcon(ic); b->setText(QString()); b->setToolButtonStyle(Qt::ToolButtonIconOnly); } else { b->setIcon(QIcon()); b->setText(text); b->setToolButtonStyle(Qt::ToolButtonTextOnly); QFont f = b->font(); f.setPointSize(11); b->setFont(f); } } const char* kEditorToolRailQss = R"( #EditorToolRail { background-color: palette(base); border: 1px solid palette(midlight); border-radius: 0px; } #EditorToolRail QToolButton { border: 1px solid transparent; border-radius: 0px; padding: 0px; background: transparent; } #EditorToolRail QToolButton:hover { background: palette(midlight); } #EditorToolRail QToolButton:checked { background: palette(highlight); color: palette(highlighted-text); } )"; const char* kFloatingModeDockQss = R"( #FloatingModeDock { background-color: palette(base); border: 1px solid palette(midlight); border-radius: 0px; } #FloatingModeDock QToolButton { border: 1px solid transparent; border-radius: 0px; padding: 0px; background: transparent; } #FloatingModeDock QToolButton:hover { background: palette(midlight); } #FloatingModeDock QComboBox { background-color: palette(button); color: palette(button-text); border: 1px solid palette(mid); border-radius: 0px; padding: 1px 3px; min-height: 17px; } #FloatingModeDock QComboBox:hover { background-color: palette(light); } #FloatingModeDock QComboBox:focus { border: 1px solid palette(highlight); } #FloatingModeDock QComboBox::drop-down { border: 0; width: 0px; background: transparent; } #FloatingModeDock QComboBox::down-arrow { image: none; width: 0px; height: 0px; } #FloatingModeDock QComboBox QAbstractItemView { border-radius: 0px; padding: 1px 0px; min-width: 84px; } #FloatingModeDock QComboBox QAbstractItemView::item { min-height: 18px; padding: 2px 8px; } )"; /// 避免滚轮先被 QScrollArea 吃掉,导致内嵌 QDoubleSpinBox 无法用滚轮调节 class SpinFriendlyScrollArea final : public QScrollArea { public: explicit SpinFriendlyScrollArea(QWidget* parent = nullptr) : QScrollArea(parent) {} protected: void wheelEvent(QWheelEvent* e) override { #if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) const QPoint inVp = viewport()->mapFrom(this, e->position().toPoint()); #else const QPoint inVp = viewport()->mapFrom(this, e->pos()); #endif if (QWidget* w = viewport()->childAt(inVp)) { for (QWidget* cur = w; cur; cur = cur->parentWidget()) { if (qobject_cast(cur)) { QApplication::sendEvent(cur, e); return; } } } QScrollArea::wheelEvent(e); } }; const char* kTimelineBarQss = R"( #TimelineDockBar QToolButton, #TimelineDockBar QPushButton { border: 1px solid palette(midlight); border-radius: 6px; padding: 4px 8px; min-height: 26px; background: palette(button); } #TimelineDockBar QToolButton:hover, #TimelineDockBar QPushButton:hover { background: palette(light); } #TimelineDockBar QToolButton:checked { background: palette(highlight); color: palette(highlighted-text); } #TimelineDockBar QCheckBox { spacing: 6px; } )"; class CanvasHost final : public QWidget { public: explicit CanvasHost(QWidget* parent = nullptr) : QWidget(parent) {} EditorCanvas* canvas = nullptr; QWidget* modeDock = nullptr; QWidget* toolDock = nullptr; QWidget* previewPlaybackBar = nullptr; QWidget* previewArrowLeft = nullptr; QWidget* previewArrowRight = nullptr; void relayoutFloaters() { if (canvas) { canvas->setGeometry(0, 0, width(), height()); canvas->lower(); } constexpr int kMargin = 10; constexpr int kGap = 10; if (modeDock && modeDock->isVisible()) { if (QLayout* lay = modeDock->layout()) { lay->activate(); } modeDock->updateGeometry(); modeDock->adjustSize(); const QSize sh = modeDock->sizeHint().expandedTo(modeDock->minimumSizeHint()); if (sh.isValid() && (modeDock->width() < sh.width() || modeDock->height() < sh.height())) { modeDock->resize(std::max(modeDock->width(), sh.width()), std::max(modeDock->height(), sh.height())); } modeDock->move(kMargin, kMargin); modeDock->adjustSize(); } if (toolDock && toolDock->isVisible()) { if (QLayout* lay = toolDock->layout()) { lay->activate(); } toolDock->updateGeometry(); toolDock->adjustSize(); int y = kMargin; if (modeDock && modeDock->isVisible()) { y = modeDock->y() + modeDock->height() + kGap; } toolDock->move(kMargin, y); } // 工具条在上层,避免与模式条叠放时误点不到按钮 if (modeDock && modeDock->isVisible()) { modeDock->raise(); } if (toolDock && toolDock->isVisible()) { toolDock->raise(); } constexpr int arrowW = 44; constexpr int arrowH = 72; auto placeArrowCenterY = [&](QWidget* arrow, bool leftSide) { if (!arrow || !arrow->isVisible()) { return; } arrow->setFixedSize(arrowW, arrowH); const int y = std::max(kMargin, (height() - arrowH) / 2); if (leftSide) { arrow->move(kMargin, y); } else { arrow->move(std::max(kMargin, width() - arrowW - kMargin), y); } arrow->raise(); }; placeArrowCenterY(previewArrowLeft, true); placeArrowCenterY(previewArrowRight, false); if (previewPlaybackBar && previewPlaybackBar->isVisible()) { if (QLayout* lay = previewPlaybackBar->layout()) { lay->activate(); } previewPlaybackBar->updateGeometry(); previewPlaybackBar->adjustSize(); const int x = std::max(kMargin, (width() - previewPlaybackBar->width()) / 2); const int y = std::max(kMargin, height() - previewPlaybackBar->height() - kMargin); previewPlaybackBar->move(x, y); previewPlaybackBar->raise(); } } protected: void resizeEvent(QResizeEvent* e) override { QWidget::resizeEvent(e); relayoutFloaters(); } void showEvent(QShowEvent* e) override { QWidget::showEvent(e); relayoutFloaters(); QTimer::singleShot(0, this, [this]() { relayoutFloaters(); }); } }; } // namespace namespace { constexpr const char* kMimeProjectNodeJson = "application/x-hfut-project-node+json"; static bool guiRunningOnWayland() { return QGuiApplication::platformName().contains(QLatin1String("wayland"), Qt::CaseInsensitive); } // 全屏 + Tool 菜单(仅非 Wayland):先按锚点对齐,再按可用工作区夹紧。仅靠 move(锚点) 时菜单右缘常超出屏幕, // 部分合成器会把窗口整体扔到屏幕最左侧。 static void placeContextMenuOnScreen(QMenu* menu, const QPoint& anchorGlobal, QScreen* screenHint) { if (!menu) { return; } menu->adjustSize(); const QSize sh = menu->size(); QScreen* scr = screenHint; if (!scr) { scr = QGuiApplication::screenAt(anchorGlobal); } if (!scr) { scr = QGuiApplication::primaryScreen(); } const QRect avail = scr ? scr->availableGeometry() : QRect(); if (!avail.isValid()) { menu->move(anchorGlobal); return; } int x = anchorGlobal.x(); int y = anchorGlobal.y(); const int w = sh.width(); const int h = sh.height(); if (x + w > avail.right()) { x = anchorGlobal.x() - w; } if (x + w > avail.right()) { x = avail.right() - w + 1; } if (x < avail.left()) { x = avail.left(); } if (y + h > avail.bottom()) { y = anchorGlobal.y() - h; } if (y + h > avail.bottom()) { y = avail.bottom() - h + 1; } if (y < avail.top()) { y = avail.top(); } menu->move(x, y); } // 主窗口全屏时(常见于 F11),以 QMainWindow 为父级的 Popup 菜单可能被压在全屏层之下不可见。 // X11 等:用 Qt::Tool + 延迟 move 抬高叠放顺序;Wayland(KWin 等)下 Tool 顶层窗的客户端 setGeometry/move 常被忽略, // 菜单会被摆在屏左,应交给 Qt 的 xdg_popup 与普通 exec(globalPos) 定位,故不在 Wayland 走 Tool 分支。 QAction* execPopupMenuAboveFullscreen(QMenu& menu, const QPoint& globalPos, QWidget* topLevelRef) { QWidget* w = topLevelRef ? topLevelRef->window() : nullptr; if (w && (w->windowState() & Qt::WindowFullScreen) && !guiRunningOnWayland()) { menu.setWindowFlag(Qt::Tool, true); QScreen* menuScreen = w->screen(); QPointer guard(&menu); QObject::connect( &menu, &QMenu::aboutToShow, &menu, [guard, menuScreen]() { if (!guard) { return; } QTimer::singleShot(0, guard.data(), [guard, menuScreen]() { if (guard) { placeContextMenuOnScreen(guard.data(), QCursor::pos(), menuScreen); } }); }, Qt::SingleShotConnection); } return menu.exec(globalPos); } class ProjectTreeWidget final : public QTreeWidget { public: explicit ProjectTreeWidget(QWidget* parent = nullptr) : QTreeWidget(parent) { setDragEnabled(true); setAcceptDrops(true); setDropIndicatorShown(true); setDragDropMode(QAbstractItemView::DragDrop); setDefaultDropAction(Qt::MoveAction); setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); setWordWrap(true); } // parentKind / parentId 为空表示“解除父子关系” std::function onNodeParentDropRequested; protected: void startDrag(Qt::DropActions supportedActions) override { Q_UNUSED(supportedActions); auto* item = currentItem(); if (!item) { return; } const QString kind = item->data(0, Qt::UserRole).toString(); if (kind != QStringLiteral("entity") && kind != QStringLiteral("tool")) { return; } const QString id = item->data(0, Qt::UserRole + 1).toString(); if (id.isEmpty()) { return; } auto* mime = new QMimeData(); QJsonObject o; o.insert(QStringLiteral("kind"), kind); o.insert(QStringLiteral("id"), id); mime->setData(QString::fromUtf8(kMimeProjectNodeJson), QJsonDocument(o).toJson(QJsonDocument::Compact)); auto* drag = new QDrag(this); drag->setMimeData(mime); drag->exec(Qt::MoveAction); } void dragEnterEvent(QDragEnterEvent* e) override { if (e && e->mimeData() && e->mimeData()->hasFormat(QString::fromUtf8(kMimeProjectNodeJson))) { e->acceptProposedAction(); return; } QTreeWidget::dragEnterEvent(e); } void dragMoveEvent(QDragMoveEvent* e) override { if (e && e->mimeData() && e->mimeData()->hasFormat(QString::fromUtf8(kMimeProjectNodeJson))) { e->acceptProposedAction(); return; } QTreeWidget::dragMoveEvent(e); } void dropEvent(QDropEvent* e) override { if (!e || !e->mimeData() || !e->mimeData()->hasFormat(QString::fromUtf8(kMimeProjectNodeJson))) { QTreeWidget::dropEvent(e); return; } const auto payload = e->mimeData()->data(QString::fromUtf8(kMimeProjectNodeJson)); const QJsonDocument doc = QJsonDocument::fromJson(payload); if (!doc.isObject()) { e->ignore(); return; } const QJsonObject o = doc.object(); const QString childKind = o.value(QStringLiteral("kind")).toString(); const QString childId = o.value(QStringLiteral("id")).toString(); if (childId.isEmpty() || (childKind != QStringLiteral("entity") && childKind != QStringLiteral("tool"))) { e->ignore(); return; } QString parentKind; QString parentId; if (auto* it = itemAt(e->position().toPoint())) { const QString kind = it->data(0, Qt::UserRole).toString(); if (kind == QStringLiteral("entity") || kind == QStringLiteral("tool")) { parentKind = kind; parentId = it->data(0, Qt::UserRole + 1).toString(); } } // 拖到空白处:解除父子关系 if (!parentId.isEmpty() && childId == parentId && childKind == parentKind) { e->ignore(); return; } if (onNodeParentDropRequested) { onNodeParentDropRequested(childKind, childId, parentKind, parentId); } e->acceptProposedAction(); } }; class HotspotAnimationListWidget final : public QListWidget { public: explicit HotspotAnimationListWidget(QWidget* parent = nullptr) : QListWidget(parent) { setSelectionMode(QAbstractItemView::SingleSelection); setDragEnabled(true); setDragDropMode(QAbstractItemView::DragOnly); setDefaultDropAction(Qt::CopyAction); setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); setWordWrap(true); setSpacing(4); } void setAnimations(const QVector>& animations) { clear(); if (animations.isEmpty()) { auto* item = new QListWidgetItem(QStringLiteral("暂无可拖动动画")); item->setFlags(Qt::ItemIsEnabled); addItem(item); return; } for (const auto& animation : animations) { auto* item = new QListWidgetItem(animation.second, this); item->setData(Qt::UserRole, animation.first); item->setFlags(item->flags() | Qt::ItemIsDragEnabled); item->setToolTip(animation.second); } } protected: void startDrag(Qt::DropActions supportedActions) override { Q_UNUSED(supportedActions); auto* item = currentItem(); if (!item) { return; } const QString animationId = item->data(Qt::UserRole).toString(); if (animationId.isEmpty()) { return; } QJsonObject payload; payload.insert(QStringLiteral("animationId"), animationId); payload.insert(QStringLiteral("label"), item->text()); auto* mime = new QMimeData(); mime->setData(QString::fromUtf8(kMimeHotspotAnimationJson), QJsonDocument(payload).toJson(QJsonDocument::Compact)); auto* drag = new QDrag(this); drag->setMimeData(mime); drag->exec(Qt::CopyAction); } }; } // namespace MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) { // 设置窗口大小 resize(1200, 800); rebuildCentralPages(); createMenus(); createProjectTreeDock(); createTimelineDock(); createResourceLibraryDock(); m_previewPlayTimer = new QTimer(this); connect(m_previewPlayTimer, &QTimer::timeout, this, [this]() { if (!m_workspace.isOpen() || !m_previewRequested || !m_previewPlaying) { return; } const QString id = effectivePreviewAnimationId(); if (id == QStringLiteral("none")) { return; } const int len = m_workspace.animationLengthFramesForId(id); if (len <= 0) { return; } if (m_previewFrame >= len - 1) { if (m_workspace.animationLoopsForId(id)) { m_previewFrame = 0; } else { m_previewFrame = len - 1; stopPreviewPlayback(); refreshEditorPage(); syncPreviewPlaybackBar(); return; } } else { ++m_previewFrame; } refreshEditorPage(); }); syncPreviewPlaybackBar(); refreshProjectTree(); updateUiEnabledState(); refreshEditorPage(); m_propertySyncTimer = new QTimer(this); m_propertySyncTimer->setSingleShot(true); connect(m_propertySyncTimer, &QTimer::timeout, this, [this]() { if (m_entityDragging) { refreshEntityPropertyPanelFast(); } else { refreshPropertyPanel(); } }); // 某些平台/窗口管理器下,dock 的初始可见性会在 QMainWindow show() 之后被重新应用一次。 // 这里在事件循环开始后再强制执行一遍“欢迎/编辑”两态策略,保证未打开项目时只显示欢迎页。 QTimer::singleShot(0, this, [this]() { applyUiMode(currentUiMode()); updateUiEnabledState(); refreshEditorPage(); }); setWindowTitle("工具"); } void MainWindow::createTimelineDock() { m_dockTimeline = new QDockWidget(QStringLiteral("动画"), this); m_dockTimeline->setAllowedAreas(Qt::BottomDockWidgetArea | Qt::TopDockWidgetArea); m_dockTimeline->setFeatures(QDockWidget::DockWidgetMovable | QDockWidget::DockWidgetFloatable); auto* bar = new QWidget(m_dockTimeline); bar->setObjectName(QStringLiteral("TimelineDockBar")); bar->setStyleSheet(QString::fromUtf8(kTimelineBarQss)); auto* layout = new QHBoxLayout(bar); layout->setContentsMargins(6, 4, 6, 4); layout->setSpacing(6); m_btnPlay = new QToolButton(bar); m_btnPlay->setText(QStringLiteral("▶")); m_btnPlay->setCheckable(true); m_btnPlay->setToolTip({}); polishCompactToolButton(m_btnPlay, 34); layout->addWidget(m_btnPlay); m_schemeSelector = new QComboBox(bar); m_schemeSelector->setMinimumWidth(140); m_schemeSelector->setToolTip({}); layout->addWidget(m_schemeSelector); m_timeline = new TimelineWidget(bar); m_timeline->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::MinimumExpanding); layout->addWidget(m_timeline, 1); // 阶段1:把时间轴视图状态抽到 model(先做最小接入,后续再把命中/交互迁移过去) m_timelineModel = new TimelineEditorModel(); m_timeline->setModel(m_timelineModel); // 合并后的关键帧按钮:一次写入位置 + userScale auto* btnKeyCombined = new QToolButton(bar); setToolButtonIconOrText(btnKeyCombined, QStringLiteral("media-record"), QStringLiteral("关键帧")); btnKeyCombined->setToolTip({}); polishCompactToolButton(btnKeyCombined, 34); layout->addWidget(btnKeyCombined); m_dockTimeline->setWidget(bar); addDockWidget(Qt::BottomDockWidgetArea, m_dockTimeline); connect(m_dockTimeline, &QDockWidget::visibilityChanged, this, [this](bool visible) { if (m_actionToggleTimeline) { m_actionToggleTimeline->blockSignals(true); m_actionToggleTimeline->setChecked(visible); m_actionToggleTimeline->blockSignals(false); } }); m_playTimer = new QTimer(this); connect(m_playTimer, &QTimer::timeout, this, [this]() { if (!m_timeline || !m_workspace.isOpen() || m_previewRequested) { return; } const int len = m_workspace.activeAnimationLengthFrames(); m_currentFrame = (m_currentFrame + 1) % std::max(1, len); m_timeline->setCurrentFrame(m_currentFrame); }); connect(m_btnPlay, &QToolButton::toggled, this, &MainWindow::onTogglePlay); connect(m_timeline, &TimelineWidget::frameScrubbed, this, [this](int v) { // 轻量实时预览:只更新画布帧,不做 refreshEditorPage 的全量重建 m_currentFrame = std::clamp(v, 0, m_workspace.activeAnimationLengthFrames() - 1); if (m_editorCanvas && m_workspace.isOpen()) { // 需要重新求值实体几何/贴图轨道,否则拖动实体与属性变更在非 0 帧会失效 m_timelineScrubbing = true; m_editorCanvas->setCurrentFrame(m_currentFrame); const core::eval::ResolvedProjectFrame rf = core::eval::evaluateAtFrame(m_workspace.project(), m_currentFrame, 10); QVector ents; QVector entOps; ents.reserve(rf.entities.size()); entOps.reserve(rf.entities.size()); for (const auto& re : rf.entities) { ents.push_back(re.entity); entOps.push_back(re.opacity); } m_editorCanvas->setEntities(ents, entOps, m_workspace.projectDir()); QVector tools; QVector toolOps; tools.reserve(rf.tools.size()); toolOps.reserve(rf.tools.size()); for (const auto& rt : rf.tools) { tools.push_back(rt.tool); toolOps.push_back(rt.opacity); } m_editorCanvas->setTools(tools, toolOps); QVector cams; cams.reserve(rf.cameras.size()); for (const auto& rc : rf.cameras) { cams.push_back(rc.camera); } m_editorCanvas->setCameraOverlays(cams, m_selectedCameraId, m_tempHiddenCameraIds); m_editorCanvas->setTempHiddenIds(m_tempHiddenEntityIds, m_tempHiddenToolIds); const bool presentation = m_previewRequested && m_workspace.hasBackground(); m_editorCanvas->setPreviewCameraViewLocked(false); if (presentation) { const QString acid = m_workspace.project().activeCameraId(); if (!acid.isEmpty()) { for (const auto& rc : rf.cameras) { if (rc.camera.id == acid) { m_editorCanvas->setPreviewCameraViewLocked(true); m_editorCanvas->applyCameraViewport(rc.camera.centerWorld, rc.camera.viewScale); break; } } } } m_timelineScrubbing = false; } else if (m_editorCanvas) { m_editorCanvas->setCurrentFrame(m_currentFrame); } }); connect(m_timeline, &TimelineWidget::frameCommitted, this, [this](int v) { // 松手再做一次较重刷新(如果后续还有需要同步的 UI) m_currentFrame = std::clamp(v, 0, m_workspace.activeAnimationLengthFrames() - 1); refreshEditorPage(); }); connect(btnKeyCombined, &QToolButton::clicked, this, &MainWindow::onInsertCombinedKey); // 动画切换(下拉里包含“新建动画…”);帧数在创建时手动输入。 connect(m_schemeSelector, QOverload::of(&QComboBox::currentIndexChanged), this, [this](int idx) { if (!m_workspace.isOpen() || !m_schemeSelector) return; const QString animId = m_schemeSelector->itemData(idx).toString(); if (animId == QStringLiteral("__create__")) { AnimationSchemeSettingsDialog dlg(AnimationSchemeSettingsDialog::Mode::Create, m_workspace, this); const int dlgResult = dlg.exec(); const QString restoreId = m_workspace.project().activeAnimationId(); if (m_schemeSelector) { m_schemeSelector->blockSignals(true); int ridx = -1; for (int i = 0; i < m_schemeSelector->count(); ++i) { if (m_schemeSelector->itemData(i).toString() == restoreId) { ridx = i; break; } } if (ridx >= 0) { m_schemeSelector->setCurrentIndex(ridx); } m_schemeSelector->blockSignals(false); } if (dlgResult != QDialog::Accepted) { return; } refreshEditorPage(); return; } if (animId.isEmpty()) return; if (animId == m_workspace.project().activeAnimationId()) return; m_workspace.project().setActiveAnimationId(animId); m_workspace.save(); if (animId == QStringLiteral("none")) { m_animationRequested = false; m_hotspotRequested = false; if (m_modeSelector) { m_modeSelector->blockSignals(true); m_modeSelector->setCurrentIndex(0); m_modeSelector->blockSignals(false); } } else { m_animationRequested = true; m_hotspotRequested = false; if (m_modeSelector) { m_modeSelector->blockSignals(true); m_modeSelector->setCurrentIndex(1); m_modeSelector->blockSignals(false); } } refreshEditorPage(); refreshProjectTree(); updateUiEnabledState(); }); connect(m_timeline, &TimelineWidget::contextMenuRequested, this, [this](const QPoint& globalPos, int frame) { if (!m_timeline) return; QMenu menu(this); QAction* actDeleteKey = menu.addAction(QStringLiteral("删除关键帧")); QAction* actSetStart = menu.addAction(QStringLiteral("设为区间起点")); QAction* actSetEnd = menu.addAction(QStringLiteral("设为区间终点")); QAction* actClear = menu.addAction(QStringLiteral("清除区间")); menu.addSeparator(); QAction* actAnim = menu.addAction(QStringLiteral("动画…")); const bool entityKeyUi = (m_workspace.isOpen() && !m_selectedEntityId.isEmpty()); const bool toolKeyUi = (m_workspace.isOpen() && m_hasSelectedTool && !m_selectedToolId.isEmpty()); const bool cameraKeyUi = (m_workspace.isOpen() && m_hasSelectedCamera && !m_selectedCameraId.isEmpty()); const bool animEditable = (m_workspace.isOpen() && m_workspace.project().activeAnimationId() != QStringLiteral("none")); actDeleteKey->setEnabled(animEditable && m_timeline->hasSelectedKeyframe() && (entityKeyUi || toolKeyUi || cameraKeyUi)); const int selA = m_timeline->selectionStart(); const int selB = m_timeline->selectionEnd(); const bool hasRange = (selA >= 0 && selB >= 0); actClear->setEnabled(hasRange); actAnim->setEnabled(animEditable && hasRange && !m_selectedEntityId.isEmpty()); // 右键命中帧:用鼠标位置对应的 frame // 右键命中 localFrame:globalFrame==localFrame m_currentFrame = std::clamp(frame, 0, m_workspace.activeAnimationLengthFrames() - 1); if (m_editorCanvas) m_editorCanvas->setCurrentFrame(m_currentFrame); QAction* chosen = execPopupMenuAboveFullscreen(menu, globalPos, this); if (!chosen) { return; } if (chosen == actDeleteKey) { if (!m_workspace.isOpen() || !m_timeline->hasSelectedKeyframe()) { return; } const int f = m_timeline->selectedKeyFrame(); bool ok = false; switch (m_timeline->selectedKeyKind()) { case TimelineWidget::KeyKind::Location: if (!m_selectedEntityId.isEmpty()) { ok = m_workspace.removeEntityLocationKey(m_selectedEntityId, f); } else if (m_hasSelectedCamera && !m_selectedCameraId.isEmpty()) { ok = m_workspace.removeCameraLocationKey(m_selectedCameraId, f); } break; case TimelineWidget::KeyKind::UserScale: if (!m_selectedEntityId.isEmpty()) { ok = m_workspace.removeEntityUserScaleKey(m_selectedEntityId, f); } else if (m_hasSelectedCamera && !m_selectedCameraId.isEmpty()) { ok = m_workspace.removeCameraScaleKey(m_selectedCameraId, f); } break; case TimelineWidget::KeyKind::Image: if (!m_selectedEntityId.isEmpty()) ok = m_workspace.removeEntityImageFrame(m_selectedEntityId, f); break; case TimelineWidget::KeyKind::Visibility: if (!m_selectedEntityId.isEmpty()) ok = m_workspace.removeEntityVisibilityKey(m_selectedEntityId, f); else if (m_hasSelectedTool && !m_selectedToolId.isEmpty()) ok = m_workspace.removeToolVisibilityKey(m_selectedToolId, f); break; default: break; } if (ok) { refreshEditorPage(); } return; } if (chosen == actSetStart) { m_timelineRangeStart = m_currentFrame; if (m_timelineRangeEnd < 0) { m_timelineRangeEnd = m_currentFrame; } if (m_timelineRangeEnd < m_timelineRangeStart) { std::swap(m_timelineRangeStart, m_timelineRangeEnd); } if (m_timeline) { m_timeline->setSelectionRange(m_timelineRangeStart, m_timelineRangeEnd); } return; } if (chosen == actSetEnd) { m_timelineRangeEnd = m_currentFrame; if (m_timelineRangeStart < 0) { m_timelineRangeStart = m_currentFrame; } if (m_timelineRangeEnd < m_timelineRangeStart) { std::swap(m_timelineRangeStart, m_timelineRangeEnd); } if (m_timeline) { m_timeline->setSelectionRange(m_timelineRangeStart, m_timelineRangeEnd); } return; } if (chosen == actClear) { m_timelineRangeStart = -1; m_timelineRangeEnd = -1; if (m_timeline) { m_timeline->setSelectionRange(-1, -1); } return; } if (chosen == actAnim) { if (m_selectedEntityId.isEmpty() || !m_workspace.isOpen()) { return; } const int fs = 0; const int fe = m_workspace.activeAnimationLengthFrames() - 1; const int a = std::clamp(std::min(selA, selB), fs, fe); const int b = std::clamp(std::max(selA, selB), fs, fe); if (a > b) { return; } FrameAnimationDialog dlg(m_workspace, m_selectedEntityId, a, b, this); dlg.exec(); refreshEditorPage(); return; } }); } void MainWindow::createResourceLibraryDock() { m_resourceLibraryDockWidget = new gui::ResourceLibraryDock(this); m_dockResourceLibrary = m_resourceLibraryDockWidget; addDockWidget(Qt::LeftDockWidgetArea, m_dockResourceLibrary); // 默认不自动弹出,用户通过“窗口-资源库”打开 m_dockResourceLibrary->setVisible(false); connect(m_dockResourceLibrary, &QDockWidget::visibilityChanged, this, [this](bool visible) { if (!m_actionToggleResourceLibrary) { return; } m_actionToggleResourceLibrary->blockSignals(true); m_actionToggleResourceLibrary->setChecked(visible); m_actionToggleResourceLibrary->blockSignals(false); }); auto* local = new core::library::FakeResourceLibraryProvider(this); auto* online = new core::library::OnlineResourceLibraryProvider(this); m_resourceLibraryProvider = local; m_resourceLibraryDockWidget->setProviders(local, online); } void MainWindow::syncCreateEntityToolButtonTooltip() { if (!m_btnCreateEntity) { return; } m_btnCreateEntity->setToolTip({}); } void MainWindow::updateStatusBarText() {} void MainWindow::onComputeDepth() { if (!m_workspace.isOpen() || !m_workspace.hasBackground()) { return; } computeDepthAsync(); } void MainWindow::computeDepthAsync() { if (!m_workspace.isOpen() || !m_workspace.hasBackground()) { return; } const auto bgAbs = m_workspace.backgroundAbsolutePath(); if (bgAbs.isEmpty() || !QFileInfo::exists(bgAbs)) { QMessageBox::warning(this, QStringLiteral("深度"), QStringLiteral("背景不存在。")); return; } QFile f(bgAbs); if (!f.open(QIODevice::ReadOnly)) { QMessageBox::warning(this, QStringLiteral("深度"), QStringLiteral("读取背景失败。")); return; } const QByteArray bgBytes = f.readAll(); f.close(); if (bgBytes.isEmpty()) { QMessageBox::warning(this, QStringLiteral("深度"), QStringLiteral("背景文件为空。")); return; } QString base; const QByteArray env = qgetenv("MODEL_SERVER_URL"); base = env.isEmpty() ? QStringLiteral("http://127.0.0.1:8000") : QString::fromUtf8(env); auto* client = new core::ModelServerClient(this); client->setBaseUrl(QUrl(base)); QString immediateErr; QNetworkReply* reply = client->computeDepthPng8Async(bgBytes, &immediateErr); if (!reply) { QMessageBox::warning(this, QStringLiteral("深度"), immediateErr.isEmpty() ? QStringLiteral("无法发起后端请求。") : immediateErr); client->deleteLater(); return; } auto* dlg = new CancelableTaskDialog(QStringLiteral("计算深度"), QStringLiteral("正在请求后端计算深度,请稍候……"), this); dlg->setAttribute(Qt::WA_DeleteOnClose, true); connect(dlg, &CancelableTaskDialog::canceled, this, [reply, dlg]() { if (reply) { reply->abort(); } if (dlg) { dlg->reject(); } }); connect(reply, &QNetworkReply::finished, this, [this, reply, dlg, client]() { const int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); const QByteArray raw = reply->readAll(); const auto netErr = reply->error(); const QString netErrStr = reply->errorString(); reply->deleteLater(); client->deleteLater(); if (dlg) { dlg->close(); } if (netErr != QNetworkReply::NoError) { // 用户取消也会走这里(OperationCanceledError 等) if (netErrStr.contains(QStringLiteral("canceled"), Qt::CaseInsensitive) || netErr == QNetworkReply::OperationCanceledError) { return; } QMessageBox::warning(this, QStringLiteral("深度"), QStringLiteral("网络错误:%1").arg(netErrStr)); return; } if (httpStatus != 200) { QString detail; const QJsonDocument jd = QJsonDocument::fromJson(raw); if (jd.isObject()) { detail = jd.object().value(QStringLiteral("detail")).toString(); } QMessageBox::warning(this, QStringLiteral("深度"), detail.isEmpty() ? QStringLiteral("后端返回HTTP %1。").arg(httpStatus) : QStringLiteral("后端错误(HTTP %1):%2").arg(httpStatus).arg(detail)); return; } if (raw.isEmpty()) { QMessageBox::warning(this, QStringLiteral("深度"), QStringLiteral("后端返回空数据。")); return; } QString err; if (!m_workspace.saveDepthMapPngBytes(raw, &err)) { QMessageBox::warning(this, QStringLiteral("深度"), err.isEmpty() ? QStringLiteral("保存深度图失败。") : err); return; } refreshEditorPage(); refreshProjectTree(); updateUiEnabledState(); refreshPreviewPage(); }); dlg->show(); } void MainWindow::onTogglePlay(bool on) { if (m_previewRequested) { if (m_btnPlay) { m_btnPlay->blockSignals(true); m_btnPlay->setChecked(false); m_btnPlay->blockSignals(false); m_btnPlay->setText(QStringLiteral("▶")); } if (m_playTimer) { m_playTimer->stop(); } m_playing = false; return; } m_playing = on; if (m_btnPlay) { m_btnPlay->setText(on ? QStringLiteral("⏸") : QStringLiteral("▶")); } if (m_playTimer) { if (on) { const int fps = m_workspace.isOpen() ? m_workspace.activeAnimationFps() : 30; m_playTimer->start(1000 / std::max(1, fps)); } else { m_playTimer->stop(); } } } void MainWindow::stopEditorPlayback() { m_playing = false; if (m_playTimer) { m_playTimer->stop(); } if (m_btnPlay) { m_btnPlay->blockSignals(true); m_btnPlay->setChecked(false); m_btnPlay->blockSignals(false); m_btnPlay->setText(QStringLiteral("▶")); } } void MainWindow::stopPreviewPlayback() { m_previewPlaying = false; if (m_previewPlayTimer) { m_previewPlayTimer->stop(); } syncPreviewPlaybackBar(); } void MainWindow::startPreviewPlayback() { if (!m_workspace.isOpen() || !m_previewRequested) { return; } if (effectivePreviewAnimationId() == QStringLiteral("none")) { stopPreviewPlayback(); return; } m_previewPlaying = true; const QString id = effectivePreviewAnimationId(); const int fps = m_workspace.animationFpsForId(id); if (m_previewPlayTimer) { m_previewPlayTimer->start(1000 / std::max(1, fps)); } syncPreviewPlaybackBar(); } void MainWindow::togglePreviewPlayback() { if (m_previewPlaying) { stopPreviewPlayback(); } else { startPreviewPlayback(); } } QVector MainWindow::previewAnimationSchemeIdsNonNone() const { QVector out; if (!m_workspace.isOpen()) { return out; } for (const auto& a : m_workspace.project().animations()) { if (a.id == QStringLiteral("none")) continue; out.push_back(a.id); } return out; } QString MainWindow::effectivePreviewAnimationId() const { if (!m_workspace.isOpen()) { return QStringLiteral("none"); } const core::Project& pr = m_workspace.project(); const bool hasSlotNone = pr.findAnimationById(QStringLiteral("none")) != nullptr; QString want = m_previewAnimationId.isEmpty() ? QStringLiteral("none") : m_previewAnimationId; if (want == QStringLiteral("none")) { if (hasSlotNone) return QStringLiteral("none"); const QString fb = firstAnimationSchemeIdNonNoneWs(pr); return fb.isEmpty() ? QStringLiteral("none") : fb; } if (pr.findAnimationById(want)) { return want; } if (hasSlotNone) return QStringLiteral("none"); const QString fb = firstAnimationSchemeIdNonNoneWs(pr); return fb.isEmpty() ? QStringLiteral("none") : fb; } void MainWindow::refreshPreviewSchemeCombo() { if (!m_previewSchemeCombo || !m_workspace.isOpen()) { return; } const QString keep = effectivePreviewAnimationId(); m_previewSchemeCombo->blockSignals(true); m_previewSchemeCombo->clear(); QString noneLabel = QStringLiteral("画布"); for (const auto& a : m_workspace.project().animations()) { if (a.id == QStringLiteral("none")) { if (!a.name.isEmpty()) noneLabel = a.name; break; } } if (m_workspace.project().findAnimationById(QStringLiteral("none"))) { m_previewSchemeCombo->addItem(noneLabel, QStringLiteral("none")); } for (const auto& a : m_workspace.project().animations()) { if (a.id == QStringLiteral("none")) { continue; } const QString label = a.name.isEmpty() ? a.id : a.name; m_previewSchemeCombo->addItem(label, a.id); } int idx = -1; for (int i = 0; i < m_previewSchemeCombo->count(); ++i) { if (m_previewSchemeCombo->itemData(i).toString() == keep) { idx = i; break; } } if (idx >= 0) { m_previewSchemeCombo->setCurrentIndex(idx); } else if (m_previewSchemeCombo->count() > 0) { m_previewSchemeCombo->setCurrentIndex(0); } m_previewSchemeCombo->blockSignals(false); } void MainWindow::requestPreviewSchemeSwitchTo(const QString& id, bool showProgress) { if (!m_previewRequested || !m_workspace.isOpen() || id.isEmpty()) { return; } const bool isNone = (id == QStringLiteral("none")); if (isNone) { if (!m_workspace.project().findAnimationById(QStringLiteral("none"))) { return; } } else { bool allowed = false; for (const QString& cand : previewAnimationSchemeIdsNonNone()) { if (cand == id) { allowed = true; break; } } if (!allowed) return; } stopPreviewPlayback(); m_previewAnimationId = id; m_previewFrame = 0; auto finishUi = [this]() { refreshPreviewSchemeCombo(); refreshEditorPage(); if (effectivePreviewAnimationId() != QStringLiteral("none")) startPreviewPlayback(); else syncPreviewPlaybackBar(); }; if (showProgress && !isNone) { QProgressDialog dlg(QStringLiteral("加载中…"), QString(), 0, 0, this); dlg.setCancelButton(nullptr); dlg.setWindowModality(Qt::ApplicationModal); dlg.setMinimumDuration(0); dlg.show(); QApplication::processEvents(QEventLoop::DialogExec | QEventLoop::AllEvents); } (void)core::eval::evaluateAtFrame(m_workspace.project(), 0, 10, id); finishUi(); } void MainWindow::switchPreviewSchemeAdjacent(int delta) { QVector ids = previewAnimationSchemeIdsNonNone(); if (ids.size() <= 1) { return; } const QString cur = effectivePreviewAnimationId(); int ix = 0; for (int i = 0; i < ids.size(); ++i) { if (ids[i] == cur) { ix = i; break; } } const int n = static_cast(ids.size()); ix = ((ix + delta) % n + n) % n; requestPreviewSchemeSwitchTo(ids[ix], true); } void MainWindow::onPreviewSchemeComboChanged(int idx) { if (!m_previewSchemeCombo || !m_workspace.isOpen() || !m_previewRequested || idx < 0) { return; } const QString animId = m_previewSchemeCombo->itemData(idx).toString(); if (animId.isEmpty() || animId == m_previewAnimationId) { return; } requestPreviewSchemeSwitchTo(animId, true); } void MainWindow::syncHotspotEnterPreviewCombo() { const bool any = previewAnimationSchemeIdsNonNone().size() > 0; if (m_btnHotspotEnterPreview) { m_btnHotspotEnterPreview->setEnabled(any); } } void MainWindow::syncEditorRailForMode() { const bool open = m_workspace.isOpen(); const bool hotspotRail = open && m_hotspotRequested && !m_previewRequested; const bool entityRail = open && !m_previewRequested && !m_hotspotRequested && !m_animationRequested; const bool animationRail = open && !m_previewRequested && m_animationRequested; if (m_railBtnMove) m_railBtnMove->setVisible(open && !m_previewRequested); if (m_railBtnZoom) m_railBtnZoom->setVisible(entityRail || animationRail); if (m_btnCreateEntity) m_btnCreateEntity->setVisible(entityRail); if (m_btnToggleDepthOverlay) m_btnToggleDepthOverlay->setVisible(entityRail || animationRail); if (m_railBtnFit) m_railBtnFit->setVisible(open && !m_previewRequested); if (m_btnRailAddHotspot) m_btnRailAddHotspot->setVisible(false); if (m_btnRailMoveHotspot) m_btnRailMoveHotspot->setVisible(hotspotRail); if (m_btnHotspotEnterPreview) m_btnHotspotEnterPreview->setVisible(hotspotRail); if (m_canvasHost) { static_cast(m_canvasHost)->relayoutFloaters(); } } void MainWindow::syncPreviewPlaybackBar() { if (!m_previewBtnPlay) { return; } m_previewBtnPlay->setText(m_previewPlaying ? QStringLiteral("暂停") : QStringLiteral("播放")); } void MainWindow::onInsertCombinedKey() { if (!m_editorCanvas || !m_workspace.isOpen()) { return; } if (m_workspace.project().activeAnimationId() == QStringLiteral("none")) { // “无”是静态模式:不允许写关键帧 return; } const int lf = std::clamp(m_currentFrame, 0, m_workspace.activeAnimationLengthFrames() - 1); if (m_hasSelectedCamera && !m_selectedCameraId.isEmpty()) { const core::eval::ResolvedProjectFrame rf = core::eval::evaluateAtFrame(m_workspace.project(), m_currentFrame, 10); for (const auto& rc : rf.cameras) { if (rc.camera.id == m_selectedCameraId) { m_workspace.setCameraLocationKey(m_selectedCameraId, lf, rc.camera.centerWorld); m_workspace.setCameraScaleKey(m_selectedCameraId, lf, rc.camera.viewScale); refreshEditorPage(); return; } } return; } if (m_selectedEntityId.isEmpty()) { return; } // 位置关键帧:使用当前帧下的动画原点 const QPointF o = m_editorCanvas->selectedAnimatedOriginWorld(); m_workspace.setEntityLocationKey(m_selectedEntityId, lf, o); // 缩放关键帧:使用当前帧下的 userScale(而非 depthScale01) const double s = m_editorCanvas->selectedUserScale(); m_workspace.setEntityUserScaleKey(m_selectedEntityId, lf, s); refreshEditorPage(); } void MainWindow::createMenus() { createFileMenu(); createEditMenu(); createAnimationMenu(); createViewMenu(); createWindowMenu(); createHelpMenu(); } void MainWindow::createAnimationMenu() { QMenu* m = menuBar()->addMenu(QStringLiteral("动画")); m_actionAnimationSettings = m->addAction(QStringLiteral("当前方案动画设置…")); connect(m_actionAnimationSettings, &QAction::triggered, this, [this]() { if (!m_workspace.isOpen()) { return; } QString targetId; if (m_previewRequested) { targetId = effectivePreviewAnimationId(); } else { targetId = m_workspace.project().activeAnimationId(); } if (targetId.isEmpty() || targetId == QStringLiteral("none")) { QMessageBox::information(this, QStringLiteral("动画"), QStringLiteral("当前无可编辑的动画方案(「无」为静态模式)。")); return; } AnimationSchemeSettingsDialog dlg(AnimationSchemeSettingsDialog::Mode::Edit, m_workspace, this); dlg.setEditTarget(targetId); if (dlg.exec() == QDialog::Accepted) { refreshPreviewSchemeCombo(); refreshEditorPage(); } }); } void MainWindow::createFileMenu() { auto m_fileMenu = menuBar()->addMenu(QString()); m_fileMenu->setTitle("文件"); // 示例:新建、打开、保存、退出 auto* newProjectAction = m_fileMenu->addAction(QString()); newProjectAction->setText("新建工程"); newProjectAction->setShortcut(QKeySequence::New); connect(newProjectAction, &QAction::triggered, this, &MainWindow::onNewProject); auto* openProjectAction = m_fileMenu->addAction(QString()); openProjectAction->setText("打开工程"); openProjectAction->setShortcut(QKeySequence::Open); connect(openProjectAction, &QAction::triggered, this, &MainWindow::onOpenProject); auto* closeProjectAction = m_fileMenu->addAction(QString()); closeProjectAction->setText(QStringLiteral("关闭工程")); closeProjectAction->setShortcut(QKeySequence::Close); connect(closeProjectAction, &QAction::triggered, this, &MainWindow::onCloseProject); m_fileMenu->addSeparator(); auto* saveAction = m_fileMenu->addAction(QString()); saveAction->setText("保存"); saveAction->setShortcut(QKeySequence::Save); connect(saveAction, &QAction::triggered, this, &MainWindow::onSaveProject); m_fileMenu->addSeparator(); auto* exitAction = m_fileMenu->addAction(QString()); exitAction->setText("退出"); exitAction->setShortcut(QKeySequence::Quit); connect(exitAction, &QAction::triggered, this, &MainWindow::close); } void MainWindow::createEditMenu() { auto* editMenu = menuBar()->addMenu(QString()); editMenu->setTitle("编辑"); // 撤销/重做 m_actionUndo = editMenu->addAction(QString()); m_actionUndo->setText("撤销"); m_actionUndo->setShortcut(QKeySequence::Undo); connect(m_actionUndo, &QAction::triggered, this, &MainWindow::onUndo); m_actionRedo = editMenu->addAction(QString()); m_actionRedo->setText("重做"); m_actionRedo->setShortcut(QKeySequence::Redo); connect(m_actionRedo, &QAction::triggered, this, &MainWindow::onRedo); editMenu->addSeparator(); // 复制/粘贴/删除 m_actionCopy = editMenu->addAction(QString()); m_actionCopy->setText("复制"); m_actionCopy->setShortcut(QKeySequence::Copy); connect(m_actionCopy, &QAction::triggered, this, &MainWindow::onCopyObject); m_actionPaste = editMenu->addAction(QString()); m_actionPaste->setText("粘贴"); m_actionPaste->setShortcut(QKeySequence::Paste); connect(m_actionPaste, &QAction::triggered, this, &MainWindow::onPasteObject); } void MainWindow::createHelpMenu() { auto* helpMenu = menuBar()->addMenu(QString()); helpMenu->setTitle("帮助"); auto* aboutAction = helpMenu->addAction(QString()); aboutAction->setText("关于"); connect(aboutAction, &QAction::triggered, this, &MainWindow::onAbout); } void MainWindow::createViewMenu() { auto* viewMenu = menuBar()->addMenu(QString()); viewMenu->setTitle("视图"); auto* canvasMenu = viewMenu->addMenu(QStringLiteral("画布")); m_actionCanvasWorldAxes = canvasMenu->addAction(QStringLiteral("世界坐标轴")); m_actionCanvasWorldAxes->setCheckable(true); m_actionCanvasWorldAxes->setChecked(true); connect(m_actionCanvasWorldAxes, &QAction::toggled, this, [this](bool on) { if (m_editorCanvas) { m_editorCanvas->setWorldAxesVisible(on); } if (m_actionCanvasAxisValues) { m_actionCanvasAxisValues->setEnabled(on); } }); m_actionCanvasAxisValues = canvasMenu->addAction(QStringLiteral("坐标轴数值")); m_actionCanvasAxisValues->setCheckable(true); m_actionCanvasAxisValues->setChecked(true); connect(m_actionCanvasAxisValues, &QAction::toggled, this, [this](bool on) { if (m_editorCanvas) { m_editorCanvas->setAxisLabelsVisible(on); } }); canvasMenu->addSeparator(); m_actionCanvasGrid = canvasMenu->addAction(QStringLiteral("参考网格")); m_actionCanvasGrid->setCheckable(true); m_actionCanvasGrid->setChecked(true); connect(m_actionCanvasGrid, &QAction::toggled, this, [this](bool on) { if (m_editorCanvas) { m_editorCanvas->setGridVisible(on); } }); m_actionCanvasCheckerboard = canvasMenu->addAction(QStringLiteral("棋盘底纹")); m_actionCanvasCheckerboard->setCheckable(true); m_actionCanvasCheckerboard->setChecked(true); connect(m_actionCanvasCheckerboard, &QAction::toggled, this, [this](bool on) { if (m_editorCanvas) { m_editorCanvas->setCheckerboardVisible(on); } }); canvasMenu->addSeparator(); m_actionCanvasDepthOverlay = canvasMenu->addAction(QStringLiteral("深度叠加")); m_actionCanvasDepthOverlay->setCheckable(true); m_actionCanvasDepthOverlay->setChecked(false); connect(m_actionCanvasDepthOverlay, &QAction::toggled, this, [this](bool on) { if (m_editorCanvas) { m_editorCanvas->setDepthOverlayEnabled(on); } if (m_btnToggleDepthOverlay) { m_btnToggleDepthOverlay->blockSignals(true); m_btnToggleDepthOverlay->setChecked(on); m_btnToggleDepthOverlay->blockSignals(false); } if (m_bgPropertySection) { m_bgPropertySection->syncDepthOverlayChecked(on); } }); m_actionCanvasGizmoLabels = canvasMenu->addAction(QStringLiteral("Gizmo 轴标签")); m_actionCanvasGizmoLabels->setCheckable(true); m_actionCanvasGizmoLabels->setChecked(true); connect(m_actionCanvasGizmoLabels, &QAction::toggled, this, [this](bool on) { if (m_editorCanvas) { m_editorCanvas->setGizmoLabelsVisible(on); } }); viewMenu->addSeparator(); m_actionEnterPreview = viewMenu->addAction(QStringLiteral("进入预览展示")); connect(m_actionEnterPreview, &QAction::triggered, this, [this]() { m_animationRequested = false; m_hotspotRequested = false; m_previewAnimationId = QStringLiteral("none"); m_previewFrame = 0; if (m_modeSelector) { m_modeSelector->blockSignals(true); m_modeSelector->setCurrentIndex(3); m_modeSelector->blockSignals(false); } setPreviewRequested(true); }); m_actionBackToEditor = viewMenu->addAction(QStringLiteral("返回编辑模式")); connect(m_actionBackToEditor, &QAction::triggered, this, [this]() { m_animationRequested = false; m_hotspotRequested = false; if (m_modeSelector) { m_modeSelector->blockSignals(true); m_modeSelector->setCurrentIndex(0); m_modeSelector->blockSignals(false); } setPreviewRequested(false); }); } void MainWindow::createWindowMenu() { auto* winMenu = menuBar()->addMenu(QString()); winMenu->setTitle(QStringLiteral("窗口")); m_actionToggleProjectTree = winMenu->addAction(QStringLiteral("项目树")); m_actionToggleProjectTree->setCheckable(true); connect(m_actionToggleProjectTree, &QAction::toggled, this, [this](bool on) { m_rightDocksNarrowHidden = false; if (m_dockProjectTree) { m_dockProjectTree->setVisible(on); } }); m_actionToggleProperties = winMenu->addAction(QStringLiteral("属性")); m_actionToggleProperties->setCheckable(true); connect(m_actionToggleProperties, &QAction::toggled, this, [this](bool on) { m_rightDocksNarrowHidden = false; if (m_dockProperties) { m_dockProperties->setVisible(on); } }); m_actionToggleTimeline = winMenu->addAction(QStringLiteral("动画面板")); m_actionToggleTimeline->setCheckable(true); connect(m_actionToggleTimeline, &QAction::toggled, this, [this](bool on) { if (m_dockTimeline) { m_dockTimeline->setVisible(on); } }); m_actionToggleResourceLibrary = winMenu->addAction(QStringLiteral("资源库")); m_actionToggleResourceLibrary->setCheckable(true); connect(m_actionToggleResourceLibrary, &QAction::toggled, this, [this](bool on) { if (m_dockResourceLibrary) { m_dockResourceLibrary->setVisible(on); } }); } void MainWindow::createProjectTreeDock() { m_dockProjectTree = new QDockWidget(QStringLiteral("项目树"), this); m_dockProjectTree->setToolTip({}); m_dockProjectTree->setAllowedAreas(Qt::LeftDockWidgetArea | Qt::RightDockWidgetArea | Qt::BottomDockWidgetArea | Qt::TopDockWidgetArea); m_dockProjectTree->setFeatures(QDockWidget::DockWidgetMovable | QDockWidget::DockWidgetFloatable | QDockWidget::DockWidgetClosable); m_dockProjectTree->setMinimumWidth(kRightDockColumnFallbackMinWidth); auto* dockContent = new QWidget(m_dockProjectTree); dockContent->setObjectName(QStringLiteral("ProjectTreeDockContent")); auto* dockLayout = new QVBoxLayout(dockContent); dockLayout->setContentsMargins(6, 6, 6, 6); dockLayout->setSpacing(0); m_projectTree = new ProjectTreeWidget(dockContent); m_projectTree->setObjectName(QStringLiteral("ProjectTree")); m_projectTree->setColumnCount(2); m_projectTree->setHeaderHidden(true); m_projectTree->setRootIsDecorated(true); m_projectTree->setIndentation(16); m_projectTree->setUniformRowHeights(true); m_projectTree->setAnimated(true); m_projectTree->setExpandsOnDoubleClick(true); // 允许拖拽来设置父子关系;真正的父子逻辑由 dropEvent 发信号驱动,不使用默认内部移动 if (m_projectTree->header()) { m_projectTree->header()->setSectionResizeMode(0, QHeaderView::ResizeToContents); m_projectTree->header()->setSectionResizeMode(1, QHeaderView::Stretch); } m_projectTree->setContextMenuPolicy(Qt::CustomContextMenu); connect(m_projectTree, &QTreeWidget::customContextMenuRequested, this, [this](const QPoint& pos) { if (!m_projectTree) { return; } auto* item = m_projectTree->itemAt(pos); if (!item) { if (m_workspace.isOpen()) { showProjectRootContextMenu(m_projectTree->viewport()->mapToGlobal(pos)); } return; } if (item == m_itemBackground) { showBackgroundContextMenu(m_projectTree->viewport()->mapToGlobal(pos)); return; } const QString kind = item->data(0, Qt::UserRole).toString(); if (kind == QStringLiteral("blackhole")) { const QString id = item->data(0, Qt::UserRole + 1).toString(); if (!id.isEmpty()) { showBlackholeContextMenu(m_projectTree->viewport()->mapToGlobal(pos), id); } return; } }); connect(m_projectTree, &QTreeWidget::itemClicked, this, &MainWindow::onProjectTreeItemClicked); static_cast(m_projectTree)->onNodeParentDropRequested = [this](const QString& childKind, const QString& childId, const QString& parentKind, const QString& parentIdOrEmpty) { if (!m_workspace.isOpen() || childId.isEmpty()) { return; } QString pid = parentIdOrEmpty; QString pk = parentKind; if (pid.isEmpty()) { pk.clear(); } if (!pid.isEmpty() && pid == childId && pk == childKind) { pid.clear(); pk.clear(); } const auto ents = m_workspace.entities(); const auto tools = m_workspace.tools(); QSet entIds; QSet toolIds; entIds.reserve(ents.size()); toolIds.reserve(tools.size()); for (const auto& e : ents) entIds.insert(e.id); for (const auto& t : tools) toolIds.insert(t.id); auto getParentOf = [&](const QString& id) -> QString { for (const auto& e : ents) { if (e.id == id) return e.parentId; } for (const auto& t : tools) { if (t.id == id) return t.parentId; } return QString(); }; auto wouldCreateCycle = [&](const QString& child, const QString& parent) -> bool { if (child.isEmpty() || parent.isEmpty()) return false; // 从 parent 往上走,若遇到 child 则成环 QSet seen; QString cur = parent; for (int guard = 0; guard < 10000 && !cur.isEmpty(); ++guard) { if (cur == child) return true; if (seen.contains(cur)) return true; seen.insert(cur); cur = getParentOf(cur); } return false; }; if (!pid.isEmpty() && wouldCreateCycle(childId, pid)) { return; } auto originOf = [&](const QString& kind, const QString& id) -> QPointF { if (kind == QStringLiteral("entity")) { for (const auto& e : ents) if (e.id == id) return e.originWorld; } if (kind == QStringLiteral("tool")) { for (const auto& t : tools) if (t.id == id) return t.originWorld; } return QPointF(); }; const QPointF childOrigin = originOf(childKind, childId); const QPointF parentOrigin = (!pid.isEmpty()) ? originOf(pk, pid) : QPointF(); const QPointF off = (!pid.isEmpty()) ? (childOrigin - parentOrigin) : QPointF(); bool ok = false; if (childKind == QStringLiteral("entity")) { ok = m_workspace.setEntityParent(childId, pid, off); } else if (childKind == QStringLiteral("tool")) { ok = m_workspace.setToolParent(childId, pid, off); } if (!ok) return; refreshEditorPage(); refreshProjectTree(); }; auto* treeScroll = new SpinFriendlyScrollArea(dockContent); treeScroll->setWidgetResizable(true); treeScroll->setFrameShape(QFrame::NoFrame); treeScroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); treeScroll->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); treeScroll->setAlignment(Qt::AlignLeft | Qt::AlignTop); treeScroll->setWidget(m_projectTree); auto* hotspotAnimPage = new QWidget(dockContent); auto* hotspotAnimLayout = new QVBoxLayout(hotspotAnimPage); hotspotAnimLayout->setContentsMargins(0, 0, 0, 0); hotspotAnimLayout->setSpacing(6); auto* hotspotAnimHint = new QLabel(QStringLiteral("拖动动画到画布创建热点"), hotspotAnimPage); hotspotAnimHint->setWordWrap(true); hotspotAnimLayout->addWidget(hotspotAnimHint); m_hotspotAnimationList = new HotspotAnimationListWidget(hotspotAnimPage); m_hotspotAnimationList->setObjectName(QStringLiteral("PropertyPanelListWidget")); m_hotspotAnimationList->setMinimumHeight(120); hotspotAnimLayout->addWidget(m_hotspotAnimationList, 1); m_rightTopDockStack = new QStackedWidget(dockContent); m_rightTopDockStack->addWidget(treeScroll); m_rightTopDockStack->addWidget(hotspotAnimPage); dockLayout->addWidget(m_rightTopDockStack, 1); m_dockProjectTree->setWidget(dockContent); m_dockProjectTree->installEventFilter(this); m_dockProperties = new QDockWidget(QStringLiteral("属性"), this); m_dockProperties->setToolTip({}); m_dockProperties->setAllowedAreas(Qt::LeftDockWidgetArea | Qt::RightDockWidgetArea | Qt::BottomDockWidgetArea | Qt::TopDockWidgetArea); m_dockProperties->setFeatures(QDockWidget::DockWidgetMovable | QDockWidget::DockWidgetFloatable | QDockWidget::DockWidgetClosable); m_dockProperties->setMinimumWidth(kRightDockColumnFallbackMinWidth); m_bgPropertySection = new gui::BackgroundPropertySection(); m_blackholePropertySection = new gui::BlackholePropertySection(); m_entityPropertySection = new gui::EntityPropertySection(); m_cameraPropertySection = new gui::CameraPropertySection(); m_toolPropertySection = new gui::ToolPropertySection(); m_hotspotPropertySection = new gui::HotspotPropertySection(); m_propertyStack = new QStackedWidget(); m_propertyStack->setObjectName(QStringLiteral("PropertyDockContent")); m_propertyStack->setContentsMargins(6, 6, 6, 6); m_propertyStack->addWidget(m_bgPropertySection); m_propertyStack->addWidget(m_blackholePropertySection); m_propertyStack->addWidget(m_entityPropertySection); m_propertyStack->addWidget(m_cameraPropertySection); m_propertyStack->addWidget(m_toolPropertySection); m_propertyStack->addWidget(m_hotspotPropertySection); connect(m_hotspotPropertySection, &gui::HotspotPropertySection::targetAnimationChanged, this, [this](const QString& hotspotId, const QString& animationId) { QVector hs = m_workspace.project().presentationHotspots(); for (auto& h : hs) { if (h.id == hotspotId) { h.targetAnimationId = animationId; break; } } m_workspace.applyPresentationHotspots(hs, true, QStringLiteral("热点")); refreshEditorPage(); }); connect(m_hotspotPropertySection, &gui::HotspotPropertySection::hotspotSelected, this, [this](const QString& hotspotId) { m_selectedHotspotId = hotspotId; m_hasSelectedEntity = false; m_selectedEntityId.clear(); m_selectedEntityDisplayNameCache.clear(); m_hasSelectedTool = false; m_selectedToolId.clear(); m_hasSelectedCamera = false; m_selectedCameraId.clear(); m_selectedBlackholeEntityId.clear(); if (m_editorCanvas) { m_editorCanvas->clearEntitySelection(); m_editorCanvas->clearCameraSelection(); if (hotspotId.isEmpty()) { m_editorCanvas->clearPresentationHotspotSelection(); } else { m_editorCanvas->setSelectedPresentationHotspotId(hotspotId); } } refreshPropertyPanel(); syncProjectTreeFromCanvasSelection(); }); connect(m_hotspotPropertySection, &gui::HotspotPropertySection::deleteRequested, this, [this](const QString& hotspotId) { QVector hs = m_workspace.project().presentationHotspots(); for (int i = 0; i < hs.size(); ++i) { if (hs[i].id == hotspotId) { hs.remove(i); break; } } m_selectedHotspotId.clear(); if (m_editorCanvas) { m_editorCanvas->clearPresentationHotspotSelection(); } m_workspace.applyPresentationHotspots(hs, true, QStringLiteral("热点")); refreshEditorPage(); }); connect(m_bgPropertySection, &gui::BackgroundPropertySection::backgroundVisibleToggled, this, [this](bool on) { if (!m_workspace.isOpen()) return; if (!m_workspace.setBackgroundVisible(on)) return; refreshProjectTree(); refreshEditorPage(); refreshPreviewPage(); }); connect(m_bgPropertySection, &gui::BackgroundPropertySection::depthOverlayToggled, this, [this](bool on) { if (m_editorCanvas) { m_editorCanvas->setDepthOverlayEnabled(on); } if (m_actionCanvasDepthOverlay) { m_actionCanvasDepthOverlay->blockSignals(true); m_actionCanvasDepthOverlay->setChecked(on); m_actionCanvasDepthOverlay->blockSignals(false); } if (m_btnToggleDepthOverlay) { m_btnToggleDepthOverlay->blockSignals(true); m_btnToggleDepthOverlay->setChecked(on); m_btnToggleDepthOverlay->blockSignals(false); } }); connect(m_entityPropertySection, &gui::EntityPropertySection::displayNameCommitted, this, [this](const QString& text) { if (m_selectedEntityId.isEmpty()) return; if (!m_workspace.setEntityDisplayName(m_selectedEntityId, text)) return; refreshProjectTree(); refreshDopeSheet(); }); connect(m_entityPropertySection, &gui::EntityPropertySection::ignoreDistanceScaleToggled, this, [this](bool on) { if (m_selectedEntityId.isEmpty()) return; if (!m_workspace.setEntityIgnoreDistanceScale(m_selectedEntityId, on)) return; refreshEditorPage(); refreshPreviewPage(); }); connect(m_entityPropertySection, &gui::EntityPropertySection::visibleToggled, this, [this](bool on) { if (m_selectedEntityId.isEmpty()) return; const int f = std::clamp(m_currentFrame, 0, m_workspace.activeAnimationLengthFrames() - 1); if (!m_workspace.setEntityVisibilityKey(m_selectedEntityId, f, on)) return; refreshEditorPage(); }); connect(m_entityPropertySection, &gui::EntityPropertySection::centerPositionEdited, this, [this](double x, double y) { if (m_selectedEntityId.isEmpty() || !m_editorCanvas) return; const double s = m_editorCanvas->selectedCombinedScale(); if (s <= 1e-9) return; const int f = std::clamp(m_currentFrame, 0, m_workspace.activeAnimationLengthFrames() - 1); QString parentId; for (const auto& e : m_workspace.entities()) { if (e.id == m_selectedEntityId) { parentId = e.parentId; break; } } const bool autoKey = m_chkAutoKeyframe && m_chkAutoKeyframe->isChecked(); QPointF targetCentroid(x, y); if (!parentId.isEmpty()) { const QPointF parentC = m_workspace.entityCentroidWorldAtFrame(parentId, f); targetCentroid = parentC + QPointF(x, y); } if (!m_workspace.moveEntityCentroidTo(m_selectedEntityId, f, targetCentroid, s, autoKey)) return; refreshEditorPage(); refreshDopeSheet(); }); connect(m_entityPropertySection, &gui::EntityPropertySection::relativeToOwnCenterEdited, this, [this](double x, double y) { if (m_selectedEntityId.isEmpty() || !m_editorCanvas) return; const double s = m_editorCanvas->selectedCombinedScale(); if (s <= 1e-9) return; const int f = std::clamp(m_currentFrame, 0, m_workspace.activeAnimationLengthFrames() - 1); const QPointF C = m_workspace.entityCentroidWorldAtFrame(m_selectedEntityId, f); const QPointF targetPivot = C + QPointF(x, y); if (!m_workspace.reanchorEntityPivot(m_selectedEntityId, f, targetPivot, s)) return; refreshEditorPage(); refreshDopeSheet(); }); connect(m_entityPropertySection, &gui::EntityPropertySection::priorityEdited, this, [this](int v) { if (m_selectedEntityId.isEmpty()) return; const int f = std::clamp(m_currentFrame, 0, m_workspace.activeAnimationLengthFrames() - 1); if (!m_workspace.setEntityPriorityKey(m_selectedEntityId, f, v)) return; refreshEditorPage(); refreshDopeSheet(); }); connect(m_entityPropertySection, &gui::EntityPropertySection::userScaleEdited, this, [this](double v) { if (m_selectedEntityId.isEmpty()) return; if (!m_workspace.setEntityUserScale(m_selectedEntityId, v, m_currentFrame)) return; refreshEditorPage(); refreshDopeSheet(); }); connect(m_entityPropertySection, &gui::EntityPropertySection::introContentEdited, this, [this]() { if (m_selectedEntityId.isEmpty() || !m_entityPropertySection) { return; } const core::EntityIntroContent intro = m_entityPropertySection->introSnapshot(); if (!m_workspace.setEntityIntroContent(m_selectedEntityId, intro)) { QMessageBox::warning(this, QStringLiteral("介绍"), QStringLiteral("自动保存失败。")); return; } refreshEditorPage(); }); connect(m_entityPropertySection, &gui::EntityPropertySection::introAddImageRequested, this, [this]() { if (m_selectedEntityId.isEmpty() || !m_entityPropertySection) { return; } const QString path = QFileDialog::getOpenFileName( this, QStringLiteral("选择配图"), {}, QStringLiteral("图片 (*.png *.jpg *.jpeg *.webp *.bmp);;所有文件 (*)")); if (path.isEmpty()) { return; } QString rel; if (!m_workspace.importEntityIntroImageFromFile(m_selectedEntityId, path, &rel)) { QMessageBox::warning(this, QStringLiteral("介绍"), QStringLiteral("导入配图失败。")); return; } m_entityPropertySection->appendIntroImagePath(rel); }); connect(m_entityPropertySection, &gui::EntityPropertySection::spriteEditRequested, this, [this]() { if (m_selectedEntityId.isEmpty() || !m_workspace.isOpen()) { return; } const int f = std::clamp(m_currentFrame, 0, m_workspace.activeAnimationLengthFrames() - 1); if (m_workspace.project().activeAnimationId() == QStringLiteral("none")) { const QString imagePath = QFileDialog::getOpenFileName( this, QStringLiteral("选择默认贴图"), {}, QStringLiteral("图片 (*.png *.jpg *.jpeg *.webp *.bmp);;所有文件 (*)")); if (imagePath.isEmpty()) return; ImageCropDialog crop(imagePath, this); if (crop.exec() != QDialog::Accepted) return; const QRect cropRect = crop.hasValidSelection() ? crop.selectedRectInImagePixels() : QRect(); QImage img = core::image_file::loadImageLimited(imagePath, core::image_decode::kWorkspaceMaxPixels); if (img.isNull()) return; if (cropRect.isValid()) { img = img.copy(cropRect); } if (img.format() != QImage::Format_ARGB32_Premultiplied) { img = img.convertToFormat(QImage::Format_ARGB32_Premultiplied); } if (!m_workspace.setEntityDefaultImage(m_selectedEntityId, img)) return; refreshEditorPage(); refreshProjectTree(); refreshDopeSheet(); return; } FrameAnimationDialog dlg(m_workspace, m_selectedEntityId, f, f, this); dlg.exec(); refreshEditorPage(); refreshDopeSheet(); }); connect(m_toolPropertySection, &gui::ToolPropertySection::textCommitted, this, [this](const QString& text) { if (m_selectedToolId.isEmpty() || !m_workspace.isOpen()) return; m_workspace.setToolText(m_selectedToolId, text); refreshEditorPage(); refreshProjectTree(); }); connect(m_toolPropertySection, &gui::ToolPropertySection::pointerTChanged, this, [this](int thousandths) { if (m_selectedToolId.isEmpty() || !m_workspace.isOpen()) return; m_workspace.setToolBubblePointerT01(m_selectedToolId, static_cast(thousandths) / 1000.0); refreshEditorPage(); }); connect(m_toolPropertySection, &gui::ToolPropertySection::fontPxChanged, this, [this](int px) { if (m_selectedToolId.isEmpty() || !m_workspace.isOpen()) return; m_workspace.setToolFontPx(m_selectedToolId, px); refreshEditorPage(); }); connect(m_toolPropertySection, &gui::ToolPropertySection::alignChanged, this, [this](int idx) { if (m_selectedToolId.isEmpty() || !m_workspace.isOpen()) return; core::Project::Tool::TextAlign a = core::Project::Tool::TextAlign::Center; if (idx == 0) a = core::Project::Tool::TextAlign::Left; else if (idx == 2) a = core::Project::Tool::TextAlign::Right; m_workspace.setToolAlign(m_selectedToolId, a); refreshEditorPage(); }); connect(m_toolPropertySection, &gui::ToolPropertySection::positionEdited, this, [this](int x, int y) { if (m_selectedToolId.isEmpty() || !m_workspace.isOpen()) return; const int f = std::clamp(m_currentFrame, 0, m_workspace.activeAnimationLengthFrames() - 1); const auto rf = core::eval::evaluateAtFrame(m_workspace.project(), f, 10); QPointF currentWorld; QPointF parentWorld; QString parentId; bool found = false; for (const auto& t : rf.tools) { if (t.tool.id == m_selectedToolId) { currentWorld = t.tool.originWorld; parentId = t.tool.parentId; found = true; break; } } if (!found) return; if (!parentId.isEmpty()) { for (const auto& e : rf.entities) { if (e.entity.id == parentId) { parentWorld = e.entity.originWorld; break; } } if (qFuzzyIsNull(parentWorld.x()) && qFuzzyIsNull(parentWorld.y())) { for (const auto& t : rf.tools) { if (t.tool.id == parentId) { parentWorld = t.tool.originWorld; break; } } } } const QPointF targetWorld = parentId.isEmpty() ? QPointF(static_cast(x), static_cast(y)) : (parentWorld + QPointF(static_cast(x), static_cast(y))); const QPointF delta = targetWorld - currentWorld; if (qFuzzyIsNull(delta.x()) && qFuzzyIsNull(delta.y())) return; if (!m_workspace.moveToolBy(m_selectedToolId, delta, f, true)) return; refreshEditorPage(); refreshDopeSheet(); }); connect(m_toolPropertySection, &gui::ToolPropertySection::priorityEdited, this, [this](int v) { if (m_selectedToolId.isEmpty() || !m_workspace.isOpen()) return; const int f = std::clamp(m_currentFrame, 0, m_workspace.activeAnimationLengthFrames() - 1); if (!m_workspace.setToolPriorityKey(m_selectedToolId, f, v)) return; refreshEditorPage(); refreshDopeSheet(); }); connect(m_toolPropertySection, &gui::ToolPropertySection::visibleToggled, this, [this](bool on) { if (m_selectedToolId.isEmpty() || !m_workspace.isOpen()) return; const int f = std::clamp(m_currentFrame, 0, m_workspace.activeAnimationLengthFrames() - 1); if (!m_workspace.setToolVisibilityKey(m_selectedToolId, f, on)) return; refreshEditorPage(); }); connect(m_cameraPropertySection, &gui::CameraPropertySection::displayNameCommitted, this, [this](const QString& text) { if (m_selectedCameraId.isEmpty() || !m_workspace.isOpen()) return; if (!m_workspace.setCameraDisplayName(m_selectedCameraId, text)) return; refreshProjectTree(); refreshPropertyPanel(); }); connect(m_cameraPropertySection, &gui::CameraPropertySection::centerEdited, this, [this](int x, int y) { if (m_selectedCameraId.isEmpty() || !m_workspace.isOpen()) return; if (!m_workspace.setCameraCenterWorld(m_selectedCameraId, QPointF(static_cast(x), static_cast(y)))) return; refreshEditorPage(); }); connect(m_cameraPropertySection, &gui::CameraPropertySection::viewScaleEdited, this, [this](double vs) { if (m_selectedCameraId.isEmpty() || !m_workspace.isOpen()) return; const int f = std::clamp(m_currentFrame, 0, m_workspace.activeAnimationLengthFrames() - 1); if (!m_workspace.setCameraViewScaleValue(m_selectedCameraId, vs, f)) return; refreshEditorPage(); refreshDopeSheet(); }); connect(m_cameraPropertySection, &gui::CameraPropertySection::activePreviewToggled, this, [this](bool on) { if (m_selectedCameraId.isEmpty() || !m_workspace.isOpen()) return; if (on) { if (!m_workspace.setActiveCameraId(m_selectedCameraId)) return; } else { if (m_workspace.project().activeCameraId() == m_selectedCameraId) { if (!m_workspace.setActiveCameraId(QString())) return; } } refreshEditorPage(); refreshPropertyPanel(); }); auto* propScroll = new SpinFriendlyScrollArea(m_dockProperties); propScroll->setWidgetResizable(true); propScroll->setFrameShape(QFrame::NoFrame); propScroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); propScroll->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); propScroll->setAlignment(Qt::AlignLeft | Qt::AlignTop); propScroll->setWidget(m_propertyStack); m_dockProperties->setWidget(propScroll); addDockWidget(Qt::RightDockWidgetArea, m_dockProjectTree); splitDockWidget(m_dockProjectTree, m_dockProperties, Qt::Vertical); resizeDocks({m_dockProjectTree, m_dockProperties}, {kProjectTreeDockStartupHeight, kPropertiesDockStartupHeight}, Qt::Vertical); resizeDocks({m_dockProjectTree, m_dockProperties}, {kRightDockColumnStartupWidth, kRightDockColumnStartupWidth}, Qt::Horizontal); connect(m_dockProjectTree, &QDockWidget::visibilityChanged, this, [this](bool visible) { if (m_actionToggleProjectTree) { m_actionToggleProjectTree->blockSignals(true); m_actionToggleProjectTree->setChecked(visible); m_actionToggleProjectTree->blockSignals(false); } }); connect(m_dockProperties, &QDockWidget::visibilityChanged, this, [this](bool visible) { if (m_actionToggleProperties) { m_actionToggleProperties->blockSignals(true); m_actionToggleProperties->setChecked(visible); m_actionToggleProperties->blockSignals(false); } }); connect(m_propertyStack, &QStackedWidget::currentChanged, this, [this](int) { QTimer::singleShot(0, this, [this]() { updateRightDockColumnMinimumWidthFromContent(); }); }); QTimer::singleShot(0, this, [this]() { updateRightDockColumnMinimumWidthFromContent(); }); } void MainWindow::updateRightDockColumnMinimumWidthFromContent() { if (!m_dockProjectTree || !m_dockProperties || !m_propertyStack) { return; } QWidget* rightTopContent = m_projectTree; if (m_rightTopDockStack && m_rightTopDockStack->currentWidget()) { rightTopContent = m_rightTopDockStack->currentWidget(); } if (!rightTopContent) { return; } rightTopContent->updateGeometry(); m_propertyStack->updateGeometry(); const int hintTree = rightTopContent->minimumSizeHint().width(); const int hintProp = m_propertyStack->minimumSizeHint().width(); constexpr int kDockFramePad = 12; // 属性面板里的数值文本会随内容变化(例如帧号/坐标/比例等),导致 minimumSizeHint 动态变宽。 // 若把它直接作为 dock 列最小宽度,会出现“数字变化 → 列宽越撑越大”的体验问题。 // 这里将属性区对列宽的影响上限限制在启动宽度,避免被动态文本推动无限变宽; // 项目树仍允许按自身最小宽度撑开,防止树项被过度裁切。 const int hintPropCapped = std::min(hintProp, kRightDockColumnStartupWidth); const int fromContent = std::max(hintTree, hintPropCapped) + kDockFramePad; const int w = std::max(kRightDockColumnFallbackMinWidth, fromContent); m_dockProjectTree->setMinimumWidth(w); m_dockProperties->setMinimumWidth(w); } void MainWindow::refreshPropertyPanel() { if (!m_bgPropertySection || !m_blackholePropertySection || !m_entityPropertySection || !m_cameraPropertySection || !m_toolPropertySection || !m_hotspotPropertySection || !m_propertyStack) { return; } QTimer::singleShot(0, this, [this]() { updateRightDockColumnMinimumWidthFromContent(); }); if (!m_workspace.isOpen()) { m_bgPropertySection->setProjectClosedAppearance(); m_bgAbsCache.clear(); m_bgSizeTextCache = QStringLiteral("-"); } else if (m_workspace.hasBackground()) { const QString bgAbs = m_workspace.backgroundAbsolutePath(); if (bgAbs != m_bgAbsCache) { m_bgAbsCache = bgAbs; if (!bgAbs.isEmpty() && QFileInfo::exists(bgAbs)) { QSize sz; if (core::image_file::probeImagePixelSize(bgAbs, &sz)) { m_bgSizeTextCache = QStringLiteral("%1 × %2").arg(sz.width()).arg(sz.height()); } else { m_bgSizeTextCache = QStringLiteral("-"); } } else { m_bgSizeTextCache = QStringLiteral("-"); } } m_bgPropertySection->setBackgroundSizeText(m_bgSizeTextCache); m_bgPropertySection->syncBackgroundVisible(m_workspace.project().backgroundVisible(), true); } else { m_bgPropertySection->setBackgroundSizeText(QStringLiteral("-")); m_bgPropertySection->syncBackgroundVisible(true, false); } if (m_editorCanvas) { m_bgPropertySection->syncDepthOverlayChecked(m_editorCanvas->depthOverlayEnabled()); } auto boolFromAnimTrackHold = [&](core::Project::AnimationTargetKind kind, const QString& targetId, core::Project::AnimationProperty prop, int frame, bool fallback) -> bool { if (!m_workspace.isOpen() || targetId.isEmpty()) return fallback; const core::Project::Animation* anim = m_workspace.project().activeAnimationOrNull(); if (!anim) return fallback; bool cur = fallback; int best = -1; for (const auto& t : anim->tracks) { if (t.targetKind != kind || t.targetId != targetId || t.property != prop) continue; for (const auto& k : t.keys) { if (k.frame <= frame && k.frame >= best) { best = k.frame; cur = k.boolValue; } } } return cur; }; const bool hotspotPropUi = m_workspace.isOpen() && m_hotspotRequested && !m_previewRequested; if (hotspotPropUi) { m_cameraPropertySection->clearDisconnected(); bool hasSelectedHotspot = m_selectedHotspotId.isEmpty(); for (const auto& hotspot : m_workspace.project().presentationHotspots()) { if (hotspot.id == m_selectedHotspotId) { hasSelectedHotspot = true; break; } } if (!hasSelectedHotspot) { m_selectedHotspotId.clear(); } m_hotspotPropertySection->applyState(m_workspace.project().presentationHotspots(), m_selectedHotspotId, nonNoneAnimationIdLabelPairsWs(m_workspace.project())); m_propertyStack->setCurrentWidget(m_hotspotPropertySection); m_dockProperties->setWindowTitle(QStringLiteral("属性 — 热点")); return; } const bool cameraUi = currentUiMode() == UiMode::AnimationEditor && m_workspace.project().activeAnimationId() != QStringLiteral("none") && m_hasSelectedCamera && m_workspace.isOpen() && !m_selectedCameraId.isEmpty(); if (cameraUi) { m_entityPropertySection->clearDisconnected(); m_toolPropertySection->clearDisconnected(); gui::CameraPropertyUiState st; const auto rf = core::eval::evaluateAtFrame(m_workspace.project(), m_currentFrame, 10); for (const auto& rc : rf.cameras) { if (rc.camera.id != m_selectedCameraId) { continue; } st.displayName = rc.camera.displayName.isEmpty() ? rc.camera.id : rc.camera.displayName; st.centerWorld = rc.camera.centerWorld; st.viewScale = rc.camera.viewScale; st.isActivePreviewCamera = (m_workspace.project().activeCameraId() == m_selectedCameraId); break; } m_cameraPropertySection->applyState(st); m_propertyStack->setCurrentWidget(m_cameraPropertySection); m_dockProperties->setWindowTitle(QStringLiteral("属性 — 摄像机")); return; } const bool toolUi = m_hasSelectedTool && m_workspace.isOpen() && !m_selectedToolId.isEmpty(); if (toolUi) { m_cameraPropertySection->clearDisconnected(); gui::ToolPropertyUiState st; const int f = std::clamp(m_currentFrame, 0, m_workspace.activeAnimationLengthFrames() - 1); QString parentId; const auto rf = core::eval::evaluateAtFrame(m_workspace.project(), m_currentFrame, 10); for (const auto& rt : rf.tools) { if (rt.tool.id == m_selectedToolId) { st.position = rt.tool.originWorld; parentId = rt.tool.parentId; break; } } for (const auto& t : m_workspace.tools()) { if (t.id == m_selectedToolId) { st.displayName = t.displayName.isEmpty() ? t.id : t.displayName; st.text = t.text; st.priority = t.priority; { const double x = std::clamp(t.bubblePointerT01, 0.0, 1.0) * 1000.0; st.pointerTThousandths = static_cast(x + 0.5); } st.fontPx = t.fontPx; st.alignIndex = (t.align == core::Project::Tool::TextAlign::Left) ? 0 : (t.align == core::Project::Tool::TextAlign::Right) ? 2 : 1; // 可见性:以新动画轨道为准(Hold);若无轨道则回退工具静态 visible st.visible = boolFromAnimTrackHold(core::Project::AnimationTargetKind::Tool, t.id, core::Project::AnimationProperty::Visibility, f, t.visible); break; } } if (!parentId.isEmpty()) { QPointF parentWorld; for (const auto& pe : rf.entities) { if (pe.entity.id == parentId) { parentWorld = pe.entity.originWorld; break; } } if (qFuzzyIsNull(parentWorld.x()) && qFuzzyIsNull(parentWorld.y())) { for (const auto& pt : rf.tools) { if (pt.tool.id == parentId) { parentWorld = pt.tool.originWorld; break; } } } st.position -= parentWorld; st.parentRelativeMode = true; } m_toolPropertySection->applyState(st); m_propertyStack->setCurrentWidget(m_toolPropertySection); m_dockProperties->setWindowTitle(QStringLiteral("属性 — 工具")); return; } const bool holeUi = m_workspace.isOpen() && !m_selectedBlackholeEntityId.isEmpty(); if (holeUi) { m_cameraPropertySection->clearDisconnected(); gui::BlackholePropertyUiState st; for (const auto& e : m_workspace.entities()) { if (e.id != m_selectedBlackholeEntityId) { continue; } st.blackholeName = e.blackholeId.isEmpty() ? QStringLiteral("blackhole-%1").arg(e.id) : e.blackholeId; st.statusText = e.blackholeVisible ? QStringLiteral("否") : QStringLiteral("是"); if (e.blackholeResolvedBy == QStringLiteral("copy_background")) { st.methodText = QStringLiteral("复制背景其他区域"); } else if (e.blackholeResolvedBy == QStringLiteral("use_original_background")) { st.methodText = QStringLiteral("使用原始背景"); } else if (e.blackholeResolvedBy == QStringLiteral("model_inpaint")) { st.methodText = QStringLiteral("模型补全"); } else if (e.blackholeResolvedBy == QStringLiteral("pending")) { st.methodText = QStringLiteral("待选择"); } else { st.methodText = QStringLiteral("未选择"); } break; } m_blackholePropertySection->applyState(st); m_propertyStack->setCurrentWidget(m_blackholePropertySection); m_dockProperties->setWindowTitle(QStringLiteral("属性 — 黑洞")); return; } const bool entUi = m_hasSelectedEntity && m_workspace.isOpen() && !m_selectedEntityId.isEmpty() && m_editorCanvas; if (!entUi) { m_entityPropertySection->clearDisconnected(); m_toolPropertySection->clearDisconnected(); m_cameraPropertySection->clearDisconnected(); m_propertyStack->setCurrentWidget(m_bgPropertySection); m_dockProperties->setWindowTitle(QStringLiteral("属性 — 背景")); return; } m_cameraPropertySection->clearDisconnected(); QString displayName; double userScale = 1.0; bool ignoreDist = false; bool entVisible = true; QString parentId; int priority = 1; core::EntityIntroContent intro; const int f = std::clamp(m_currentFrame, 0, m_workspace.activeAnimationLengthFrames() - 1); for (const auto& e : m_workspace.entities()) { if (e.id == m_selectedEntityId) { displayName = e.displayName; userScale = e.userScale; intro = e.intro; ignoreDist = e.ignoreDistanceScale; parentId = e.parentId; priority = e.priority; // 可见性:以新动画轨道为准(Hold);若无轨道则回退实体静态 visible entVisible = boolFromAnimTrackHold(core::Project::AnimationTargetKind::Entity, e.id, core::Project::AnimationProperty::Visibility, f, e.visible); break; } } m_selectedEntityDisplayNameCache = displayName; const QPointF centroidW = m_editorCanvas->selectedEntityCentroidWorld(); const QPointF pivotW = m_editorCanvas->selectedEntityPivotWorld(); gui::EntityPropertyUiState st; st.displayName = displayName.isEmpty() ? m_selectedEntityId : displayName; st.depthZ = m_selectedEntityDepth; st.distanceScaleText = QStringLiteral("%1(自动)").arg(m_editorCanvas->selectedDistanceScaleMultiplier(), 0, 'f', 3); st.hasParent = !parentId.isEmpty(); st.centerPosition = st.hasParent ? (centroidW - m_workspace.entityCentroidWorldAtFrame(parentId, f)) : centroidW; st.relativeToOwnCenter = pivotW - centroidW; st.priority = priority; st.userScale = userScale; st.ignoreDistanceScale = ignoreDist; st.visible = entVisible; { const QByteArray png = m_workspace.entityImagePngAt(m_selectedEntityId, f); if (!png.isEmpty()) { QImage img; img.loadFromData(png, "PNG"); if (!img.isNull() && img.format() != QImage::Format_ARGB32_Premultiplied) { img = img.convertToFormat(QImage::Format_ARGB32_Premultiplied); } st.spritePreview = img; } } st.intro = intro; m_entityPropertySection->applyState(st); m_propertyStack->setCurrentWidget(m_entityPropertySection); m_dockProperties->setWindowTitle(QStringLiteral("属性 — 实体")); } void MainWindow::refreshEntityPropertyPanelFast() { if (!m_entityPropertySection || !m_propertyStack || !m_editorCanvas) { return; } const bool entUi = m_hasSelectedEntity && m_workspace.isOpen() && !m_selectedEntityId.isEmpty(); if (!entUi) { return; } const int f = std::clamp(m_currentFrame, 0, m_workspace.activeAnimationLengthFrames() - 1); QString parentId; int priority = 1; for (const auto& e : m_workspace.entities()) { if (e.id == m_selectedEntityId) { parentId = e.parentId; priority = e.priority; break; } } const QPointF centroidW = m_editorCanvas->selectedEntityCentroidWorld(); const QPointF pivotW = m_editorCanvas->selectedEntityPivotWorld(); gui::EntityPropertyUiState st; const QString dn = m_selectedEntityDisplayNameCache; st.displayName = dn.isEmpty() ? m_selectedEntityId : dn; st.depthZ = m_selectedEntityDepth; st.distanceScaleText = QStringLiteral("%1(自动)").arg(m_editorCanvas->selectedDistanceScaleMultiplier(), 0, 'f', 3); st.hasParent = !parentId.isEmpty(); st.centerPosition = st.hasParent ? (centroidW - m_workspace.entityCentroidWorldAtFrame(parentId, f)) : centroidW; st.relativeToOwnCenter = pivotW - centroidW; st.priority = priority; st.userScale = m_editorCanvas->selectedUserScale(); { const QByteArray png = m_workspace.entityImagePngAt(m_selectedEntityId, f); if (!png.isEmpty()) { QImage img; img.loadFromData(png, "PNG"); if (!img.isNull() && img.format() != QImage::Format_ARGB32_Premultiplied) { img = img.convertToFormat(QImage::Format_ARGB32_Premultiplied); } st.spritePreview = img; } } // ignoreDistanceScale 在拖动中不变更,fast path 不必更新(避免再遍历 entities) m_entityPropertySection->applyState(st); // 拖动中不切换 stack、不改 dock 标题,避免多余布局开销 } void MainWindow::refreshProjectTree() { if (!m_projectTree) { return; } const bool hotspotPanel = m_workspace.isOpen() && currentUiMode() == UiMode::HotspotEditor; if (m_hotspotAnimationList) { static_cast(m_hotspotAnimationList) ->setAnimations(m_workspace.isOpen() ? nonNoneAnimationIdLabelPairsWs(m_workspace.project()) : QVector>{}); } if (m_rightTopDockStack) { m_rightTopDockStack->setCurrentIndex(hotspotPanel ? 1 : 0); } if (m_dockProjectTree) { m_dockProjectTree->setWindowTitle(hotspotPanel ? QStringLiteral("动画面板") : QStringLiteral("项目树")); } m_projectTree->clear(); const int iconPm = style()->pixelMetric(QStyle::PM_SmallIconSize); const int eyeSide = std::max(24, iconPm + 8); m_itemBackground = new QTreeWidgetItem(m_projectTree); const bool hasBg = m_workspace.isOpen() && m_workspace.hasBackground(); m_itemBackground->setText(1, hasBg ? QStringLiteral("背景") : QStringLiteral("背景(空白)")); m_itemBackground->setTextAlignment(1, Qt::AlignRight | Qt::AlignVCenter); m_itemBackground->setData(0, Qt::UserRole, QStringLiteral("background")); // “眼睛”按钮(固定尺寸,各行一致);canvasTempOnly 表示仅画布临时显隐,与属性里工程可见性无关 auto makeEye = [this, eyeSide, iconPm](bool visible, bool canvasTempOnly = false) -> QToolButton* { auto* btn = new QToolButton(m_projectTree); btn->setObjectName(QStringLiteral("ProjectTreeEyeButton")); btn->setFixedSize(eyeSide, eyeSide); btn->setIconSize(QSize(iconPm, iconPm)); btn->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed); btn->setAutoRaise(true); btn->setCheckable(true); btn->setChecked(visible); btn->setToolTip({}); // 尽量用主题图标,失败则退化为文本 const QIcon onIcon = QIcon::fromTheme(QStringLiteral("view-visible")); const QIcon offIcon = QIcon::fromTheme(QStringLiteral("view-hidden")); if (!onIcon.isNull() && !offIcon.isNull()) { btn->setIcon(visible ? onIcon : offIcon); } else { // 某些 Linux 环境没有主题图标/emoji 字体,使用明确的文字回退,保证可见。 btn->setText(visible ? QStringLiteral("显") : QStringLiteral("隐")); } connect(btn, &QToolButton::toggled, this, [btn, canvasTempOnly](bool on) { Q_UNUSED(canvasTempOnly); btn->setToolTip({}); const QIcon visIcon = QIcon::fromTheme(QStringLiteral("view-visible")); const QIcon hidIcon = QIcon::fromTheme(QStringLiteral("view-hidden")); if (!visIcon.isNull() && !hidIcon.isNull()) { btn->setIcon(on ? visIcon : hidIcon); } else { btn->setText(on ? QStringLiteral("显") : QStringLiteral("隐")); } }); return btn; }; const bool bgVisible = m_workspace.isOpen() ? m_workspace.project().backgroundVisible() : true; auto* bgEye = makeEye(bgVisible); bgEye->setEnabled(hasBg); m_projectTree->setItemWidget(m_itemBackground, 0, bgEye); connect(bgEye, &QToolButton::toggled, this, [this](bool on) { if (!m_workspace.isOpen()) return; if (!m_workspace.setBackgroundVisible(on)) return; refreshEditorPage(); refreshPreviewPage(); }); // 实体 + 工具:支持父子层级显示,同时保持“远到近”顺序(同层级内) QVector sortedEnts; QVector sortedTools; QHash entById; QHash toolById; if (m_workspace.isOpen()) { sortedEnts = m_workspace.entities(); sortedTools = m_workspace.tools(); for (const auto& e : sortedEnts) entById.insert(e.id, e); for (const auto& t : sortedTools) toolById.insert(t.id, t); std::stable_sort(sortedEnts.begin(), sortedEnts.end(), [](const core::Project::Entity& a, const core::Project::Entity& b) { if (a.depth != b.depth) { return a.depth < b.depth; } return a.id < b.id; }); std::stable_sort(sortedTools.begin(), sortedTools.end(), [](const core::Project::Tool& a, const core::Project::Tool& b) { return a.id < b.id; }); } // 黑洞节点:挂在“背景”下,和实体渲染解耦(黑洞可见性独立于实体可见性) QVector blackholeEnts; blackholeEnts.reserve(sortedEnts.size()); for (const auto& e : sortedEnts) { if (!e.cutoutPolygonWorld.isEmpty()) { blackholeEnts.push_back(&e); } } std::stable_sort(blackholeEnts.begin(), blackholeEnts.end(), [](const core::Project::Entity* a, const core::Project::Entity* b) { const QString an = a->displayName.isEmpty() ? a->id : a->displayName; const QString bn = b->displayName.isEmpty() ? b->id : b->displayName; return an < bn; }); for (const auto* e : blackholeEnts) { auto* it = new QTreeWidgetItem(m_itemBackground); const QString base = e->displayName.isEmpty() ? e->id : e->displayName; const QString holeName = e->blackholeId.isEmpty() ? QStringLiteral("blackhole-%1").arg(e->id) : e->blackholeId; it->setText(1, QStringLiteral("黑洞 · %1").arg(base)); it->setToolTip(1, {}); it->setTextAlignment(1, Qt::AlignRight | Qt::AlignVCenter); it->setData(0, Qt::UserRole, QStringLiteral("blackhole")); it->setData(0, Qt::UserRole + 1, e->id); // 绑定实体 id,便于定位 cutout 多边形 it->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled); } struct NodeRef { QString kind; // "entity" / "tool" QString id; }; auto keyOf = [](const QString& kind, const QString& id) -> QString { return kind + QStringLiteral(":") + id; }; QHash> children; children.reserve(sortedEnts.size() + sortedTools.size()); auto parentKeyOrRoot = [&](const QString& pid) -> QString { if (pid.isEmpty()) return QString(); if (entById.contains(pid)) return keyOf(QStringLiteral("entity"), pid); if (toolById.contains(pid)) return keyOf(QStringLiteral("tool"), pid); return QString(); }; for (const auto& e : sortedEnts) { children[parentKeyOrRoot(e.parentId)].push_back(NodeRef{QStringLiteral("entity"), e.id}); } for (const auto& t : sortedTools) { children[parentKeyOrRoot(t.parentId)].push_back(NodeRef{QStringLiteral("tool"), t.id}); } auto makeEntityItem = [&](QTreeWidgetItem* parentItem, const core::Project::Entity& e) -> QTreeWidgetItem* { auto* it = parentItem ? new QTreeWidgetItem(parentItem) : new QTreeWidgetItem(m_projectTree); it->setText(1, e.displayName.isEmpty() ? e.id : e.displayName); it->setTextAlignment(1, Qt::AlignRight | Qt::AlignVCenter); it->setData(0, Qt::UserRole, QStringLiteral("entity")); it->setData(0, Qt::UserRole + 1, e.id); it->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled | Qt::ItemIsDragEnabled | Qt::ItemIsDropEnabled); auto* eye = makeEye(!m_tempHiddenEntityIds.contains(e.id), true); m_projectTree->setItemWidget(it, 0, eye); connect(eye, &QToolButton::toggled, this, [this, id = e.id](bool on) { if (!m_workspace.isOpen()) return; if (on) m_tempHiddenEntityIds.remove(id); else m_tempHiddenEntityIds.insert(id); if (m_editorCanvas) { m_editorCanvas->setTempHiddenIds(m_tempHiddenEntityIds, m_tempHiddenToolIds); } }); return it; }; auto makeToolItem = [&](QTreeWidgetItem* parentItem, const core::Project::Tool& t) -> QTreeWidgetItem* { auto* it = parentItem ? new QTreeWidgetItem(parentItem) : new QTreeWidgetItem(m_projectTree); it->setText(1, t.displayName.isEmpty() ? t.id : t.displayName); it->setTextAlignment(1, Qt::AlignRight | Qt::AlignVCenter); it->setData(0, Qt::UserRole, QStringLiteral("tool")); it->setData(0, Qt::UserRole + 1, t.id); it->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled | Qt::ItemIsDragEnabled | Qt::ItemIsDropEnabled); auto* eye = makeEye(!m_tempHiddenToolIds.contains(t.id), true); m_projectTree->setItemWidget(it, 0, eye); connect(eye, &QToolButton::toggled, this, [this, id = t.id](bool on) { if (!m_workspace.isOpen()) return; if (on) m_tempHiddenToolIds.remove(id); else m_tempHiddenToolIds.insert(id); if (m_editorCanvas) { m_editorCanvas->setTempHiddenIds(m_tempHiddenEntityIds, m_tempHiddenToolIds); } }); return it; }; QSet visiting; std::function addSubtree; addSubtree = [&](const QString& parentKey, QTreeWidgetItem* parentItem) { const auto list = children.value(parentKey); for (const auto& n : list) { const QString nk = keyOf(n.kind, n.id); if (visiting.contains(nk)) { continue; } visiting.insert(nk); QTreeWidgetItem* it = nullptr; if (n.kind == QStringLiteral("entity")) { it = makeEntityItem(parentItem, entById.value(n.id)); } else if (n.kind == QStringLiteral("tool")) { it = makeToolItem(parentItem, toolById.value(n.id)); } if (it) { addSubtree(nk, it); } visiting.remove(nk); } }; addSubtree(QString(), nullptr); if (m_workspace.isOpen() && currentUiMode() == UiMode::AnimationEditor && m_workspace.project().activeAnimationId() != QStringLiteral("none")) { for (const auto& c : m_workspace.cameras()) { auto* it = new QTreeWidgetItem(m_projectTree); it->setText(1, c.displayName.isEmpty() ? QStringLiteral("摄像机") : c.displayName); it->setTextAlignment(1, Qt::AlignRight | Qt::AlignVCenter); it->setData(0, Qt::UserRole, QStringLiteral("camera")); it->setData(0, Qt::UserRole + 1, c.id); it->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled); auto* eye = makeEye(!m_tempHiddenCameraIds.contains(c.id), true); m_projectTree->setItemWidget(it, 0, eye); connect(eye, &QToolButton::toggled, this, [this, id = c.id](bool on) { if (!m_workspace.isOpen()) return; if (on) m_tempHiddenCameraIds.remove(id); else m_tempHiddenCameraIds.insert(id); if (m_editorCanvas) { m_editorCanvas->setTempHiddenCameraIds(m_tempHiddenCameraIds); } }); } } m_projectTree->expandAll(); if (m_projectTree->header()) { m_projectTree->header()->setSectionResizeMode(0, QHeaderView::Fixed); m_projectTree->setColumnWidth(0, eyeSide + 6); m_projectTree->header()->setSectionResizeMode(1, QHeaderView::Stretch); } syncProjectTreeFromCanvasSelection(); QTimer::singleShot(0, this, [this]() { updateRightDockColumnMinimumWidthFromContent(); }); } void MainWindow::syncProjectTreeFromCanvasSelection() { if (!m_projectTree) { return; } m_syncingTreeSelection = true; m_projectTree->blockSignals(true); if ((!m_hasSelectedEntity || m_selectedEntityId.isEmpty()) && (!m_hasSelectedTool || m_selectedToolId.isEmpty()) && (!m_hasSelectedCamera || m_selectedCameraId.isEmpty()) && m_selectedBlackholeEntityId.isEmpty()) { m_projectTree->clearSelection(); } else { QTreeWidgetItem* found = nullptr; for (QTreeWidgetItemIterator it(m_projectTree); *it; ++it) { QTreeWidgetItem* node = *it; const QString kind = node->data(0, Qt::UserRole).toString(); const QString id = node->data(0, Qt::UserRole + 1).toString(); if (m_hasSelectedEntity && !m_selectedEntityId.isEmpty() && kind == QStringLiteral("entity") && id == m_selectedEntityId) { found = node; break; } if (m_hasSelectedTool && !m_selectedToolId.isEmpty() && kind == QStringLiteral("tool") && id == m_selectedToolId) { found = node; break; } if (m_hasSelectedCamera && !m_selectedCameraId.isEmpty() && kind == QStringLiteral("camera") && id == m_selectedCameraId) { found = node; break; } if (!m_selectedBlackholeEntityId.isEmpty() && kind == QStringLiteral("blackhole") && id == m_selectedBlackholeEntityId) { found = node; break; } } if (found) { m_projectTree->setCurrentItem(found); m_projectTree->scrollToItem(found); } else { m_projectTree->clearSelection(); } } m_projectTree->blockSignals(false); m_syncingTreeSelection = false; } void MainWindow::onProjectTreeItemClicked(QTreeWidgetItem* item, int column) { Q_UNUSED(column); if (!item || m_syncingTreeSelection || !m_editorCanvas || !m_workspace.isOpen()) { return; } const QString kind = item->data(0, Qt::UserRole).toString(); if (kind == QStringLiteral("entity")) { const QString id = item->data(0, Qt::UserRole + 1).toString(); if (!id.isEmpty()) { m_selectedBlackholeEntityId.clear(); if (m_editorCanvas) { m_editorCanvas->clearBlackholeSelection(); } m_hasSelectedTool = false; m_selectedToolId.clear(); m_hasSelectedCamera = false; m_selectedCameraId.clear(); if (m_timeline) { m_timeline->setToolKeyframeTracks({}, {}); } if (m_editorCanvas) { m_editorCanvas->clearCameraSelection(); } m_editorCanvas->selectEntityById(id); } } else if (kind == QStringLiteral("tool")) { const QString id = item->data(0, Qt::UserRole + 1).toString(); if (!id.isEmpty()) { m_selectedBlackholeEntityId.clear(); if (m_editorCanvas) { m_editorCanvas->clearBlackholeSelection(); } m_hasSelectedTool = true; m_selectedToolId = id; m_hasSelectedEntity = false; m_selectedEntityId.clear(); m_hasSelectedCamera = false; m_selectedCameraId.clear(); if (m_editorCanvas) { m_editorCanvas->clearEntitySelection(); m_editorCanvas->clearCameraSelection(); } if (m_timeline) { updateTimelineTracks(); } refreshPropertyPanel(); } } else if (kind == QStringLiteral("camera")) { const QString id = item->data(0, Qt::UserRole + 1).toString(); if (!id.isEmpty()) { m_selectedBlackholeEntityId.clear(); if (m_editorCanvas) { m_editorCanvas->clearBlackholeSelection(); } m_hasSelectedTool = false; m_selectedToolId.clear(); m_hasSelectedEntity = false; m_selectedEntityId.clear(); m_hasSelectedCamera = true; m_selectedCameraId = id; if (m_editorCanvas) { m_editorCanvas->clearEntitySelection(); m_editorCanvas->selectCameraById(id); } if (m_timeline) { updateTimelineTracks(); } refreshPropertyPanel(); } } else if (kind == QStringLiteral("blackhole")) { const QString entityId = item->data(0, Qt::UserRole + 1).toString(); if (!entityId.isEmpty()) { m_selectedBlackholeEntityId = entityId; m_hasSelectedTool = false; m_selectedToolId.clear(); m_hasSelectedEntity = false; m_selectedEntityId.clear(); m_selectedEntityDisplayNameCache.clear(); m_hasSelectedCamera = false; m_selectedCameraId.clear(); if (m_editorCanvas) { m_editorCanvas->clearEntitySelection(); m_editorCanvas->clearCameraSelection(); m_editorCanvas->selectBlackholeByEntityId(entityId); } updateTimelineTracks(); refreshPropertyPanel(); } } else if (kind == QStringLiteral("background")) { m_selectedBlackholeEntityId.clear(); m_hasSelectedTool = false; m_selectedToolId.clear(); m_hasSelectedCamera = false; m_selectedCameraId.clear(); m_editorCanvas->clearEntitySelection(); m_editorCanvas->clearBlackholeSelection(); m_editorCanvas->clearCameraSelection(); updateTimelineTracks(); } } void MainWindow::updateUiEnabledState() { const bool projectOpen = m_workspace.isOpen(); const bool hasBg = projectOpen && m_workspace.hasBackground(); const bool hasDepth = projectOpen && m_workspace.hasDepth(); if (!(projectOpen && hasBg) && m_previewRequested) { m_previewRequested = false; } if (!(projectOpen && hasBg) && m_hotspotRequested) { m_hotspotRequested = false; } // 背景为空白时:禁止除“设置背景”外的其它操作 if (m_actionUndo) m_actionUndo->setEnabled(projectOpen && hasBg && m_workspace.canUndo()); if (m_actionRedo) m_actionRedo->setEnabled(projectOpen && hasBg && m_workspace.canRedo()); if (m_actionCopy) m_actionCopy->setEnabled(projectOpen && hasBg); if (m_actionPaste) m_actionPaste->setEnabled(projectOpen && hasBg); if (m_actionEnterPreview) m_actionEnterPreview->setEnabled(projectOpen && hasBg && !m_previewRequested); if (m_actionBackToEditor) m_actionBackToEditor->setEnabled(projectOpen && m_previewRequested); if (m_actionAnimationSettings) m_actionAnimationSettings->setEnabled(projectOpen && hasBg); if (m_btnToggleDepthOverlay) m_btnToggleDepthOverlay->setEnabled(hasDepth); if (m_bgPropertySection) m_bgPropertySection->setDepthOverlayCheckEnabled(hasDepth); // 创建实体不强依赖深度(无深度时 depth=0),但更推荐先算深度 if (m_btnCreateEntity) m_btnCreateEntity->setEnabled(hasBg); if (m_editorCanvas) { // 门禁:没有深度时只强制关闭叠加;创建实体仍允许 if (!hasDepth && m_editorCanvas->depthOverlayEnabled()) { m_editorCanvas->setDepthOverlayEnabled(false); if (m_btnToggleDepthOverlay) { m_btnToggleDepthOverlay->blockSignals(true); m_btnToggleDepthOverlay->setChecked(false); m_btnToggleDepthOverlay->blockSignals(false); } if (m_bgPropertySection) { m_bgPropertySection->syncDepthOverlayChecked(false); } if (m_actionCanvasDepthOverlay) { m_actionCanvasDepthOverlay->blockSignals(true); m_actionCanvasDepthOverlay->setChecked(false); m_actionCanvasDepthOverlay->blockSignals(false); } } } if (m_modeSelector) { m_modeSelector->setEnabled(projectOpen); m_modeSelector->blockSignals(true); int mix = 0; if (m_previewRequested) mix = 3; else if (m_animationRequested) mix = 1; else if (m_hotspotRequested) mix = 2; m_modeSelector->setCurrentIndex(mix); m_modeSelector->setItemData(0, projectOpen ? QVariant() : QVariant(0), Qt::UserRole - 1); m_modeSelector->setItemData(1, projectOpen ? QVariant() : QVariant(0), Qt::UserRole - 1); m_modeSelector->setItemData(2, (projectOpen && hasBg) ? QVariant() : QVariant(0), Qt::UserRole - 1); m_modeSelector->setItemData(3, (projectOpen && hasBg) ? QVariant() : QVariant(0), Qt::UserRole - 1); m_modeSelector->blockSignals(false); } if (m_previewBackToCanvasBtn) { m_previewBackToCanvasBtn->setVisible(m_previewRequested && projectOpen && effectivePreviewAnimationId() != QStringLiteral("none")); } if (projectOpen && hasBg) { syncHotspotEnterPreviewCombo(); } // 统一套用两态 UI 策略(欢迎/编辑) applyUiMode(currentUiMode()); const bool editorCanvasUi = projectOpen && !m_previewRequested; if (m_actionCanvasWorldAxes) m_actionCanvasWorldAxes->setEnabled(editorCanvasUi); if (m_actionCanvasAxisValues) { m_actionCanvasAxisValues->setEnabled(editorCanvasUi && m_editorCanvas && m_editorCanvas->worldAxesVisible()); } if (m_actionCanvasGrid) m_actionCanvasGrid->setEnabled(editorCanvasUi); if (m_actionCanvasCheckerboard) m_actionCanvasCheckerboard->setEnabled(editorCanvasUi); if (m_actionCanvasDepthOverlay) m_actionCanvasDepthOverlay->setEnabled(editorCanvasUi && hasDepth); if (m_actionCanvasGizmoLabels) m_actionCanvasGizmoLabels->setEnabled(editorCanvasUi); syncCanvasViewMenuFromState(); } void MainWindow::syncCanvasViewMenuFromState() { if (!m_editorCanvas) { return; } const auto syncCheck = [](QAction* a, bool checked) { if (!a) { return; } a->blockSignals(true); a->setChecked(checked); a->blockSignals(false); }; syncCheck(m_actionCanvasWorldAxes, m_editorCanvas->worldAxesVisible()); syncCheck(m_actionCanvasAxisValues, m_editorCanvas->axisLabelsVisible()); syncCheck(m_actionCanvasGrid, m_editorCanvas->gridVisible()); syncCheck(m_actionCanvasCheckerboard, m_editorCanvas->checkerboardVisible()); syncCheck(m_actionCanvasDepthOverlay, m_editorCanvas->depthOverlayEnabled()); syncCheck(m_actionCanvasGizmoLabels, m_editorCanvas->gizmoLabelsVisible()); if (m_bgPropertySection) { m_bgPropertySection->syncDepthOverlayChecked(m_editorCanvas->depthOverlayEnabled()); } } MainWindow::UiMode MainWindow::currentUiMode() const { if (!m_workspace.isOpen()) { return UiMode::Welcome; } if (m_previewRequested && m_workspace.hasBackground()) { return UiMode::Preview; } if (m_animationRequested) { return UiMode::AnimationEditor; } if (m_hotspotRequested && m_workspace.hasBackground()) { return UiMode::HotspotEditor; } return UiMode::EntityEditor; } void MainWindow::applyUiMode(UiMode mode) { const bool projectOpen = (mode == UiMode::EntityEditor || mode == UiMode::AnimationEditor || mode == UiMode::HotspotEditor || mode == UiMode::Preview); const bool preview = (mode == UiMode::Preview); const bool animationEditor = (mode == UiMode::AnimationEditor); // 中央页面:欢迎 / 工作区(编辑与预览共用同一画布,仅状态不同) if (!projectOpen) { showWelcomePage(); } else { showEditorPage(); refreshEditorPage(); } // Dock 显隐策略: // - Welcome:所有 dock 必须隐藏,确保“未打开项目时只显示欢迎界面” // - Entity/Animation/Hotspot:显示编辑相关 dock // - Preview:默认隐藏 dock,提供“纯展示”视图 if (m_dockProjectTree) { if (!projectOpen || preview) { m_dockProjectTree->setVisible(false); if (!projectOpen) { m_rightDocksNarrowHidden = false; } } else if (m_rightDocksNarrowHidden) { m_dockProjectTree->setVisible(false); } else { m_dockProjectTree->setVisible(true); } } if (m_dockProperties) { if (!projectOpen || preview) { m_dockProperties->setVisible(false); } else if (m_rightDocksNarrowHidden) { m_dockProperties->setVisible(false); } else { m_dockProperties->setVisible(true); } } if (m_dockTimeline) { m_dockTimeline->setVisible(projectOpen && !preview && animationEditor); } if (m_dockResourceLibrary) { // Preview 维持“纯展示”,Welcome 也隐藏;Editor 允许用户手动打开 if (!projectOpen || preview) { m_dockResourceLibrary->setVisible(false); } } if (m_floatingModeDock) { m_floatingModeDock->setVisible(projectOpen); } if (m_floatingToolDock) { m_floatingToolDock->setVisible(projectOpen && !preview); } syncEditorRailForMode(); if (m_previewPlaybackBar) { m_previewPlaybackBar->setVisible(projectOpen && preview); } if (!preview || !projectOpen) { if (m_previewArrowLeft) { m_previewArrowLeft->hide(); } if (m_previewArrowRight) { m_previewArrowRight->hide(); } } if (m_canvasHost) { m_canvasHost->updateGeometry(); static_cast(m_canvasHost)->relayoutFloaters(); m_canvasHost->update(); } // 视图菜单开关: // - Welcome:禁用并强制取消勾选(避免用户把 dock 再显示出来) // - Editor:启用,并与 dock 可见性保持一致 // - Preview:仍允许切回编辑(通过预览开关),但 dock 开关禁用以保持展示简洁 if (m_actionToggleProjectTree) { m_actionToggleProjectTree->setEnabled(projectOpen && !preview); m_actionToggleProjectTree->blockSignals(true); m_actionToggleProjectTree->setChecked(projectOpen && !preview && m_dockProjectTree && m_dockProjectTree->isVisible()); m_actionToggleProjectTree->blockSignals(false); } if (m_actionToggleProperties) { m_actionToggleProperties->setEnabled(projectOpen && !preview); m_actionToggleProperties->blockSignals(true); m_actionToggleProperties->setChecked(projectOpen && !preview && m_dockProperties && m_dockProperties->isVisible()); m_actionToggleProperties->blockSignals(false); } if (m_actionToggleTimeline) { m_actionToggleTimeline->setEnabled(projectOpen && !preview && animationEditor); m_actionToggleTimeline->blockSignals(true); m_actionToggleTimeline->setChecked(projectOpen && !preview && animationEditor && m_dockTimeline && m_dockTimeline->isVisible()); m_actionToggleTimeline->blockSignals(false); } if (m_actionToggleResourceLibrary) { m_actionToggleResourceLibrary->setEnabled(projectOpen && !preview); m_actionToggleResourceLibrary->blockSignals(true); m_actionToggleResourceLibrary->setChecked(projectOpen && !preview && m_dockResourceLibrary && m_dockResourceLibrary->isVisible()); m_actionToggleResourceLibrary->blockSignals(false); } } void MainWindow::showProjectRootContextMenu(const QPoint& globalPos) { QMenu menu(this); QAction* actRename = menu.addAction(QStringLiteral("重命名项目…")); menu.addSeparator(); QAction* actPreview = menu.addAction(QStringLiteral("进入预览")); QAction* actBack = menu.addAction(QStringLiteral("返回编辑")); const bool canPreview = m_workspace.isOpen() && m_workspace.hasBackground(); actPreview->setEnabled(canPreview); actBack->setEnabled(m_previewRequested); QAction* chosen = execPopupMenuAboveFullscreen(menu, globalPos, this); if (!chosen) { return; } if (chosen == actRename) { bool ok = false; const QString cur = m_workspace.project().name(); const QString t = QInputDialog::getText(this, QStringLiteral("重命名项目"), QStringLiteral("项目名称:"), QLineEdit::Normal, cur, &ok); if (!ok) { return; } if (!m_workspace.setProjectTitle(t)) { QMessageBox::warning(this, QStringLiteral("重命名"), QStringLiteral("名称无效或保存失败。")); return; } refreshProjectTree(); refreshPropertyPanel(); updateUiEnabledState(); return; } if (chosen == actPreview) { m_animationRequested = false; m_hotspotRequested = false; m_previewAnimationId = QStringLiteral("none"); m_previewFrame = 0; if (m_modeSelector) { m_modeSelector->blockSignals(true); m_modeSelector->setCurrentIndex(3); m_modeSelector->blockSignals(false); } setPreviewRequested(true); } else if (chosen == actBack) { m_animationRequested = false; m_hotspotRequested = false; if (m_modeSelector) { m_modeSelector->blockSignals(true); m_modeSelector->setCurrentIndex(0); m_modeSelector->blockSignals(false); } setPreviewRequested(false); } } void MainWindow::rebuildCentralPages() { m_centerStack = new QStackedWidget(this); auto* centerShell = new QWidget(this); auto* shellLayout = new QVBoxLayout(centerShell); shellLayout->setContentsMargins(0, 0, 0, 0); shellLayout->setSpacing(0); shellLayout->addWidget(m_centerStack, 1); // 欢迎页(两张 card:左侧快速开始 + 右侧最近项目) m_pageWelcome = new QWidget(m_centerStack); m_pageWelcome->setObjectName(QStringLiteral("WelcomePage")); auto* welcomeRoot = new QHBoxLayout(m_pageWelcome); welcomeRoot->setContentsMargins(28, 28, 28, 28); welcomeRoot->setSpacing(18); auto* leftCard = new QFrame(m_pageWelcome); leftCard->setObjectName(QStringLiteral("WelcomeCard")); leftCard->setFrameShape(QFrame::NoFrame); leftCard->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Preferred); leftCard->setMinimumWidth(360); leftCard->setMaximumWidth(460); auto* leftOuter = new QVBoxLayout(leftCard); leftOuter->setContentsMargins(18, 18, 18, 18); leftOuter->setSpacing(12); auto* title = new QLabel(QStringLiteral("欢迎使用"), leftCard); title->setObjectName(QStringLiteral("WelcomeTitle")); leftOuter->addWidget(title); auto* desc = new QLabel(QStringLiteral("创建或打开一个项目以开始编辑。"), leftCard); desc->setObjectName(QStringLiteral("WelcomeDesc")); desc->setWordWrap(true); leftOuter->addWidget(desc); auto* buttonsRow = new QHBoxLayout(); buttonsRow->setSpacing(10); auto* btnCreate = new QPushButton(QStringLiteral("创建项目"), leftCard); btnCreate->setObjectName(QStringLiteral("WelcomePrimaryButton")); auto* btnOpen = new QPushButton(QStringLiteral("打开项目"), leftCard); buttonsRow->addWidget(btnCreate); buttonsRow->addWidget(btnOpen); buttonsRow->addStretch(1); leftOuter->addLayout(buttonsRow); leftOuter->addStretch(1); connect(btnCreate, &QPushButton::clicked, this, &MainWindow::onNewProject); connect(btnOpen, &QPushButton::clicked, this, &MainWindow::onOpenProject); auto* recentFrame = new QFrame(m_pageWelcome); recentFrame->setObjectName(QStringLiteral("WelcomeCard")); recentFrame->setFrameShape(QFrame::NoFrame); auto* recentOuter = new QVBoxLayout(recentFrame); recentOuter->setContentsMargins(18, 18, 18, 18); recentOuter->setSpacing(10); auto* recentTitle = new QLabel(QStringLiteral("最近的项目"), recentFrame); recentTitle->setObjectName(QStringLiteral("WelcomeSectionTitle")); recentOuter->addWidget(recentTitle); m_welcomeRecentEmptyLabel = new QLabel(QStringLiteral("暂无最近打开的项目"), recentFrame); m_welcomeRecentEmptyLabel->setObjectName(QStringLiteral("WelcomeHint")); m_welcomeRecentEmptyLabel->setWordWrap(true); recentOuter->addWidget(m_welcomeRecentEmptyLabel); m_welcomeRecentTree = new QTreeWidget(recentFrame); m_welcomeRecentTree->setHeaderLabels({QStringLiteral("项目名称"), QStringLiteral("路径")}); m_welcomeRecentTree->setRootIsDecorated(false); m_welcomeRecentTree->setAlternatingRowColors(true); m_welcomeRecentTree->setSelectionMode(QAbstractItemView::SingleSelection); m_welcomeRecentTree->setSelectionBehavior(QAbstractItemView::SelectRows); m_welcomeRecentTree->setUniformRowHeights(true); m_welcomeRecentTree->setContextMenuPolicy(Qt::CustomContextMenu); m_welcomeRecentTree->header()->setStretchLastSection(true); m_welcomeRecentTree->header()->setSectionResizeMode(0, QHeaderView::ResizeToContents); m_welcomeRecentTree->header()->setSectionResizeMode(1, QHeaderView::Stretch); recentOuter->addWidget(m_welcomeRecentTree, 1); connect(m_welcomeRecentTree, &QTreeWidget::itemDoubleClicked, this, [this](QTreeWidgetItem* item, int) { if (!item) { return; } const QString path = item->data(0, Qt::UserRole).toString(); if (!path.isEmpty()) { openProjectFromPath(path); } }); connect(m_welcomeRecentTree, &QWidget::customContextMenuRequested, this, [this](const QPoint& pos) { QTreeWidgetItem* item = m_welcomeRecentTree->itemAt(pos); if (!item) { return; } QMenu menu(this); QAction* actRemove = menu.addAction(QStringLiteral("从列表中移除")); QAction* chosen = execPopupMenuAboveFullscreen(menu, m_welcomeRecentTree->viewport()->mapToGlobal(pos), this); if (chosen == actRemove) { const QString path = item->data(0, Qt::UserRole).toString(); if (!path.isEmpty()) { m_recentHistory.removeAndSave(path); refreshWelcomeRecentList(); } } }); welcomeRoot->addWidget(leftCard, 0); welcomeRoot->addWidget(recentFrame, 1); // 工作区:全屏画布 + 左上角浮动模式切换 + 左侧浮动工具栏 auto* canvasHost = new CanvasHost(m_centerStack); m_pageEditor = canvasHost; m_canvasHost = canvasHost; m_editorCanvas = new EditorCanvas(canvasHost); canvasHost->canvas = m_editorCanvas; m_entityIntroPopup = new gui::EntityIntroPopup(this); m_previewArrowLeft = new QToolButton(canvasHost); m_previewArrowLeft->setText(QStringLiteral("◀")); m_previewArrowLeft->setToolTip({}); polishCompactToolButton(m_previewArrowLeft, 44); m_previewArrowLeft->setStyleSheet( QStringLiteral("QToolButton { background-color: rgba(40,40,40,168); border-radius: 8px; color: palette(text); " "padding: 4px 8px; } QToolButton:hover { background-color: rgba(60,60,60,208); }")); m_previewArrowLeft->hide(); connect(m_previewArrowLeft, &QToolButton::clicked, this, [this]() { switchPreviewSchemeAdjacent(-1); }); m_previewArrowRight = new QToolButton(canvasHost); m_previewArrowRight->setText(QStringLiteral("▶")); m_previewArrowRight->setToolTip({}); polishCompactToolButton(m_previewArrowRight, 44); m_previewArrowRight->setStyleSheet( QStringLiteral("QToolButton { background-color: rgba(40,40,40,168); border-radius: 8px; color: palette(text); " "padding: 4px 8px; } QToolButton:hover { background-color: rgba(60,60,60,208); }")); m_previewArrowRight->hide(); connect(m_previewArrowRight, &QToolButton::clicked, this, [this]() { switchPreviewSchemeAdjacent(1); }); canvasHost->previewArrowLeft = m_previewArrowLeft; canvasHost->previewArrowRight = m_previewArrowRight; m_previewPlaybackBar = new QFrame(canvasHost); m_previewPlaybackBar->setObjectName(QStringLiteral("PreviewPlaybackBar")); m_previewPlaybackBar->setStyleSheet(QString::fromUtf8(kTimelineBarQss)); auto* pbl = new QHBoxLayout(m_previewPlaybackBar); pbl->setContentsMargins(10, 6, 10, 6); pbl->setSpacing(8); m_previewSchemeCombo = new QComboBox(m_previewPlaybackBar); m_previewSchemeCombo->setMinimumWidth(160); pbl->addWidget(m_previewSchemeCombo); m_previewBtnPlay = new QToolButton(m_previewPlaybackBar); m_previewBtnPlay->setCheckable(false); m_previewBtnPlay->setText(QStringLiteral("播放")); m_previewBtnPlay->setToolTip({}); polishCompactToolButton(m_previewBtnPlay, 36); pbl->addWidget(m_previewBtnPlay); m_previewPlaybackBar->setParent(canvasHost); m_floatingModeDock = new QFrame(canvasHost); m_floatingModeDock->setObjectName(QStringLiteral("FloatingModeDock")); m_floatingModeDock->setFrameShape(QFrame::NoFrame); m_floatingModeDock->setStyleSheet(QString::fromUtf8(kFloatingModeDockQss)); auto* modeDockLayout = new QHBoxLayout(m_floatingModeDock); modeDockLayout->setContentsMargins(4, 4, 4, 4); modeDockLayout->setSpacing(6); m_modeSelector = new QComboBox(m_floatingModeDock); m_modeSelector->addItem(QStringLiteral("编辑")); m_modeSelector->addItem(QStringLiteral("动画")); m_modeSelector->addItem(QStringLiteral("热点")); m_modeSelector->addItem(QStringLiteral("预览")); connect(m_modeSelector, QOverload::of(&QComboBox::currentIndexChanged), this, [this](int index) { if (index == 0) { m_animationRequested = false; m_hotspotRequested = false; setPreviewRequested(false); if (m_workspace.isOpen() && m_workspace.project().activeAnimationId() != QStringLiteral("none")) { m_workspace.project().setActiveAnimationId(QStringLiteral("none")); m_workspace.save(); } if (m_editorCanvas) { m_editorCanvas->setTool(EditorCanvas::Tool::Move); m_editorCanvas->clearCameraSelection(); } if (m_railBtnMove) { m_railBtnMove->setChecked(true); } } else if (index == 1) { m_animationRequested = true; m_hotspotRequested = false; setPreviewRequested(false); if (m_workspace.isOpen() && m_workspace.project().activeAnimationId() == QStringLiteral("none")) { const QString firstAnim = firstAnimationSchemeIdNonNoneWs(m_workspace.project()); if (!firstAnim.isEmpty()) { m_workspace.project().setActiveAnimationId(firstAnim); m_workspace.save(); } } if (m_editorCanvas) { m_editorCanvas->setTool(EditorCanvas::Tool::Move); } if (m_railBtnMove) { m_railBtnMove->setChecked(true); } } else if (index == 2) { m_animationRequested = false; m_hotspotRequested = true; setPreviewRequested(false); if (m_workspace.isOpen() && m_workspace.project().activeAnimationId() != QStringLiteral("none")) { m_workspace.project().setActiveAnimationId(QStringLiteral("none")); m_workspace.save(); } syncHotspotEnterPreviewCombo(); if (m_editorCanvas) { m_editorCanvas->clearPresentationEntityFocus(); m_editorCanvas->setTool(EditorCanvas::Tool::Move); m_editorCanvas->clearCameraSelection(); } if (m_railBtnMove) { m_railBtnMove->setChecked(true); } } else { m_animationRequested = false; m_hotspotRequested = false; m_previewAnimationId = QStringLiteral("none"); m_previewFrame = 0; setPreviewRequested(true); } syncEditorRailForMode(); updateUiEnabledState(); refreshEditorPage(); refreshProjectTree(); }); modeDockLayout->addWidget(m_modeSelector); canvasHost->modeDock = m_floatingModeDock; m_floatingToolDock = new QFrame(canvasHost); m_floatingToolDock->setObjectName(QStringLiteral("EditorToolRail")); m_floatingToolDock->setFrameShape(QFrame::NoFrame); m_floatingToolDock->setStyleSheet(QString::fromUtf8(kEditorToolRailQss)); m_floatingToolDock->setFixedWidth(40); auto* toolLayout = new QVBoxLayout(m_floatingToolDock); toolLayout->setContentsMargins(1, 2, 1, 2); toolLayout->setSpacing(2); auto* group = new QButtonGroup(m_floatingToolDock); group->setExclusive(true); m_railBtnMove = new QToolButton(m_floatingToolDock); m_railBtnMove->setCheckable(true); m_railBtnMove->setChecked(true); setToolButtonIconOrText(m_railBtnMove, QStringLiteral("transform-move"), QStringLiteral("移")); m_railBtnMove->setToolTip({}); polishCompactToolButton(m_railBtnMove, 30); toolLayout->addWidget(m_railBtnMove, 0, Qt::AlignHCenter); group->addButton(m_railBtnMove, static_cast(EditorCanvas::Tool::Move)); m_railBtnZoom = new QToolButton(m_floatingToolDock); m_railBtnZoom->setCheckable(true); setToolButtonIconOrText(m_railBtnZoom, QStringLiteral("zoom-in"), QStringLiteral("放")); m_railBtnZoom->setToolTip({}); polishCompactToolButton(m_railBtnZoom, 30); toolLayout->addWidget(m_railBtnZoom, 0, Qt::AlignHCenter); group->addButton(m_railBtnZoom, static_cast(EditorCanvas::Tool::Zoom)); m_btnCreateEntity = new QToolButton(m_floatingToolDock); m_btnCreateEntity->setCheckable(true); setToolButtonIconOrText(m_btnCreateEntity, QStringLiteral("draw-brush"), QStringLiteral("创")); m_btnCreateEntity->setToolTip({}); polishCompactToolButton(m_btnCreateEntity, 30); toolLayout->addWidget(m_btnCreateEntity, 0, Qt::AlignHCenter); group->addButton(m_btnCreateEntity, static_cast(EditorCanvas::Tool::CreateEntity)); if (!m_createEntityPopup) { m_createEntityPopup = new ToolOptionPopup(this); m_createEntityPopup->setOptions({ {static_cast(EditorCanvas::EntityCreateSegmentMode::Manual), QStringLiteral("手动分割")}, {static_cast(EditorCanvas::EntityCreateSegmentMode::Snap), QStringLiteral("吸附分割")}, {static_cast(EditorCanvas::EntityCreateSegmentMode::Sam), QStringLiteral("模型分割")}, }); connect(m_createEntityPopup, &ToolOptionPopup::optionChosen, this, [this](int id) { if (!m_editorCanvas) return; m_editorCanvas->setEntityCreateSegmentMode(static_cast(id)); syncCreateEntityToolButtonTooltip(); }); } connect(m_btnCreateEntity, &QToolButton::clicked, this, [this]() { if (!m_editorCanvas || !m_btnCreateEntity) return; if (m_btnCreateEntity->isChecked() && m_editorCanvas->tool() == EditorCanvas::Tool::CreateEntity) { // 已选中时再次单击:弹出选择面板 if (m_createEntityPopup) { m_createEntityPopup->popupNearToolButton(m_btnCreateEntity); } } }); syncCreateEntityToolButtonTooltip(); m_btnToggleDepthOverlay = new QToolButton(m_floatingToolDock); m_btnToggleDepthOverlay->setCheckable(true); m_btnToggleDepthOverlay->setChecked(false); setToolButtonIconOrText(m_btnToggleDepthOverlay, QStringLiteral("color-profile"), QStringLiteral("深")); m_btnToggleDepthOverlay->setToolTip({}); polishCompactToolButton(m_btnToggleDepthOverlay, 30); toolLayout->addWidget(m_btnToggleDepthOverlay, 0, Qt::AlignHCenter); m_railBtnFit = new QToolButton(m_floatingToolDock); setToolButtonIconOrText(m_railBtnFit, QStringLiteral("zoom-fit-best"), QStringLiteral("框")); m_railBtnFit->setToolTip({}); polishCompactToolButton(m_railBtnFit, 30); toolLayout->addWidget(m_railBtnFit, 0, Qt::AlignHCenter); m_btnRailAddHotspot = new QToolButton(m_floatingToolDock); m_btnRailAddHotspot->setCheckable(true); setToolButtonIconOrText(m_btnRailAddHotspot, QStringLiteral("list-add"), QStringLiteral("+")); m_btnRailAddHotspot->setToolTip({}); polishCompactToolButton(m_btnRailAddHotspot, 30); toolLayout->addWidget(m_btnRailAddHotspot, 0, Qt::AlignHCenter); group->addButton(m_btnRailAddHotspot, static_cast(EditorCanvas::Tool::AddHotspot)); m_btnRailMoveHotspot = new QToolButton(m_floatingToolDock); m_btnRailMoveHotspot->setCheckable(true); setToolButtonIconOrText(m_btnRailMoveHotspot, QStringLiteral("transform-move"), QStringLiteral("点")); m_btnRailMoveHotspot->setToolTip({}); polishCompactToolButton(m_btnRailMoveHotspot, 30); toolLayout->addWidget(m_btnRailMoveHotspot, 0, Qt::AlignHCenter); group->addButton(m_btnRailMoveHotspot, static_cast(EditorCanvas::Tool::MoveHotspot)); m_btnHotspotEnterPreview = new QToolButton(m_floatingToolDock); m_btnHotspotEnterPreview->setText(QStringLiteral("预览")); m_btnHotspotEnterPreview->setVisible(false); m_btnHotspotEnterPreview->setToolTip({}); polishCompactToolButton(m_btnHotspotEnterPreview, 30); toolLayout->addWidget(m_btnHotspotEnterPreview, 0, Qt::AlignHCenter); toolLayout->addStretch(1); canvasHost->toolDock = m_floatingToolDock; m_floatingModeDock->setParent(canvasHost); m_floatingToolDock->setParent(canvasHost); m_editorCanvas->setMouseTracking(true); m_canvasHost->setMouseTracking(true); m_editorCanvas->installEventFilter(this); m_editorCanvas->setParent(canvasHost); m_floatingToolDock->raise(); m_floatingModeDock->raise(); connect(m_editorCanvas, &EditorCanvas::hoveredWorldPosChanged, this, [this](const QPointF& p) { m_lastWorldPos = p; updateStatusBarText(); }); connect(m_editorCanvas, &EditorCanvas::hoveredWorldPosDepthChanged, this, [this](const QPointF& p, int z) { m_lastWorldPos = p; m_lastWorldZ = z; updateStatusBarText(); }); connect(m_editorCanvas, &EditorCanvas::selectedEntityChanged, this, [this](bool hasSel, const QString& id, int depth, const QPointF& origin) { if (hasSel && !id.isEmpty()) { m_selectedHotspotId.clear(); if (m_editorCanvas) { m_editorCanvas->clearPresentationHotspotSelection(); } } m_hasSelectedEntity = hasSel; m_selectedEntityId = id; m_selectedBlackholeEntityId.clear(); m_selectedEntityDepth = depth; m_selectedEntityOrigin = origin; m_hasSelectedTool = false; m_selectedToolId.clear(); m_hasSelectedCamera = false; m_selectedCameraId.clear(); if (hasSel && !id.isEmpty()) { for (const auto& e : m_workspace.entities()) { if (e.id == id) { m_selectedEntityDisplayNameCache = e.displayName; break; } } } updateTimelineTracks(); if (!m_timelineScrubbing) { updateStatusBarText(); refreshPropertyPanel(); syncProjectTreeFromCanvasSelection(); } }); connect(m_editorCanvas, &EditorCanvas::selectedToolChanged, this, [this](bool hasSel, const QString& id, const QPointF& origin) { Q_UNUSED(origin); if (hasSel && !id.isEmpty()) { m_selectedHotspotId.clear(); if (m_editorCanvas) { m_editorCanvas->clearPresentationHotspotSelection(); } } m_hasSelectedTool = hasSel; m_selectedToolId = id; m_selectedBlackholeEntityId.clear(); if (hasSel) { m_hasSelectedEntity = false; m_selectedEntityId.clear(); m_selectedEntityDisplayNameCache.clear(); m_hasSelectedCamera = false; m_selectedCameraId.clear(); } updateTimelineTracks(); if (!m_timelineScrubbing) { updateStatusBarText(); refreshPropertyPanel(); syncProjectTreeFromCanvasSelection(); } }); connect(m_editorCanvas, &EditorCanvas::entityDragActiveChanged, this, [this](bool on) { m_entityDragging = on; if (on && m_btnPlay && m_btnPlay->isChecked()) { // 拖动实体时自动暂停,避免播放驱动时间轴刷新干扰拖动 m_btnPlay->setChecked(false); } }); connect(m_editorCanvas, &EditorCanvas::selectedEntityPreviewChanged, this, [this](const QString& id, int depth, const QPointF& origin) { if (id.isEmpty() || !m_workspace.isOpen() || !m_entityPropertySection) { return; } // 拖动中低频同步属性面板,不重建控件 m_hasSelectedEntity = true; m_selectedEntityId = id; m_selectedEntityDepth = depth; m_selectedEntityOrigin = origin; if (m_propertySyncTimer) { if (!m_propertySyncTimer->isActive()) { // 属性同步 30Hz:避免拖动时 UI 抢占 m_propertySyncTimer->start(33); } } else { refreshPropertyPanel(); } }); connect(m_editorCanvas, &EditorCanvas::requestAddEntity, this, [this](const core::Project::Entity& e, const QImage& img) { core::Project::Entity ent = e; if (ent.id.isEmpty()) { // 生成稳定且不重复的 id:entity- QSet used; for (const auto& ex : m_workspace.entities()) { used.insert(ex.id); } int n = static_cast(m_workspace.entities().size()) + 1; for (int guard = 0; guard < 100000; ++guard, ++n) { const QString cand = QStringLiteral("entity-%1").arg(n); if (!used.contains(cand)) { ent.id = cand; break; } } } ent.blackholeVisible = true; if (ent.blackholeId.isEmpty() && !ent.id.isEmpty()) { ent.blackholeId = QStringLiteral("blackhole-%1").arg(ent.id); } ent.blackholeResolvedBy = QStringLiteral("pending"); if (!m_workspace.addEntity(ent, img)) { QMessageBox::warning(this, QStringLiteral("实体"), QStringLiteral("保存实体失败。")); return; } refreshEditorPage(); refreshProjectTree(); updateUiEnabledState(); }); connect(m_editorCanvas, &EditorCanvas::requestAddTool, this, [this](const core::Project::Tool& tool) { if (!m_workspace.isOpen()) { return; } core::Project::Tool t = tool; if (t.id.isEmpty()) { QSet used; for (const auto& ex : m_workspace.tools()) { used.insert(ex.id); } int n = static_cast(m_workspace.tools().size()) + 1; for (int guard = 0; guard < 100000; ++guard, ++n) { const QString cand = QStringLiteral("tool-%1").arg(n); if (!used.contains(cand)) { t.id = cand; break; } } } if (!m_workspace.addTool(t)) { QMessageBox::warning(this, QStringLiteral("工具"), QStringLiteral("保存工具失败。")); return; } refreshEditorPage(); refreshProjectTree(); updateUiEnabledState(); }); connect(m_editorCanvas, &EditorCanvas::requestSamSegment, this, [this](const QByteArray& cropRgbPng, const QByteArray& overlayPng, const QPointF& cropTopLeftWorld, const QJsonArray& pointCoords, const QJsonArray& pointLabels, const QJsonArray& boxXyxy) { if (!m_workspace.isOpen() || !m_workspace.hasBackground()) { QMessageBox::warning(this, QStringLiteral("实体"), QStringLiteral("请先打开项目并设置背景。")); return; } QString base; const QByteArray env = qgetenv("MODEL_SERVER_URL"); base = env.isEmpty() ? QStringLiteral("http://127.0.0.1:8000") : QString::fromUtf8(env); auto* client = new core::ModelServerClient(this); client->setBaseUrl(QUrl(base)); QString immediateErr; QNetworkReply* reply = client->segmentSamPromptAsync( cropRgbPng, overlayPng, pointCoords, pointLabels, boxXyxy, &immediateErr); if (!reply) { QMessageBox::warning(this, QStringLiteral("SAM 分割"), immediateErr.isEmpty() ? QStringLiteral("无法发起后端请求。") : immediateErr); client->deleteLater(); return; } auto* dlg = new CancelableTaskDialog(QStringLiteral("SAM 分割"), QStringLiteral("正在请求后端进行分割,请稍候……"), this); dlg->setAttribute(Qt::WA_DeleteOnClose, true); connect(dlg, &CancelableTaskDialog::canceled, this, [reply, dlg]() { if (reply) { reply->abort(); } if (dlg) { dlg->reject(); } }); connect(reply, &QNetworkReply::finished, this, [this, reply, dlg, client, cropTopLeftWorld]() { const int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); const QByteArray raw = reply->readAll(); const auto netErr = reply->error(); const QString netErrStr = reply->errorString(); reply->deleteLater(); client->deleteLater(); if (dlg) { dlg->close(); } if (netErr != QNetworkReply::NoError) { if (netErrStr.contains(QStringLiteral("canceled"), Qt::CaseInsensitive) || netErr == QNetworkReply::OperationCanceledError) { return; } QMessageBox::warning(this, QStringLiteral("SAM 分割"), QStringLiteral("网络错误:%1").arg(netErrStr)); return; } if (httpStatus != 200) { QString detail; const QJsonDocument jerr = QJsonDocument::fromJson(raw); if (jerr.isObject()) { detail = jerr.object().value(QStringLiteral("detail")).toString(); } QMessageBox::warning( this, QStringLiteral("SAM 分割"), detail.isEmpty() ? QStringLiteral("后端返回 HTTP %1。").arg(httpStatus) : QStringLiteral("后端错误(HTTP %1):%2").arg(httpStatus).arg(detail)); return; } const QJsonDocument jd = QJsonDocument::fromJson(raw); if (!jd.isObject()) { QMessageBox::warning(this, QStringLiteral("SAM 分割"), QStringLiteral("响应不是 JSON。")); return; } const QJsonObject obj = jd.object(); if (!obj.value(QStringLiteral("success")).toBool()) { const QString err = obj.value(QStringLiteral("error")).toString(); QMessageBox::warning(this, QStringLiteral("SAM 分割"), err.isEmpty() ? QStringLiteral("分割失败。") : err); return; } const QJsonArray contour = obj.value(QStringLiteral("contour")).toArray(); if (contour.size() < 3) { QMessageBox::warning(this, QStringLiteral("SAM 分割"), QStringLiteral("轮廓点数不足。")); return; } QVector polyWorld; polyWorld.reserve(contour.size()); for (const QJsonValue& v : contour) { if (!v.isArray()) { continue; } const QJsonArray p = v.toArray(); if (p.size() < 2) { continue; } const double x = p.at(0).toDouble(); const double y = p.at(1).toDouble(); polyWorld.append(cropTopLeftWorld + QPointF(x, y)); } if (polyWorld.size() < 3) { QMessageBox::warning(this, QStringLiteral("SAM 分割"), QStringLiteral("无效轮廓数据。")); return; } core::Project::Entity ent; ent.id.clear(); ent.cutoutPolygonWorld = polyWorld; ent.originWorld = entity_cutout::polygonCentroid(ent.cutoutPolygonWorld); ent.polygonLocal.clear(); ent.polygonLocal.reserve(ent.cutoutPolygonWorld.size()); for (const auto& pt : ent.cutoutPolygonWorld) { ent.polygonLocal.push_back(pt - ent.originWorld); } const QString bgAbs = m_workspace.backgroundAbsolutePath(); QImage depth8; if (m_workspace.hasDepth()) { const QString dpath = m_workspace.depthAbsolutePath(); if (!dpath.isEmpty() && QFileInfo::exists(dpath) && !bgAbs.isEmpty()) { depth8 = core::image_file::loadDepthMapAlignedToBackground(dpath, bgAbs); } } const QPointF c = entity_cutout::polygonCentroid(ent.cutoutPolygonWorld); int z = 0; if (!depth8.isNull()) { const int xi = static_cast(std::floor(c.x())); const int yi = static_cast(std::floor(c.y())); if (xi >= 0 && yi >= 0 && xi < depth8.width() && yi < depth8.height()) { z = static_cast(static_cast(depth8.constScanLine(yi))[xi]); } } ent.depth = z; { const double ds01 = static_cast(std::clamp(z, 0, 255)) / 255.0; ent.distanceScaleCalibMult = 0.5 + ds01 * 1.0; } QImage bg = core::image_file::loadImageLimited(bgAbs, core::image_decode::kWorkspaceMaxPixels); if (!bg.isNull() && bg.format() != QImage::Format_ARGB32_Premultiplied) { bg = bg.convertToFormat(QImage::Format_ARGB32_Premultiplied); } QImage cutout; if (!bg.isNull()) { QPointF topLeft; cutout = entity_cutout::extractEntityImage(bg, ent.cutoutPolygonWorld, topLeft); ent.imageTopLeftWorld = topLeft; } QSet used; for (const auto& ex : m_workspace.entities()) { used.insert(ex.id); } int n = static_cast(m_workspace.entities().size()) + 1; for (int guard = 0; guard < 100000; ++guard, ++n) { const QString cand = QStringLiteral("entity-%1").arg(n); if (!used.contains(cand)) { ent.id = cand; break; } } // 不直接落盘:进入待确认(可微调) m_editorCanvas->setPendingEntityPolygonWorld(polyWorld); }); dlg->show(); }); connect(m_editorCanvas, &EditorCanvas::requestFinalizePendingEntity, this, [this](const QVector& polyWorld) { if (!m_workspace.isOpen() || !m_workspace.hasBackground()) { return; } if (polyWorld.size() < 3) { return; } // 自动深度 int z = 0; if (m_workspace.hasDepth()) { const QString dpath = m_workspace.depthAbsolutePath(); const QString bgAbs = m_workspace.backgroundAbsolutePath(); const QImage depth8 = core::image_file::loadDepthMapAlignedToBackground(dpath, bgAbs); if (!depth8.isNull()) { const QPointF c = entity_cutout::polygonCentroid(polyWorld); const int xi = static_cast(std::floor(c.x())); const int yi = static_cast(std::floor(c.y())); if (xi >= 0 && yi >= 0 && xi < depth8.width() && yi < depth8.height()) { z = static_cast(static_cast(depth8.constScanLine(yi))[xi]); } } } // 生成稳定且不重复的 id:entity-,同时作为“默认名称” QString newId; { QSet used; for (const auto& ex : m_workspace.entities()) { used.insert(ex.id); } int n = static_cast(m_workspace.entities().size()) + 1; for (int guard = 0; guard < 100000; ++guard, ++n) { const QString cand = QStringLiteral("entity-%1").arg(n); if (!used.contains(cand)) { newId = cand; break; } } } EntityFinalizeDialog dlg(this); dlg.setDefaultName(newId.isEmpty() ? QStringLiteral("entity-1") : newId); dlg.setUserScale(1.0); if (dlg.exec() != QDialog::Accepted) { return; } core::Project::Entity ent; ent.id = newId; ent.displayName = dlg.name(); ent.cutoutPolygonWorld = polyWorld; ent.originWorld = entity_cutout::polygonCentroid(ent.cutoutPolygonWorld); ent.polygonLocal.clear(); ent.polygonLocal.reserve(ent.cutoutPolygonWorld.size()); for (const auto& pt : ent.cutoutPolygonWorld) { ent.polygonLocal.push_back(pt - ent.originWorld); } ent.depth = std::clamp(z, 0, 255); ent.userScale = std::max(1e-6, dlg.userScale()); { const double ds01 = static_cast(ent.depth) / 255.0; ent.distanceScaleCalibMult = 0.5 + ds01 * 1.0; } // 若用户把名称清空,则 displayName 置空,UI 会回退显示 id(保持原习惯) if (ent.displayName == ent.id) { // 默认情况保留 displayName=id,便于树上直接显示 entity-x } if (ent.displayName.isEmpty()) { // 允许空:界面会用 id 展示 } ent.blackholeVisible = true; if (ent.blackholeId.isEmpty() && !ent.id.isEmpty()) { ent.blackholeId = QStringLiteral("blackhole-%1").arg(ent.id); } ent.blackholeResolvedBy = QStringLiteral("pending"); // 大图必须按 1:1 region 解码,否则 polyWorld(逻辑像素)与 bg 像素空间不一致会产生严重错位。 QImage cutout; { const QRectF bb = entity_cutout::pathFromWorldPolygon(ent.cutoutPolygonWorld).boundingRect(); const int left = int(std::floor(bb.left())); const int top = int(std::floor(bb.top())); const int right = int(std::ceil(bb.right())); const int bottom = int(std::ceil(bb.bottom())); QSize bgLogical; if (!core::image_file::probeImagePixelSize(m_workspace.backgroundAbsolutePath(), &bgLogical)) { bgLogical = QSize(); } if (bgLogical.isValid()) { const QRect region = entity_cutout::clampRectToImage( QRect(QPoint(left, top), QPoint(right, bottom)), bgLogical); QImage bgRegion; if (!region.isEmpty() && core::image_file::loadRegionToQImageExact(m_workspace.backgroundAbsolutePath(), region, &bgRegion) && !bgRegion.isNull()) { if (bgRegion.format() != QImage::Format_ARGB32_Premultiplied) { bgRegion = bgRegion.convertToFormat(QImage::Format_ARGB32_Premultiplied); } QVector localPoly; localPoly.reserve(ent.cutoutPolygonWorld.size()); for (const auto& p : ent.cutoutPolygonWorld) { localPoly.push_back(p - region.topLeft()); } QPointF localTopLeft; cutout = entity_cutout::extractEntityImage(bgRegion, localPoly, localTopLeft); ent.imageTopLeftWorld = region.topLeft() + localTopLeft; } } } if (!m_workspace.addEntity(ent, cutout)) { QMessageBox::warning(this, QStringLiteral("实体"), QStringLiteral("保存实体失败。")); return; } if (m_editorCanvas) { m_editorCanvas->clearPendingEntityPolygon(); } refreshEditorPage(); refreshProjectTree(); updateUiEnabledState(); }); connect(m_editorCanvas, &EditorCanvas::requestMoveEntity, this, [this](const QString& id, const QPointF& delta) { // 动画编辑:拖动即写入当前位置关键帧,避免被既有关键帧插值“拉回去” const bool autoKey = true; if (!m_workspace.moveEntityBy(id, delta, std::clamp(m_currentFrame, 0, m_workspace.activeAnimationLengthFrames() - 1), autoKey)) { return; } refreshEditorPage(); refreshDopeSheet(); updateUiEnabledState(); }); connect(m_editorCanvas, &EditorCanvas::requestMoveTool, this, [this](const QString& id, const QPointF& delta) { const bool autoKey = true; if (!m_workspace.moveToolBy(id, delta, std::clamp(m_currentFrame, 0, m_workspace.activeAnimationLengthFrames() - 1), autoKey)) { return; } refreshEditorPage(); refreshProjectTree(); updateUiEnabledState(); }); connect(m_editorCanvas, &EditorCanvas::requestMoveCamera, this, [this](const QString& id, const QPointF& delta) { const bool autoKey = true; if (!m_workspace.moveCameraBy(id, delta, std::clamp(m_currentFrame, 0, m_workspace.activeAnimationLengthFrames() - 1), autoKey)) { return; } refreshEditorPage(); refreshProjectTree(); updateUiEnabledState(); }); connect(m_editorCanvas, &EditorCanvas::requestCameraViewScaleAdjust, this, [this](const QString& id, double factor) { if (id.isEmpty() || !m_workspace.isOpen()) return; const core::eval::ResolvedProjectFrame resFrame = core::eval::evaluateAtFrame(m_workspace.project(), m_currentFrame, 10); for (const auto& rc : resFrame.cameras) { if (rc.camera.id != id) continue; const double ns = std::clamp(rc.camera.viewScale * factor, 1e-6, 1e3); const int kf = std::clamp(m_currentFrame, 0, m_workspace.activeAnimationLengthFrames() - 1); if (!m_workspace.setCameraViewScaleValue(id, ns, kf)) return; refreshEditorPage(); refreshDopeSheet(); return; } }); connect(m_editorCanvas, &EditorCanvas::requestAddPresentationHotspot, this, [this](const QPointF& w) { QVector hs = m_workspace.project().presentationHotspots(); core::Project::PresentationHotspot nh; nh.id = nextPresentationHotspotIdWs(hs); nh.centerWorld = w; nh.targetAnimationId.clear(); const QString newId = nh.id; hs.push_back(std::move(nh)); m_workspace.applyPresentationHotspots(hs, true, QStringLiteral("热点")); m_selectedHotspotId = newId; if (m_editorCanvas) { m_editorCanvas->setSelectedPresentationHotspotId(m_selectedHotspotId); } refreshEditorPage(); }); connect(m_editorCanvas, &EditorCanvas::requestAddPresentationHotspotForAnimation, this, [this](const QPointF& w, const QString& animationId) { if (animationId.isEmpty()) { return; } QVector hs = m_workspace.project().presentationHotspots(); core::Project::PresentationHotspot nh; nh.id = nextPresentationHotspotIdWs(hs); nh.centerWorld = w; nh.targetAnimationId = animationId; const QString newId = nh.id; hs.push_back(std::move(nh)); m_workspace.applyPresentationHotspots(hs, true, QStringLiteral("热点")); m_selectedHotspotId = newId; if (m_editorCanvas) { m_editorCanvas->setSelectedPresentationHotspotId(m_selectedHotspotId); } refreshEditorPage(); }); connect(m_editorCanvas, &EditorCanvas::presentationHotspotMoved, this, [this](const QString& hotspotId, const QPointF& c) { QVector hs = m_workspace.project().presentationHotspots(); for (auto& h : hs) { if (h.id == hotspotId) { h.centerWorld = c; break; } } m_workspace.applyPresentationHotspots(hs, true, QStringLiteral("热点")); refreshEditorPage(); }); connect(m_editorCanvas, &EditorCanvas::selectedPresentationHotspotChanged, this, [this](const QString& hotspotId) { m_selectedHotspotId = hotspotId; if (!hotspotId.isEmpty()) { m_hasSelectedEntity = false; m_selectedEntityId.clear(); m_selectedEntityDisplayNameCache.clear(); m_hasSelectedTool = false; m_selectedToolId.clear(); m_hasSelectedCamera = false; m_selectedCameraId.clear(); m_selectedBlackholeEntityId.clear(); if (m_editorCanvas) { m_editorCanvas->clearEntitySelection(); m_editorCanvas->clearCameraSelection(); } } if (!m_timelineScrubbing) { refreshPropertyPanel(); syncProjectTreeFromCanvasSelection(); } }); connect(m_editorCanvas, &EditorCanvas::hotspotPlayRequested, this, [this](const QString& hid) { for (const auto& h : m_workspace.project().presentationHotspots()) { if (h.id == hid) { const QString tid = h.targetAnimationId; if (!tid.isEmpty() && tid != QStringLiteral("none")) { requestPreviewSchemeSwitchTo(tid, true); } return; } } }); connect(m_editorCanvas, &EditorCanvas::selectedCameraChanged, this, [this](bool hasSel, const QString& id, const QPointF& centerWorld, double viewScale) { Q_UNUSED(centerWorld); Q_UNUSED(viewScale); if (hasSel && !id.isEmpty()) { m_selectedHotspotId.clear(); if (m_editorCanvas) { m_editorCanvas->clearPresentationHotspotSelection(); } } m_hasSelectedCamera = hasSel; m_selectedCameraId = id; if (hasSel) { m_hasSelectedEntity = false; m_selectedEntityId.clear(); m_selectedEntityDisplayNameCache.clear(); m_hasSelectedTool = false; m_selectedToolId.clear(); m_selectedBlackholeEntityId.clear(); } updateTimelineTracks(); if (!m_timelineScrubbing) { updateStatusBarText(); refreshPropertyPanel(); syncProjectTreeFromCanvasSelection(); } }); connect(m_editorCanvas, &EditorCanvas::requestResolveBlackholeCopy, this, [this](const QString& entityId, const QPoint& sourceOffsetPx) { if (!m_workspace.resolveBlackholeByCopyBackground(entityId, sourceOffsetPx, true)) { QMessageBox::warning( this, QStringLiteral("黑洞修复"), QStringLiteral("复制背景区域失败。请重新拖动取样框,确保采样区域在背景范围内。")); return; } refreshProjectTree(); updateUiEnabledState(); if (m_editorCanvas) { m_editorCanvas->notifyBlackholeOverlaysChanged(); } refreshEditorPage(); if (m_previewRequested) { refreshPreviewPage(); } }); connect(m_editorCanvas, &EditorCanvas::presentationEntityIntroRequested, this, [this](const QString& id, QPointF anchorView) { if (!m_entityIntroPopup || !m_workspace.isOpen()) { return; } m_entityIntroPopup->setProjectDir(m_workspace.projectDir()); bool found = false; for (const auto& e : m_workspace.entities()) { if (e.id == id) { m_entityIntroPopup->setContent(e.intro); found = true; break; } } if (!found) { core::EntityIntroContent empty; m_entityIntroPopup->setContent(empty); } m_entityIntroPopup->showNearCanvasPoint(anchorView.toPoint(), m_editorCanvas); }); connect(m_editorCanvas, &EditorCanvas::presentationInteractionDismissed, this, [this]() { if (m_entityIntroPopup) { m_entityIntroPopup->clearAndHide(); } }); connect(group, &QButtonGroup::idClicked, this, [this](int id) { if (!m_editorCanvas) { return; } m_editorCanvas->setTool(static_cast(id)); }); connect(m_btnToggleDepthOverlay, &QToolButton::toggled, this, [this](bool on) { if (m_editorCanvas) { m_editorCanvas->setDepthOverlayEnabled(on); } if (m_actionCanvasDepthOverlay) { m_actionCanvasDepthOverlay->blockSignals(true); m_actionCanvasDepthOverlay->setChecked(on); m_actionCanvasDepthOverlay->blockSignals(false); } if (m_bgPropertySection) { m_bgPropertySection->syncDepthOverlayChecked(on); } }); connect(m_railBtnFit, &QToolButton::clicked, this, [this]() { if (m_editorCanvas) { m_editorCanvas->zoomToFit(); } }); connect(m_btnHotspotEnterPreview, &QToolButton::clicked, this, [this]() { const QString aid = firstAnimationSchemeIdNonNoneWs(m_workspace.project()); if (aid.isEmpty()) { return; } m_animationRequested = false; m_hotspotRequested = false; m_previewAnimationId = aid; m_previewFrame = 0; setPreviewRequested(true); if (m_modeSelector) { m_modeSelector->blockSignals(true); m_modeSelector->setCurrentIndex(3); m_modeSelector->blockSignals(false); } requestPreviewSchemeSwitchTo(aid, true); }); m_centerStack->addWidget(m_pageWelcome); m_centerStack->addWidget(m_pageEditor); setCentralWidget(centerShell); syncHotspotEnterPreviewCombo(); syncEditorRailForMode(); showWelcomePage(); } void MainWindow::showWelcomePage() { if (m_centerStack && m_pageWelcome) { m_centerStack->setCurrentWidget(m_pageWelcome); } refreshWelcomeRecentList(); } void MainWindow::refreshWelcomeRecentList() { if (!m_welcomeRecentTree || !m_welcomeRecentEmptyLabel) { return; } m_welcomeRecentTree->clear(); const QStringList paths = m_recentHistory.load(); m_welcomeRecentEmptyLabel->setVisible(paths.isEmpty()); m_welcomeRecentTree->setVisible(!paths.isEmpty()); const QFontMetrics fm(m_welcomeRecentTree->font()); const int vw = m_welcomeRecentTree->viewport()->width(); const int elideW = std::max(160, vw - 200); for (const QString& path : paths) { auto* item = new QTreeWidgetItem(m_welcomeRecentTree); item->setText(0, QFileInfo(path).fileName()); item->setText(1, fm.elidedText(path, Qt::ElideMiddle, elideW)); item->setToolTip(0, {}); item->setToolTip(1, {}); item->setData(0, Qt::UserRole, path); } } void MainWindow::openProjectFromPath(const QString& dir) { if (dir.isEmpty()) { return; } if (m_workspace.isOpen()) { onCloseProject(); } if (!m_workspace.openExisting(dir)) { QMessageBox::warning(this, QStringLiteral("打开项目"), QStringLiteral("打开项目失败(缺少或损坏 project.json)。")); m_recentHistory.removeAndSave(dir); refreshWelcomeRecentList(); return; } m_recentHistory.addAndSave(m_workspace.projectDir()); refreshWelcomeRecentList(); presentWorkspaceEditorWithBlockingInitialLoad(); } void MainWindow::presentWorkspaceEditorWithBlockingInitialLoad() { m_animationRequested = m_workspace.isOpen() && m_workspace.project().activeAnimationId() != QStringLiteral("none"); m_hotspotRequested = false; m_selectedHotspotId.clear(); QProgressDialog progress(QStringLiteral("加载中"), QString(), 0, 0, this); progress.setWindowModality(Qt::ApplicationModal); progress.setMinimumDuration(0); progress.setCancelButton(nullptr); progress.setRange(0, 0); progress.show(); QCoreApplication::processEvents(QEventLoop::ExcludeUserInputEvents); // 注意:这里不要自动“对齐实体中心/迁移枢轴”,否则会覆盖用户手动设置的「相对位置(枢轴相对形心)」。 // 若需要旧工程一次性迁移,应放到显式菜单操作或带版本标记的单次迁移逻辑中。 showEditorPage(); refreshEditorPage(); refreshProjectTree(); if (m_editorCanvas) { m_editorCanvas->update(); } QCoreApplication::processEvents(QEventLoop::ExcludeUserInputEvents); if (m_editorCanvas && m_workspace.hasBackground()) { QEventLoop loop; QMetaObject::Connection conn = QObject::connect(m_editorCanvas, &EditorCanvas::initialBackgroundLoadFinished, &loop, &QEventLoop::quit); QTimer::singleShot(120000, &loop, &QEventLoop::quit); loop.exec(); QObject::disconnect(conn); } updateUiEnabledState(); refreshPreviewPage(); } void MainWindow::showEditorPage() { if (m_centerStack && m_pageEditor) { m_centerStack->setCurrentWidget(m_pageEditor); } if (m_canvasHost) { m_canvasHost->updateGeometry(); static_cast(m_canvasHost)->relayoutFloaters(); QTimer::singleShot(0, m_canvasHost, [this]() { if (m_canvasHost) { static_cast(m_canvasHost)->relayoutFloaters(); } }); } } void MainWindow::showPreviewPage() { showEditorPage(); } void MainWindow::setPreviewRequested(bool preview) { if (preview && (!m_workspace.isOpen() || !m_workspace.hasBackground())) { return; } if (preview) { stopEditorPlayback(); if (m_previewAnimationId.isEmpty()) { m_previewAnimationId = QStringLiteral("none"); } const QString prevId = effectivePreviewAnimationId(); const int plen = m_workspace.animationLengthFramesForId(prevId); m_previewFrame = std::clamp(m_previewFrame, 0, std::max(0, plen - 1)); } else { stopPreviewPlayback(); } m_previewRequested = preview; if (!preview && m_editorCanvas) { m_editorCanvas->clearPresentationEntityFocus(); } if (!preview && m_entityIntroPopup) { m_entityIntroPopup->clearAndHide(); } updateUiEnabledState(); if (preview) { refreshPreviewPage(); } } void MainWindow::refreshPreviewPage() { refreshEditorPage(); } void MainWindow::refreshEditorPage() { if (!m_pageEditor) { return; } const bool open = m_workspace.isOpen(); const auto bgAbs = open ? m_workspace.backgroundAbsolutePath() : QString(); if (m_editorCanvas) { const bool presentation = open && m_previewRequested && m_workspace.hasBackground(); m_editorCanvas->setPresentationPreviewMode(presentation); m_editorCanvas->setBackgroundImagePath(bgAbs); m_editorCanvas->setBackgroundVisible(open ? m_workspace.project().backgroundVisible() : true); m_editorCanvas->setDepthMapPath(open ? m_workspace.depthAbsolutePath() : QString()); if (open) { // —— 方案选择器(时间轴最前)—— if (m_schemeSelector) { m_schemeSelector->blockSignals(true); m_schemeSelector->clear(); const auto& animations = m_workspace.project().animations(); for (const auto& a : animations) { const QString label = a.name.isEmpty() ? a.id : a.name; m_schemeSelector->addItem(label, a.id); } m_schemeSelector->addItem(QStringLiteral("+ 新建动画…"), QStringLiteral("__create__")); const QString activeId = m_workspace.project().activeAnimationId(); int idx = -1; for (int i = 0; i < m_schemeSelector->count(); ++i) { if (m_schemeSelector->itemData(i).toString() == activeId) { idx = i; break; } } if (idx < 0 && m_schemeSelector->count() > 0) idx = 0; if (idx >= 0) m_schemeSelector->setCurrentIndex(idx); m_schemeSelector->blockSignals(false); } applyTimelineFromProject(); core::eval::ResolvedProjectFrame rf; const bool presentationPreview = presentation; if (presentationPreview && !m_previewPlaying) { refreshPreviewSchemeCombo(); } if (presentationPreview) { const QString pvId = effectivePreviewAnimationId(); const int pvLen = m_workspace.animationLengthFramesForId(pvId); m_previewFrame = std::clamp(m_previewFrame, 0, std::max(0, pvLen - 1)); rf = core::eval::evaluateAtFrame(m_workspace.project(), m_previewFrame, 10, pvId); } else { rf = core::eval::evaluateAtFrame(m_workspace.project(), m_currentFrame, 10); } QVector ents; ents.reserve(rf.entities.size()); QVector entOps; entOps.reserve(rf.entities.size()); for (const auto& re : rf.entities) { ents.push_back(re.entity); entOps.push_back(re.opacity); } m_editorCanvas->setEntities(ents, entOps, m_workspace.projectDir()); QVector tools; QVector opacities; tools.reserve(rf.tools.size()); opacities.reserve(rf.tools.size()); for (const auto& rt : rf.tools) { tools.push_back(rt.tool); opacities.push_back(rt.opacity); } m_editorCanvas->setTools(tools, opacities); QVector cams; cams.reserve(rf.cameras.size()); for (const auto& rc : rf.cameras) { cams.push_back(rc.camera); } const QString pvIdForCam = presentationPreview ? effectivePreviewAnimationId() : QString(); const bool animationEditor = currentUiMode() == UiMode::AnimationEditor; const bool allowCameraUi = (presentationPreview && pvIdForCam != QStringLiteral("none")) || (!presentationPreview && animationEditor && m_workspace.project().activeAnimationId() != QStringLiteral("none")); if (!allowCameraUi) { m_hasSelectedCamera = false; m_selectedCameraId.clear(); m_editorCanvas->setCameraOverlays({}, QString(), {}); m_editorCanvas->setPreviewCameraViewLocked(false); m_editorCanvas->clearCameraSelection(); } else { m_editorCanvas->setCameraOverlays(cams, m_selectedCameraId, m_tempHiddenCameraIds); m_editorCanvas->setPreviewCameraViewLocked(false); if (presentation) { const QString acid = m_workspace.project().activeCameraId(); if (!acid.isEmpty() && pvIdForCam != QStringLiteral("none")) { for (const auto& rc : rf.cameras) { if (rc.camera.id == acid) { m_editorCanvas->setPreviewCameraViewLocked(true); m_editorCanvas->applyCameraViewport(rc.camera.centerWorld, rc.camera.viewScale); break; } } } } } m_editorCanvas->setCurrentFrame(presentationPreview ? m_previewFrame : m_currentFrame); m_editorCanvas->setTempHiddenIds(m_tempHiddenEntityIds, m_tempHiddenToolIds); m_editorCanvas->setPresentationHotspots(m_workspace.project().presentationHotspots()); m_editorCanvas->setPresentationHotspotEditorActive(m_hotspotRequested && !m_previewRequested); m_editorCanvas->setPresentationHotspotPlaybackTargetEnabled(presentationPreview && effectivePreviewAnimationId() == QStringLiteral("none")); m_editorCanvas->setSelectedPresentationHotspotId(m_selectedHotspotId); updateTimelineTracks(); } else { m_editorCanvas->setEntities({}, {}, QString()); m_editorCanvas->setCameraOverlays({}, QString(), {}); if (m_timeline) { m_timeline->setKeyframeTracks({}, {}, {}, {}); m_timeline->setToolKeyframeTracks({}, {}); } } } refreshPropertyPanel(); if (m_canvasHost) { m_canvasHost->updateGeometry(); static_cast(m_canvasHost)->relayoutFloaters(); QTimer::singleShot(0, m_canvasHost, [this]() { if (m_canvasHost) { static_cast(m_canvasHost)->relayoutFloaters(); } }); } refreshDopeSheet(); } void MainWindow::updateTimelineTracks() { if (!m_timeline || !m_workspace.isOpen()) { return; } const bool wantEntity = !m_selectedEntityId.isEmpty(); const bool wantTool = (m_hasSelectedTool && !m_selectedToolId.isEmpty()); const bool wantCamera = (m_hasSelectedCamera && !m_selectedCameraId.isEmpty()); if (!wantEntity && !wantTool && !wantCamera) { m_timeline->setKeyframeTracks({}, {}, {}, {}); m_timeline->setToolKeyframeTracks({}, {}); return; } const core::Project::Animation* anim = m_workspace.project().activeAnimationOrNull(); if (!anim) { m_timeline->setKeyframeTracks({}, {}, {}, {}); // 与旧注释一致:不选中工具时不要强行清空 tool tracks if (wantTool) { m_timeline->setToolKeyframeTracks({}, {}); } return; } auto framesOfTrack = [&](core::Project::AnimationTargetKind kind, const QString& targetId, core::Project::AnimationProperty prop) -> QVector { QVector out; if (targetId.isEmpty()) return out; for (const auto& t : anim->tracks) { if (t.targetKind != kind || t.targetId != targetId || t.property != prop) { continue; } out.reserve(out.size() + t.keys.size()); for (const auto& k : t.keys) { out.push_back(k.frame); } } std::sort(out.begin(), out.end()); out.erase(std::unique(out.begin(), out.end()), out.end()); return out; }; auto framesOfImage = [](const QVector& keys) { QVector out; out.reserve(keys.size()); for (const auto& k : keys) out.push_back(k.frame); return out; }; if (wantEntity) { const auto loc = framesOfTrack(core::Project::AnimationTargetKind::Entity, m_selectedEntityId, core::Project::AnimationProperty::Position); const auto sc = framesOfTrack(core::Project::AnimationTargetKind::Entity, m_selectedEntityId, core::Project::AnimationProperty::UserScale); const auto im = m_workspace.entitySpriteFrames(m_selectedEntityId); const auto vis = framesOfTrack(core::Project::AnimationTargetKind::Entity, m_selectedEntityId, core::Project::AnimationProperty::Visibility); m_timeline->setKeyframeTracks(loc, sc, framesOfImage(im), vis); } else if (wantCamera) { const auto loc = framesOfTrack(core::Project::AnimationTargetKind::Camera, m_selectedCameraId, core::Project::AnimationProperty::Position); const auto sc = framesOfTrack(core::Project::AnimationTargetKind::Camera, m_selectedCameraId, core::Project::AnimationProperty::CameraScale); m_timeline->setKeyframeTracks(loc, sc, {}, {}); } else { m_timeline->setKeyframeTracks({}, {}, {}, {}); } // 注意:未选中工具时不能调用 setToolKeyframeTracks({}, {}),其实现会清空 m_locFrames/m_scaleFrames, // 从而冲掉上面已为实体/摄像机写入的轨道数据。 if (wantTool) { const auto loc = framesOfTrack(core::Project::AnimationTargetKind::Tool, m_selectedToolId, core::Project::AnimationProperty::Position); const auto vis = framesOfTrack(core::Project::AnimationTargetKind::Tool, m_selectedToolId, core::Project::AnimationProperty::Visibility); m_timeline->setToolKeyframeTracks(loc, vis); } } void MainWindow::applyTimelineFromProject() { if (!m_timeline || !m_workspace.isOpen()) { return; } const int g = std::max(0, m_currentFrame); const int local = std::clamp(g, 0, m_workspace.activeAnimationLengthFrames() - 1); m_timeline->setFrameRange(0, m_workspace.activeAnimationLengthFrames()); m_timeline->setCurrentFrameProgrammatic(local); if (m_editorCanvas) m_editorCanvas->setCurrentFrame(g); } void MainWindow::refreshDopeSheet() { if (!m_dopeTree) { return; } m_dopeTree->clear(); if (!m_workspace.isOpen()) { return; } const int f = m_currentFrame; const core::Project::Animation* anim = m_workspace.project().activeAnimationOrNull(); auto hasKeyAtFrame = [&](core::Project::AnimationTargetKind kind, const QString& targetId, core::Project::AnimationProperty prop) -> bool { if (!anim || targetId.isEmpty()) return false; for (const auto& t : anim->tracks) { if (t.targetKind != kind || t.targetId != targetId || t.property != prop) continue; for (const auto& k : t.keys) { if (k.frame == f) return true; } } return false; }; const auto& ents = m_workspace.entities(); for (const auto& e : ents) { auto* parent = new QTreeWidgetItem(m_dopeTree); parent->setText(0, e.displayName.isEmpty() ? e.id : e.displayName); parent->setData(0, Qt::UserRole, e.id); parent->setData(0, Qt::UserRole + 1, -1); parent->setText(1, QString()); auto addChannel = [&](const QString& label, int channel, bool hasKey) { auto* ch = new QTreeWidgetItem(parent); ch->setText(0, label); ch->setData(0, Qt::UserRole, e.id); ch->setData(0, Qt::UserRole + 1, channel); ch->setText(1, hasKey ? QStringLiteral("●") : QStringLiteral("—")); }; const bool hasLoc = hasKeyAtFrame(core::Project::AnimationTargetKind::Entity, e.id, core::Project::AnimationProperty::Position); const bool hasSc = hasKeyAtFrame(core::Project::AnimationTargetKind::Entity, e.id, core::Project::AnimationProperty::DepthScale); bool hasIm = false; for (const auto& k : m_workspace.entitySpriteFrames(e.id)) { if (k.frame == f) { hasIm = true; break; } } const QString locLabel = e.parentId.isEmpty() ? QStringLiteral("位置") : QStringLiteral("相对位置"); addChannel(locLabel, 0, hasLoc); addChannel(QStringLiteral("缩放"), 1, hasSc); addChannel(QStringLiteral("图像"), 2, hasIm); } m_dopeTree->expandAll(); } void MainWindow::showBackgroundContextMenu(const QPoint& globalPos) { QMenu menu(this); QAction* actComputeDepth = menu.addAction(QStringLiteral("计算深度")); actComputeDepth->setEnabled(m_workspace.isOpen() && m_workspace.hasBackground()); QAction* chosen = execPopupMenuAboveFullscreen(menu, globalPos, this); if (!chosen) { return; } if (chosen == actComputeDepth) { computeDepthAsync(); } refreshProjectTree(); updateUiEnabledState(); refreshEditorPage(); refreshPreviewPage(); } void MainWindow::showBlackholeContextMenu(const QPoint& globalPos, const QString& entityId) { if (entityId.isEmpty() || !m_workspace.isOpen()) { return; } m_selectedBlackholeEntityId = entityId; if (m_editorCanvas) { m_editorCanvas->selectBlackholeByEntityId(entityId); } syncProjectTreeFromCanvasSelection(); QString holeLabel = entityId; for (const auto& e : m_workspace.entities()) { if (e.id == entityId) { if (!e.blackholeId.isEmpty()) { holeLabel = e.blackholeId; } else { holeLabel = QStringLiteral("blackhole-%1").arg(entityId); } break; } } QMenu menu(this); QAction* actResolve = menu.addAction(QStringLiteral("修复")); QAction* chosen = execPopupMenuAboveFullscreen(menu, globalPos, this); if (!chosen || chosen != actResolve) { return; } BlackholeResolveDialog dlg(holeLabel, this); if (dlg.exec() != QDialog::Accepted) { return; } bool ok = false; if (dlg.selectedAlgorithm() == BlackholeResolveDialog::Algorithm::CopyBackgroundRegion) { if (!m_editorCanvas || !m_editorCanvas->startBlackholeCopyResolve(entityId)) { QMessageBox::warning( this, QStringLiteral("黑洞修复"), QStringLiteral("无法进入画布拖动模式,请确认黑洞与背景数据有效。")); return; } return; } else if (dlg.selectedAlgorithm() == BlackholeResolveDialog::Algorithm::UseOriginalBackground) { ok = m_workspace.resolveBlackholeByUseOriginalBackground(entityId); if (!ok) { QMessageBox::warning(this, QStringLiteral("黑洞修复"), QStringLiteral("应用“使用原始背景”失败。")); } } else { // 模型补全:裁剪 -> mask -> 请求后端 -> 预览 -> 接受后保存独立覆盖层(不修改 background 原图) const QString bgAbs = m_workspace.backgroundAbsolutePath(); if (bgAbs.isEmpty() || !QFileInfo::exists(bgAbs)) { QMessageBox::warning(this, QStringLiteral("黑洞修复"), QStringLiteral("背景文件无效。")); return; } QSize bgLogical; if (!core::image_file::probeImagePixelSize(bgAbs, &bgLogical) || !bgLogical.isValid() || bgLogical.width() < 1 || bgLogical.height() < 1) { QMessageBox::warning(this, QStringLiteral("黑洞修复"), QStringLiteral("无法读取背景尺寸。")); return; } QVector holePolyWorld; for (const auto& e : m_workspace.entities()) { if (e.id == entityId) { holePolyWorld = e.cutoutPolygonWorld; break; } } if (holePolyWorld.size() < 3) { QMessageBox::warning(this, QStringLiteral("黑洞修复"), QStringLiteral("黑洞数据无效(多边形点数不足)。")); return; } QPainterPath holePath; holePath.addPolygon(QPolygonF(holePolyWorld)); holePath.closeSubpath(); // 给模型更多上下文,避免“只看见一小块洞”导致输出色块/噪声。 // 经验:将黑洞外接矩形在面积上放大约 4 倍(线性约 2 倍)会明显提升纹理连续性。 constexpr double kExpandLinear = 2.0; // 线性放大倍数:2x -> 面积约 4x constexpr int kMinMargin = 160; // 兜底最小边距(避免洞很小时上下文不足) const QRect holeRect = holePath.boundingRect().toAlignedRect(); const int marginX = std::max(kMinMargin, int(std::round((holeRect.width() * (kExpandLinear - 1.0)) / 2.0))); const int marginY = std::max(kMinMargin, int(std::round((holeRect.height() * (kExpandLinear - 1.0)) / 2.0))); QRect cropRect = holeRect.adjusted(-marginX, -marginY, marginX, marginY); cropRect = cropRect.intersected(QRect(QPoint(0, 0), bgLogical)); if (!cropRect.isValid() || cropRect.width() <= 1 || cropRect.height() <= 1) { QMessageBox::warning(this, QStringLiteral("黑洞修复"), QStringLiteral("裁剪区域无效。")); return; } const int maxDec = std::clamp(std::max(cropRect.width(), cropRect.height()), 512, 4096); QImage bg; if (!core::image_file::loadRegionToQImage(bgAbs, cropRect, maxDec, maxDec, &bg) || bg.isNull()) { QMessageBox::warning(this, QStringLiteral("黑洞修复"), QStringLiteral("读取背景失败。")); return; } if (bg.format() != QImage::Format_ARGB32_Premultiplied) { bg = bg.convertToFormat(QImage::Format_ARGB32_Premultiplied); } const QImage cropRgb = bg.convertToFormat(QImage::Format_RGB888); QImage mask(bg.size(), QImage::Format_Grayscale8); mask.fill(0); { QPainter pm(&mask); pm.setRenderHint(QPainter::Antialiasing, true); QTransform worldToMask; worldToMask.translate(-cropRect.left(), -cropRect.top()); worldToMask.scale(bg.width() / double(cropRect.width()), bg.height() / double(cropRect.height())); pm.setWorldTransform(worldToMask); pm.fillPath(holePath, QColor(255, 255, 255)); pm.end(); } // 重要:给后端 inpaint 的 mask 应尽量是“硬二值”,否则灰度边缘会导致半重绘, // 进而留下人物的模糊残影。这里做阈值化 + 轻微膨胀覆盖边缘残留。 { // 阈值化 for (int y = 0; y < mask.height(); ++y) { uchar* row = mask.scanLine(y); for (int x = 0; x < mask.width(); ++x) { row[x] = (row[x] >= 128) ? 255 : 0; } } // 二值膨胀(半径 2px),更彻底地“剔除人物边缘” constexpr int kDilateRadius = 2; for (int it = 0; it < kDilateRadius; ++it) { QImage dil(mask.size(), QImage::Format_Grayscale8); dil.fill(0); for (int y = 0; y < mask.height(); ++y) { for (int x = 0; x < mask.width(); ++x) { int best = 0; for (int dy = -1; dy <= 1 && best == 0; ++dy) { const int yy = y + dy; if (yy < 0 || yy >= mask.height()) continue; const uchar* r = mask.constScanLine(yy); for (int dx = -1; dx <= 1; ++dx) { const int xx = x + dx; if (xx < 0 || xx >= mask.width()) continue; if (r[xx] != 0) { best = 255; break; } } } dil.scanLine(y)[x] = uchar(best); } } mask = dil; } } // 关键:黑洞区域在原始背景里通常是“被抠走的前景(人)像素”,但我们目标是补背景。 // 直接用“平均色”抹掉会让模型更容易输出大块色块,纹理连续性变差。 // 改为:先做一次较强的模糊,再把 mask 内像素替换成模糊后的背景(保留整体明暗/色彩走向)。 QImage cropForInpaint = cropRgb.convertToFormat(QImage::Format_RGB888); { const QImage m8 = mask.convertToFormat(QImage::Format_Grayscale8); // 对整块做较强 box blur(多次 7x7 近似高斯),再用它覆盖洞内区域 QImage blurred = cropForInpaint; auto boxBlurOnce7 = [](const QImage& src) -> QImage { QImage out(src.size(), QImage::Format_RGB888); const int w = src.width(); const int h = src.height(); for (int y = 0; y < h; ++y) { uchar* dstRow = out.scanLine(y); for (int x = 0; x < w; ++x) { int sr = 0, sg = 0, sb = 0, cnt = 0; for (int dy = -3; dy <= 3; ++dy) { const int yy = y + dy; if (yy < 0 || yy >= h) continue; const uchar* srcRow = src.constScanLine(yy); for (int dx = -3; dx <= 3; ++dx) { const int xx = x + dx; if (xx < 0 || xx >= w) continue; const int idx = xx * 3; sr += int(srcRow[idx + 0]); sg += int(srcRow[idx + 1]); sb += int(srcRow[idx + 2]); cnt += 1; } } const int di = x * 3; dstRow[di + 0] = uchar(std::clamp((sr + cnt / 2) / std::max(1, cnt), 0, 255)); dstRow[di + 1] = uchar(std::clamp((sg + cnt / 2) / std::max(1, cnt), 0, 255)); dstRow[di + 2] = uchar(std::clamp((sb + cnt / 2) / std::max(1, cnt), 0, 255)); } } return out; }; // 多次迭代:更强的平滑,避免人物纹理残留影响模型 for (int i = 0; i < 2; ++i) { blurred = boxBlurOnce7(blurred); } // 对 LaMa 额外处理:洞外一圈“抑制环带”也做弱化,减少邻近人物对补洞的强干扰。 // 思路:ring = dilate(mask, R) - mask,把 ring 内像素往 blurred 拉近。 constexpr int kLamaContextSuppressRadius = 10; // 8~16 常见有效 QImage ringMask(mask.size(), QImage::Format_Grayscale8); ringMask.fill(0); { // 先构造 dilated(mask, R) QImage dil = m8; for (int it = 0; it < kLamaContextSuppressRadius; ++it) { QImage d2(dil.size(), QImage::Format_Grayscale8); d2.fill(0); for (int y = 0; y < dil.height(); ++y) { for (int x = 0; x < dil.width(); ++x) { int best = 0; for (int dy = -1; dy <= 1 && best == 0; ++dy) { const int yy = y + dy; if (yy < 0 || yy >= dil.height()) continue; const uchar* row = dil.constScanLine(yy); for (int dx = -1; dx <= 1; ++dx) { const int xx = x + dx; if (xx < 0 || xx >= dil.width()) continue; if (row[xx] != 0) { best = 255; break; } } } d2.scanLine(y)[x] = uchar(best); } } dil = d2; } // ring = dil - m8 for (int y = 0; y < ringMask.height(); ++y) { const uchar* drow = dil.constScanLine(y); const uchar* mrow = m8.constScanLine(y); uchar* rrow = ringMask.scanLine(y); for (int x = 0; x < ringMask.width(); ++x) { rrow[x] = (drow[x] != 0 && mrow[x] == 0) ? 255 : 0; } } } for (int y = 0; y < cropForInpaint.height(); ++y) { const uchar* mr = m8.constScanLine(y); uchar* pr = cropForInpaint.scanLine(y); const uchar* br = blurred.constScanLine(y); for (int x = 0; x < cropForInpaint.width(); ++x) { if (mr[x] == 0) continue; const int idx = x * 3; pr[idx + 0] = br[idx + 0]; pr[idx + 1] = br[idx + 1]; pr[idx + 2] = br[idx + 2]; } } // ring 弱化(对洞外邻域的人物/实体“去结构”) for (int y = 0; y < cropForInpaint.height(); ++y) { const uchar* rr = ringMask.constScanLine(y); uchar* pr = cropForInpaint.scanLine(y); const uchar* br = blurred.constScanLine(y); for (int x = 0; x < cropForInpaint.width(); ++x) { if (rr[x] == 0) continue; const int idx = x * 3; // mix: 40% 原始 + 60% blurred(保留大色块但削弱结构) pr[idx + 0] = uchar((int(pr[idx + 0]) * 40 + int(br[idx + 0]) * 60 + 50) / 100); pr[idx + 1] = uchar((int(pr[idx + 1]) * 40 + int(br[idx + 1]) * 60 + 50) / 100); pr[idx + 2] = uchar((int(pr[idx + 2]) * 40 + int(br[idx + 2]) * 60 + 50) / 100); } } } QByteArray cropPng; { QBuffer buf(&cropPng); if (!buf.open(QIODevice::WriteOnly) || !cropForInpaint.save(&buf, "PNG")) { QMessageBox::warning(this, QStringLiteral("黑洞修复"), QStringLiteral("编码裁剪图失败。")); return; } } QByteArray maskPng; { QBuffer buf(&maskPng); if (!buf.open(QIODevice::WriteOnly) || !mask.save(&buf, "PNG")) { QMessageBox::warning(this, QStringLiteral("黑洞修复"), QStringLiteral("编码 mask 失败。")); return; } } QString base; const QByteArray env = qgetenv("MODEL_SERVER_URL"); base = env.isEmpty() ? QStringLiteral("http://127.0.0.1:8000") : QString::fromUtf8(env); auto* client = new core::ModelServerClient(this); client->setBaseUrl(QUrl(base)); QString immediateErr; const QString prompt = dlg.promptText(); QNetworkReply* reply = client->inpaintAsync( cropPng, maskPng, QStringLiteral("flux_fill"), prompt, QString(), 0.72, 1024, &immediateErr); if (!reply) { QMessageBox::warning(this, QStringLiteral("黑洞修复"), immediateErr.isEmpty() ? QStringLiteral("无法发起后端请求。") : immediateErr); client->deleteLater(); return; } auto* task = new CancelableTaskDialog(QStringLiteral("黑洞修复"), QStringLiteral("补全中…"), this); task->setAttribute(Qt::WA_DeleteOnClose, true); connect(task, &CancelableTaskDialog::canceled, this, [reply, task]() { if (reply) reply->abort(); if (task) task->reject(); }); connect(reply, &QNetworkReply::finished, this, [this, reply, task, client, entityId, bg, cropRect, cropRgb, mask, cropForInpaint, holePath]() mutable { const int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); const QByteArray raw = reply->readAll(); const auto netErr = reply->error(); const QString netErrStr = reply->errorString(); reply->deleteLater(); client->deleteLater(); if (task) task->close(); if (netErr != QNetworkReply::NoError) { QMessageBox::warning(this, QStringLiteral("黑洞修复"), QStringLiteral("网络错误:%1").arg(netErrStr)); return; } if (httpStatus != 200) { QString detail; const QJsonDocument jerr = QJsonDocument::fromJson(raw); if (jerr.isObject()) { detail = jerr.object().value(QStringLiteral("detail")).toString(); } QMessageBox::warning(this, QStringLiteral("黑洞修复"), detail.isEmpty() ? QStringLiteral("后端返回 HTTP %1。").arg(httpStatus) : QStringLiteral("后端错误(HTTP %1):%2").arg(httpStatus).arg(detail)); return; } const QJsonDocument jd = QJsonDocument::fromJson(raw); if (!jd.isObject()) { QMessageBox::warning(this, QStringLiteral("黑洞修复"), QStringLiteral("响应不是 JSON。")); return; } const QJsonObject obj = jd.object(); if (!obj.value(QStringLiteral("success")).toBool()) { const QString err = obj.value(QStringLiteral("error")).toString(); QMessageBox::warning(this, QStringLiteral("黑洞修复"), err.isEmpty() ? QStringLiteral("补全失败。") : err); return; } const QString outB64 = obj.value(QStringLiteral("output_image_b64")).toString(); if (outB64.isEmpty()) { QMessageBox::warning(this, QStringLiteral("黑洞修复"), QStringLiteral("后端未返回图像数据。")); return; } QImage inpainted; { const QByteArray bytes = QByteArray::fromBase64(outB64.toLatin1()); inpainted.loadFromData(bytes, "PNG"); } if (inpainted.isNull()) { QMessageBox::warning(this, QStringLiteral("黑洞修复"), QStringLiteral("解析补全图失败。")); return; } if (inpainted.size() != cropRgb.size()) { inpainted = inpainted.scaled(cropRgb.size(), Qt::IgnoreAspectRatio, Qt::SmoothTransformation); } if (inpainted.format() != QImage::Format_RGB888) { inpainted = inpainted.convertToFormat(QImage::Format_RGB888); } // 合成:mask 羽化后 alpha blend,避免硬边与块状感 QImage after = cropRgb.convertToFormat(QImage::Format_RGB888); QImage alpha = mask.convertToFormat(QImage::Format_Grayscale8); { // 轻微膨胀 + 模糊,让边缘更自然(开销小,且无需额外依赖) QImage dil(alpha.size(), QImage::Format_Grayscale8); dil.fill(0); for (int y = 0; y < alpha.height(); ++y) { for (int x = 0; x < alpha.width(); ++x) { int best = 0; for (int dy = -1; dy <= 1; ++dy) { const int yy = y + dy; if (yy < 0 || yy >= alpha.height()) continue; const uchar* row = alpha.constScanLine(yy); for (int dx = -1; dx <= 1; ++dx) { const int xx = x + dx; if (xx < 0 || xx >= alpha.width()) continue; best = std::max(best, int(row[xx])); } } dil.scanLine(y)[x] = uchar(best); } } alpha = dil; // 简单 box blur 3x3 QImage blur(alpha.size(), QImage::Format_Grayscale8); blur.fill(0); for (int y = 0; y < alpha.height(); ++y) { for (int x = 0; x < alpha.width(); ++x) { int sum = 0; int cnt = 0; for (int dy = -1; dy <= 1; ++dy) { const int yy = y + dy; if (yy < 0 || yy >= alpha.height()) continue; const uchar* row = alpha.constScanLine(yy); for (int dx = -1; dx <= 1; ++dx) { const int xx = x + dx; if (xx < 0 || xx >= alpha.width()) continue; sum += int(row[xx]); cnt += 1; } } blur.scanLine(y)[x] = uchar(std::clamp(sum / std::max(1, cnt), 0, 255)); } } alpha = blur; } for (int y = 0; y < after.height(); ++y) { const uchar* arow = alpha.constScanLine(y); uchar* orow = after.scanLine(y); const uchar* irow = inpainted.constScanLine(y); for (int x = 0; x < after.width(); ++x) { const int a = int(arow[x]); // 0..255 if (a <= 0) continue; const int idx = x * 3; for (int c = 0; c < 3; ++c) { const int o = int(orow[idx + c]); const int n = int(irow[idx + c]); const int v = (o * (255 - a) + n * a + 127) / 255; orow[idx + c] = uchar(std::clamp(v, 0, 255)); } } } const QRectF cropF(cropRect); QImage before = cropForInpaint.convertToFormat(QImage::Format_RGB888); { QPainter pb(&before); pb.setRenderHint(QPainter::Antialiasing, true); QTransform xf; xf.translate(-cropRect.left(), -cropRect.top()); xf.scale(before.width() / double(cropRect.width()), before.height() / double(cropRect.height())); pb.setWorldTransform(xf); for (const auto& e : m_workspace.entities()) { if (e.id == entityId) { continue; } if (!e.blackholeVisible || e.cutoutPolygonWorld.size() < 3) { continue; } const QPainterPath op = entity_cutout::pathFromWorldPolygon(e.cutoutPolygonWorld); if (!op.boundingRect().intersects(cropF)) { continue; } pb.fillPath(op, QColor(0, 0, 0)); } pb.fillPath(holePath, QColor(0, 0, 0)); const qreal w = std::max(1.0, 2.0 / std::min(qAbs(xf.m11()), qAbs(xf.m22()))); pb.setPen(QPen(QColor(255, 210, 72), w)); pb.setBrush(Qt::NoBrush); pb.drawPath(holePath); pb.end(); } // 修复后:叠画其他黑洞(与画布一致);当前洞仅描边,中间保留模型补全结果。 QImage afterView = after.convertToFormat(QImage::Format_RGB888); { QPainter pa(&afterView); pa.setRenderHint(QPainter::Antialiasing, true); QTransform xf; xf.translate(-cropRect.left(), -cropRect.top()); xf.scale(afterView.width() / double(cropRect.width()), afterView.height() / double(cropRect.height())); pa.setWorldTransform(xf); for (const auto& e : m_workspace.entities()) { if (e.id == entityId) { continue; } if (!e.blackholeVisible || e.cutoutPolygonWorld.size() < 3) { continue; } const QPainterPath op = entity_cutout::pathFromWorldPolygon(e.cutoutPolygonWorld); if (!op.boundingRect().intersects(cropF)) { continue; } pa.fillPath(op, QColor(0, 0, 0)); } const qreal w = std::max(1.0, 2.0 / std::min(qAbs(xf.m11()), qAbs(xf.m22()))); pa.setPen(QPen(QColor(255, 210, 72), w)); pa.setBrush(Qt::NoBrush); pa.drawPath(holePath); pa.end(); } InpaintPreviewDialog preview(QStringLiteral("预览"), this); preview.setImages(before, afterView); if (preview.exec() != QDialog::Accepted) { return; } if (after.size() != cropRect.size()) { after = after.scaled(cropRect.size(), Qt::IgnoreAspectRatio, Qt::SmoothTransformation); } const bool ok2 = m_workspace.resolveBlackholeByModelInpaint(entityId, after, cropRect, true); if (!ok2) { QMessageBox::warning(this, QStringLiteral("黑洞修复"), QStringLiteral("保存修复覆盖层失败。")); return; } refreshProjectTree(); updateUiEnabledState(); if (m_editorCanvas) { m_editorCanvas->notifyBlackholeOverlaysChanged(); } refreshEditorPage(); if (m_previewRequested) { refreshPreviewPage(); } }); task->show(); return; } if (ok) { refreshProjectTree(); updateUiEnabledState(); refreshEditorPage(); if (m_previewRequested) { refreshPreviewPage(); } } } void MainWindow::onNewProject() { if (m_workspace.isOpen()) { onCloseProject(); } // 选择父目录:项目会在该目录下自动创建一个新文件夹 const auto parentDir = QFileDialog::getExistingDirectory(this, "选择父目录"); if (parentDir.isEmpty()) { return; } bool ok = false; const auto name = QInputDialog::getText(this, "新项目", QStringLiteral("项目名称:"), QLineEdit::Normal, QStringLiteral("新项目"), &ok); if (!ok) { return; } const auto imagePath = QFileDialog::getOpenFileName( this, QStringLiteral("选择背景图片"), QString(), QStringLiteral("Images (*.png *.jpg *.jpeg *.bmp *.webp);;All Files (*)")); if (imagePath.isEmpty()) { QMessageBox::warning(this, QStringLiteral("新项目"), QStringLiteral("创建项目失败:必须选择背景图片。")); return; } ImageCropDialog crop(imagePath, this); QRect cropRect; // null 表示不裁剪 -> 使用整图 if (crop.exec() == QDialog::Accepted) { // 用户点了“确定”但没有选择裁剪区域:按“不裁剪”处理,使用整图 if (crop.hasValidSelection()) { cropRect = crop.selectedRectInImagePixels(); if (cropRect.isNull()) { QMessageBox::warning(this, QStringLiteral("新项目"), QStringLiteral("创建项目失败:裁剪区域无效。")); return; } } } if (!m_workspace.createNew(parentDir, name, imagePath, cropRect)) { QMessageBox::warning(this, QStringLiteral("新项目"), QStringLiteral("创建项目失败。")); return; } m_recentHistory.addAndSave(m_workspace.projectDir()); refreshWelcomeRecentList(); presentWorkspaceEditorWithBlockingInitialLoad(); } void MainWindow::onOpenProject() { if (m_workspace.isOpen()) { onCloseProject(); } const auto dir = QFileDialog::getExistingDirectory(this, QStringLiteral("打开项目")); if (dir.isEmpty()) { return; } openProjectFromPath(dir); } void MainWindow::onSaveProject() { // TODO: } void MainWindow::onCloseProject() { if (!m_workspace.isOpen()) { return; } m_animationRequested = false; m_hotspotRequested = false; m_selectedHotspotId.clear(); m_previewRequested = false; stopPreviewPlayback(); m_previewAnimationId.clear(); m_previewFrame = 0; m_playing = false; if (m_playTimer) { m_playTimer->stop(); } if (m_btnPlay) { m_btnPlay->blockSignals(true); m_btnPlay->setChecked(false); m_btnPlay->setText(QStringLiteral("▶")); m_btnPlay->blockSignals(false); } m_workspace.close(); m_rightDocksNarrowHidden = false; m_hasSelectedEntity = false; m_selectedEntityDepth = 0; m_selectedEntityOrigin = QPointF(); m_selectedEntityId.clear(); m_hasSelectedCamera = false; m_selectedCameraId.clear(); m_tempHiddenCameraIds.clear(); m_currentFrame = 0; refreshEditorPage(); refreshProjectTree(); updateUiEnabledState(); refreshPreviewPage(); QTimer::singleShot(0, this, [this]() { updateRightDockColumnMinimumWidthFromContent(); }); } void MainWindow::onUndo() { if (!m_workspace.undo()) { return; } refreshEditorPage(); refreshProjectTree(); updateUiEnabledState(); refreshPreviewPage(); } void MainWindow::onRedo() { if (!m_workspace.redo()) { return; } refreshEditorPage(); refreshProjectTree(); updateUiEnabledState(); refreshPreviewPage(); } void MainWindow::onCopyObject() { // TODO: } void MainWindow::onPasteObject() { // TODO: } void MainWindow::onAbout() { auto aboutDialog = new AboutWindow(this); aboutDialog->exec(); } void MainWindow::updatePreviewEdgeArrowsHover(const QPointF& canvasHostPos) { if (!m_previewArrowLeft || !m_previewArrowRight || !m_canvasHost || !m_previewRequested) { return; } if (previewAnimationSchemeIdsNonNone().size() <= 1) { m_previewArrowLeft->hide(); m_previewArrowRight->hide(); return; } constexpr qreal kZone = 96.0; const qreal w = m_canvasHost->width(); const bool nearLeft = canvasHostPos.x() < kZone; const bool nearRight = canvasHostPos.x() > w - kZone; m_previewArrowLeft->setVisible(m_previewRequested && nearLeft); m_previewArrowRight->setVisible(m_previewRequested && nearRight); if (auto* ch = dynamic_cast(m_canvasHost)) { ch->relayoutFloaters(); } } bool MainWindow::eventFilter(QObject* watched, QEvent* event) { if (watched == m_editorCanvas && m_previewRequested && m_editorCanvas && m_canvasHost) { if (event->type() == QEvent::MouseMove) { const auto me = static_cast(event); #if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) const QPoint pe = me->position().toPoint(); #else const QPoint pe = me->pos(); #endif const QPoint inHost = m_editorCanvas->mapTo(m_canvasHost, pe); updatePreviewEdgeArrowsHover(QPointF(inHost)); } else if (event->type() == QEvent::Leave) { if (m_previewArrowLeft) { m_previewArrowLeft->hide(); } if (m_previewArrowRight) { m_previewArrowRight->hide(); } if (auto* ch = dynamic_cast(m_canvasHost)) { ch->relayoutFloaters(); } } } if (event->type() == QEvent::Resize && watched == m_dockProjectTree) { if (m_dockProjectTree && m_workspace.isOpen() && !m_previewRequested && !m_dockProjectTree->isFloating()) { const int w = m_dockProjectTree->width(); if (w > 0 && w < kRightDockAutoHideBelow && m_dockProjectTree->isVisible()) { m_rightDocksNarrowHidden = true; m_dockProjectTree->hide(); if (m_dockProperties) { m_dockProperties->hide(); } } } } return false; }