2634 lines
102 KiB
C++
2634 lines
102 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 "timeline/TimelineWidget.h"
|
||
#include "dialogs/FrameAnimationDialog.h"
|
||
#include "dialogs/EntityIntroPopup.h"
|
||
|
||
#include <QAbstractItemView>
|
||
#include <QAbstractSpinBox>
|
||
#include <QApplication>
|
||
#include <QAction>
|
||
#include <QBoxLayout>
|
||
#include <QButtonGroup>
|
||
#include <QCheckBox>
|
||
#include <QComboBox>
|
||
#include <QDockWidget>
|
||
#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 <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
|
||
|
||
/// @brief MainWindow 类实现,负责构建和管理主界面,包括菜单、停靠窗口和属性面板。
|
||
MainWindow::MainWindow(QWidget* parent)
|
||
: QMainWindow(parent) {
|
||
// 设置窗口大小
|
||
resize(1200, 800);
|
||
|
||
rebuildCentralPages();
|
||
|
||
createMenus();
|
||
createProjectTreeDock();
|
||
createTimelineDock();
|
||
|
||
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* dockContent = new QWidget(m_dockTimeline);
|
||
auto* outer = new QVBoxLayout(dockContent);
|
||
outer->setContentsMargins(10, 6, 10, 6);
|
||
outer->setSpacing(6);
|
||
|
||
auto* bar = new QWidget(dockContent);
|
||
bar->setObjectName(QStringLiteral("TimelineDockBar"));
|
||
bar->setStyleSheet(QString::fromUtf8(kTimelineBarQss));
|
||
auto* layout = new QHBoxLayout(bar);
|
||
layout->setContentsMargins(0, 0, 0, 0);
|
||
layout->setSpacing(8);
|
||
|
||
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_timeline = new TimelineWidget(bar);
|
||
m_timeline->setFrameRange(0, 600);
|
||
m_timeline->setCurrentFrame(0);
|
||
layout->addWidget(m_timeline, 1);
|
||
|
||
m_frameLabel = new QLabel(QStringLiteral("0"), bar);
|
||
m_frameLabel->setMinimumWidth(40);
|
||
m_frameLabel->setAlignment(Qt::AlignCenter);
|
||
{
|
||
QFont f = m_frameLabel->font();
|
||
f.setBold(true);
|
||
f.setPointSizeF(f.pointSizeF() + 0.5);
|
||
m_frameLabel->setFont(f);
|
||
}
|
||
layout->addWidget(m_frameLabel);
|
||
|
||
// 合并后的关键帧按钮:一次写入位置 + userScale
|
||
auto* btnKeyCombined = new QToolButton(bar);
|
||
setToolButtonIconOrText(btnKeyCombined, QStringLiteral("media-record"), QStringLiteral("关键帧"));
|
||
btnKeyCombined->setToolTip(QStringLiteral("在当前帧记录实体的位置与缩放关键帧"));
|
||
polishCompactToolButton(btnKeyCombined, 34);
|
||
layout->addWidget(btnKeyCombined);
|
||
|
||
outer->addWidget(bar);
|
||
|
||
// 旧版 DopeSheet 去除,预留一个占位条,后续可在此添加区间选择或更紧凑的可视化
|
||
outer->addStretch(1);
|
||
|
||
m_dockTimeline->setWidget(dockContent);
|
||
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;
|
||
}
|
||
int v = m_timeline->currentFrame();
|
||
if (v >= 600) {
|
||
v = 0;
|
||
} else {
|
||
++v;
|
||
}
|
||
m_timeline->setCurrentFrame(v);
|
||
});
|
||
|
||
connect(m_btnPlay, &QToolButton::toggled, this, &MainWindow::onTogglePlay);
|
||
connect(m_timeline, &TimelineWidget::frameScrubbed, this, [this](int v) {
|
||
// 轻量实时预览:只更新画布帧,不做 refreshEditorPage 的全量重建
|
||
m_currentFrame = v;
|
||
if (m_frameLabel) {
|
||
m_frameLabel->setText(QString::number(v));
|
||
}
|
||
if (m_editorCanvas && m_workspace.isOpen()) {
|
||
// 需要重新求值实体几何/贴图轨道,否则拖动实体与属性变更在非 0 帧会失效
|
||
m_timelineScrubbing = true;
|
||
m_editorCanvas->setCurrentFrame(v);
|
||
m_editorCanvas->setEntities(m_workspace.entities(), m_workspace.projectDir());
|
||
m_timelineScrubbing = false;
|
||
} else if (m_editorCanvas) {
|
||
m_editorCanvas->setCurrentFrame(v);
|
||
}
|
||
});
|
||
connect(m_timeline, &TimelineWidget::frameCommitted, this, [this](int v) {
|
||
// 松手再做一次较重刷新(如果后续还有需要同步的 UI)
|
||
m_currentFrame = v;
|
||
refreshEditorPage();
|
||
});
|
||
connect(btnKeyCombined, &QToolButton::clicked, this, &MainWindow::onInsertCombinedKey);
|
||
|
||
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 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());
|
||
actDeleteKey->setEnabled(m_workspace.isOpen() && !m_selectedEntityId.isEmpty() && m_timeline->hasSelectedKeyframe());
|
||
|
||
// 右键命中帧:用鼠标位置对应的 frame
|
||
m_currentFrame = std::clamp(frame, 0, 600);
|
||
if (m_frameLabel) m_frameLabel->setText(QString::number(m_currentFrame));
|
||
if (m_editorCanvas) m_editorCanvas->setCurrentFrame(m_currentFrame);
|
||
|
||
QAction* chosen = menu.exec(globalPos);
|
||
if (!chosen) {
|
||
return;
|
||
}
|
||
if (chosen == actDeleteKey) {
|
||
if (!m_workspace.isOpen() || m_selectedEntityId.isEmpty() || !m_timeline->hasSelectedKeyframe()) {
|
||
return;
|
||
}
|
||
const int f = m_timeline->selectedKeyFrame();
|
||
bool ok = false;
|
||
switch (m_timeline->selectedKeyKind()) {
|
||
case TimelineWidget::KeyKind::Location:
|
||
ok = m_workspace.removeEntityLocationKey(m_selectedEntityId, f);
|
||
break;
|
||
case TimelineWidget::KeyKind::UserScale:
|
||
ok = m_workspace.removeEntityUserScaleKey(m_selectedEntityId, f);
|
||
break;
|
||
case TimelineWidget::KeyKind::Image:
|
||
ok = m_workspace.removeEntityImageFrame(m_selectedEntityId, f);
|
||
break;
|
||
default:
|
||
break;
|
||
}
|
||
if (ok) {
|
||
refreshEditorPage();
|
||
}
|
||
return;
|
||
}
|
||
if (chosen == actSetStart) {
|
||
m_timelineRangeStart = m_currentFrame;
|
||
if (m_timelineRangeEnd < 0) {
|
||
m_timelineRangeEnd = m_currentFrame;
|
||
}
|
||
if (m_timelineRangeEnd < m_timelineRangeStart) {
|
||
std::swap(m_timelineRangeStart, m_timelineRangeEnd);
|
||
}
|
||
if (m_timeline) {
|
||
m_timeline->setSelectionRange(m_timelineRangeStart, m_timelineRangeEnd);
|
||
}
|
||
return;
|
||
}
|
||
if (chosen == actSetEnd) {
|
||
m_timelineRangeEnd = m_currentFrame;
|
||
if (m_timelineRangeStart < 0) {
|
||
m_timelineRangeStart = m_currentFrame;
|
||
}
|
||
if (m_timelineRangeEnd < m_timelineRangeStart) {
|
||
std::swap(m_timelineRangeStart, m_timelineRangeEnd);
|
||
}
|
||
if (m_timeline) {
|
||
m_timeline->setSelectionRange(m_timelineRangeStart, m_timelineRangeEnd);
|
||
}
|
||
return;
|
||
}
|
||
if (chosen == actClear) {
|
||
m_timelineRangeStart = -1;
|
||
m_timelineRangeEnd = -1;
|
||
if (m_timeline) {
|
||
m_timeline->setSelectionRange(-1, -1);
|
||
}
|
||
return;
|
||
}
|
||
if (chosen == actAnim) {
|
||
if (m_selectedEntityId.isEmpty() || !m_workspace.isOpen()) {
|
||
return;
|
||
}
|
||
const int a = std::clamp(std::min(selA, selB), 0, 600);
|
||
const int b = std::clamp(std::max(selA, selB), 0, 600);
|
||
if (a > b) {
|
||
return;
|
||
}
|
||
FrameAnimationDialog dlg(m_workspace, m_selectedEntityId, a, b, this);
|
||
dlg.exec();
|
||
refreshEditorPage();
|
||
}
|
||
});
|
||
}
|
||
|
||
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, o);
|
||
|
||
// 缩放关键帧:使用当前帧下的 userScale(而非 depthScale01)
|
||
const double s = m_editorCanvas->selectedUserScale();
|
||
m_workspace.setEntityUserScaleKey(m_selectedEntityId, m_currentFrame, s);
|
||
|
||
refreshEditorPage();
|
||
}
|
||
|
||
void MainWindow::createMenus() {
|
||
createFileMenu();
|
||
createEditMenu();
|
||
createViewMenu();
|
||
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("视图");
|
||
|
||
m_actionToggleProjectTree = viewMenu->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 = viewMenu->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 = viewMenu->addAction(QStringLiteral("动画面板"));
|
||
m_actionToggleTimeline->setCheckable(true);
|
||
connect(m_actionToggleTimeline, &QAction::toggled, this, [this](bool on) {
|
||
if (m_dockTimeline) {
|
||
m_dockTimeline->setVisible(on);
|
||
}
|
||
});
|
||
|
||
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::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 QTreeWidget(dockContent);
|
||
m_projectTree->setColumnCount(2);
|
||
m_projectTree->setHeaderHidden(true);
|
||
m_projectTree->setRootIsDecorated(false);
|
||
m_projectTree->setIndentation(0);
|
||
m_projectTree->setUniformRowHeights(true);
|
||
m_projectTree->setDragEnabled(false);
|
||
m_projectTree->setAcceptDrops(false);
|
||
m_projectTree->setDropIndicatorShown(false);
|
||
m_projectTree->setDragDropMode(QAbstractItemView::NoDragDrop);
|
||
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));
|
||
}
|
||
});
|
||
connect(m_projectTree, &QTreeWidget::itemClicked, this, &MainWindow::onProjectTreeItemClicked);
|
||
|
||
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_propertyStack = new QStackedWidget();
|
||
m_propertyStack->setContentsMargins(4, 4, 4, 4);
|
||
m_propertyStack->setMaximumWidth(kPropertyPanelContentMaxWidth);
|
||
m_propertyStack->addWidget(m_bgPropertySection);
|
||
m_propertyStack->addWidget(m_entityPropertySection);
|
||
|
||
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::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);
|
||
});
|
||
|
||
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_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());
|
||
}
|
||
|
||
const bool entUi = m_hasSelectedEntity && m_workspace.isOpen() && !m_selectedEntityId.isEmpty() && m_editorCanvas;
|
||
if (!entUi) {
|
||
m_entityPropertySection->clearDisconnected();
|
||
m_propertyStack->setCurrentWidget(m_bgPropertySection);
|
||
m_dockProperties->setWindowTitle(QStringLiteral("属性 — 背景"));
|
||
return;
|
||
}
|
||
|
||
QString displayName;
|
||
double userScale = 1.0;
|
||
core::EntityIntroContent intro;
|
||
for (const auto& e : m_workspace.entities()) {
|
||
if (e.id == m_selectedEntityId) {
|
||
displayName = e.displayName;
|
||
userScale = e.userScale;
|
||
intro = e.intro;
|
||
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.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();
|
||
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"));
|
||
|
||
// “眼睛”按钮(固定尺寸,各行一致)
|
||
auto makeEye = [this, eyeSide, iconPm](bool visible) -> 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);
|
||
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](bool on) {
|
||
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;
|
||
if (m_workspace.isOpen()) {
|
||
sortedEnts = m_workspace.entities();
|
||
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;
|
||
});
|
||
}
|
||
for (const auto& e : sortedEnts) {
|
||
auto* it = 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);
|
||
|
||
auto* eye = makeEye(e.visible);
|
||
m_projectTree->setItemWidget(it, 0, eye);
|
||
connect(eye, &QToolButton::toggled, this, [this, id = e.id](bool on) {
|
||
if (!m_workspace.setEntityVisible(id, on)) return;
|
||
refreshEditorPage();
|
||
updateUiEnabledState();
|
||
});
|
||
}
|
||
|
||
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_projectTree->clearSelection();
|
||
} else {
|
||
QTreeWidgetItem* found = nullptr;
|
||
for (QTreeWidgetItemIterator it(m_projectTree); *it; ++it) {
|
||
QTreeWidgetItem* node = *it;
|
||
if (node->data(0, Qt::UserRole).toString() == QStringLiteral("entity")
|
||
&& node->data(0, Qt::UserRole + 1).toString() == m_selectedEntityId) {
|
||
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_editorCanvas->selectEntityById(id);
|
||
}
|
||
} else if (kind == QStringLiteral("background")) {
|
||
m_editorCanvas->clearEntitySelection();
|
||
}
|
||
}
|
||
|
||
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_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);
|
||
}
|
||
}
|
||
|
||
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) {
|
||
if (m_timelineScrubbing) {
|
||
return;
|
||
}
|
||
m_hasSelectedEntity = hasSel;
|
||
m_selectedEntityId = id;
|
||
m_selectedEntityDepth = depth;
|
||
m_selectedEntityOrigin = origin;
|
||
updateStatusBarText();
|
||
refreshPropertyPanel();
|
||
syncProjectTreeFromCanvasSelection();
|
||
if (m_timeline && m_workspace.isOpen() && hasSel && !id.isEmpty()) {
|
||
const core::Project::Entity* hit = nullptr;
|
||
for (const auto& e : m_workspace.entities()) {
|
||
if (e.id == id) {
|
||
hit = &e;
|
||
m_selectedEntityDisplayNameCache = e.displayName;
|
||
break;
|
||
}
|
||
}
|
||
m_timeline->setKeyframeTracks(hit);
|
||
} else if (m_timeline) {
|
||
m_timeline->setKeyframeTracks(nullptr);
|
||
}
|
||
});
|
||
|
||
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::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, autoKey)) {
|
||
return;
|
||
}
|
||
refreshEditorPage();
|
||
refreshDopeSheet();
|
||
updateUiEnabledState();
|
||
});
|
||
|
||
connect(m_editorCanvas, &EditorCanvas::presentationEntityIntroRequested, this,
|
||
[this](const QString& id, QPointF anchorView) {
|
||
if (!m_entityIntroPopup || !m_workspace.isOpen()) {
|
||
return;
|
||
}
|
||
const core::Project::Entity* hit = nullptr;
|
||
for (const auto& e : m_workspace.entities()) {
|
||
if (e.id == id) {
|
||
hit = &e;
|
||
break;
|
||
}
|
||
}
|
||
m_entityIntroPopup->setProjectDir(m_workspace.projectDir());
|
||
if (hit) {
|
||
m_entityIntroPopup->setContent(hit->intro);
|
||
} else {
|
||
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) {
|
||
applyTimelineFromProject();
|
||
m_editorCanvas->setEntities(m_workspace.entities(), m_workspace.projectDir());
|
||
m_editorCanvas->setCurrentFrame(m_currentFrame);
|
||
if (m_timeline && !m_selectedEntityId.isEmpty()) {
|
||
const core::Project::Entity* hit = nullptr;
|
||
for (const auto& e : m_workspace.entities()) {
|
||
if (e.id == m_selectedEntityId) {
|
||
hit = &e;
|
||
break;
|
||
}
|
||
}
|
||
m_timeline->setKeyframeTracks(hit);
|
||
} else if (m_timeline) {
|
||
m_timeline->setKeyframeTracks(nullptr);
|
||
}
|
||
} else {
|
||
m_editorCanvas->setEntities({}, QString());
|
||
if (m_timeline) {
|
||
m_timeline->setKeyframeTracks(nullptr);
|
||
}
|
||
}
|
||
}
|
||
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::applyTimelineFromProject() {
|
||
if (!m_timeline || !m_workspace.isOpen()) {
|
||
return;
|
||
}
|
||
const int v = std::clamp(m_currentFrame, 0, 600);
|
||
m_currentFrame = v;
|
||
m_timeline->setFrameRange(0, 600);
|
||
m_timeline->setCurrentFrame(v);
|
||
if (m_frameLabel) {
|
||
m_frameLabel->setText(QString::number(v));
|
||
}
|
||
if (m_editorCanvas) {
|
||
m_editorCanvas->setCurrentFrame(v);
|
||
}
|
||
}
|
||
|
||
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;
|
||
}
|