Files
hfut-bishe/client/gui/main_window/MainWindow.cpp
T
2026-05-14 18:37:18 +08:00

5512 lines
228 KiB
C++
Raw Blame History

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