5534 lines
229 KiB
C++
5534 lines
229 KiB
C++
#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";
|
||
|
||
void applyVisibleBlackholesToBackground(QImage& bgPremul, const QVector<core::Project::Entity>& entities) {
|
||
for (const auto& ex : entities) {
|
||
if (ex.blackholeVisible && ex.cutoutPolygonWorld.size() >= 3) {
|
||
entity_cutout::applyBlackFillToBackground(bgPremul, ex.cutoutPolygonWorld);
|
||
}
|
||
}
|
||
}
|
||
|
||
[[nodiscard]] QVector<QVector<QPointF>> visibleBlackholePolygonsFromEntities(
|
||
const QVector<core::Project::Entity>& entities) {
|
||
QVector<QVector<QPointF>> holes;
|
||
for (const auto& ex : entities) {
|
||
if (ex.blackholeVisible && ex.cutoutPolygonWorld.size() >= 3) {
|
||
holes.push_back(ex.cutoutPolygonWorld);
|
||
}
|
||
}
|
||
return holes;
|
||
}
|
||
|
||
[[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 抬高叠放顺序;Wayland(KWin 等)下 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
|
||
// 右键命中 localFrame:globalFrame==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()) {
|
||
// 生成稳定且不重复的 id:entity-<n>
|
||
QSet<QString> used;
|
||
for (const auto& ex : m_workspace.entities()) {
|
||
used.insert(ex.id);
|
||
}
|
||
int n = static_cast<int>(m_workspace.entities().size()) + 1;
|
||
for (int guard = 0; guard < 100000; ++guard, ++n) {
|
||
const QString cand = QStringLiteral("entity-%1").arg(n);
|
||
if (!used.contains(cand)) {
|
||
ent.id = cand;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
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);
|
||
}
|
||
applyVisibleBlackholesToBackground(bg, m_workspace.entities());
|
||
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]);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 生成稳定且不重复的 id:entity-<n>,同时作为“默认名称”
|
||
QString newId;
|
||
{
|
||
QSet<QString> used;
|
||
for (const auto& ex : m_workspace.entities()) {
|
||
used.insert(ex.id);
|
||
}
|
||
int n = static_cast<int>(m_workspace.entities().size()) + 1;
|
||
for (int guard = 0; guard < 100000; ++guard, ++n) {
|
||
const QString cand = QStringLiteral("entity-%1").arg(n);
|
||
if (!used.contains(cand)) {
|
||
newId = cand;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
EntityFinalizeDialog dlg(this);
|
||
dlg.setDefaultName(newId.isEmpty() ? QStringLiteral("entity-1") : newId);
|
||
dlg.setUserScale(1.0);
|
||
if (dlg.exec() != QDialog::Accepted) {
|
||
return;
|
||
}
|
||
|
||
core::Project::Entity ent;
|
||
ent.id = newId;
|
||
ent.displayName = dlg.name();
|
||
ent.cutoutPolygonWorld = polyWorld;
|
||
ent.originWorld = entity_cutout::polygonCentroid(ent.cutoutPolygonWorld);
|
||
ent.polygonLocal.clear();
|
||
ent.polygonLocal.reserve(ent.cutoutPolygonWorld.size());
|
||
for (const auto& pt : ent.cutoutPolygonWorld) {
|
||
ent.polygonLocal.push_back(pt - ent.originWorld);
|
||
}
|
||
ent.depth = std::clamp(z, 0, 255);
|
||
ent.userScale = std::max(1e-6, dlg.userScale());
|
||
{
|
||
const double ds01 = static_cast<double>(ent.depth) / 255.0;
|
||
ent.distanceScaleCalibMult = 0.5 + ds01 * 1.0;
|
||
}
|
||
// 若用户把名称清空,则 displayName 置空,UI 会回退显示 id(保持原习惯)
|
||
if (ent.displayName == ent.id) {
|
||
// 默认情况保留 displayName=id,便于树上直接显示 entity-x
|
||
}
|
||
if (ent.displayName.isEmpty()) {
|
||
// 允许空:界面会用 id 展示
|
||
}
|
||
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);
|
||
}
|
||
entity_cutout::applyBlackHolesToImageRegion(
|
||
bgRegion, region, visibleBlackholePolygonsFromEntities(m_workspace.entities()));
|
||
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;
|
||
}
|