#include "main_window/MainWindow.h" #include "dialogs/AboutWindow.h" #include "dialogs/CancelableTaskDialog.h" #include "dialogs/EntityFinalizeDialog.h" #include "dialogs/BlackholeResolveDialog.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 "timeline/TimelineWidget.h" #include "dialogs/FrameAnimationDialog.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 #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 水平方向可拖到的最小宽度(须小于 kRightDockAutoHideBelow,否则无法触发自动隐藏) constexpr int kRightDockMinimumWidth = 80; /// 列宽小于此值时自动隐藏右侧两 dock constexpr int kRightDockAutoHideBelow = 92; /// 右侧 dock 列最大宽度,避免过宽挤占画布 constexpr int kRightDockMaximumWidth = 252; /// 属性区表单内容最大宽度(dock 仍可略宽,两侧留白,避免 SpinBox 被拉得过开) constexpr int kPropertyPanelContentMaxWidth = 232; /// 启动时垂直分割高度:项目树较矮、属性区较高 constexpr int kProjectTreeDockStartupHeight = 148; constexpr int kPropertiesDockStartupHeight = 392; void polishCompactToolButton(QToolButton* b, int px = 40) { if (!b) return; b->setFixedSize(px, px); b->setFocusPolicy(Qt::NoFocus); b->setAutoRaise(true); b->setIconSize(QSize(px - 14, px - 14)); } 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: 10px; } #EditorToolRail QToolButton { border: 1px solid transparent; border-radius: 8px; padding: 2px; 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: 10px; } #FloatingModeDock QComboBox { background-color: palette(button); color: palette(button-text); border: 1px solid palette(mid); border-radius: 6px; padding: 4px 6px; min-height: 22px; } #FloatingModeDock QComboBox:hover { background-color: palette(light); } #FloatingModeDock QComboBox:focus { border: 1px solid palette(highlight); } )"; /// 避免滚轮先被 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; 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(); } 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"; 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); } // 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(); } }; } // namespace /// @brief MainWindow 类实现,负责构建和管理主界面,包括菜单、停靠窗口和属性面板。 MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) { // 设置窗口大小 resize(1200, 800); rebuildCentralPages(); createMenus(); createProjectTreeDock(); createTimelineDock(); createResourceLibraryDock(); if (m_previewBtnPlay && m_previewBtnPause && m_btnPlay) { connect(m_previewBtnPlay, &QToolButton::clicked, this, [this]() { if (m_btnPlay && !m_btnPlay->isChecked()) { m_btnPlay->setChecked(true); } }); connect(m_previewBtnPause, &QToolButton::clicked, this, [this]() { if (m_btnPlay && m_btnPlay->isChecked()) { m_btnPlay->setChecked(false); } }); } 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("工具"); statusBar()->showMessage("就绪"); } 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(QStringLiteral("播放 / 暂停")); polishCompactToolButton(m_btnPlay, 34); layout->addWidget(m_btnPlay); m_schemeSelector = new QComboBox(bar); m_schemeSelector->setMinimumWidth(140); m_schemeSelector->setToolTip(QStringLiteral("动画方案")); layout->addWidget(m_schemeSelector); m_timeline = new TimelineWidget(bar); m_timeline->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::MinimumExpanding); layout->addWidget(m_timeline, 1); // 合并后的关键帧按钮:一次写入位置 + userScale auto* btnKeyCombined = new QToolButton(bar); setToolButtonIconOrText(btnKeyCombined, QStringLiteral("media-record"), QStringLiteral("关键帧")); btnKeyCombined->setToolTip(QStringLiteral("在当前帧记录实体的位置与缩放关键帧")); 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()) { return; } // 简化:无编排,globalFrame==localFrame,固定 600 帧循环 m_currentFrame = (m_currentFrame + 1) % core::Project::kClipFixedFrames; 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, core::Project::kClipFixedFrames - 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); m_editorCanvas->setTempHiddenIds(m_tempHiddenEntityIds, m_tempHiddenToolIds); 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, core::Project::kClipFixedFrames - 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 schemeId = m_schemeSelector->itemData(idx).toString(); // 特殊项:新建方案 if (schemeId == QStringLiteral("__create__")) { auto& proj = m_workspace.project(); auto nextId = [](const QString& prefix, const QStringList& existing) { int n = 1; while (existing.contains(prefix + QString::number(n))) ++n; return prefix + QString::number(n); }; QStringList clipIds; for (const auto& c : proj.animationClips()) clipIds << c.id; QStringList schemeIds; for (const auto& s : proj.animationSchemes()) schemeIds << s.id; const QString clipId = nextId(QStringLiteral("clip-"), clipIds); core::Project::AnimationClip clip; clip.id = clipId; clip.name = QStringLiteral("Clip_%1").arg(proj.animationClips().size() + 1, 3, 10, QChar('0')); auto clips = proj.animationClips(); clips.push_back(clip); proj.setAnimationClips(clips); const QString schemeNewId = nextId(QStringLiteral("scheme-"), schemeIds); core::Project::NlaStrip st; st.id = QStringLiteral("strip-1"); st.clipId = clipId; st.startSlot = 0; st.slotLen = 1; st.enabled = true; core::Project::NlaTrack tr; tr.id = QStringLiteral("track-1"); tr.name = QStringLiteral("Track"); tr.strips = {st}; core::Project::AnimationScheme scheme; scheme.id = schemeNewId; scheme.name = QStringLiteral("方案_%1").arg(proj.animationSchemes().size() + 1, 3, 10, QChar('0')); scheme.tracks = {tr}; auto schemes = proj.animationSchemes(); schemes.push_back(scheme); proj.setAnimationSchemes(schemes); proj.setActiveSchemeId(schemeNewId); proj.setSelectedStripId(st.id); m_workspace.save(); refreshEditorPage(); return; } if (schemeId.isEmpty()) return; if (schemeId == m_workspace.project().activeSchemeId()) return; m_workspace.project().setActiveSchemeId(schemeId); // 切换时默认选中该方案第一条带 const auto* scheme = m_workspace.project().findSchemeById(schemeId); QString firstStrip; if (scheme) { for (const auto& tr : scheme->tracks) { if (!tr.strips.isEmpty()) { firstStrip = tr.strips.front().id; break; } } } m_workspace.project().setSelectedStripId(firstStrip); m_workspace.save(); refreshEditorPage(); }); 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()); actDeleteKey->setEnabled(m_workspace.isOpen() && m_timeline->hasSelectedKeyframe() && (entityKeyUi || toolKeyUi)); const int selA = m_timeline->selectionStart(); const int selB = m_timeline->selectionEnd(); const bool hasRange = (selA >= 0 && selB >= 0); actClear->setEnabled(hasRange); actAnim->setEnabled(hasRange && !m_selectedEntityId.isEmpty() && m_workspace.isOpen()); // 右键命中帧:用鼠标位置对应的 frame // 右键命中 localFrame:globalFrame==localFrame m_currentFrame = std::clamp(frame, 0, core::Project::kClipFixedFrames - 1); if (m_editorCanvas) m_editorCanvas->setCurrentFrame(m_currentFrame); QAction* chosen = menu.exec(globalPos); 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); break; case TimelineWidget::KeyKind::UserScale: if (!m_selectedEntityId.isEmpty()) ok = m_workspace.removeEntityUserScaleKey(m_selectedEntityId, 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 % core::Project::kClipFixedFrames; if (m_timelineRangeEnd < 0) { m_timelineRangeEnd = m_currentFrame % core::Project::kClipFixedFrames; } 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 % core::Project::kClipFixedFrames; if (m_timelineRangeStart < 0) { m_timelineRangeStart = m_currentFrame % core::Project::kClipFixedFrames; } 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 = core::Project::kClipFixedFrames - 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 || !m_editorCanvas) { return; } using Mode = EditorCanvas::EntityCreateSegmentMode; const Mode m = m_editorCanvas->entityCreateSegmentMode(); if (m == Mode::Manual) { m_btnCreateEntity->setToolTip(QStringLiteral("创建实体:手动分割\n" "再次单击本按钮可选择:手动/吸附/模型")); } else if (m == Mode::Snap) { m_btnCreateEntity->setToolTip(QStringLiteral("创建实体:吸附分割(前端边缘吸附算法)\n" "再次单击本按钮可选择:手动/吸附/模型")); } else { m_btnCreateEntity->setToolTip(QStringLiteral("创建实体:模型分割(SAM)\n" "再次单击本按钮可选择:手动/吸附/模型")); } } void MainWindow::updateStatusBarText() { // 坐标系:图片左上角为 (0,0),单位为像素(world 坐标与背景像素一致) if (m_hasSelectedEntity) { statusBar()->showMessage(QStringLiteral("实体中心 (%1, %2, %3) | 鼠标 (%4, %5, %6)") .arg(m_selectedEntityOrigin.x(), 0, 'f', 1) .arg(m_selectedEntityOrigin.y(), 0, 'f', 1) .arg(m_selectedEntityDepth) .arg(m_lastWorldPos.x(), 0, 'f', 1) .arg(m_lastWorldPos.y(), 0, 'f', 1) .arg(m_lastWorldZ)); } else { statusBar()->showMessage(QStringLiteral("鼠标 (%1, %2, %3)") .arg(m_lastWorldPos.x(), 0, 'f', 1) .arg(m_lastWorldPos.y(), 0, 'f', 1) .arg(m_lastWorldZ)); } } 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) { statusBar()->showMessage(QStringLiteral("已取消计算深度")); 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; } statusBar()->showMessage(QStringLiteral("深度已计算")); refreshEditorPage(); refreshProjectTree(); updateUiEnabledState(); refreshPreviewPage(); }); dlg->show(); } void MainWindow::onTogglePlay(bool on) { m_playing = on; if (m_btnPlay) { m_btnPlay->setText(on ? QStringLiteral("⏸") : QStringLiteral("▶")); } if (m_playTimer) { if (on) { const int fps = 30; // 固定 30fps 播放 m_playTimer->start(1000 / fps); } else { m_playTimer->stop(); } } syncPreviewPlaybackBar(); } void MainWindow::syncPreviewPlaybackBar() { if (!m_previewBtnPlay || !m_previewBtnPause) { return; } m_previewBtnPlay->setEnabled(!m_playing); m_previewBtnPause->setEnabled(m_playing); } void MainWindow::onInsertCombinedKey() { if (m_selectedEntityId.isEmpty() || !m_editorCanvas) { return; } // 位置关键帧:使用当前帧下的动画原点 const QPointF o = m_editorCanvas->selectedAnimatedOriginWorld(); m_workspace.setEntityLocationKey(m_selectedEntityId, m_currentFrame % core::Project::kClipFixedFrames, o); // 缩放关键帧:使用当前帧下的 userScale(而非 depthScale01) const double s = m_editorCanvas->selectedUserScale(); m_workspace.setEntityUserScaleKey(m_selectedEntityId, m_currentFrame % core::Project::kClipFixedFrames, s); refreshEditorPage(); } void MainWindow::createMenus() { createFileMenu(); createEditMenu(); createViewMenu(); createWindowMenu(); createHelpMenu(); } 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]() { setPreviewRequested(true); }); m_actionBackToEditor = viewMenu->addAction(QStringLiteral("返回编辑模式")); connect(m_actionBackToEditor, &QAction::triggered, this, [this]() { 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(QStringLiteral("右键条目可切换编辑与预览。")); m_dockProjectTree->setAllowedAreas(Qt::LeftDockWidgetArea | Qt::RightDockWidgetArea | Qt::BottomDockWidgetArea | Qt::TopDockWidgetArea); m_dockProjectTree->setFeatures(QDockWidget::DockWidgetMovable | QDockWidget::DockWidgetFloatable | QDockWidget::DockWidgetClosable); m_dockProjectTree->setMinimumWidth(kRightDockMinimumWidth); auto* dockContent = new QWidget(m_dockProjectTree); auto* dockLayout = new QVBoxLayout(dockContent); dockLayout->setContentsMargins(4, 4, 4, 4); dockLayout->setSpacing(4); m_projectTree = new ProjectTreeWidget(dockContent); m_projectTree->setColumnCount(2); m_projectTree->setHeaderHidden(true); m_projectTree->setRootIsDecorated(true); m_projectTree->setIndentation(14); m_projectTree->setUniformRowHeights(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::ScrollBarAsNeeded); treeScroll->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); treeScroll->setWidget(m_projectTree); dockLayout->addWidget(treeScroll, 1); m_dockProjectTree->setWidget(dockContent); m_dockProjectTree->installEventFilter(this); m_dockProperties = new QDockWidget(QStringLiteral("属性"), this); m_dockProperties->setToolTip(QStringLiteral("在项目树中右键条目可预览或操作背景。")); m_dockProperties->setAllowedAreas(Qt::LeftDockWidgetArea | Qt::RightDockWidgetArea | Qt::BottomDockWidgetArea | Qt::TopDockWidgetArea); m_dockProperties->setFeatures(QDockWidget::DockWidgetMovable | QDockWidget::DockWidgetFloatable | QDockWidget::DockWidgetClosable); m_dockProperties->setMinimumWidth(200); m_bgPropertySection = new gui::BackgroundPropertySection(); m_blackholePropertySection = new gui::BlackholePropertySection(); m_entityPropertySection = new gui::EntityPropertySection(); m_toolPropertySection = new gui::ToolPropertySection(); m_propertyStack = new QStackedWidget(); m_propertyStack->setContentsMargins(4, 4, 4, 4); m_propertyStack->setMaximumWidth(kPropertyPanelContentMaxWidth); m_propertyStack->addWidget(m_bgPropertySection); m_propertyStack->addWidget(m_blackholePropertySection); m_propertyStack->addWidget(m_entityPropertySection); m_propertyStack->addWidget(m_toolPropertySection); 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, core::Project::kClipFixedFrames - 1); if (!m_workspace.setEntityVisibilityKey(m_selectedEntityId, f, on)) return; refreshEditorPage(); }); connect(m_entityPropertySection, &gui::EntityPropertySection::pivotEdited, this, [this](double x, double y) { if (m_selectedEntityId.isEmpty() || !m_editorCanvas) return; const double s = m_editorCanvas->selectedCombinedScale(); if (s <= 1e-9) return; QPointF targetPivot(x, y); QString parentId; for (const auto& e : m_workspace.entities()) { if (e.id == m_selectedEntityId) { parentId = e.parentId; break; } } if (!parentId.isEmpty()) { const auto rf = core::eval::evaluateAtFrame(m_workspace.project(), m_currentFrame, 10); for (const auto& pe : rf.entities) { if (pe.entity.id == parentId) { targetPivot += pe.entity.originWorld; break; } } for (const auto& pt : rf.tools) { if (pt.tool.id == parentId) { targetPivot += pt.tool.originWorld; break; } } } if (!m_workspace.reanchorEntityPivot(m_selectedEntityId, m_currentFrame, targetPivot, s)) return; refreshEditorPage(); refreshDopeSheet(); }); connect(m_entityPropertySection, &gui::EntityPropertySection::centroidEdited, this, [this](double x, double y) { if (m_selectedEntityId.isEmpty() || !m_editorCanvas) return; const double s = m_editorCanvas->selectedCombinedScale(); if (s <= 1e-9) return; QPointF targetCentroid(x, y); QString parentId; for (const auto& e : m_workspace.entities()) { if (e.id == m_selectedEntityId) { parentId = e.parentId; break; } } if (!parentId.isEmpty()) { const auto rf = core::eval::evaluateAtFrame(m_workspace.project(), m_currentFrame, 10); for (const auto& pe : rf.entities) { if (pe.entity.id == parentId) { targetCentroid += pe.entity.originWorld; break; } } for (const auto& pt : rf.tools) { if (pt.tool.id == parentId) { targetCentroid += pt.tool.originWorld; break; } } } const bool autoKey = m_chkAutoKeyframe && m_chkAutoKeyframe->isChecked(); if (!m_workspace.moveEntityCentroidTo(m_selectedEntityId, m_currentFrame, targetCentroid, s, autoKey)) 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_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](double x, double y) { if (m_selectedToolId.isEmpty() || !m_workspace.isOpen()) return; const int f = std::clamp(m_currentFrame, 0, core::Project::kClipFixedFrames - 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(x, y) : (parentWorld + QPointF(x, 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::visibleToggled, this, [this](bool on) { if (m_selectedToolId.isEmpty() || !m_workspace.isOpen()) return; const int f = std::clamp(m_currentFrame, 0, core::Project::kClipFixedFrames - 1); if (!m_workspace.setToolVisibilityKey(m_selectedToolId, f, on)) return; refreshEditorPage(); }); auto* propScroll = new SpinFriendlyScrollArea(m_dockProperties); propScroll->setWidgetResizable(true); propScroll->setFrameShape(QFrame::NoFrame); propScroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAsNeeded); propScroll->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); 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); 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); } }); // 停靠时限制右侧列最大宽度;浮动时解除,避免属性/项目树窗口过窄难用 const auto applyRightDockColumnMaxWidth = [](QDockWidget* dock) { if (!dock) { return; } dock->setMaximumWidth(dock->isFloating() ? QWIDGETSIZE_MAX : kRightDockMaximumWidth); }; applyRightDockColumnMaxWidth(m_dockProjectTree); applyRightDockColumnMaxWidth(m_dockProperties); connect(m_dockProjectTree, &QDockWidget::topLevelChanged, this, [applyRightDockColumnMaxWidth, this](bool) { applyRightDockColumnMaxWidth(m_dockProjectTree); applyRightDockColumnMaxWidth(m_dockProperties); }); connect(m_dockProperties, &QDockWidget::topLevelChanged, this, [applyRightDockColumnMaxWidth, this](bool) { applyRightDockColumnMaxWidth(m_dockProjectTree); applyRightDockColumnMaxWidth(m_dockProperties); }); } void MainWindow::refreshPropertyPanel() { if (!m_bgPropertySection || !m_blackholePropertySection || !m_entityPropertySection || !m_toolPropertySection || !m_propertyStack) { return; } 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)) { const QImage img(bgAbs); m_bgSizeTextCache = img.isNull() ? QStringLiteral("-") : QStringLiteral("%1 × %2").arg(img.width()).arg(img.height()); } 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 activeClipForUi = [this]() -> const core::Project::AnimationClip* { if (!m_workspace.isOpen()) return nullptr; const auto& allClips = m_workspace.project().animationClips(); const auto* scheme = m_workspace.project().activeSchemeOrNull(); const core::Project::AnimationClip* clip = nullptr; if (scheme) { const QString stripId = m_workspace.project().selectedStripId(); const core::Project::NlaStrip* chosenStrip = nullptr; if (!stripId.isEmpty()) { for (const auto& tr : scheme->tracks) { for (const auto& st : tr.strips) { if (st.id == stripId) { chosenStrip = &st; break; } } if (chosenStrip) break; } } if (!chosenStrip) { for (const auto& tr : scheme->tracks) { for (const auto& st : tr.strips) { if (st.enabled && !st.muted) { chosenStrip = &st; break; } } if (chosenStrip) break; } } if (chosenStrip) { clip = m_workspace.project().findClipById(chosenStrip->clipId); } } if (!clip && !allClips.isEmpty()) clip = &allClips.front(); return clip; }; auto visAtFrame = [](const QVector& keys, int frame, bool defaultVisible) { if (keys.isEmpty()) return defaultVisible; bool cur = defaultVisible; int best = -1; for (const auto& k : keys) { if (k.frame <= frame && k.frame >= best) { best = k.frame; cur = k.value; } } return cur; }; const bool toolUi = m_hasSelectedTool && m_workspace.isOpen() && !m_selectedToolId.isEmpty(); if (toolUi) { gui::ToolPropertyUiState st; const int f = std::clamp(m_currentFrame, 0, core::Project::kClipFixedFrames - 1); const auto* clip = activeClipForUi(); 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; { 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; const QVector keys = (clip && clip->toolVisibilityKeys.contains(t.id)) ? clip->toolVisibilityKeys.value(t.id) : QVector{}; st.visible = visAtFrame(keys, 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) { 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_propertyStack->setCurrentWidget(m_bgPropertySection); m_dockProperties->setWindowTitle(QStringLiteral("属性 — 背景")); return; } QString displayName; double userScale = 1.0; bool ignoreDist = false; bool entVisible = true; QString parentId; core::EntityIntroContent intro; const int f = std::clamp(m_currentFrame, 0, core::Project::kClipFixedFrames - 1); const auto* clip = activeClipForUi(); 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; const QVector keys = (clip && clip->entityVisibilityKeys.contains(e.id)) ? clip->entityVisibilityKeys.value(e.id) : QVector{}; entVisible = visAtFrame(keys, f, e.visible); break; } } m_selectedEntityDisplayNameCache = displayName; 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.pivot = m_editorCanvas->selectedAnimatedOriginWorld(); st.centroid = m_editorCanvas->selectedEntityCentroidWorld(); if (!parentId.isEmpty()) { QPointF parentWorld; const auto rf = core::eval::evaluateAtFrame(m_workspace.project(), m_currentFrame, 10); 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.pivot -= parentWorld; st.centroid -= parentWorld; st.parentRelativeMode = true; } st.userScale = userScale; st.ignoreDistanceScale = ignoreDist; st.visible = entVisible; 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; } 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.pivot = m_editorCanvas->selectedAnimatedOriginWorld(); st.centroid = m_editorCanvas->selectedEntityCentroidWorld(); QString parentId; for (const auto& e : m_workspace.entities()) { if (e.id == m_selectedEntityId) { parentId = e.parentId; break; } } if (!parentId.isEmpty()) { QPointF parentWorld; const auto rf = core::eval::evaluateAtFrame(m_workspace.project(), m_currentFrame, 10); 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.pivot -= parentWorld; st.centroid -= parentWorld; st.parentRelativeMode = true; } st.userScale = m_editorCanvas->selectedUserScale(); // ignoreDistanceScale 在拖动中不变更,fast path 不必更新(避免再遍历 entities) m_entityPropertySection->applyState(st); // 拖动中不切换 stack、不改 dock 标题,避免多余布局开销 } void MainWindow::refreshProjectTree() { if (!m_projectTree) { return; } 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->setFixedSize(eyeSide, eyeSide); btn->setIconSize(QSize(iconPm, iconPm)); btn->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed); btn->setAutoRaise(true); btn->setCheckable(true); btn->setChecked(visible); if (canvasTempOnly) { btn->setToolTip(visible ? QStringLiteral("画布显示中。点击可仅在此视图中暂时隐藏(工程可见性在「属性」)") : QStringLiteral("画布已暂时隐藏。点击恢复显示(工程可见性在「属性」)")); } else { btn->setToolTip(visible ? QStringLiteral("隐藏") : QStringLiteral("显示")); } // 尽量用主题图标,失败则退化为文本 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 { btn->setText(visible ? QStringLiteral("👁") : QStringLiteral("×")); } connect(btn, &QToolButton::toggled, this, [btn, canvasTempOnly](bool on) { if (canvasTempOnly) { btn->setToolTip(on ? QStringLiteral("画布显示中。点击可仅在此视图中暂时隐藏(工程可见性在「属性」)") : QStringLiteral("画布已暂时隐藏。点击恢复显示(工程可见性在「属性」)")); } else { btn->setToolTip(on ? QStringLiteral("隐藏") : QStringLiteral("显示")); } 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, QStringLiteral("节点:%1").arg(holeName)); 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); 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(); } 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_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_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(); if (m_timeline) { m_timeline->setToolKeyframeTracks({}, {}); } 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(); if (m_editorCanvas) { m_editorCanvas->clearEntitySelection(); } 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(); if (m_editorCanvas) { m_editorCanvas->clearEntitySelection(); m_editorCanvas->selectBlackholeByEntityId(entityId); } updateTimelineTracks(); refreshPropertyPanel(); } } else if (kind == QStringLiteral("background")) { m_selectedBlackholeEntityId.clear(); m_hasSelectedTool = false; m_selectedToolId.clear(); m_editorCanvas->clearEntitySelection(); m_editorCanvas->clearBlackholeSelection(); 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 (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_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); m_modeSelector->setCurrentIndex(m_previewRequested ? 1 : 0); m_modeSelector->setItemData(0, projectOpen ? QVariant() : QVariant(0), Qt::UserRole - 1); m_modeSelector->setItemData(1, (projectOpen && hasBg) ? QVariant() : QVariant(0), Qt::UserRole - 1); m_modeSelector->blockSignals(false); } // 统一套用两态 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; } return UiMode::Editor; } void MainWindow::applyUiMode(UiMode mode) { const bool projectOpen = (mode == UiMode::Editor || mode == UiMode::Preview); const bool preview = (mode == UiMode::Preview); // 中央页面:欢迎 / 工作区(编辑与预览共用同一画布,仅状态不同) if (!projectOpen) { showWelcomePage(); } else { showEditorPage(); refreshEditorPage(); } // Dock 显隐策略: // - Welcome:所有 dock 必须隐藏,确保“未打开项目时只显示欢迎界面” // - Editor:按照默认规则显示(当前只有项目树) // - 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); } 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); } if (m_previewPlaybackBar) { m_previewPlaybackBar->setVisible(projectOpen && preview); } 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); m_actionToggleTimeline->blockSignals(true); m_actionToggleTimeline->setChecked(projectOpen && !preview && 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 = menu.exec(globalPos); 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) { setPreviewRequested(true); } else if (chosen == actBack) { 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); // 欢迎页(左:操作说明;右:最近项目,类似 Qt Creator) m_pageWelcome = new QWidget(m_centerStack); auto* welcomeRoot = new QHBoxLayout(m_pageWelcome); welcomeRoot->setContentsMargins(40, 40, 40, 40); welcomeRoot->setSpacing(32); auto* welcomeLeft = new QVBoxLayout(); welcomeLeft->setSpacing(16); auto* title = new QLabel(QStringLiteral("欢迎使用"), m_pageWelcome); QFont f = title->font(); f.setPointSize(f.pointSize() + 6); f.setBold(true); title->setFont(f); welcomeLeft->addWidget(title); auto* desc = new QLabel(QStringLiteral("请创建或打开一个项目。"), m_pageWelcome); desc->setWordWrap(true); welcomeLeft->addWidget(desc); auto* buttonsRow = new QHBoxLayout(); auto* btnCreate = new QPushButton(QStringLiteral("创建项目"), m_pageWelcome); auto* btnOpen = new QPushButton(QStringLiteral("打开项目"), m_pageWelcome); buttonsRow->addWidget(btnCreate); buttonsRow->addWidget(btnOpen); buttonsRow->addStretch(1); welcomeLeft->addLayout(buttonsRow); welcomeLeft->addStretch(1); connect(btnCreate, &QPushButton::clicked, this, &MainWindow::onNewProject); connect(btnOpen, &QPushButton::clicked, this, &MainWindow::onOpenProject); auto* recentFrame = new QFrame(m_pageWelcome); recentFrame->setFrameShape(QFrame::StyledPanel); auto* recentOuter = new QVBoxLayout(recentFrame); recentOuter->setContentsMargins(16, 16, 16, 16); recentOuter->setSpacing(8); auto* recentTitle = new QLabel(QStringLiteral("最近的项目"), recentFrame); QFont rf = recentTitle->font(); rf.setBold(true); recentTitle->setFont(rf); recentOuter->addWidget(recentTitle); m_welcomeRecentEmptyLabel = new QLabel(QStringLiteral("暂无最近打开的项目"), recentFrame); m_welcomeRecentEmptyLabel->setStyleSheet(QStringLiteral("QLabel { color: palette(mid); }")); 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 = menu.exec(m_welcomeRecentTree->viewport()->mapToGlobal(pos)); if (chosen == actRemove) { const QString path = item->data(0, Qt::UserRole).toString(); if (!path.isEmpty()) { m_recentHistory.removeAndSave(path); refreshWelcomeRecentList(); } } }); welcomeRoot->addLayout(welcomeLeft, 1); 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_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_previewBtnPlay = new QToolButton(m_previewPlaybackBar); m_previewBtnPlay->setText(QStringLiteral("播放")); m_previewBtnPlay->setToolTip(QStringLiteral("播放时间轴")); m_previewBtnPause = new QToolButton(m_previewPlaybackBar); m_previewBtnPause->setText(QStringLiteral("暂停")); m_previewBtnPause->setToolTip(QStringLiteral("暂停时间轴")); polishCompactToolButton(m_previewBtnPlay, 36); polishCompactToolButton(m_previewBtnPause, 36); pbl->addWidget(m_previewBtnPlay); pbl->addWidget(m_previewBtnPause); m_previewPlaybackBar->setParent(canvasHost); m_previewPlaybackBar->hide(); canvasHost->previewPlaybackBar = m_previewPlaybackBar; 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(8, 4, 10, 4); modeDockLayout->setSpacing(0); m_modeSelector = new QComboBox(m_floatingModeDock); m_modeSelector->addItem(QStringLiteral("编辑")); m_modeSelector->addItem(QStringLiteral("预览")); { QFontMetrics fm(m_modeSelector->font()); int textW = 0; for (int i = 0; i < m_modeSelector->count(); ++i) { textW = std::max(textW, fm.horizontalAdvance(m_modeSelector->itemText(i))); } const int indicator = m_modeSelector->style()->pixelMetric(QStyle::PM_ScrollBarExtent, nullptr, m_modeSelector); // 文本 + 下拉箭头 + QSS 水平 padding(约 6+6)与边框余量 m_modeSelector->setFixedWidth(textW + indicator + 14); } connect(m_modeSelector, &QComboBox::currentIndexChanged, this, [this](int index) { setPreviewRequested(index == 1); }); 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(52); auto* toolLayout = new QVBoxLayout(m_floatingToolDock); toolLayout->setContentsMargins(6, 8, 6, 8); toolLayout->setSpacing(6); auto* group = new QButtonGroup(m_floatingToolDock); group->setExclusive(true); auto* btnMove = new QToolButton(m_floatingToolDock); btnMove->setCheckable(true); btnMove->setChecked(true); setToolButtonIconOrText(btnMove, QStringLiteral("transform-move"), QStringLiteral("移")); btnMove->setToolTip(QStringLiteral("移动")); polishCompactToolButton(btnMove, 40); toolLayout->addWidget(btnMove, 0, Qt::AlignHCenter); group->addButton(btnMove, static_cast(EditorCanvas::Tool::Move)); auto* btnZoom = new QToolButton(m_floatingToolDock); btnZoom->setCheckable(true); setToolButtonIconOrText(btnZoom, QStringLiteral("zoom-in"), QStringLiteral("放")); btnZoom->setToolTip(QStringLiteral("缩放")); polishCompactToolButton(btnZoom, 40); toolLayout->addWidget(btnZoom, 0, Qt::AlignHCenter); group->addButton(btnZoom, 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(QStringLiteral("创建实体")); polishCompactToolButton(m_btnCreateEntity, 40); 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(); statusBar()->showMessage(QStringLiteral("已切换分割方式")); }); } 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(QStringLiteral("深度叠加")); polishCompactToolButton(m_btnToggleDepthOverlay, 40); toolLayout->addWidget(m_btnToggleDepthOverlay, 0, Qt::AlignHCenter); auto* btnFit = new QToolButton(m_floatingToolDock); setToolButtonIconOrText(btnFit, QStringLiteral("zoom-fit-best"), QStringLiteral("框")); btnFit->setToolTip(QStringLiteral("适配视口")); polishCompactToolButton(btnFit, 40); toolLayout->addWidget(btnFit, 0, Qt::AlignHCenter); toolLayout->addStretch(1); canvasHost->toolDock = m_floatingToolDock; m_floatingModeDock->setParent(canvasHost); m_floatingToolDock->setParent(canvasHost); 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) { m_hasSelectedEntity = hasSel; m_selectedEntityId = id; m_selectedBlackholeEntityId.clear(); m_selectedEntityDepth = depth; m_selectedEntityOrigin = origin; m_hasSelectedTool = false; m_selectedToolId.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); m_hasSelectedTool = hasSel; m_selectedToolId = id; m_selectedBlackholeEntityId.clear(); if (hasSel) { m_hasSelectedEntity = false; m_selectedEntityId.clear(); m_selectedEntityDisplayNameCache.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) { statusBar()->showMessage(QStringLiteral("已取消分割")); 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); } QImage depth8; if (m_workspace.hasDepth()) { const QString dpath = m_workspace.depthAbsolutePath(); if (!dpath.isEmpty() && QFileInfo::exists(dpath)) { const QImage dimg(dpath); if (!dimg.isNull()) { depth8 = dimg.convertToFormat(QImage::Format_Grayscale8); } } } 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; } const QString bgAbs = m_workspace.backgroundAbsolutePath(); QImage bg(bgAbs); 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); statusBar()->showMessage(QStringLiteral("分割完成:可拖动顶点微调,回车/点击空白确认")); }); 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(); QImage depth8(dpath); if (!depth8.isNull()) { depth8 = depth8.convertToFormat(QImage::Format_Grayscale8); 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"); QImage bg(m_workspace.backgroundAbsolutePath()); 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; } if (!m_workspace.addEntity(ent, cutout)) { QMessageBox::warning(this, QStringLiteral("实体"), QStringLiteral("保存实体失败。")); return; } if (m_editorCanvas) { m_editorCanvas->clearPendingEntityPolygon(); } statusBar()->showMessage(QStringLiteral("实体已创建")); 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, m_currentFrame % core::Project::kClipFixedFrames, 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, m_currentFrame % core::Project::kClipFixedFrames, autoKey)) { return; } refreshEditorPage(); refreshProjectTree(); updateUiEnabledState(); }); 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; } statusBar()->showMessage(QStringLiteral("黑洞已通过背景复制修复")); refreshProjectTree(); updateUiEnabledState(); if (m_editorCanvas) { m_editorCanvas->notifyBackgroundContentChanged(); } 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(btnFit, &QToolButton::clicked, this, [this]() { if (m_editorCanvas) { m_editorCanvas->zoomToFit(); } }); m_centerStack->addWidget(m_pageWelcome); m_centerStack->addWidget(m_pageEditor); setCentralWidget(centerShell); 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, path); item->setToolTip(1, path); 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(); statusBar()->showMessage(QStringLiteral("项目已打开:%1").arg(m_workspace.projectDir())); refreshEditorPage(); refreshProjectTree(); 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; } 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& schemes = m_workspace.project().animationSchemes(); for (const auto& s : schemes) { const QString label = s.name.isEmpty() ? s.id : s.name; m_schemeSelector->addItem(label, s.id); } m_schemeSelector->addItem(QStringLiteral("+ 新建方案…"), QStringLiteral("__create__")); const QString activeId = m_workspace.project().activeSchemeId(); 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(); const core::eval::ResolvedProjectFrame 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); m_editorCanvas->setCurrentFrame(m_currentFrame); m_editorCanvas->setTempHiddenIds(m_tempHiddenEntityIds, m_tempHiddenToolIds); updateTimelineTracks(); } else { m_editorCanvas->setEntities({}, {}, 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()); // 未选中时不显示关键帧(按需求) if (!wantEntity) m_timeline->setKeyframeTracks({}, {}, {}, {}); if (!wantTool) m_timeline->setToolKeyframeTracks({}, {}); if (!wantEntity && !wantTool) return; // 选择当前 clip(与 workspace 写入规则一致) const core::Project::AnimationClip* clip = nullptr; const auto& allClips = m_workspace.project().animationClips(); const auto* scheme = m_workspace.project().activeSchemeOrNull(); if (scheme) { const QString stripId = m_workspace.project().selectedStripId(); const core::Project::NlaStrip* chosenStrip = nullptr; if (!stripId.isEmpty()) { for (const auto& tr : scheme->tracks) { for (const auto& st : tr.strips) { if (st.id == stripId) { chosenStrip = &st; break; } } if (chosenStrip) break; } } if (!chosenStrip) { for (const auto& tr : scheme->tracks) { for (const auto& st : tr.strips) { if (st.enabled && !st.muted) { chosenStrip = &st; break; } } if (chosenStrip) break; } } if (chosenStrip) { clip = m_workspace.project().findClipById(chosenStrip->clipId); } } if (!clip && !allClips.isEmpty()) { clip = &allClips.front(); } if (!clip) return; auto framesOfVec2 = [](const QVector& keys) { QVector out; out.reserve(keys.size()); for (const auto& k : keys) out.push_back(k.frame); return out; }; auto framesOfDouble = [](const QVector& keys) { QVector out; out.reserve(keys.size()); for (const auto& k : keys) out.push_back(k.frame); 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; }; auto framesOfBool = [](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 = clip->entityLocationKeys.value(m_selectedEntityId); const auto sc = clip->entityUserScaleKeys.value(m_selectedEntityId); const auto im = clip->entityImageFrames.value(m_selectedEntityId); const auto vis = clip->entityVisibilityKeys.value(m_selectedEntityId); m_timeline->setKeyframeTracks(framesOfVec2(loc), framesOfDouble(sc), framesOfImage(im), framesOfBool(vis)); } if (wantTool) { const auto loc = clip->toolLocationKeys.value(m_selectedToolId); const auto vis = clip->toolVisibilityKeys.value(m_selectedToolId); m_timeline->setToolKeyframeTracks(framesOfVec2(loc), framesOfBool(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, core::Project::kClipFixedFrames - 1); m_timeline->setFrameRange(0, core::Project::kClipFixedFrames); 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 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("—")); }; bool hasLoc = false; for (const auto& k : e.locationKeys) { if (k.frame == f) { hasLoc = true; break; } } bool hasSc = false; for (const auto& k : e.depthScaleKeys) { if (k.frame == f) { hasSc = true; break; } } bool hasIm = false; for (const auto& k : e.imageFrames) { 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 = menu.exec(globalPos); 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 = menu.exec(globalPos); 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; } statusBar()->showMessage(QStringLiteral("拖动画布中的青色取样框,松开鼠标即应用;Esc 取消")); return; } else { ok = m_workspace.resolveBlackholeByUseOriginalBackground(entityId); if (!ok) { QMessageBox::warning(this, QStringLiteral("黑洞修复"), QStringLiteral("应用“使用原始背景”失败。")); } } if (ok) { statusBar()->showMessage(QStringLiteral("黑洞修复已应用")); 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(); statusBar()->showMessage(QStringLiteral("项目已创建:%1").arg(m_workspace.projectDir())); refreshEditorPage(); refreshProjectTree(); updateUiEnabledState(); refreshPreviewPage(); } 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_previewRequested = false; 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_currentFrame = 0; statusBar()->showMessage(QStringLiteral("工程已关闭")); refreshEditorPage(); refreshProjectTree(); updateUiEnabledState(); refreshPreviewPage(); } void MainWindow::onUndo() { if (!m_workspace.undo()) { statusBar()->showMessage(QStringLiteral("无法撤销")); return; } refreshEditorPage(); refreshProjectTree(); updateUiEnabledState(); refreshPreviewPage(); } void MainWindow::onRedo() { if (!m_workspace.redo()) { statusBar()->showMessage(QStringLiteral("无法重做")); return; } refreshEditorPage(); refreshProjectTree(); updateUiEnabledState(); refreshPreviewPage(); } void MainWindow::onCopyObject() { // TODO: } void MainWindow::onPasteObject() { // TODO: } void MainWindow::onAbout() { auto aboutDialog = new AboutWindow(this); aboutDialog->exec(); } bool MainWindow::eventFilter(QObject* watched, QEvent* event) { 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; }