initial commit

This commit is contained in:
2026-04-07 20:55:30 +08:00
commit 81d1fb7856
84 changed files with 11929 additions and 0 deletions

View File

@@ -0,0 +1,66 @@
#include "dialogs/AboutWindow.h"
#include <QVBoxLayout>
#include <QLabel>
#include <QPushButton>
#include <QHBoxLayout>
#include <QDesktopServices>
#include <QUrl>
#include <QFont>
AboutWindow::AboutWindow(QWidget* parent)
: QDialog(parent)
{
setWindowTitle("About");
setFixedSize(400, 300);
// ===== 标题 =====
titleLabel = new QLabel("Landscape Interactive Tool");
QFont titleFont;
titleFont.setPointSize(16);
titleFont.setBold(true);
titleLabel->setFont(titleFont);
titleLabel->setAlignment(Qt::AlignCenter);
// ===== 版本 =====
versionLabel = new QLabel("Version: 1.0.0");
versionLabel->setAlignment(Qt::AlignCenter);
// ===== 作者 =====
authorLabel = new QLabel("Author: 丁伟豪");
authorLabel->setAlignment(Qt::AlignCenter);
// ===== 描述 =====
descLabel = new QLabel("An interactive tool for landscape visualization.\n"
"Built with Qt.");
descLabel->setAlignment(Qt::AlignCenter);
descLabel->setWordWrap(true);
// // ===== GitHub 按钮 =====
// githubButton = new QPushButton("GitHub");
// connect(githubButton, &QPushButton::clicked, []() {
// QDesktopServices::openUrl(QUrl("https://github.com/your_repo"));
// });
// ===== 关闭按钮 =====
closeButton = new QPushButton("Close");
connect(closeButton, &QPushButton::clicked, this, &QDialog::accept);
// ===== 按钮布局 =====
QHBoxLayout* buttonLayout = new QHBoxLayout;
buttonLayout->addStretch();
// buttonLayout->addWidget(githubButton);
buttonLayout->addWidget(closeButton);
// ===== 主布局 =====
QVBoxLayout* layout = new QVBoxLayout(this);
layout->addWidget(titleLabel);
layout->addWidget(versionLabel);
layout->addWidget(authorLabel);
layout->addSpacing(10);
layout->addWidget(descLabel);
layout->addStretch();
layout->addLayout(buttonLayout);
setLayout(layout);
}

View File

@@ -0,0 +1,20 @@
#pragma once
#include <QDialog>
class QLabel;
class QPushButton;
class AboutWindow : public QDialog
{
Q_OBJECT
public:
explicit AboutWindow(QWidget* parent = nullptr);
private:
QLabel* titleLabel;
QLabel* versionLabel;
QLabel* authorLabel;
QLabel* descLabel;
// QPushButton* githubButton;
QPushButton* closeButton;
};

View File

@@ -0,0 +1,50 @@
#include "dialogs/CancelableTaskDialog.h"
#include <QBoxLayout>
#include <QLabel>
#include <QProgressBar>
#include <QPushButton>
CancelableTaskDialog::CancelableTaskDialog(const QString& title,
const QString& message,
QWidget* parent)
: QDialog(parent) {
setWindowTitle(title);
setModal(true);
setMinimumWidth(420);
auto* root = new QVBoxLayout(this);
root->setContentsMargins(14, 14, 14, 14);
root->setSpacing(10);
m_label = new QLabel(message, this);
m_label->setWordWrap(true);
root->addWidget(m_label);
m_bar = new QProgressBar(this);
m_bar->setRange(0, 0); // 不定进度
root->addWidget(m_bar);
auto* row = new QHBoxLayout();
row->addStretch(1);
m_btnCancel = new QPushButton(QStringLiteral("取消"), this);
row->addWidget(m_btnCancel);
root->addLayout(row);
connect(m_btnCancel, &QPushButton::clicked, this, &CancelableTaskDialog::onCancel);
}
void CancelableTaskDialog::setMessage(const QString& message) {
if (m_label) {
m_label->setText(message);
}
}
void CancelableTaskDialog::onCancel() {
if (m_canceled) {
return;
}
m_canceled = true;
emit canceled();
}

View File

@@ -0,0 +1,35 @@
#pragma once
#include <QDialog>
#include <QString>
class QLabel;
class QProgressBar;
class QPushButton;
// 可复用的“长任务提示框”:显示提示文本 + 不定进度条 + 取消按钮。
// - 任务本身由调用方启动(例如网络请求/后台线程)
// - 调用方在取消时应中止任务,并调用 reject()/close()
class CancelableTaskDialog final : public QDialog {
Q_OBJECT
public:
explicit CancelableTaskDialog(const QString& title,
const QString& message,
QWidget* parent = nullptr);
void setMessage(const QString& message);
bool wasCanceled() const { return m_canceled; }
signals:
void canceled();
private slots:
void onCancel();
private:
QLabel* m_label = nullptr;
QProgressBar* m_bar = nullptr;
QPushButton* m_btnCancel = nullptr;
bool m_canceled = false;
};

View File

@@ -0,0 +1,252 @@
#include "dialogs/FrameAnimationDialog.h"
#include "core/animation/AnimationSampling.h"
#include "core/workspace/ProjectWorkspace.h"
#include <QBoxLayout>
#include <QDir>
#include <QFileDialog>
#include <QFileInfo>
#include <QImage>
#include <QLabel>
#include <QListWidget>
#include <QMessageBox>
#include <QPixmap>
#include <QPushButton>
namespace {
QString resolvedImageAbsForFrame(const core::ProjectWorkspace& ws,
const core::Project::Entity& e,
int frame) {
const QString rel = core::sampleImagePath(e.imageFrames, frame, e.imagePath);
if (rel.isEmpty()) return {};
const QString abs = QDir(ws.projectDir()).filePath(rel);
return abs;
}
} // namespace
FrameAnimationDialog::FrameAnimationDialog(core::ProjectWorkspace& workspace,
const QString& entityId,
int startFrame,
int endFrame,
QWidget* parent)
: QDialog(parent)
, m_workspace(workspace)
, m_entityId(entityId) {
setWindowTitle(QStringLiteral("区间动画帧"));
setModal(true);
setMinimumSize(720, 420);
m_start = std::min(startFrame, endFrame);
m_end = std::max(startFrame, endFrame);
auto* root = new QVBoxLayout(this);
root->setContentsMargins(12, 12, 12, 12);
root->setSpacing(10);
m_title = new QLabel(this);
m_title->setText(QStringLiteral("实体 %1 | 区间 [%2, %3]").arg(m_entityId).arg(m_start).arg(m_end));
root->addWidget(m_title);
auto* mid = new QHBoxLayout();
root->addLayout(mid, 1);
m_list = new QListWidget(this);
m_list->setMinimumWidth(240);
mid->addWidget(m_list, 0);
auto* right = new QVBoxLayout();
mid->addLayout(right, 1);
m_preview = new QLabel(this);
m_preview->setMinimumSize(320, 240);
m_preview->setFrameShape(QFrame::StyledPanel);
m_preview->setAlignment(Qt::AlignCenter);
m_preview->setText(QStringLiteral("选择一帧"));
right->addWidget(m_preview, 1);
auto* row = new QHBoxLayout();
right->addLayout(row);
m_btnReplace = new QPushButton(QStringLiteral("替换此帧…"), this);
m_btnClear = new QPushButton(QStringLiteral("清除此帧(恢复默认)"), this);
row->addWidget(m_btnReplace);
row->addWidget(m_btnClear);
auto* row2 = new QHBoxLayout();
right->addLayout(row2);
m_btnImportFiles = new QPushButton(QStringLiteral("批量导入(多选图片)…"), this);
m_btnImportFolder = new QPushButton(QStringLiteral("批量导入(文件夹)…"), this);
row2->addWidget(m_btnImportFiles);
row2->addWidget(m_btnImportFolder);
row2->addStretch(1);
auto* closeRow = new QHBoxLayout();
root->addLayout(closeRow);
closeRow->addStretch(1);
auto* btnClose = new QPushButton(QStringLiteral("关闭"), this);
closeRow->addWidget(btnClose);
connect(btnClose, &QPushButton::clicked, this, &QDialog::accept);
connect(m_list, &QListWidget::currentRowChanged, this, [this](int) { onSelectFrame(); });
connect(m_btnReplace, &QPushButton::clicked, this, &FrameAnimationDialog::onReplaceCurrentFrame);
connect(m_btnClear, &QPushButton::clicked, this, &FrameAnimationDialog::onClearCurrentFrame);
connect(m_btnImportFiles, &QPushButton::clicked, this, &FrameAnimationDialog::onBatchImportFiles);
connect(m_btnImportFolder, &QPushButton::clicked, this, &FrameAnimationDialog::onBatchImportFolder);
rebuildFrameList();
if (m_list->count() > 0) {
m_list->setCurrentRow(0);
}
}
void FrameAnimationDialog::rebuildFrameList() {
m_list->clear();
if (!m_workspace.isOpen()) return;
const auto& ents = m_workspace.entities();
const core::Project::Entity* hit = nullptr;
for (const auto& e : ents) {
if (e.id == m_entityId) {
hit = &e;
break;
}
}
if (!hit) return;
// 默认贴图(用于 UI 提示)
m_defaultImageAbs.clear();
if (!hit->imagePath.isEmpty()) {
const QString abs = QDir(m_workspace.projectDir()).filePath(hit->imagePath);
if (QFileInfo::exists(abs)) {
m_defaultImageAbs = abs;
}
}
for (int f = m_start; f <= m_end; ++f) {
bool hasCustom = false;
for (const auto& k : hit->imageFrames) {
if (k.frame == f) {
hasCustom = true;
break;
}
}
auto* it = new QListWidgetItem(QStringLiteral("%1%2").arg(f).arg(hasCustom ? QStringLiteral(" *") : QString()));
it->setData(Qt::UserRole, f);
m_list->addItem(it);
}
}
void FrameAnimationDialog::onSelectFrame() {
auto* it = m_list->currentItem();
if (!it) return;
const int f = it->data(Qt::UserRole).toInt();
updatePreviewForFrame(f);
}
void FrameAnimationDialog::updatePreviewForFrame(int frame) {
if (!m_workspace.isOpen()) return;
const auto& ents = m_workspace.entities();
const core::Project::Entity* hit = nullptr;
for (const auto& e : ents) {
if (e.id == m_entityId) {
hit = &e;
break;
}
}
if (!hit) return;
const QString abs = resolvedImageAbsForFrame(m_workspace, *hit, frame);
if (abs.isEmpty() || !QFileInfo::exists(abs)) {
m_preview->setText(QStringLiteral("无图像"));
return;
}
QPixmap pm(abs);
if (pm.isNull()) {
m_preview->setText(QStringLiteral("加载失败"));
return;
}
m_preview->setPixmap(pm.scaled(m_preview->size(), Qt::KeepAspectRatio, Qt::SmoothTransformation));
}
bool FrameAnimationDialog::applyImageToFrame(int frame, const QString& absImagePath) {
QImage img(absImagePath);
if (img.isNull()) {
return false;
}
if (img.format() != QImage::Format_ARGB32_Premultiplied) {
img = img.convertToFormat(QImage::Format_ARGB32_Premultiplied);
}
return m_workspace.setEntityImageFrame(m_entityId, frame, img);
}
void FrameAnimationDialog::onReplaceCurrentFrame() {
auto* it = m_list->currentItem();
if (!it) return;
const int f = it->data(Qt::UserRole).toInt();
const QString path = QFileDialog::getOpenFileName(
this,
QStringLiteral("选择该帧图像"),
QString(),
QStringLiteral("Images (*.png *.jpg *.jpeg *.bmp *.webp);;All Files (*)"));
if (path.isEmpty()) return;
if (!applyImageToFrame(f, path)) {
QMessageBox::warning(this, QStringLiteral("动画帧"), QStringLiteral("写入该帧失败。"));
return;
}
rebuildFrameList();
updatePreviewForFrame(f);
}
void FrameAnimationDialog::onClearCurrentFrame() {
auto* it = m_list->currentItem();
if (!it) return;
const int f = it->data(Qt::UserRole).toInt();
if (!m_workspace.removeEntityImageFrame(m_entityId, f)) {
return;
}
rebuildFrameList();
updatePreviewForFrame(f);
}
void FrameAnimationDialog::onBatchImportFiles() {
const QStringList paths = QFileDialog::getOpenFileNames(
this,
QStringLiteral("选择逐帧动画图片(按文件名排序)"),
QString(),
QStringLiteral("Images (*.png *.jpg *.jpeg *.bmp *.webp);;All Files (*)"));
if (paths.isEmpty()) return;
QStringList sorted = paths;
sorted.sort(Qt::CaseInsensitive);
const int need = m_end - m_start + 1;
const int count = std::min(need, static_cast<int>(sorted.size()));
for (int i = 0; i < count; ++i) {
applyImageToFrame(m_start + i, sorted[i]);
}
rebuildFrameList();
onSelectFrame();
}
void FrameAnimationDialog::onBatchImportFolder() {
const QString dir = QFileDialog::getExistingDirectory(this, QStringLiteral("选择逐帧动画图片文件夹"));
if (dir.isEmpty()) return;
QDir d(dir);
d.setFilter(QDir::Files | QDir::Readable);
d.setSorting(QDir::Name);
const QStringList filters = {QStringLiteral("*.png"),
QStringLiteral("*.jpg"),
QStringLiteral("*.jpeg"),
QStringLiteral("*.bmp"),
QStringLiteral("*.webp")};
const QStringList files = d.entryList(filters, QDir::Files, QDir::Name);
if (files.isEmpty()) return;
const int need = m_end - m_start + 1;
const int count = std::min(need, static_cast<int>(files.size()));
for (int i = 0; i < count; ++i) {
applyImageToFrame(m_start + i, d.filePath(files[i]));
}
rebuildFrameList();
onSelectFrame();
}

View File

@@ -0,0 +1,52 @@
#pragma once
#include <QDialog>
#include <QString>
#include <QStringList>
namespace core {
class ProjectWorkspace;
}
class QLabel;
class QListWidget;
class QPushButton;
class FrameAnimationDialog final : public QDialog {
Q_OBJECT
public:
FrameAnimationDialog(core::ProjectWorkspace& workspace,
const QString& entityId,
int startFrame,
int endFrame,
QWidget* parent = nullptr);
private slots:
void onSelectFrame();
void onReplaceCurrentFrame();
void onClearCurrentFrame();
void onBatchImportFiles();
void onBatchImportFolder();
private:
void rebuildFrameList();
void updatePreviewForFrame(int frame);
bool applyImageToFrame(int frame, const QString& absImagePath);
private:
core::ProjectWorkspace& m_workspace;
QString m_entityId;
int m_start = 0;
int m_end = 0;
QLabel* m_title = nullptr;
QListWidget* m_list = nullptr;
QLabel* m_preview = nullptr;
QPushButton* m_btnReplace = nullptr;
QPushButton* m_btnClear = nullptr;
QPushButton* m_btnImportFiles = nullptr;
QPushButton* m_btnImportFolder = nullptr;
QString m_defaultImageAbs;
};

View File

@@ -0,0 +1,209 @@
#include "dialogs/ImageCropDialog.h"
#include <QBoxLayout>
#include <QDialogButtonBox>
#include <QLabel>
#include <QMouseEvent>
#include <QPainter>
#include <QPushButton>
#include <QtMath>
class ImageCropDialog::CropView final : public QWidget {
public:
explicit CropView(QWidget* parent = nullptr)
: QWidget(parent) {
setMouseTracking(true);
setMinimumSize(480, 320);
}
void setImage(const QImage& img) {
m_image = img;
m_selection = {};
updateGeometry();
update();
}
bool hasSelection() const { return !m_selection.isNull() && m_selection.width() > 0 && m_selection.height() > 0; }
QRect selectionInImagePixels() const {
if (m_image.isNull() || !hasSelection()) {
return {};
}
const auto map = viewToImageTransform();
// selection 是 view 坐标;映射到 image 像素坐标
const QRectF selF = QRectF(m_selection).normalized();
bool invertible = false;
const QTransform inv = map.inverted(&invertible);
if (!invertible) {
return {};
}
const QPointF topLeftImg = inv.map(selF.topLeft());
const QPointF bottomRightImg = inv.map(selF.bottomRight());
// 使用 floor/ceil避免因为取整导致宽高变 0
const int left = qFloor(std::min(topLeftImg.x(), bottomRightImg.x()));
const int top = qFloor(std::min(topLeftImg.y(), bottomRightImg.y()));
const int right = qCeil(std::max(topLeftImg.x(), bottomRightImg.x()));
const int bottom = qCeil(std::max(topLeftImg.y(), bottomRightImg.y()));
QRect r(QPoint(left, top), QPoint(right, bottom));
r = r.normalized().intersected(QRect(0, 0, m_image.width(), m_image.height()));
return r;
}
void resetSelection() {
m_selection = {};
update();
}
protected:
void paintEvent(QPaintEvent*) override {
QPainter p(this);
p.fillRect(rect(), palette().window());
if (m_image.isNull()) {
p.setPen(palette().text().color());
p.drawText(rect(), Qt::AlignCenter, QStringLiteral("无法加载图片"));
return;
}
const auto map = viewToImageTransform();
p.setRenderHint(QPainter::SmoothPixmapTransform, true);
p.setTransform(map);
p.drawImage(QPoint(0, 0), m_image);
p.resetTransform();
if (hasSelection()) {
// 避免 CompositionMode_Clear 在某些平台/样式下表现异常:
// 用“围绕选区画四块遮罩”的方式实现高亮裁剪区域。
const QRect sel = m_selection.normalized().intersected(rect());
const QColor shade(0, 0, 0, 120);
// 上
p.fillRect(QRect(0, 0, width(), sel.top()), shade);
// 下
p.fillRect(QRect(0, sel.bottom(), width(), height() - sel.bottom()), shade);
// 左
p.fillRect(QRect(0, sel.top(), sel.left(), sel.height()), shade);
// 右
p.fillRect(QRect(sel.right(), sel.top(), width() - sel.right(), sel.height()), shade);
p.setPen(QPen(QColor(255, 255, 255, 220), 2));
p.drawRect(sel);
}
}
void mousePressEvent(QMouseEvent* e) override {
if (m_image.isNull() || e->button() != Qt::LeftButton) {
return;
}
m_dragging = true;
m_anchor = e->position().toPoint();
m_selection = QRect(m_anchor, m_anchor);
update();
}
void mouseMoveEvent(QMouseEvent* e) override {
if (!m_dragging) {
return;
}
const QPoint cur = e->position().toPoint();
m_selection = QRect(m_anchor, cur).normalized();
update();
}
void mouseReleaseEvent(QMouseEvent* e) override {
if (e->button() != Qt::LeftButton) {
return;
}
m_dragging = false;
update();
}
private:
QTransform viewToImageTransform() const {
// 让图片按比例 fit 到 view 中居中显示
const QSizeF viewSize = size();
const QSizeF imgSize = m_image.size();
const qreal sx = viewSize.width() / imgSize.width();
const qreal sy = viewSize.height() / imgSize.height();
const qreal s = std::min(sx, sy);
const qreal drawW = imgSize.width() * s;
const qreal drawH = imgSize.height() * s;
const qreal offsetX = (viewSize.width() - drawW) / 2.0;
const qreal offsetY = (viewSize.height() - drawH) / 2.0;
QTransform t;
t.translate(offsetX, offsetY);
t.scale(s, s);
return t;
}
private:
QImage m_image;
bool m_dragging = false;
QPoint m_anchor;
QRect m_selection;
};
ImageCropDialog::ImageCropDialog(const QString& imagePath, QWidget* parent)
: QDialog(parent),
m_imagePath(imagePath) {
setWindowTitle(QStringLiteral("裁剪图片"));
setModal(true);
resize(900, 600);
loadImageOrClose();
rebuildUi();
}
void ImageCropDialog::loadImageOrClose() {
m_image = QImage(m_imagePath);
if (m_image.isNull()) {
reject();
}
}
void ImageCropDialog::rebuildUi() {
auto* root = new QVBoxLayout(this);
auto* hint = new QLabel(QStringLiteral("拖拽选择裁剪区域(不选则使用整张图)。"), this);
root->addWidget(hint);
m_view = new CropView(this);
m_view->setImage(m_image);
root->addWidget(m_view, 1);
auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
m_okButton = buttons->button(QDialogButtonBox::Ok);
auto* resetBtn = new QPushButton(QStringLiteral("重置选择"), this);
buttons->addButton(resetBtn, QDialogButtonBox::ActionRole);
connect(resetBtn, &QPushButton::clicked, this, &ImageCropDialog::onReset);
connect(buttons, &QDialogButtonBox::accepted, this, &ImageCropDialog::onOk);
connect(buttons, &QDialogButtonBox::rejected, this, &ImageCropDialog::reject);
root->addWidget(buttons);
}
bool ImageCropDialog::hasValidSelection() const {
return m_view && m_view->hasSelection();
}
QRect ImageCropDialog::selectedRectInImagePixels() const {
if (!m_view) {
return {};
}
return m_view->selectionInImagePixels();
}
void ImageCropDialog::onReset() {
if (m_view) {
m_view->resetSelection();
}
}
void ImageCropDialog::onOk() {
accept();
}

View File

@@ -0,0 +1,34 @@
#pragma once
#include <QDialog>
#include <QImage>
#include <QRect>
class QLabel;
class QPushButton;
class ImageCropDialog final : public QDialog {
Q_OBJECT
public:
explicit ImageCropDialog(const QString& imagePath, QWidget* parent = nullptr);
bool hasValidSelection() const;
QRect selectedRectInImagePixels() const;
private slots:
void onReset();
void onOk();
private:
void loadImageOrClose();
void rebuildUi();
private:
class CropView;
CropView* m_view = nullptr;
QPushButton* m_okButton = nullptr;
QString m_imagePath;
QImage m_image;
};