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

62
client/gui/CMakeLists.txt Normal file
View File

@@ -0,0 +1,62 @@
# 模块app入口、main_window主窗口与时间轴等、editor画布、dialogs裁剪/关于)
set(GUI_ROOT ${CMAKE_CURRENT_SOURCE_DIR})
set(GUI_SOURCES
${GUI_ROOT}/app/main.cpp
${GUI_ROOT}/main_window/MainWindow.cpp
${GUI_ROOT}/main_window/RecentProjectHistory.cpp
${GUI_ROOT}/dialogs/AboutWindow.cpp
${GUI_ROOT}/dialogs/ImageCropDialog.cpp
${GUI_ROOT}/dialogs/FrameAnimationDialog.cpp
${GUI_ROOT}/dialogs/CancelableTaskDialog.cpp
${GUI_ROOT}/editor/EditorCanvas.cpp
${GUI_ROOT}/params/ParamControls.cpp
${GUI_ROOT}/props/BackgroundPropertySection.cpp
${GUI_ROOT}/props/EntityPropertySection.cpp
${GUI_ROOT}/timeline/TimelineWidget.cpp
)
set(GUI_HEADERS
${GUI_ROOT}/main_window/MainWindow.h
${GUI_ROOT}/main_window/RecentProjectHistory.h
${GUI_ROOT}/dialogs/AboutWindow.h
${GUI_ROOT}/dialogs/ImageCropDialog.h
${GUI_ROOT}/dialogs/FrameAnimationDialog.h
${GUI_ROOT}/dialogs/CancelableTaskDialog.h
${GUI_ROOT}/editor/EditorCanvas.h
${GUI_ROOT}/params/ParamControls.h
${GUI_ROOT}/props/BackgroundPropertySection.h
${GUI_ROOT}/props/EntityPropertySection.h
${GUI_ROOT}/props/PropertySectionWidget.h
${GUI_ROOT}/timeline/TimelineWidget.h
)
if(QT_PACKAGE STREQUAL "Qt6")
qt_add_executable(LandscapeInteractiveToolApp
${GUI_SOURCES}
${GUI_HEADERS}
)
else()
add_executable(LandscapeInteractiveToolApp
${GUI_SOURCES}
${GUI_HEADERS}
)
endif()
target_include_directories(LandscapeInteractiveToolApp
PRIVATE
${SRC_ROOT}
${GUI_ROOT}
)
target_link_libraries(LandscapeInteractiveToolApp
PRIVATE
${QT_PACKAGE}::Core
${QT_PACKAGE}::Gui
${QT_PACKAGE}::Widgets
core
)
set_target_properties(LandscapeInteractiveToolApp PROPERTIES
OUTPUT_NAME "landscape_tool"
)

13
client/gui/app/main.cpp Normal file
View File

@@ -0,0 +1,13 @@
#include "main_window/MainWindow.h"
#include <QApplication>
int main(int argc, char* argv[]) {
QApplication app(argc, argv);
app.setApplicationName(QStringLiteral("landscape tool"));
MainWindow window;
window.show();
return app.exec();
}

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;
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,179 @@
#pragma once
#include "core/domain/Project.h"
#include <QPixmap>
#include <QPointF>
#include <QImage>
#include <QPainterPath>
#include <QVector>
#include <QWidget>
#include <QElapsedTimer>
class EditorCanvas final : public QWidget {
Q_OBJECT
public:
enum class Tool { Move, Zoom, CreateEntity };
Q_ENUM(Tool)
explicit EditorCanvas(QWidget* parent = nullptr);
void setBackgroundImagePath(const QString& absolutePath);
QString backgroundImagePath() const { return m_bgAbsPath; }
void setBackgroundVisible(bool on);
bool backgroundVisible() const { return m_backgroundVisible; }
void setDepthMapPath(const QString& absolutePath);
void setDepthOverlayEnabled(bool on);
bool depthOverlayEnabled() const { return m_depthOverlayEnabled; }
void setTool(Tool tool);
Tool tool() const { return m_tool; }
void resetView();
void zoomToFit();
void setWorldAxesVisible(bool on);
bool worldAxesVisible() const { return m_worldAxesVisible; }
void setAxisLabelsVisible(bool on);
bool axisLabelsVisible() const { return m_axisLabelsVisible; }
void setGizmoLabelsVisible(bool on);
bool gizmoLabelsVisible() const { return m_gizmoLabelsVisible; }
void setGridVisible(bool on);
bool gridVisible() const { return m_gridVisible; }
void setCheckerboardVisible(bool on);
bool checkerboardVisible() const { return m_checkerboardVisible; }
// 预览呈现:完整背景 + 全部实体(忽略显隐开关),隐藏编辑辅助元素,仅可平移/缩放查看
void setPresentationPreviewMode(bool on);
bool presentationPreviewMode() const { return m_presentationPreviewMode; }
void setEntities(const QVector<core::Project::Entity>& entities, const QString& projectDirAbs);
void setCurrentFrame(int frame);
int currentFrame() const { return m_currentFrame; }
bool isDraggingEntity() const { return m_draggingEntity; }
void selectEntityById(const QString& id);
void clearEntitySelection();
// 与动画求值一致的原点/缩放(用于 K 帧与自动关键帧)
QPointF selectedAnimatedOriginWorld() const;
double selectedDepthScale01() const;
QPointF selectedEntityCentroidWorld() const;
double selectedDistanceScaleMultiplier() const;
double selectedUserScale() const;
double selectedCombinedScale() const;
enum class DragMode { None, Free, AxisX, AxisY };
signals:
void hoveredWorldPosChanged(const QPointF& worldPos);
void hoveredWorldPosDepthChanged(const QPointF& worldPos, int depthZ);
void selectedEntityChanged(bool hasSelection, const QString& id, int depth, const QPointF& originWorld);
void requestAddEntity(const core::Project::Entity& entity, const QImage& image);
void requestMoveEntity(const QString& id, const QPointF& delta);
void entityDragActiveChanged(bool on);
void selectedEntityPreviewChanged(const QString& id, int depth, const QPointF& originWorld);
protected:
void paintEvent(QPaintEvent* e) override;
void resizeEvent(QResizeEvent* e) override;
void mousePressEvent(QMouseEvent* e) override;
void mouseMoveEvent(QMouseEvent* e) override;
void mouseReleaseEvent(QMouseEvent* e) override;
void wheelEvent(QWheelEvent* e) override;
private:
void ensurePixmapLoaded() const;
void invalidatePixmap();
void updateCursor();
QPointF viewToWorld(const QPointF& v) const;
QPointF worldToView(const QPointF& w) const;
QRectF worldRectOfBackground() const;
private:
struct Entity {
QString id;
QRectF rect; // world 坐标(用于拖拽与约束)
QVector<QPointF> polygonWorld; // 非空则使用 polygon
QPainterPath pathWorld; // polygonWorld 对应的 world 路径(缓存,避免每帧重建)
QVector<QPointF> cutoutPolygonWorld;
QColor color;
// 实体独立信息:
int depth = 0; // 0..255,来自划分区域平均深度
QImage image; // 抠图后的实体图像(带透明)
QPointF imageTopLeft; // image 对应的 world 左上角
double visualScale = 1.0; // 实体在 world 坐标下的缩放(用于贴图绘制)
double userScale = 1.0; // 与深度距离缩放相乘
QPointF animatedOriginWorld;
double animatedDepthScale01 = 0.5;
// 编辑模式下实体被设为隐藏时:不响应点选且不绘制,除非当前选中(便于树选隐藏实体)
bool hiddenInEditMode = false;
};
int hitTestEntity(const QPointF& worldPos) const;
private:
QString m_bgAbsPath;
bool m_backgroundVisible = true;
mutable QPixmap m_bgPixmap;
mutable bool m_pixmapDirty = true;
mutable QImage m_bgImage; // 原背景(用于抠图/填充)
mutable QImage m_bgImageCutout; // 抠图后的背景(实体区域填黑)
mutable bool m_bgImageDirty = true;
mutable bool m_bgCutoutDirty = true;
QString m_depthAbsPath;
mutable QImage m_depthImage8;
mutable bool m_depthDirty = true;
bool m_depthOverlayEnabled = false;
int m_depthOverlayAlpha = 110;
bool m_worldAxesVisible = true;
bool m_axisLabelsVisible = true;
bool m_gizmoLabelsVisible = true;
bool m_gridVisible = true;
bool m_checkerboardVisible = true;
bool m_presentationPreviewMode = false;
Tool m_tool = Tool::Move;
qreal m_scale = 1.0;
QPointF m_pan; // world 原点对应的 view 坐标偏移view = world*scale + pan
bool m_dragging = false;
bool m_draggingEntity = false;
bool m_drawingEntity = false;
QPointF m_lastMouseView;
// 拖动以“实体原点 animatedOriginWorld”为基准避免因缩放导致 rect/topLeft 抖动
QPointF m_entityDragOffsetOriginWorld;
QPointF m_entityDragStartAnimatedOrigin;
// 拖动性能优化:拖动过程中不逐点修改 polygonWorld而是保留基准形状+增量参数,在 paint 时做变换预览
bool m_dragPreviewActive = false;
QVector<QPointF> m_dragPolyBase;
QPainterPath m_dragPathBase;
QPointF m_dragImageTopLeftBase;
QRectF m_dragRectBase;
QPointF m_dragOriginBase;
QPointF m_dragDelta; // 纯平移
QPointF m_dragCentroidBase;
double m_dragScaleBase = 1.0; // 拖动开始时的 visualScale
double m_dragScaleRatio = 1.0; // 相对 m_dragScaleBase 的缩放比(由深度重算驱动)
QElapsedTimer m_previewEmitTimer;
qint64 m_lastPreviewEmitMs = 0;
qint64 m_lastDepthScaleRecalcMs = 0;
int m_selectedEntity = -1;
DragMode m_dragMode = DragMode::None;
QPointF m_dragStartMouseWorld;
QVector<Entity> m_entities;
QVector<QPointF> m_strokeWorld;
int m_currentFrame = 0;
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,177 @@
#pragma once
#include "core/workspace/ProjectWorkspace.h"
#include "main_window/RecentProjectHistory.h"
#include <QMainWindow>
#include <QPointF>
#include <QFrame>
#include <QIcon>
#include <QTimer>
class QAction;
class QCheckBox;
class QComboBox;
class QDockWidget;
class QFormLayout;
class QLabel;
class QMenu;
class QFrame;
class QIcon;
class QPushButton;
class QSlider;
class QStackedWidget;
class QToolButton;
class QTreeWidget;
class QTreeWidgetItem;
class QWidget;
class EditorCanvas;
class TimelineWidget;
namespace gui {
class BackgroundPropertySection;
class EntityPropertySection;
}
class MainWindow : public QMainWindow {
Q_OBJECT
public:
explicit MainWindow(QWidget* parent = nullptr);
protected:
bool eventFilter(QObject* watched, QEvent* event) override;
private slots:
// 文件菜单槽函数
void onNewProject();
void onOpenProject();
void onSaveProject();
void onCloseProject();
// 编辑菜单槽函数
void onUndo();
void onRedo();
void onCopyObject();
void onPasteObject();
// 帮助菜单槽函数
void onAbout();
void onComputeDepth();
void onTogglePlay(bool on);
void onInsertCombinedKey(); // 位置 + userScale
void onProjectTreeItemClicked(QTreeWidgetItem* item, int column);
private:
void computeDepthAsync();
// UI 状态分三种:
// - Welcome未打开项目。只显示欢迎页其它 dock 一律隐藏,视图开关禁用。
// - Editor已打开项目。显示编辑页按默认规则显示 dock同时允许用户通过“视图”菜单控制。
// - Preview预览展示。用于全流程完成后的展示要求项目已打开且背景不为空
enum class UiMode { Welcome, Editor, Preview };
void createMenus(); // 菜单和工具栏
void createFileMenu(); // 文件菜单
void createEditMenu(); // 编辑菜单
void createHelpMenu(); // 帮助菜单
void createViewMenu(); // 视图菜单
void createProjectTreeDock();
void createTimelineDock();
void refreshProjectTree();
void updateUiEnabledState(); // 更新“可用性/勾选/默认显隐”,不要做业务逻辑
void applyUiMode(UiMode mode); // 统一控制 welcome/editor 两态的显隐策略
UiMode currentUiMode() const; // 根据 workspace 状态推导
void syncCanvasViewMenuFromState();
void showProjectRootContextMenu(const QPoint& globalPos);
void showBackgroundContextMenu(const QPoint& globalPos);
void rebuildCentralPages();
void showWelcomePage();
void showEditorPage();
void showPreviewPage();
void refreshWelcomeRecentList();
void openProjectFromPath(const QString& dir);
void refreshPreviewPage();
void refreshEditorPage();
void applyTimelineFromProject();
void refreshDopeSheet();
void setPreviewRequested(bool preview);
QStackedWidget* m_centerStack = nullptr;
QWidget* m_pageWelcome = nullptr;
QTreeWidget* m_welcomeRecentTree = nullptr;
QLabel* m_welcomeRecentEmptyLabel = nullptr;
QWidget* m_pageEditor = nullptr;
QWidget* m_canvasHost = nullptr;
QFrame* m_floatingModeDock = nullptr;
QFrame* m_floatingToolDock = nullptr;
QComboBox* m_modeSelector = nullptr;
QStackedWidget* m_propertyStack = nullptr;
gui::BackgroundPropertySection* m_bgPropertySection = nullptr;
gui::EntityPropertySection* m_entityPropertySection = nullptr;
QToolButton* m_btnCreateEntity = nullptr;
QToolButton* m_btnToggleDepthOverlay = nullptr;
EditorCanvas* m_editorCanvas = nullptr;
QTreeWidget* m_projectTree = nullptr;
QDockWidget* m_dockProjectTree = nullptr;
QDockWidget* m_dockProperties = nullptr;
QDockWidget* m_dockTimeline = nullptr;
QTreeWidgetItem* m_itemBackground = nullptr;
QAction* m_actionUndo = nullptr;
QAction* m_actionRedo = nullptr;
QAction* m_actionCopy = nullptr;
QAction* m_actionPaste = nullptr;
QAction* m_actionToggleProjectTree = nullptr;
QAction* m_actionToggleProperties = nullptr;
QAction* m_actionToggleTimeline = nullptr;
QAction* m_actionEnterPreview = nullptr;
QAction* m_actionBackToEditor = nullptr;
QAction* m_actionCanvasWorldAxes = nullptr;
QAction* m_actionCanvasAxisValues = nullptr;
QAction* m_actionCanvasGrid = nullptr;
QAction* m_actionCanvasCheckerboard = nullptr;
QAction* m_actionCanvasDepthOverlay = nullptr;
QAction* m_actionCanvasGizmoLabels = nullptr;
core::ProjectWorkspace m_workspace;
RecentProjectHistory m_recentHistory;
bool m_previewRequested = false;
/// 因右侧栏过窄自动收起;用户通过视图菜单再次打开时清除
bool m_rightDocksNarrowHidden = false;
QPointF m_lastWorldPos;
int m_lastWorldZ = -1;
bool m_hasSelectedEntity = false;
bool m_syncingTreeSelection = false;
int m_selectedEntityDepth = 0;
QPointF m_selectedEntityOrigin;
QString m_selectedEntityId;
QString m_selectedEntityDisplayNameCache;
QString m_bgAbsCache;
QString m_bgSizeTextCache;
void updateStatusBarText();
void refreshPropertyPanel();
void refreshEntityPropertyPanelFast();
void syncProjectTreeFromCanvasSelection();
bool m_timelineScrubbing = false;
bool m_entityDragging = false;
QTimer* m_propertySyncTimer = nullptr;
int m_currentFrame = 0;
bool m_playing = false;
QTimer* m_playTimer = nullptr;
TimelineWidget* m_timeline = nullptr;
QToolButton* m_btnPlay = nullptr;
QLabel* m_frameLabel = nullptr;
// 时间轴区间选择(用于逐帧贴图动画)
int m_timelineRangeStart = -1;
int m_timelineRangeEnd = -1;
QCheckBox* m_chkAutoKeyframe = nullptr;
// 旧版 DopeSheet 已移除,这里保留占位便于后续扩展区间 UI如自定义小部件
QTreeWidget* m_dopeTree = nullptr;
QPushButton* m_btnDopeDeleteKey = nullptr;
};

View File

@@ -0,0 +1,100 @@
#include "main_window/RecentProjectHistory.h"
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonValue>
#include <QDebug>
#include <QStandardPaths>
QString RecentProjectHistory::cacheFilePath() {
const QString base = QStandardPaths::writableLocation(QStandardPaths::GenericCacheLocation);
return QDir(base).filePath(QStringLiteral("landscape_tool/recent_projects.cache"));
}
QString RecentProjectHistory::normalizePath(const QString& path) {
if (path.isEmpty()) {
return QString();
}
const QFileInfo fi(path);
const QString c = fi.canonicalFilePath();
return c.isEmpty() ? QDir::cleanPath(fi.absoluteFilePath()) : c;
}
QStringList RecentProjectHistory::dedupeNewestFirst(const QStringList& paths) {
QStringList out;
out.reserve(paths.size());
for (const QString& p : paths) {
const QString n = normalizePath(p);
if (n.isEmpty()) {
continue;
}
if (out.contains(n)) {
continue;
}
out.append(n);
if (out.size() >= kMaxEntries) {
break;
}
}
return out;
}
QStringList RecentProjectHistory::load() const {
const QString filePath = cacheFilePath();
QFile f(filePath);
if (!f.open(QIODevice::ReadOnly)) {
return {};
}
const QJsonDocument doc = QJsonDocument::fromJson(f.readAll());
if (!doc.isArray()) {
return {};
}
QStringList paths;
for (const QJsonValue& v : doc.array()) {
if (v.isString()) {
paths.append(v.toString());
}
}
return dedupeNewestFirst(paths);
}
bool RecentProjectHistory::save(const QStringList& paths) const {
const QString filePath = cacheFilePath();
const QFileInfo fi(filePath);
QDir().mkpath(fi.absolutePath());
QJsonArray arr;
for (const QString& p : dedupeNewestFirst(paths)) {
arr.append(p);
}
const QJsonDocument doc(arr);
QFile f(filePath);
if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
qWarning() << "RecentProjectHistory: cannot write" << filePath;
return false;
}
f.write(doc.toJson(QJsonDocument::Compact));
return true;
}
void RecentProjectHistory::addAndSave(const QString& projectDir) {
const QString n = normalizePath(projectDir);
if (n.isEmpty()) {
return;
}
QStringList paths = load();
paths.removeAll(n);
paths.prepend(n);
save(paths);
}
void RecentProjectHistory::removeAndSave(const QString& projectDir) {
const QString n = normalizePath(projectDir);
QStringList paths = load();
paths.removeAll(n);
save(paths);
}

View File

@@ -0,0 +1,21 @@
#pragma once
#include <QString>
#include <QStringList>
class RecentProjectHistory {
public:
static constexpr int kMaxEntries = 15;
static QString cacheFilePath();
QStringList load() const;
bool save(const QStringList& paths) const;
void addAndSave(const QString& projectDir);
void removeAndSave(const QString& projectDir);
static QString normalizePath(const QString& path);
private:
static QStringList dedupeNewestFirst(const QStringList& paths);
};

View File

@@ -0,0 +1,127 @@
#include "params/ParamControls.h"
#include <algorithm>
#include <cmath>
#include <QDoubleSpinBox>
#include <QHBoxLayout>
#include <QSlider>
namespace gui {
Float01ParamControl::Float01ParamControl(QWidget* parent)
: QWidget(parent) {
auto* row = new QHBoxLayout(this);
row->setContentsMargins(0, 0, 0, 0);
row->setSpacing(8);
m_slider = new QSlider(Qt::Horizontal, this);
m_slider->setRange(0, 1000);
m_slider->setSingleStep(1);
m_slider->setPageStep(10);
row->addWidget(m_slider, 1);
m_spin = new QDoubleSpinBox(this);
m_spin->setRange(0.0, 1.0);
m_spin->setDecimals(3);
m_spin->setSingleStep(0.01);
m_spin->setMinimumWidth(84);
row->addWidget(m_spin);
connect(m_slider, &QSlider::valueChanged, this, [this]() { syncFromSlider(); });
connect(m_spin, qOverload<double>(&QDoubleSpinBox::valueChanged), this, [this]() { syncFromSpin(); });
setValue01(0.5);
}
void Float01ParamControl::setEnabled(bool on) {
QWidget::setEnabled(on);
if (m_slider) m_slider->setEnabled(on);
if (m_spin) m_spin->setEnabled(on);
}
double Float01ParamControl::value01() const {
return m_spin ? m_spin->value() : 0.5;
}
void Float01ParamControl::setValue01(double v) {
const double clamped = std::clamp(v, 0.0, 1.0);
m_block = true;
if (m_spin) m_spin->setValue(clamped);
if (m_slider) m_slider->setValue(static_cast<int>(std::lround(clamped * 1000.0)));
m_block = false;
}
void Float01ParamControl::syncFromSlider() {
if (m_block || !m_slider || !m_spin) return;
m_block = true;
const double v = static_cast<double>(m_slider->value()) / 1000.0;
m_spin->setValue(v);
m_block = false;
emit valueChanged01(v);
}
void Float01ParamControl::syncFromSpin() {
if (m_block || !m_slider || !m_spin) return;
m_block = true;
const double v = m_spin->value();
m_slider->setValue(static_cast<int>(std::lround(v * 1000.0)));
m_block = false;
emit valueChanged01(v);
}
Vec2ParamControl::Vec2ParamControl(QWidget* parent)
: QWidget(parent) {
auto* row = new QHBoxLayout(this);
row->setContentsMargins(0, 0, 0, 0);
row->setSpacing(8);
m_x = new QDoubleSpinBox(this);
m_x->setRange(-1e9, 1e9);
m_x->setDecimals(2);
m_x->setSingleStep(1.0);
m_x->setMinimumWidth(88);
row->addWidget(m_x, 1);
m_y = new QDoubleSpinBox(this);
m_y->setRange(-1e9, 1e9);
m_y->setDecimals(2);
m_y->setSingleStep(1.0);
m_y->setMinimumWidth(88);
row->addWidget(m_y, 1);
connect(m_x, qOverload<double>(&QDoubleSpinBox::valueChanged), this, [this]() { emitIfChanged(); });
connect(m_y, qOverload<double>(&QDoubleSpinBox::valueChanged), this, [this]() { emitIfChanged(); });
setValue(0.0, 0.0);
}
void Vec2ParamControl::setEnabled(bool on) {
QWidget::setEnabled(on);
if (m_x) m_x->setEnabled(on);
if (m_y) m_y->setEnabled(on);
}
void Vec2ParamControl::setValue(double x, double y) {
m_block = true;
if (m_x) m_x->setValue(x);
if (m_y) m_y->setValue(y);
m_lastX = x;
m_lastY = y;
m_block = false;
}
double Vec2ParamControl::x() const { return m_x ? m_x->value() : 0.0; }
double Vec2ParamControl::y() const { return m_y ? m_y->value() : 0.0; }
void Vec2ParamControl::emitIfChanged() {
if (m_block || !m_x || !m_y) return;
const double nx = m_x->value();
const double ny = m_y->value();
if (nx == m_lastX && ny == m_lastY) return;
m_lastX = nx;
m_lastY = ny;
emit valueChanged(nx, ny);
}
} // namespace gui

View File

@@ -0,0 +1,60 @@
#pragma once
#include <QWidget>
class QDoubleSpinBox;
class QSlider;
class QLabel;
namespace gui {
// 0..1 浮点参数Slider + DoubleSpinBox可复用
class Float01ParamControl final : public QWidget {
Q_OBJECT
public:
explicit Float01ParamControl(QWidget* parent = nullptr);
void setValue01(double v);
double value01() const;
void setEnabled(bool on);
signals:
void valueChanged01(double v);
private:
void syncFromSlider();
void syncFromSpin();
QSlider* m_slider = nullptr;
QDoubleSpinBox* m_spin = nullptr;
bool m_block = false;
};
// Vec2 参数:两个 DoubleSpinBox可复用
class Vec2ParamControl final : public QWidget {
Q_OBJECT
public:
explicit Vec2ParamControl(QWidget* parent = nullptr);
void setValue(double x, double y);
double x() const;
double y() const;
void setEnabled(bool on);
signals:
void valueChanged(double x, double y);
private:
void emitIfChanged();
QDoubleSpinBox* m_x = nullptr;
QDoubleSpinBox* m_y = nullptr;
bool m_block = false;
double m_lastX = 0.0;
double m_lastY = 0.0;
};
} // namespace gui

View File

@@ -0,0 +1,77 @@
#include "props/BackgroundPropertySection.h"
#include <QCheckBox>
#include <QFormLayout>
#include <QLabel>
#include <QVBoxLayout>
namespace gui {
BackgroundPropertySection::BackgroundPropertySection(QWidget* parent)
: PropertySectionWidget(parent) {
auto* lay = new QVBoxLayout(this);
lay->setContentsMargins(0, 0, 0, 0);
lay->setSpacing(6);
auto* form = new QFormLayout();
form->setContentsMargins(0, 0, 0, 0);
form->setSpacing(6);
m_sizeLabel = new QLabel(QStringLiteral("-"), this);
m_sizeLabel->setTextInteractionFlags(Qt::TextSelectableByMouse);
form->addRow(QStringLiteral("背景尺寸"), m_sizeLabel);
m_showBackground = new QCheckBox(QStringLiteral("显示背景"), this);
m_showBackground->setToolTip(QStringLiteral("是否绘制背景图"));
form->addRow(QString(), m_showBackground);
m_depthOverlay = new QCheckBox(QStringLiteral("叠加深度"), this);
m_depthOverlay->setToolTip(QStringLiteral("在背景上叠加深度伪彩图"));
form->addRow(QString(), m_depthOverlay);
lay->addLayout(form);
lay->addStretch(1);
connect(m_showBackground, &QCheckBox::toggled, this, &BackgroundPropertySection::backgroundVisibleToggled);
connect(m_depthOverlay, &QCheckBox::toggled, this, &BackgroundPropertySection::depthOverlayToggled);
}
void BackgroundPropertySection::setBackgroundSizeText(const QString& text) {
if (m_sizeLabel) {
m_sizeLabel->setText(text);
}
}
void BackgroundPropertySection::syncBackgroundVisible(bool visible, bool controlsEnabled) {
if (!m_showBackground) {
return;
}
m_showBackground->blockSignals(true);
m_showBackground->setChecked(visible);
m_showBackground->setEnabled(controlsEnabled);
m_showBackground->blockSignals(false);
}
void BackgroundPropertySection::syncDepthOverlayChecked(bool on) {
if (!m_depthOverlay) {
return;
}
m_depthOverlay->blockSignals(true);
m_depthOverlay->setChecked(on);
m_depthOverlay->blockSignals(false);
}
void BackgroundPropertySection::setDepthOverlayCheckEnabled(bool on) {
if (m_depthOverlay) {
m_depthOverlay->setEnabled(on);
}
}
void BackgroundPropertySection::setProjectClosedAppearance() {
setBackgroundSizeText(QStringLiteral("-"));
syncBackgroundVisible(true, false);
syncDepthOverlayChecked(false);
setDepthOverlayCheckEnabled(false);
}
} // namespace gui

View File

@@ -0,0 +1,32 @@
#pragma once
#include "props/PropertySectionWidget.h"
class QLabel;
class QCheckBox;
namespace gui {
// 背景相关属性:尺寸、显隐、深度叠加(可嵌入 QStackedWidget 的一页)
class BackgroundPropertySection final : public PropertySectionWidget {
Q_OBJECT
public:
explicit BackgroundPropertySection(QWidget* parent = nullptr);
void setBackgroundSizeText(const QString& text);
void syncBackgroundVisible(bool visible, bool controlsEnabled);
void syncDepthOverlayChecked(bool on);
void setDepthOverlayCheckEnabled(bool on);
void setProjectClosedAppearance();
signals:
void backgroundVisibleToggled(bool on);
void depthOverlayToggled(bool on);
private:
QLabel* m_sizeLabel = nullptr;
QCheckBox* m_showBackground = nullptr;
QCheckBox* m_depthOverlay = nullptr;
};
} // namespace gui

View File

@@ -0,0 +1,108 @@
#include "props/EntityPropertySection.h"
#include "params/ParamControls.h"
#include <QDoubleSpinBox>
#include <QFormLayout>
#include <QLabel>
#include <QLineEdit>
#include <QVBoxLayout>
namespace gui {
EntityPropertySection::EntityPropertySection(QWidget* parent)
: PropertySectionWidget(parent) {
auto* lay = new QVBoxLayout(this);
lay->setContentsMargins(0, 0, 0, 0);
lay->setSpacing(6);
auto* form = new QFormLayout();
form->setContentsMargins(0, 0, 0, 0);
form->setSpacing(6);
m_name = new QLineEdit(this);
m_name->setPlaceholderText(QStringLiteral("显示名称"));
m_name->setToolTip(QStringLiteral("仅显示用;内部 id 不变"));
form->addRow(QStringLiteral("名称"), m_name);
m_depth = new QLabel(QStringLiteral("-"), this);
m_distScale = new QLabel(QStringLiteral("-"), this);
for (QLabel* lab : {m_depth, m_distScale}) {
lab->setTextInteractionFlags(Qt::TextSelectableByMouse);
}
form->addRow(QStringLiteral("深度"), m_depth);
form->addRow(QStringLiteral("距离缩放"), m_distScale);
m_pivot = new Vec2ParamControl(this);
m_pivot->setToolTip(QStringLiteral("枢轴在世界坐标中的位置(限制在轮廓包络内),用于重定位局部原点"));
form->addRow(QStringLiteral("中心坐标"), m_pivot);
m_centroid = new Vec2ParamControl(this);
m_centroid->setToolTip(QStringLiteral("实体几何质心的世界坐标;修改将整体平移实体"));
form->addRow(QStringLiteral("位置"), m_centroid);
m_userScale = new QDoubleSpinBox(this);
m_userScale->setRange(0.05, 20.0);
m_userScale->setDecimals(3);
m_userScale->setSingleStep(0.05);
m_userScale->setValue(1.0);
m_userScale->setToolTip(QStringLiteral("人为整体缩放,与深度距离缩放相乘"));
form->addRow(QStringLiteral("整体缩放"), m_userScale);
lay->addLayout(form);
lay->addStretch(1);
connect(m_name, &QLineEdit::editingFinished, this, [this]() {
if (m_name) {
emit displayNameCommitted(m_name->text());
}
});
connect(m_pivot, &Vec2ParamControl::valueChanged, this, &EntityPropertySection::pivotEdited);
connect(m_centroid, &Vec2ParamControl::valueChanged, this, &EntityPropertySection::centroidEdited);
connect(m_userScale, qOverload<double>(&QDoubleSpinBox::valueChanged), this, &EntityPropertySection::userScaleEdited);
}
void EntityPropertySection::clearDisconnected() {
setEditingEnabled(false);
if (m_name) {
m_name->blockSignals(true);
m_name->clear();
m_name->blockSignals(false);
}
if (m_depth) m_depth->setText(QStringLiteral("-"));
if (m_distScale) m_distScale->setText(QStringLiteral("-"));
if (m_pivot) m_pivot->setValue(0.0, 0.0);
if (m_centroid) m_centroid->setValue(0.0, 0.0);
if (m_userScale) {
m_userScale->blockSignals(true);
m_userScale->setValue(1.0);
m_userScale->blockSignals(false);
}
}
void EntityPropertySection::applyState(const EntityPropertyUiState& s) {
setEditingEnabled(true);
if (m_name) {
m_name->blockSignals(true);
m_name->setText(s.displayName);
m_name->blockSignals(false);
}
if (m_depth) m_depth->setText(QString::number(s.depthZ));
if (m_distScale) m_distScale->setText(s.distanceScaleText);
if (m_pivot) m_pivot->setValue(s.pivot.x(), s.pivot.y());
if (m_centroid) m_centroid->setValue(s.centroid.x(), s.centroid.y());
if (m_userScale) {
m_userScale->blockSignals(true);
m_userScale->setValue(s.userScale);
m_userScale->blockSignals(false);
}
}
void EntityPropertySection::setEditingEnabled(bool on) {
if (m_name) m_name->setEnabled(on);
if (m_pivot) m_pivot->setEnabled(on);
if (m_centroid) m_centroid->setEnabled(on);
if (m_userScale) m_userScale->setEnabled(on);
}
} // namespace gui

View File

@@ -0,0 +1,52 @@
#pragma once
#include "props/PropertySectionWidget.h"
#include <QPointF>
#include <QString>
class QLabel;
class QLineEdit;
class QDoubleSpinBox;
namespace gui {
class Vec2ParamControl;
}
namespace gui {
struct EntityPropertyUiState {
QString displayName;
int depthZ = 0;
QString distanceScaleText;
QPointF pivot;
QPointF centroid;
double userScale = 1.0;
};
// 实体相关属性(可嵌入 QStackedWidget 的一页)
class EntityPropertySection final : public PropertySectionWidget {
Q_OBJECT
public:
explicit EntityPropertySection(QWidget* parent = nullptr);
void clearDisconnected();
void applyState(const EntityPropertyUiState& s);
void setEditingEnabled(bool on);
signals:
void displayNameCommitted(const QString& text);
void pivotEdited(double x, double y);
void centroidEdited(double x, double y);
void userScaleEdited(double value);
private:
QLineEdit* m_name = nullptr;
QLabel* m_depth = nullptr;
QLabel* m_distScale = nullptr;
Vec2ParamControl* m_pivot = nullptr;
Vec2ParamControl* m_centroid = nullptr;
QDoubleSpinBox* m_userScale = nullptr;
};
} // namespace gui

View File

@@ -0,0 +1,13 @@
#pragma once
#include <QWidget>
namespace gui {
// 属性 dock 中可切换的「一节」的公共基类:便于以后扩展更多对象类型(灯光、相机等)
class PropertySectionWidget : public QWidget {
public:
explicit PropertySectionWidget(QWidget* parent = nullptr) : QWidget(parent) {}
};
} // namespace gui

View File

@@ -0,0 +1,310 @@
#include "timeline/TimelineWidget.h"
#include <algorithm>
#include <QMouseEvent>
#include <QPainter>
#include <QWheelEvent>
namespace {
int clampFrame(int f, int a, int b) {
if (a > b) std::swap(a, b);
return std::clamp(f, a, b);
}
} // namespace
TimelineWidget::TimelineWidget(QWidget* parent)
: QWidget(parent) {
setMouseTracking(true);
setMinimumHeight(28);
setFocusPolicy(Qt::StrongFocus);
}
void TimelineWidget::setFrameRange(int start, int end) {
if (m_start == start && m_end == end) {
return;
}
m_start = start;
m_end = end;
m_currentFrame = clampFrame(m_currentFrame, m_start, m_end);
update();
}
void TimelineWidget::setCurrentFrame(int frame) {
setFrameInternal(frame, false);
}
void TimelineWidget::setSelectionRange(int start, int end) {
if (start < 0 || end < 0) {
m_selStart = -1;
m_selEnd = -1;
update();
return;
}
m_selStart = clampFrame(std::min(start, end), m_start, m_end);
m_selEnd = clampFrame(std::max(start, end), m_start, m_end);
update();
}
void TimelineWidget::setKeyframeTracks(const core::Project::Entity* e) {
m_locFrames.clear();
m_scaleFrames.clear();
m_imgFrames.clear();
if (!e) {
update();
return;
}
m_locFrames.reserve(e->locationKeys.size());
for (const auto& k : e->locationKeys) m_locFrames.push_back(k.frame);
m_scaleFrames.reserve(e->userScaleKeys.size());
for (const auto& k : e->userScaleKeys) m_scaleFrames.push_back(k.frame);
m_imgFrames.reserve(e->imageFrames.size());
for (const auto& k : e->imageFrames) m_imgFrames.push_back(k.frame);
auto uniqSort = [](QVector<int>& v) {
std::sort(v.begin(), v.end());
v.erase(std::unique(v.begin(), v.end()), v.end());
};
uniqSort(m_locFrames);
uniqSort(m_scaleFrames);
uniqSort(m_imgFrames);
// 轨道变了:若当前选中的关键帧不再存在,则清除
auto contains = [](const QVector<int>& v, int f) {
return std::binary_search(v.begin(), v.end(), f);
};
bool ok = true;
if (m_selKeyKind == KeyKind::Location) ok = contains(m_locFrames, m_selKeyFrame);
if (m_selKeyKind == KeyKind::UserScale) ok = contains(m_scaleFrames, m_selKeyFrame);
if (m_selKeyKind == KeyKind::Image) ok = contains(m_imgFrames, m_selKeyFrame);
if (!ok) {
m_selKeyKind = KeyKind::None;
m_selKeyFrame = -1;
emit keyframeSelectionChanged(m_selKeyKind, m_selKeyFrame);
}
update();
}
QRect TimelineWidget::trackRect() const {
const int pad = 8;
const int h = height();
return QRect(pad, 0, std::max(1, width() - pad * 2), h);
}
int TimelineWidget::xToFrame(int x) const {
const QRect r = trackRect();
if (r.width() <= 1) return m_start;
const double t = std::clamp((x - r.left()) / double(r.width() - 1), 0.0, 1.0);
const int span = std::max(1, m_end - m_start);
const int f = m_start + int(std::round(t * span));
return clampFrame(f, m_start, m_end);
}
int TimelineWidget::frameToX(int frame) const {
const QRect r = trackRect();
if (r.width() <= 1) return r.left();
const int f = clampFrame(frame, m_start, m_end);
const int span = std::max(1, m_end - m_start);
const double t = double(f - m_start) / double(span);
return r.left() + int(std::round(t * (r.width() - 1)));
}
void TimelineWidget::setFrameInternal(int frame, bool commit) {
const int f = clampFrame(frame, m_start, m_end);
if (m_currentFrame == f && !commit) {
return;
}
m_currentFrame = f;
update();
emit frameScrubbed(f);
if (commit) {
emit frameCommitted(f);
}
}
void TimelineWidget::paintEvent(QPaintEvent*) {
QPainter p(this);
p.setRenderHint(QPainter::Antialiasing, true);
const QRect r = rect();
p.fillRect(r, palette().base());
const QRect tr = trackRect().adjusted(0, 8, 0, -8);
const QColor rail = palette().mid().color();
p.setPen(Qt::NoPen);
p.setBrush(rail);
p.drawRoundedRect(tr, 6, 6);
// selection range
if (m_selStart >= 0 && m_selEnd >= 0) {
const int x0 = frameToX(m_selStart);
const int x1 = frameToX(m_selEnd);
QRect sel(QPoint(std::min(x0, x1), tr.top()), QPoint(std::max(x0, x1), tr.bottom()));
sel = sel.adjusted(0, 2, 0, -2);
QColor c = palette().highlight().color();
c.setAlpha(50);
p.setBrush(c);
p.drawRoundedRect(sel, 4, 4);
}
auto drawDots = [&](const QVector<int>& frames, const QColor& c, int y) {
p.setBrush(c);
p.setPen(Qt::NoPen);
for (int f : frames) {
if (f < m_start || f > m_end) continue;
const int x = frameToX(f);
const bool sel =
(m_selKeyFrame == f)
&& ((m_selKeyKind == KeyKind::Image && &frames == &m_imgFrames)
|| (m_selKeyKind == KeyKind::Location && &frames == &m_locFrames)
|| (m_selKeyKind == KeyKind::UserScale && &frames == &m_scaleFrames));
if (sel) {
p.setPen(QPen(palette().highlight().color(), 2.0));
p.setBrush(c);
p.drawEllipse(QPointF(x, y), 4.4, 4.4);
p.setPen(Qt::NoPen);
} else {
p.drawEllipse(QPointF(x, y), 2.6, 2.6);
}
}
};
const int yMid = tr.center().y();
drawDots(m_imgFrames, QColor(80, 160, 255, 230), yMid - 6);
drawDots(m_locFrames, QColor(255, 120, 0, 230), yMid);
drawDots(m_scaleFrames, QColor(140, 220, 140, 230), yMid + 6);
// current frame caret
const int cx = frameToX(m_currentFrame);
p.setPen(QPen(palette().highlight().color(), 2.0));
p.drawLine(QPoint(cx, tr.top() - 6), QPoint(cx, tr.bottom() + 6));
}
static bool hitDot(const QPoint& pos, int dotX, int dotY, int radiusPx) {
const int dx = pos.x() - dotX;
const int dy = pos.y() - dotY;
return (dx * dx + dy * dy) <= (radiusPx * radiusPx);
}
static int findNearestFrameInTrack(const QVector<int>& frames, int frame) {
if (frames.isEmpty()) return -1;
const auto it = std::lower_bound(frames.begin(), frames.end(), frame);
if (it == frames.begin()) return *it;
if (it == frames.end()) return frames.back();
const int a = *(it - 1);
const int b = *it;
return (std::abs(frame - a) <= std::abs(b - frame)) ? a : b;
}
static void findIntervalAround(const QVector<int>& allFrames, int frame, int& outA, int& outB) {
outA = -1;
outB = -1;
if (allFrames.size() < 2) return;
const auto it = std::upper_bound(allFrames.begin(), allFrames.end(), frame);
if (it == allFrames.begin() || it == allFrames.end()) return;
outA = *(it - 1);
outB = *it;
}
void TimelineWidget::mousePressEvent(QMouseEvent* e) {
if (e->button() == Qt::RightButton) {
emit contextMenuRequested(mapToGlobal(e->pos()), xToFrame(e->pos().x()));
return;
}
if (e->button() == Qt::LeftButton) {
m_pressPos = e->pos();
m_moved = false;
m_dragging = true;
setFrameInternal(xToFrame(e->pos().x()), false);
e->accept();
return;
}
QWidget::mousePressEvent(e);
}
void TimelineWidget::mouseMoveEvent(QMouseEvent* e) {
if (m_dragging) {
if ((e->pos() - m_pressPos).manhattanLength() > 3) {
m_moved = true;
}
setFrameInternal(xToFrame(e->pos().x()), false);
e->accept();
return;
}
QWidget::mouseMoveEvent(e);
}
void TimelineWidget::mouseReleaseEvent(QMouseEvent* e) {
if (m_dragging && e->button() == Qt::LeftButton) {
m_dragging = false;
const int f = xToFrame(e->pos().x());
setFrameInternal(f, true);
// 点击(非拖拽)时做选中:关键帧或区间
if (!m_moved) {
const QRect tr = trackRect().adjusted(0, 8, 0, -8);
const int yMid = tr.center().y();
const int yImg = yMid - 6;
const int yLoc = yMid;
const int ySc = yMid + 6;
const int rad = 7;
auto trySelectKey = [&](KeyKind kind, const QVector<int>& frames, int laneY) -> bool {
const int nearest = findNearestFrameInTrack(frames, f);
if (nearest < 0) return false;
const int x = frameToX(nearest);
if (hitDot(e->pos(), x, laneY, rad)) {
m_selKeyKind = kind;
m_selKeyFrame = nearest;
emit keyframeSelectionChanged(m_selKeyKind, m_selKeyFrame);
update();
return true;
}
return false;
};
// 先尝试命中关键帧(按 lane 优先)
if (trySelectKey(KeyKind::Image, m_imgFrames, yImg)
|| trySelectKey(KeyKind::Location, m_locFrames, yLoc)
|| trySelectKey(KeyKind::UserScale, m_scaleFrames, ySc)) {
// 选中关键帧时清掉区间
if (m_selStart >= 0 && m_selEnd >= 0) {
m_selStart = -1;
m_selEnd = -1;
emit intervalSelectionChanged(m_selStart, m_selEnd);
}
} else {
// 未命中关键帧:尝试选中由关键帧切分出的区间(使用三轨道的并集)
QVector<int> all = m_locFrames;
all += m_scaleFrames;
all += m_imgFrames;
std::sort(all.begin(), all.end());
all.erase(std::unique(all.begin(), all.end()), all.end());
int a = -1, b = -1;
findIntervalAround(all, f, a, b);
if (a >= 0 && b >= 0) {
setSelectionRange(a, b);
emit intervalSelectionChanged(m_selStart, m_selEnd);
// 选中区间时清掉关键帧选中
if (m_selKeyKind != KeyKind::None) {
m_selKeyKind = KeyKind::None;
m_selKeyFrame = -1;
emit keyframeSelectionChanged(m_selKeyKind, m_selKeyFrame);
}
}
}
}
e->accept();
return;
}
QWidget::mouseReleaseEvent(e);
}
void TimelineWidget::wheelEvent(QWheelEvent* e) {
const int delta = (e->angleDelta().y() > 0) ? 1 : -1;
setFrameInternal(m_currentFrame + delta, true);
e->accept();
}

View File

@@ -0,0 +1,69 @@
#pragma once
#include "core/domain/Project.h"
#include <QWidget>
class TimelineWidget final : public QWidget {
Q_OBJECT
public:
explicit TimelineWidget(QWidget* parent = nullptr);
void setFrameRange(int start, int end);
void setCurrentFrame(int frame);
int currentFrame() const { return m_currentFrame; }
void setSelectionRange(int start, int end); // -1,-1 清除
int selectionStart() const { return m_selStart; }
int selectionEnd() const { return m_selEnd; }
// 只显示“当前选中实体”的关键帧标记
void setKeyframeTracks(const core::Project::Entity* entityOrNull);
enum class KeyKind { None, Location, UserScale, Image };
KeyKind selectedKeyKind() const { return m_selKeyKind; }
int selectedKeyFrame() const { return m_selKeyFrame; }
bool hasSelectedKeyframe() const { return m_selKeyKind != KeyKind::None && m_selKeyFrame >= 0; }
signals:
void frameScrubbed(int frame); // 拖动中实时触发(用于实时预览)
void frameCommitted(int frame); // 松手/点击确认(用于较重的刷新)
void contextMenuRequested(const QPoint& globalPos, int frame);
void keyframeSelectionChanged(KeyKind kind, int frame);
void intervalSelectionChanged(int start, int end);
protected:
void paintEvent(QPaintEvent*) override;
void mousePressEvent(QMouseEvent*) override;
void mouseMoveEvent(QMouseEvent*) override;
void mouseReleaseEvent(QMouseEvent*) override;
void wheelEvent(QWheelEvent*) override;
private:
int xToFrame(int x) const;
int frameToX(int frame) const;
QRect trackRect() const;
void setFrameInternal(int frame, bool commit);
private:
int m_start = 0;
int m_end = 600;
int m_currentFrame = 0;
int m_selStart = -1;
int m_selEnd = -1;
bool m_dragging = false;
QPoint m_pressPos;
bool m_moved = false;
// snapshot避免频繁遍历 workspace
QVector<int> m_locFrames;
QVector<int> m_scaleFrames;
QVector<int> m_imgFrames;
KeyKind m_selKeyKind = KeyKind::None;
int m_selKeyFrame = -1;
};