3377 lines
135 KiB
C++
3377 lines
135 KiB
C++
#include "main_window/MainWindow.h"
|
||
#include "dialogs/AboutWindow.h"
|
||
#include "dialogs/CancelableTaskDialog.h"
|
||
#include "dialogs/EntityFinalizeDialog.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/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 = 288;
|
||
/// 属性区表单内容最大宽度(dock 仍可略宽,两侧留白,避免 SpinBox 被拉得过开)
|
||
constexpr int kPropertyPanelContentMaxWidth = 268;
|
||
/// 启动时垂直分割高度:项目树较矮、属性区较高
|
||
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
|
||
// 右键命中 localFrame:globalFrame==localFrame
|
||
m_currentFrame = std::clamp(frame, 0, core::Project::kClipFixedFrames - 1);
|
||
if (m_editorCanvas) m_editorCanvas->setCurrentFrame(m_currentFrame);
|
||
|
||
QAction* chosen = menu.exec(globalPos);
|
||
if (!chosen) {
|
||
return;
|
||
}
|
||
if (chosen == actDeleteKey) {
|
||
if (!m_workspace.isOpen() || !m_timeline->hasSelectedKeyframe()) {
|
||
return;
|
||
}
|
||
const int f = m_timeline->selectedKeyFrame();
|
||
bool ok = false;
|
||
switch (m_timeline->selectedKeyKind()) {
|
||
case TimelineWidget::KeyKind::Location:
|
||
if (!m_selectedEntityId.isEmpty()) ok = m_workspace.removeEntityLocationKey(m_selectedEntityId, f);
|
||
break;
|
||
case TimelineWidget::KeyKind::UserScale:
|
||
if (!m_selectedEntityId.isEmpty()) ok = m_workspace.removeEntityUserScaleKey(m_selectedEntityId, f);
|
||
break;
|
||
case TimelineWidget::KeyKind::Image:
|
||
if (!m_selectedEntityId.isEmpty()) ok = m_workspace.removeEntityImageFrame(m_selectedEntityId, f);
|
||
break;
|
||
case TimelineWidget::KeyKind::Visibility:
|
||
if (!m_selectedEntityId.isEmpty()) ok = m_workspace.removeEntityVisibilityKey(m_selectedEntityId, f);
|
||
else if (m_hasSelectedTool && !m_selectedToolId.isEmpty()) ok = m_workspace.removeToolVisibilityKey(m_selectedToolId, f);
|
||
break;
|
||
default:
|
||
break;
|
||
}
|
||
if (ok) {
|
||
refreshEditorPage();
|
||
}
|
||
return;
|
||
}
|
||
if (chosen == actSetStart) {
|
||
m_timelineRangeStart = m_currentFrame % core::Project::kClipFixedFrames;
|
||
if (m_timelineRangeEnd < 0) {
|
||
m_timelineRangeEnd = m_currentFrame % core::Project::kClipFixedFrames;
|
||
}
|
||
if (m_timelineRangeEnd < m_timelineRangeStart) {
|
||
std::swap(m_timelineRangeStart, m_timelineRangeEnd);
|
||
}
|
||
if (m_timeline) {
|
||
m_timeline->setSelectionRange(m_timelineRangeStart, m_timelineRangeEnd);
|
||
}
|
||
return;
|
||
}
|
||
if (chosen == actSetEnd) {
|
||
m_timelineRangeEnd = m_currentFrame % core::Project::kClipFixedFrames;
|
||
if (m_timelineRangeStart < 0) {
|
||
m_timelineRangeStart = m_currentFrame % core::Project::kClipFixedFrames;
|
||
}
|
||
if (m_timelineRangeEnd < m_timelineRangeStart) {
|
||
std::swap(m_timelineRangeStart, m_timelineRangeEnd);
|
||
}
|
||
if (m_timeline) {
|
||
m_timeline->setSelectionRange(m_timelineRangeStart, m_timelineRangeEnd);
|
||
}
|
||
return;
|
||
}
|
||
if (chosen == actClear) {
|
||
m_timelineRangeStart = -1;
|
||
m_timelineRangeEnd = -1;
|
||
if (m_timeline) {
|
||
m_timeline->setSelectionRange(-1, -1);
|
||
}
|
||
return;
|
||
}
|
||
if (chosen == actAnim) {
|
||
if (m_selectedEntityId.isEmpty() || !m_workspace.isOpen()) {
|
||
return;
|
||
}
|
||
const int fs = 0;
|
||
const int fe = core::Project::kClipFixedFrames - 1;
|
||
const int a = std::clamp(std::min(selA, selB), fs, fe);
|
||
const int b = std::clamp(std::max(selA, selB), fs, fe);
|
||
if (a > b) {
|
||
return;
|
||
}
|
||
FrameAnimationDialog dlg(m_workspace, m_selectedEntityId, a, b, this);
|
||
dlg.exec();
|
||
refreshEditorPage();
|
||
return;
|
||
}
|
||
});
|
||
}
|
||
|
||
void MainWindow::createResourceLibraryDock() {
|
||
m_resourceLibraryDockWidget = new gui::ResourceLibraryDock(this);
|
||
m_dockResourceLibrary = m_resourceLibraryDockWidget;
|
||
addDockWidget(Qt::LeftDockWidgetArea, m_dockResourceLibrary);
|
||
|
||
// 默认不自动弹出,用户通过“窗口-资源库”打开
|
||
m_dockResourceLibrary->setVisible(false);
|
||
|
||
connect(m_dockResourceLibrary, &QDockWidget::visibilityChanged, this, [this](bool visible) {
|
||
if (!m_actionToggleResourceLibrary) {
|
||
return;
|
||
}
|
||
m_actionToggleResourceLibrary->blockSignals(true);
|
||
m_actionToggleResourceLibrary->setChecked(visible);
|
||
m_actionToggleResourceLibrary->blockSignals(false);
|
||
});
|
||
|
||
auto* local = new core::library::FakeResourceLibraryProvider(this);
|
||
auto* online = new core::library::OnlineResourceLibraryProvider(this);
|
||
m_resourceLibraryProvider = local;
|
||
m_resourceLibraryDockWidget->setProviders(local, online);
|
||
}
|
||
|
||
void MainWindow::syncCreateEntityToolButtonTooltip() {
|
||
if (!m_btnCreateEntity || !m_editorCanvas) {
|
||
return;
|
||
}
|
||
using Mode = EditorCanvas::EntityCreateSegmentMode;
|
||
const Mode m = m_editorCanvas->entityCreateSegmentMode();
|
||
if (m == Mode::Manual) {
|
||
m_btnCreateEntity->setToolTip(QStringLiteral("创建实体:手动分割\n"
|
||
"再次单击本按钮可选择:手动/吸附/模型"));
|
||
} else if (m == Mode::Snap) {
|
||
m_btnCreateEntity->setToolTip(QStringLiteral("创建实体:吸附分割(前端边缘吸附算法)\n"
|
||
"再次单击本按钮可选择:手动/吸附/模型"));
|
||
} else {
|
||
m_btnCreateEntity->setToolTip(QStringLiteral("创建实体:模型分割(SAM)\n"
|
||
"再次单击本按钮可选择:手动/吸附/模型"));
|
||
}
|
||
}
|
||
|
||
void MainWindow::updateStatusBarText() {
|
||
// 坐标系:图片左上角为 (0,0),单位为像素(world 坐标与背景像素一致)
|
||
if (m_hasSelectedEntity) {
|
||
statusBar()->showMessage(QStringLiteral("实体中心 (%1, %2, %3) | 鼠标 (%4, %5, %6)")
|
||
.arg(m_selectedEntityOrigin.x(), 0, 'f', 1)
|
||
.arg(m_selectedEntityOrigin.y(), 0, 'f', 1)
|
||
.arg(m_selectedEntityDepth)
|
||
.arg(m_lastWorldPos.x(), 0, 'f', 1)
|
||
.arg(m_lastWorldPos.y(), 0, 'f', 1)
|
||
.arg(m_lastWorldZ));
|
||
} else {
|
||
statusBar()->showMessage(QStringLiteral("鼠标 (%1, %2, %3)")
|
||
.arg(m_lastWorldPos.x(), 0, 'f', 1)
|
||
.arg(m_lastWorldPos.y(), 0, 'f', 1)
|
||
.arg(m_lastWorldZ));
|
||
}
|
||
}
|
||
|
||
void MainWindow::onComputeDepth() {
|
||
if (!m_workspace.isOpen() || !m_workspace.hasBackground()) {
|
||
return;
|
||
}
|
||
computeDepthAsync();
|
||
}
|
||
|
||
void MainWindow::computeDepthAsync() {
|
||
if (!m_workspace.isOpen() || !m_workspace.hasBackground()) {
|
||
return;
|
||
}
|
||
|
||
const auto bgAbs = m_workspace.backgroundAbsolutePath();
|
||
if (bgAbs.isEmpty() || !QFileInfo::exists(bgAbs)) {
|
||
QMessageBox::warning(this, QStringLiteral("深度"), QStringLiteral("背景不存在。"));
|
||
return;
|
||
}
|
||
|
||
QFile f(bgAbs);
|
||
if (!f.open(QIODevice::ReadOnly)) {
|
||
QMessageBox::warning(this, QStringLiteral("深度"), QStringLiteral("读取背景失败。"));
|
||
return;
|
||
}
|
||
const QByteArray bgBytes = f.readAll();
|
||
f.close();
|
||
if (bgBytes.isEmpty()) {
|
||
QMessageBox::warning(this, QStringLiteral("深度"), QStringLiteral("背景文件为空。"));
|
||
return;
|
||
}
|
||
|
||
QString base;
|
||
const QByteArray env = qgetenv("MODEL_SERVER_URL");
|
||
base = env.isEmpty() ? QStringLiteral("http://127.0.0.1:8000") : QString::fromUtf8(env);
|
||
|
||
auto* client = new core::ModelServerClient(this);
|
||
client->setBaseUrl(QUrl(base));
|
||
|
||
QString immediateErr;
|
||
QNetworkReply* reply = client->computeDepthPng8Async(bgBytes, &immediateErr);
|
||
if (!reply) {
|
||
QMessageBox::warning(this, QStringLiteral("深度"), immediateErr.isEmpty() ? QStringLiteral("无法发起后端请求。") : immediateErr);
|
||
client->deleteLater();
|
||
return;
|
||
}
|
||
|
||
auto* dlg = new CancelableTaskDialog(QStringLiteral("计算深度"),
|
||
QStringLiteral("正在请求后端计算深度,请稍候……"),
|
||
this);
|
||
dlg->setAttribute(Qt::WA_DeleteOnClose, true);
|
||
|
||
connect(dlg, &CancelableTaskDialog::canceled, this, [reply, dlg]() {
|
||
if (reply) {
|
||
reply->abort();
|
||
}
|
||
if (dlg) {
|
||
dlg->reject();
|
||
}
|
||
});
|
||
|
||
connect(reply, &QNetworkReply::finished, this, [this, reply, dlg, client]() {
|
||
const int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
||
const QByteArray raw = reply->readAll();
|
||
const auto netErr = reply->error();
|
||
const QString netErrStr = reply->errorString();
|
||
|
||
reply->deleteLater();
|
||
client->deleteLater();
|
||
|
||
if (dlg) {
|
||
dlg->close();
|
||
}
|
||
|
||
if (netErr != QNetworkReply::NoError) {
|
||
// 用户取消也会走这里(OperationCanceledError 等)
|
||
if (netErrStr.contains(QStringLiteral("canceled"), Qt::CaseInsensitive) ||
|
||
netErr == QNetworkReply::OperationCanceledError) {
|
||
statusBar()->showMessage(QStringLiteral("已取消计算深度"));
|
||
return;
|
||
}
|
||
QMessageBox::warning(this, QStringLiteral("深度"), QStringLiteral("网络错误:%1").arg(netErrStr));
|
||
return;
|
||
}
|
||
if (httpStatus != 200) {
|
||
QString detail;
|
||
const QJsonDocument jd = QJsonDocument::fromJson(raw);
|
||
if (jd.isObject()) {
|
||
detail = jd.object().value(QStringLiteral("detail")).toString();
|
||
}
|
||
QMessageBox::warning(this,
|
||
QStringLiteral("深度"),
|
||
detail.isEmpty()
|
||
? QStringLiteral("后端返回HTTP %1。").arg(httpStatus)
|
||
: QStringLiteral("后端错误(HTTP %1):%2").arg(httpStatus).arg(detail));
|
||
return;
|
||
}
|
||
if (raw.isEmpty()) {
|
||
QMessageBox::warning(this, QStringLiteral("深度"), QStringLiteral("后端返回空数据。"));
|
||
return;
|
||
}
|
||
|
||
QString err;
|
||
if (!m_workspace.saveDepthMapPngBytes(raw, &err)) {
|
||
QMessageBox::warning(this, QStringLiteral("深度"), err.isEmpty() ? QStringLiteral("保存深度图失败。") : err);
|
||
return;
|
||
}
|
||
|
||
statusBar()->showMessage(QStringLiteral("深度已计算"));
|
||
refreshEditorPage();
|
||
refreshProjectTree();
|
||
updateUiEnabledState();
|
||
refreshPreviewPage();
|
||
});
|
||
|
||
dlg->show();
|
||
}
|
||
|
||
void MainWindow::onTogglePlay(bool on) {
|
||
m_playing = on;
|
||
if (m_btnPlay) {
|
||
m_btnPlay->setText(on ? QStringLiteral("⏸") : QStringLiteral("▶"));
|
||
}
|
||
if (m_playTimer) {
|
||
if (on) {
|
||
const int fps = 30; // 固定 30fps 播放
|
||
m_playTimer->start(1000 / fps);
|
||
} else {
|
||
m_playTimer->stop();
|
||
}
|
||
}
|
||
syncPreviewPlaybackBar();
|
||
}
|
||
|
||
void MainWindow::syncPreviewPlaybackBar() {
|
||
if (!m_previewBtnPlay || !m_previewBtnPause) {
|
||
return;
|
||
}
|
||
m_previewBtnPlay->setEnabled(!m_playing);
|
||
m_previewBtnPause->setEnabled(m_playing);
|
||
}
|
||
|
||
void MainWindow::onInsertCombinedKey() {
|
||
if (m_selectedEntityId.isEmpty() || !m_editorCanvas) {
|
||
return;
|
||
}
|
||
// 位置关键帧:使用当前帧下的动画原点
|
||
const QPointF o = m_editorCanvas->selectedAnimatedOriginWorld();
|
||
m_workspace.setEntityLocationKey(m_selectedEntityId, m_currentFrame % core::Project::kClipFixedFrames, o);
|
||
|
||
// 缩放关键帧:使用当前帧下的 userScale(而非 depthScale01)
|
||
const double s = m_editorCanvas->selectedUserScale();
|
||
m_workspace.setEntityUserScaleKey(m_selectedEntityId, m_currentFrame % core::Project::kClipFixedFrames, s);
|
||
|
||
refreshEditorPage();
|
||
}
|
||
|
||
void MainWindow::createMenus() {
|
||
createFileMenu();
|
||
createEditMenu();
|
||
createViewMenu();
|
||
createWindowMenu();
|
||
createHelpMenu();
|
||
}
|
||
|
||
void MainWindow::createFileMenu() {
|
||
auto m_fileMenu = menuBar()->addMenu(QString());
|
||
m_fileMenu->setTitle("文件");
|
||
|
||
// 示例:新建、打开、保存、退出
|
||
auto* newProjectAction = m_fileMenu->addAction(QString());
|
||
newProjectAction->setText("新建工程");
|
||
newProjectAction->setShortcut(QKeySequence::New);
|
||
connect(newProjectAction, &QAction::triggered, this, &MainWindow::onNewProject);
|
||
|
||
auto* openProjectAction = m_fileMenu->addAction(QString());
|
||
openProjectAction->setText("打开工程");
|
||
openProjectAction->setShortcut(QKeySequence::Open);
|
||
connect(openProjectAction, &QAction::triggered, this, &MainWindow::onOpenProject);
|
||
|
||
auto* closeProjectAction = m_fileMenu->addAction(QString());
|
||
closeProjectAction->setText(QStringLiteral("关闭工程"));
|
||
closeProjectAction->setShortcut(QKeySequence::Close);
|
||
connect(closeProjectAction, &QAction::triggered, this, &MainWindow::onCloseProject);
|
||
|
||
m_fileMenu->addSeparator();
|
||
|
||
auto* saveAction = m_fileMenu->addAction(QString());
|
||
saveAction->setText("保存");
|
||
saveAction->setShortcut(QKeySequence::Save);
|
||
connect(saveAction, &QAction::triggered, this, &MainWindow::onSaveProject);
|
||
|
||
m_fileMenu->addSeparator();
|
||
|
||
auto* exitAction = m_fileMenu->addAction(QString());
|
||
exitAction->setText("退出");
|
||
exitAction->setShortcut(QKeySequence::Quit);
|
||
connect(exitAction, &QAction::triggered, this, &MainWindow::close);
|
||
}
|
||
|
||
void MainWindow::createEditMenu() {
|
||
auto* editMenu = menuBar()->addMenu(QString());
|
||
editMenu->setTitle("编辑");
|
||
|
||
// 撤销/重做
|
||
m_actionUndo = editMenu->addAction(QString());
|
||
m_actionUndo->setText("撤销");
|
||
m_actionUndo->setShortcut(QKeySequence::Undo);
|
||
connect(m_actionUndo, &QAction::triggered, this, &MainWindow::onUndo);
|
||
|
||
m_actionRedo = editMenu->addAction(QString());
|
||
m_actionRedo->setText("重做");
|
||
m_actionRedo->setShortcut(QKeySequence::Redo);
|
||
connect(m_actionRedo, &QAction::triggered, this, &MainWindow::onRedo);
|
||
|
||
editMenu->addSeparator();
|
||
|
||
// 复制/粘贴/删除
|
||
m_actionCopy = editMenu->addAction(QString());
|
||
m_actionCopy->setText("复制");
|
||
m_actionCopy->setShortcut(QKeySequence::Copy);
|
||
connect(m_actionCopy, &QAction::triggered, this, &MainWindow::onCopyObject);
|
||
|
||
m_actionPaste = editMenu->addAction(QString());
|
||
m_actionPaste->setText("粘贴");
|
||
m_actionPaste->setShortcut(QKeySequence::Paste);
|
||
connect(m_actionPaste, &QAction::triggered, this, &MainWindow::onPasteObject);
|
||
}
|
||
|
||
void MainWindow::createHelpMenu() {
|
||
auto* helpMenu = menuBar()->addMenu(QString());
|
||
helpMenu->setTitle("帮助");
|
||
|
||
auto* aboutAction = helpMenu->addAction(QString());
|
||
aboutAction->setText("关于");
|
||
connect(aboutAction, &QAction::triggered, this, &MainWindow::onAbout);
|
||
}
|
||
|
||
void MainWindow::createViewMenu() {
|
||
auto* viewMenu = menuBar()->addMenu(QString());
|
||
viewMenu->setTitle("视图");
|
||
|
||
|
||
|
||
auto* canvasMenu = viewMenu->addMenu(QStringLiteral("画布"));
|
||
|
||
m_actionCanvasWorldAxes = canvasMenu->addAction(QStringLiteral("世界坐标轴"));
|
||
m_actionCanvasWorldAxes->setCheckable(true);
|
||
m_actionCanvasWorldAxes->setChecked(true);
|
||
connect(m_actionCanvasWorldAxes, &QAction::toggled, this, [this](bool on) {
|
||
if (m_editorCanvas) {
|
||
m_editorCanvas->setWorldAxesVisible(on);
|
||
}
|
||
if (m_actionCanvasAxisValues) {
|
||
m_actionCanvasAxisValues->setEnabled(on);
|
||
}
|
||
});
|
||
|
||
m_actionCanvasAxisValues = canvasMenu->addAction(QStringLiteral("坐标轴数值"));
|
||
m_actionCanvasAxisValues->setCheckable(true);
|
||
m_actionCanvasAxisValues->setChecked(true);
|
||
connect(m_actionCanvasAxisValues, &QAction::toggled, this, [this](bool on) {
|
||
if (m_editorCanvas) {
|
||
m_editorCanvas->setAxisLabelsVisible(on);
|
||
}
|
||
});
|
||
|
||
canvasMenu->addSeparator();
|
||
|
||
m_actionCanvasGrid = canvasMenu->addAction(QStringLiteral("参考网格"));
|
||
m_actionCanvasGrid->setCheckable(true);
|
||
m_actionCanvasGrid->setChecked(true);
|
||
connect(m_actionCanvasGrid, &QAction::toggled, this, [this](bool on) {
|
||
if (m_editorCanvas) {
|
||
m_editorCanvas->setGridVisible(on);
|
||
}
|
||
});
|
||
|
||
m_actionCanvasCheckerboard = canvasMenu->addAction(QStringLiteral("棋盘底纹"));
|
||
m_actionCanvasCheckerboard->setCheckable(true);
|
||
m_actionCanvasCheckerboard->setChecked(true);
|
||
connect(m_actionCanvasCheckerboard, &QAction::toggled, this, [this](bool on) {
|
||
if (m_editorCanvas) {
|
||
m_editorCanvas->setCheckerboardVisible(on);
|
||
}
|
||
});
|
||
|
||
canvasMenu->addSeparator();
|
||
|
||
m_actionCanvasDepthOverlay = canvasMenu->addAction(QStringLiteral("深度叠加"));
|
||
m_actionCanvasDepthOverlay->setCheckable(true);
|
||
m_actionCanvasDepthOverlay->setChecked(false);
|
||
connect(m_actionCanvasDepthOverlay, &QAction::toggled, this, [this](bool on) {
|
||
if (m_editorCanvas) {
|
||
m_editorCanvas->setDepthOverlayEnabled(on);
|
||
}
|
||
if (m_btnToggleDepthOverlay) {
|
||
m_btnToggleDepthOverlay->blockSignals(true);
|
||
m_btnToggleDepthOverlay->setChecked(on);
|
||
m_btnToggleDepthOverlay->blockSignals(false);
|
||
}
|
||
if (m_bgPropertySection) {
|
||
m_bgPropertySection->syncDepthOverlayChecked(on);
|
||
}
|
||
});
|
||
|
||
m_actionCanvasGizmoLabels = canvasMenu->addAction(QStringLiteral("Gizmo 轴标签"));
|
||
m_actionCanvasGizmoLabels->setCheckable(true);
|
||
m_actionCanvasGizmoLabels->setChecked(true);
|
||
connect(m_actionCanvasGizmoLabels, &QAction::toggled, this, [this](bool on) {
|
||
if (m_editorCanvas) {
|
||
m_editorCanvas->setGizmoLabelsVisible(on);
|
||
}
|
||
});
|
||
|
||
viewMenu->addSeparator();
|
||
m_actionEnterPreview = viewMenu->addAction(QStringLiteral("进入预览展示"));
|
||
connect(m_actionEnterPreview, &QAction::triggered, this, [this]() {
|
||
setPreviewRequested(true);
|
||
});
|
||
|
||
m_actionBackToEditor = viewMenu->addAction(QStringLiteral("返回编辑模式"));
|
||
connect(m_actionBackToEditor, &QAction::triggered, this, [this]() {
|
||
setPreviewRequested(false);
|
||
});
|
||
}
|
||
|
||
void MainWindow::createWindowMenu() {
|
||
auto* winMenu = menuBar()->addMenu(QString());
|
||
winMenu->setTitle(QStringLiteral("窗口"));
|
||
|
||
m_actionToggleProjectTree = winMenu->addAction(QStringLiteral("项目树"));
|
||
m_actionToggleProjectTree->setCheckable(true);
|
||
connect(m_actionToggleProjectTree, &QAction::toggled, this, [this](bool on) {
|
||
m_rightDocksNarrowHidden = false;
|
||
if (m_dockProjectTree) {
|
||
m_dockProjectTree->setVisible(on);
|
||
}
|
||
});
|
||
|
||
m_actionToggleProperties = winMenu->addAction(QStringLiteral("属性"));
|
||
m_actionToggleProperties->setCheckable(true);
|
||
connect(m_actionToggleProperties, &QAction::toggled, this, [this](bool on) {
|
||
m_rightDocksNarrowHidden = false;
|
||
if (m_dockProperties) {
|
||
m_dockProperties->setVisible(on);
|
||
}
|
||
});
|
||
|
||
m_actionToggleTimeline = winMenu->addAction(QStringLiteral("动画面板"));
|
||
m_actionToggleTimeline->setCheckable(true);
|
||
connect(m_actionToggleTimeline, &QAction::toggled, this, [this](bool on) {
|
||
if (m_dockTimeline) {
|
||
m_dockTimeline->setVisible(on);
|
||
}
|
||
});
|
||
|
||
m_actionToggleResourceLibrary = winMenu->addAction(QStringLiteral("资源库"));
|
||
m_actionToggleResourceLibrary->setCheckable(true);
|
||
connect(m_actionToggleResourceLibrary, &QAction::toggled, this, [this](bool on) {
|
||
if (m_dockResourceLibrary) {
|
||
m_dockResourceLibrary->setVisible(on);
|
||
}
|
||
});
|
||
}
|
||
|
||
void MainWindow::createProjectTreeDock() {
|
||
m_dockProjectTree = new QDockWidget(QStringLiteral("项目树"), this);
|
||
m_dockProjectTree->setToolTip(QStringLiteral("右键条目可切换编辑与预览。"));
|
||
m_dockProjectTree->setAllowedAreas(Qt::LeftDockWidgetArea | Qt::RightDockWidgetArea |
|
||
Qt::BottomDockWidgetArea | Qt::TopDockWidgetArea);
|
||
m_dockProjectTree->setFeatures(QDockWidget::DockWidgetMovable | QDockWidget::DockWidgetFloatable |
|
||
QDockWidget::DockWidgetClosable);
|
||
m_dockProjectTree->setMinimumWidth(kRightDockMinimumWidth);
|
||
|
||
auto* dockContent = new QWidget(m_dockProjectTree);
|
||
auto* dockLayout = new QVBoxLayout(dockContent);
|
||
dockLayout->setContentsMargins(4, 4, 4, 4);
|
||
dockLayout->setSpacing(4);
|
||
|
||
m_projectTree = new ProjectTreeWidget(dockContent);
|
||
m_projectTree->setColumnCount(2);
|
||
m_projectTree->setHeaderHidden(true);
|
||
m_projectTree->setRootIsDecorated(true);
|
||
m_projectTree->setIndentation(14);
|
||
m_projectTree->setUniformRowHeights(true);
|
||
// 允许拖拽来设置父子关系;真正的父子逻辑由 dropEvent 发信号驱动,不使用默认内部移动
|
||
if (m_projectTree->header()) {
|
||
m_projectTree->header()->setSectionResizeMode(0, QHeaderView::ResizeToContents);
|
||
m_projectTree->header()->setSectionResizeMode(1, QHeaderView::Stretch);
|
||
}
|
||
m_projectTree->setContextMenuPolicy(Qt::CustomContextMenu);
|
||
connect(m_projectTree, &QTreeWidget::customContextMenuRequested, this,
|
||
[this](const QPoint& pos) {
|
||
if (!m_projectTree) {
|
||
return;
|
||
}
|
||
auto* item = m_projectTree->itemAt(pos);
|
||
if (!item) {
|
||
if (m_workspace.isOpen()) {
|
||
showProjectRootContextMenu(m_projectTree->viewport()->mapToGlobal(pos));
|
||
}
|
||
return;
|
||
}
|
||
if (item == m_itemBackground) {
|
||
showBackgroundContextMenu(m_projectTree->viewport()->mapToGlobal(pos));
|
||
return;
|
||
}
|
||
});
|
||
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(236);
|
||
|
||
m_bgPropertySection = new gui::BackgroundPropertySection();
|
||
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_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;
|
||
if (!m_workspace.reanchorEntityPivot(m_selectedEntityId, m_currentFrame, QPointF(x, y), 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;
|
||
const bool autoKey = m_chkAutoKeyframe && m_chkAutoKeyframe->isChecked();
|
||
if (!m_workspace.moveEntityCentroidTo(m_selectedEntityId, m_currentFrame, QPointF(x, y), 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::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_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();
|
||
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;
|
||
}
|
||
}
|
||
m_toolPropertySection->applyState(st);
|
||
m_propertyStack->setCurrentWidget(m_toolPropertySection);
|
||
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;
|
||
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;
|
||
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();
|
||
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();
|
||
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;
|
||
});
|
||
}
|
||
|
||
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_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 (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_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_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("background")) {
|
||
m_hasSelectedTool = false;
|
||
m_selectedToolId.clear();
|
||
m_editorCanvas->clearEntitySelection();
|
||
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_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;
|
||
if (hasSel) {
|
||
m_hasSelectedEntity = false;
|
||
m_selectedEntityId.clear();
|
||
m_selectedEntityDisplayNameCache.clear();
|
||
}
|
||
updateTimelineTracks();
|
||
if (!m_timelineScrubbing) {
|
||
updateStatusBarText();
|
||
refreshPropertyPanel();
|
||
syncProjectTreeFromCanvasSelection();
|
||
}
|
||
});
|
||
|
||
connect(m_editorCanvas, &EditorCanvas::entityDragActiveChanged, this, [this](bool on) {
|
||
m_entityDragging = on;
|
||
if (on && m_btnPlay && m_btnPlay->isChecked()) {
|
||
// 拖动实体时自动暂停,避免播放驱动时间轴刷新干扰拖动
|
||
m_btnPlay->setChecked(false);
|
||
}
|
||
});
|
||
|
||
connect(m_editorCanvas, &EditorCanvas::selectedEntityPreviewChanged, this,
|
||
[this](const QString& id, int depth, const QPointF& origin) {
|
||
if (id.isEmpty() || !m_workspace.isOpen() || !m_entityPropertySection) {
|
||
return;
|
||
}
|
||
// 拖动中低频同步属性面板,不重建控件
|
||
m_hasSelectedEntity = true;
|
||
m_selectedEntityId = id;
|
||
m_selectedEntityDepth = depth;
|
||
m_selectedEntityOrigin = origin;
|
||
if (m_propertySyncTimer) {
|
||
if (!m_propertySyncTimer->isActive()) {
|
||
// 属性同步 30Hz:避免拖动时 UI 抢占
|
||
m_propertySyncTimer->start(33);
|
||
}
|
||
} else {
|
||
refreshPropertyPanel();
|
||
}
|
||
});
|
||
connect(m_editorCanvas, &EditorCanvas::requestAddEntity, this, [this](const core::Project::Entity& e, const QImage& img) {
|
||
core::Project::Entity ent = e;
|
||
if (ent.id.isEmpty()) {
|
||
// 生成稳定且不重复的 id:entity-<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;
|
||
}
|
||
}
|
||
}
|
||
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]);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 生成稳定且不重复的 id:entity-<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 展示
|
||
}
|
||
|
||
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::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;
|
||
}
|
||
}
|
||
addChannel(QStringLiteral("位置"), 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::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;
|
||
}
|