Files
hfut-bishe/client/gui/main_window/MainWindow.cpp
2026-04-09 23:13:33 +08:00

3740 lines
150 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#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 <QAbstractItemView>
#include <QAbstractSpinBox>
#include <QApplication>
#include <QAction>
#include <QBoxLayout>
#include <QButtonGroup>
#include <QCheckBox>
#include <QComboBox>
#include <QDockWidget>
#include <QDrag>
#include <QEvent>
#include <QFrame>
#include <QTimer>
#include <QFormLayout>
#include <QInputDialog>
#include <QLabel>
#include <QMenu>
#include <QMenuBar>
#include <QPushButton>
#include <QStackedWidget>
#include <QStatusBar>
#include <QToolButton>
#include <QTreeWidget>
#include <QTreeWidgetItem>
#include <QDragEnterEvent>
#include <QDropEvent>
#include <QMimeData>
#include <functional>
#include <QTreeWidgetItemIterator>
#include <QLineEdit>
#include <QFileDialog>
#include <QFileInfo>
#include <QFontMetrics>
#include <QHeaderView>
#include <QImage>
#include <QMessageBox>
#include <QPixmap>
#include <QFile>
#include <QIcon>
#include <QResizeEvent>
#include <QScrollArea>
#include <QShowEvent>
#include <QtGlobal>
#include <QWheelEvent>
#include <QSizePolicy>
#include <QStyle>
#include <QUrl>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonValue>
#include <QSet>
#include <QJsonObject>
#include <QJsonArray>
#include <QSet>
#include <algorithm>
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<QAbstractSpinBox*>(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<void(const QString& childKind, const QString& childId, const QString& parentKind, const QString& parentId)>
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<core::Project::Entity> ents;
QVector<double> 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<core::Project::Tool> tools;
QVector<double> 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<int>::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
// 右键命中 localFrameglobalFrame==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<ProjectTreeWidget*>(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<QString> entIds;
QSet<QString> 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<QString> 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<double>(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<core::Project::ToolKeyframeBool>& 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<int>(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<core::Project::ToolKeyframeBool> keys =
(clip && clip->toolVisibilityKeys.contains(t.id))
? clip->toolVisibilityKeys.value(t.id)
: QVector<core::Project::ToolKeyframeBool>{};
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<core::Project::ToolKeyframeBool> keys =
(clip && clip->entityVisibilityKeys.contains(e.id))
? clip->entityVisibilityKeys.value(e.id)
: QVector<core::Project::ToolKeyframeBool>{};
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<core::Project::Entity> sortedEnts;
QVector<core::Project::Tool> sortedTools;
QHash<QString, core::Project::Entity> entById;
QHash<QString, core::Project::Tool> 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<const core::Project::Entity*> 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<QString, QVector<NodeRef>> 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<QString> visiting;
std::function<void(const QString&, QTreeWidgetItem*)> 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<CanvasHost*>(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<int>(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<int>(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<int>(EditorCanvas::Tool::CreateEntity));
if (!m_createEntityPopup) {
m_createEntityPopup = new ToolOptionPopup(this);
m_createEntityPopup->setOptions({
{static_cast<int>(EditorCanvas::EntityCreateSegmentMode::Manual), QStringLiteral("手动分割")},
{static_cast<int>(EditorCanvas::EntityCreateSegmentMode::Snap), QStringLiteral("吸附分割")},
{static_cast<int>(EditorCanvas::EntityCreateSegmentMode::Sam), QStringLiteral("模型分割")},
});
connect(m_createEntityPopup, &ToolOptionPopup::optionChosen, this, [this](int id) {
if (!m_editorCanvas) return;
m_editorCanvas->setEntityCreateSegmentMode(static_cast<EditorCanvas::EntityCreateSegmentMode>(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()) {
// 生成稳定且不重复的 identity-<n>
QSet<QString> used;
for (const auto& ex : m_workspace.entities()) {
used.insert(ex.id);
}
int n = static_cast<int>(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<QString> used;
for (const auto& ex : m_workspace.tools()) {
used.insert(ex.id);
}
int n = static_cast<int>(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<QPointF> 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<int>(std::floor(c.x()));
const int yi = static_cast<int>(std::floor(c.y()));
if (xi >= 0 && yi >= 0 && xi < depth8.width() && yi < depth8.height()) {
z = static_cast<int>(static_cast<const uchar*>(depth8.constScanLine(yi))[xi]);
}
}
ent.depth = z;
{
const double ds01 = static_cast<double>(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<QString> used;
for (const auto& ex : m_workspace.entities()) {
used.insert(ex.id);
}
int n = static_cast<int>(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<QPointF>& 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<int>(std::floor(c.x()));
const int yi = static_cast<int>(std::floor(c.y()));
if (xi >= 0 && yi >= 0 && xi < depth8.width() && yi < depth8.height()) {
z = static_cast<int>(static_cast<const uchar*>(depth8.constScanLine(yi))[xi]);
}
}
}
// 生成稳定且不重复的 identity-<n>,同时作为“默认名称”
QString newId;
{
QSet<QString> used;
for (const auto& ex : m_workspace.entities()) {
used.insert(ex.id);
}
int n = static_cast<int>(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<double>(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<EditorCanvas::Tool>(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<CanvasHost*>(m_canvasHost)->relayoutFloaters();
QTimer::singleShot(0, m_canvasHost, [this]() {
if (m_canvasHost) {
static_cast<CanvasHost*>(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<core::Project::Entity> ents;
ents.reserve(rf.entities.size());
QVector<double> 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<core::Project::Tool> tools;
QVector<double> 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<CanvasHost*>(m_canvasHost)->relayoutFloaters();
QTimer::singleShot(0, m_canvasHost, [this]() {
if (m_canvasHost) {
static_cast<CanvasHost*>(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<core::Project::Entity::KeyframeVec2>& keys) {
QVector<int> out;
out.reserve(keys.size());
for (const auto& k : keys) out.push_back(k.frame);
return out;
};
auto framesOfDouble = [](const QVector<core::Project::Entity::KeyframeDouble>& keys) {
QVector<int> out;
out.reserve(keys.size());
for (const auto& k : keys) out.push_back(k.frame);
return out;
};
auto framesOfImage = [](const QVector<core::Project::Entity::ImageFrame>& keys) {
QVector<int> out;
out.reserve(keys.size());
for (const auto& k : keys) out.push_back(k.frame);
return out;
};
auto framesOfBool = [](const QVector<core::Project::ToolKeyframeBool>& keys) {
QVector<int> 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;
}