initial commit
This commit is contained in:
4
client/CMakeLists.txt
Normal file
4
client/CMakeLists.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
set(SRC_ROOT ${CMAKE_CURRENT_SOURCE_DIR})
|
||||
|
||||
add_subdirectory(core)
|
||||
add_subdirectory(gui)
|
||||
24
client/README.md
Normal file
24
client/README.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# Client(Qt 桌面端)
|
||||
|
||||
## 目录结构(按模块)
|
||||
|
||||
**`core/`**(静态库 `core`,include 根为 `client/core`)
|
||||
|
||||
- `domain/` — 领域模型:`Project`、`Entity` 等
|
||||
- `persistence/` — `PersistentBinaryObject`(统一二进制头与原子写)、`EntityPayloadBinary`(`.hfe` / 旧 `.anim`)
|
||||
- `workspace/` — 项目目录、索引 JSON、撤销栈:`ProjectWorkspace`
|
||||
- `depth/` — 假深度图生成:`DepthService`
|
||||
- `animation/` — 关键帧采样(Hold / 线性插值):`AnimationSampling`
|
||||
|
||||
**`gui/`**(可执行程序 `landscape_tool`,额外 include 根为 `client/gui`)
|
||||
|
||||
- `app/` — 入口 `main.cpp`
|
||||
- `main_window/` — 主窗口与时间轴等:`MainWindow`
|
||||
- `editor/` — 编辑画布:`EditorCanvas`
|
||||
- `dialogs/` — `AboutWindow`、`ImageCropDialog`
|
||||
|
||||
引用方式示例:`#include "core/workspace/ProjectWorkspace.h"`(以 `client/` 为根)、`#include "editor/EditorCanvas.h"`(以 `client/gui/` 为根)。
|
||||
|
||||
## 界面语言
|
||||
|
||||
界面文案为中文(无运行时语言切换)。
|
||||
39
client/core/CMakeLists.txt
Normal file
39
client/core/CMakeLists.txt
Normal file
@@ -0,0 +1,39 @@
|
||||
# 模块:domain、persistence、workspace、depth、animation(时间采样)
|
||||
set(CORE_ROOT ${CMAKE_CURRENT_SOURCE_DIR})
|
||||
|
||||
set(CORE_SOURCES
|
||||
${CORE_ROOT}/domain/Project.cpp
|
||||
${CORE_ROOT}/workspace/ProjectWorkspace.cpp
|
||||
${CORE_ROOT}/persistence/PersistentBinaryObject.cpp
|
||||
${CORE_ROOT}/persistence/EntityPayloadBinary.cpp
|
||||
${CORE_ROOT}/animation/AnimationSampling.cpp
|
||||
${CORE_ROOT}/depth/DepthService.cpp
|
||||
${CORE_ROOT}/net/ModelServerClient.cpp
|
||||
)
|
||||
|
||||
set(CORE_HEADERS
|
||||
${CORE_ROOT}/domain/Project.h
|
||||
${CORE_ROOT}/workspace/ProjectWorkspace.h
|
||||
${CORE_ROOT}/persistence/PersistentBinaryObject.h
|
||||
${CORE_ROOT}/persistence/EntityPayloadBinary.h
|
||||
${CORE_ROOT}/animation/AnimationSampling.h
|
||||
${CORE_ROOT}/depth/DepthService.h
|
||||
${CORE_ROOT}/net/ModelServerClient.h
|
||||
)
|
||||
|
||||
add_library(core STATIC
|
||||
${CORE_SOURCES}
|
||||
${CORE_HEADERS}
|
||||
)
|
||||
|
||||
target_include_directories(core
|
||||
PUBLIC
|
||||
${CORE_ROOT}
|
||||
)
|
||||
|
||||
target_link_libraries(core
|
||||
PUBLIC
|
||||
${QT_PACKAGE}::Core
|
||||
${QT_PACKAGE}::Gui
|
||||
${QT_PACKAGE}::Network
|
||||
)
|
||||
191
client/core/animation/AnimationSampling.cpp
Normal file
191
client/core/animation/AnimationSampling.cpp
Normal file
@@ -0,0 +1,191 @@
|
||||
#include "animation/AnimationSampling.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace core {
|
||||
|
||||
namespace {
|
||||
|
||||
template <typename KeyT, typename FrameGetter>
|
||||
void sortKeysByFrame(QVector<KeyT>& keys, FrameGetter getFrame) {
|
||||
std::sort(keys.begin(), keys.end(), [&](const KeyT& a, const KeyT& b) { return getFrame(a) < getFrame(b); });
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
QPointF sampleLocation(const QVector<Project::Entity::KeyframeVec2>& keys,
|
||||
int frame,
|
||||
const QPointF& fallbackOrigin,
|
||||
KeyInterpolation mode) {
|
||||
QVector<Project::Entity::KeyframeVec2> sorted = keys;
|
||||
sortKeysByFrame(sorted, [](const Project::Entity::KeyframeVec2& k) { return k.frame; });
|
||||
|
||||
if (sorted.isEmpty()) {
|
||||
return fallbackOrigin;
|
||||
}
|
||||
|
||||
if (mode == KeyInterpolation::Hold) {
|
||||
QPointF out = fallbackOrigin;
|
||||
int best = -1;
|
||||
for (const auto& k : sorted) {
|
||||
if (k.frame <= frame && k.frame >= best) {
|
||||
best = k.frame;
|
||||
out = k.value;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Linear:区间外夹持到端点;中间在相邻关键帧间线性插值(对 x、y 分别 lerp)
|
||||
const auto& first = sorted.front();
|
||||
const auto& last = sorted.back();
|
||||
if (frame <= first.frame) {
|
||||
return first.value;
|
||||
}
|
||||
if (frame >= last.frame) {
|
||||
return last.value;
|
||||
}
|
||||
|
||||
for (int i = 0; i + 1 < sorted.size(); ++i) {
|
||||
const int f0 = sorted[i].frame;
|
||||
const int f1 = sorted[i + 1].frame;
|
||||
if (frame < f0) {
|
||||
continue;
|
||||
}
|
||||
if (frame <= f1) {
|
||||
if (f1 == f0 || frame == f0) {
|
||||
return sorted[i].value;
|
||||
}
|
||||
const double t = static_cast<double>(frame - f0) / static_cast<double>(f1 - f0);
|
||||
const QPointF& a = sorted[i].value;
|
||||
const QPointF& b = sorted[i + 1].value;
|
||||
return QPointF(a.x() + (b.x() - a.x()) * t, a.y() + (b.y() - a.y()) * t);
|
||||
}
|
||||
}
|
||||
return last.value;
|
||||
}
|
||||
|
||||
double sampleDepthScale01(const QVector<Project::Entity::KeyframeFloat01>& keys,
|
||||
int frame,
|
||||
double fallback01,
|
||||
KeyInterpolation mode) {
|
||||
QVector<Project::Entity::KeyframeFloat01> sorted = keys;
|
||||
sortKeysByFrame(sorted, [](const Project::Entity::KeyframeFloat01& k) { return k.frame; });
|
||||
|
||||
const double fb = std::clamp(fallback01, 0.0, 1.0);
|
||||
|
||||
if (sorted.isEmpty()) {
|
||||
return fb;
|
||||
}
|
||||
|
||||
if (mode == KeyInterpolation::Hold) {
|
||||
double out = fb;
|
||||
int best = -1;
|
||||
for (const auto& k : sorted) {
|
||||
if (k.frame <= frame && k.frame >= best) {
|
||||
best = k.frame;
|
||||
out = k.value;
|
||||
}
|
||||
}
|
||||
return std::clamp(out, 0.0, 1.0);
|
||||
}
|
||||
|
||||
const auto& first = sorted.front();
|
||||
const auto& last = sorted.back();
|
||||
if (frame <= first.frame) {
|
||||
return std::clamp(first.value, 0.0, 1.0);
|
||||
}
|
||||
if (frame >= last.frame) {
|
||||
return std::clamp(last.value, 0.0, 1.0);
|
||||
}
|
||||
|
||||
for (int i = 0; i + 1 < sorted.size(); ++i) {
|
||||
const int f0 = sorted[i].frame;
|
||||
const int f1 = sorted[i + 1].frame;
|
||||
if (frame < f0) {
|
||||
continue;
|
||||
}
|
||||
if (frame <= f1) {
|
||||
if (f1 == f0 || frame == f0) {
|
||||
return std::clamp(sorted[i].value, 0.0, 1.0);
|
||||
}
|
||||
const double t = static_cast<double>(frame - f0) / static_cast<double>(f1 - f0);
|
||||
const double a = sorted[i].value;
|
||||
const double b = sorted[i + 1].value;
|
||||
return std::clamp(a + (b - a) * t, 0.0, 1.0);
|
||||
}
|
||||
}
|
||||
return std::clamp(last.value, 0.0, 1.0);
|
||||
}
|
||||
|
||||
double sampleUserScale(const QVector<Project::Entity::KeyframeDouble>& keys,
|
||||
int frame,
|
||||
double fallback,
|
||||
KeyInterpolation mode) {
|
||||
QVector<Project::Entity::KeyframeDouble> sorted = keys;
|
||||
sortKeysByFrame(sorted, [](const Project::Entity::KeyframeDouble& k) { return k.frame; });
|
||||
|
||||
const double fb = std::max(fallback, 1e-6);
|
||||
|
||||
if (sorted.isEmpty()) {
|
||||
return fb;
|
||||
}
|
||||
|
||||
if (mode == KeyInterpolation::Hold) {
|
||||
double out = fb;
|
||||
int best = -1;
|
||||
for (const auto& k : sorted) {
|
||||
if (k.frame <= frame && k.frame >= best) {
|
||||
best = k.frame;
|
||||
out = k.value;
|
||||
}
|
||||
}
|
||||
return std::max(out, 1e-6);
|
||||
}
|
||||
|
||||
const auto& first = sorted.front();
|
||||
const auto& last = sorted.back();
|
||||
if (frame <= first.frame) {
|
||||
return std::max(first.value, 1e-6);
|
||||
}
|
||||
if (frame >= last.frame) {
|
||||
return std::max(last.value, 1e-6);
|
||||
}
|
||||
|
||||
for (int i = 0; i + 1 < sorted.size(); ++i) {
|
||||
const int f0 = sorted[i].frame;
|
||||
const int f1 = sorted[i + 1].frame;
|
||||
if (frame < f0) {
|
||||
continue;
|
||||
}
|
||||
if (frame <= f1) {
|
||||
if (f1 == f0 || frame == f0) {
|
||||
return std::max(sorted[i].value, 1e-6);
|
||||
}
|
||||
const double t = static_cast<double>(frame - f0) / static_cast<double>(f1 - f0);
|
||||
const double a = sorted[i].value;
|
||||
const double b = sorted[i + 1].value;
|
||||
return std::max(a + (b - a) * t, 1e-6);
|
||||
}
|
||||
}
|
||||
return std::max(last.value, 1e-6);
|
||||
}
|
||||
|
||||
QString sampleImagePath(const QVector<Project::Entity::ImageFrame>& frames,
|
||||
int frame,
|
||||
const QString& fallbackPath) {
|
||||
QVector<Project::Entity::ImageFrame> sorted = frames;
|
||||
sortKeysByFrame(sorted, [](const Project::Entity::ImageFrame& k) { return k.frame; });
|
||||
|
||||
QString out = fallbackPath;
|
||||
int best = -1;
|
||||
for (const auto& k : sorted) {
|
||||
if (k.frame <= frame && k.frame >= best && !k.imagePath.isEmpty()) {
|
||||
best = k.frame;
|
||||
out = k.imagePath;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace core
|
||||
33
client/core/animation/AnimationSampling.h
Normal file
33
client/core/animation/AnimationSampling.h
Normal file
@@ -0,0 +1,33 @@
|
||||
#pragma once
|
||||
|
||||
#include "domain/Project.h"
|
||||
|
||||
#include <QPointF>
|
||||
#include <QString>
|
||||
#include <QVector>
|
||||
|
||||
namespace core {
|
||||
|
||||
enum class KeyInterpolation { Hold, Linear };
|
||||
|
||||
// 关键帧按 frame 排序后使用;内部会对副本排序以保证稳健。
|
||||
[[nodiscard]] QPointF sampleLocation(const QVector<Project::Entity::KeyframeVec2>& keys,
|
||||
int frame,
|
||||
const QPointF& fallbackOrigin,
|
||||
KeyInterpolation mode);
|
||||
|
||||
[[nodiscard]] double sampleDepthScale01(const QVector<Project::Entity::KeyframeFloat01>& keys,
|
||||
int frame,
|
||||
double fallback01,
|
||||
KeyInterpolation mode);
|
||||
|
||||
[[nodiscard]] double sampleUserScale(const QVector<Project::Entity::KeyframeDouble>& keys,
|
||||
int frame,
|
||||
double fallback,
|
||||
KeyInterpolation mode);
|
||||
|
||||
[[nodiscard]] QString sampleImagePath(const QVector<Project::Entity::ImageFrame>& frames,
|
||||
int frame,
|
||||
const QString& fallbackPath);
|
||||
|
||||
} // namespace core
|
||||
58
client/core/depth/DepthService.cpp
Normal file
58
client/core/depth/DepthService.cpp
Normal file
@@ -0,0 +1,58 @@
|
||||
#include "depth/DepthService.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace core {
|
||||
|
||||
QImage DepthService::computeFakeDepth(const QSize& size) {
|
||||
if (size.isEmpty() || size.width() <= 0 || size.height() <= 0) {
|
||||
return {};
|
||||
}
|
||||
QImage depth(size, QImage::Format_Grayscale8);
|
||||
if (depth.isNull()) {
|
||||
return {};
|
||||
}
|
||||
depth.fill(0);
|
||||
return depth;
|
||||
}
|
||||
|
||||
QImage DepthService::computeFakeDepthFromBackground(const QImage& background) {
|
||||
if (background.isNull()) {
|
||||
return {};
|
||||
}
|
||||
return computeFakeDepth(background.size());
|
||||
}
|
||||
|
||||
QImage DepthService::depthToColormapOverlay(const QImage& depth8, int alpha) {
|
||||
if (depth8.isNull()) {
|
||||
return {};
|
||||
}
|
||||
const QImage src = (depth8.format() == QImage::Format_Grayscale8) ? depth8 : depth8.convertToFormat(QImage::Format_Grayscale8);
|
||||
if (src.isNull()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const int a = std::clamp(alpha, 0, 255);
|
||||
QImage out(src.size(), QImage::Format_ARGB32_Premultiplied);
|
||||
if (out.isNull()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
for (int y = 0; y < src.height(); ++y) {
|
||||
const uchar* row = src.constScanLine(y);
|
||||
QRgb* dst = reinterpret_cast<QRgb*>(out.scanLine(y));
|
||||
for (int x = 0; x < src.width(); ++x) {
|
||||
const int d = static_cast<int>(row[x]); // 0..255
|
||||
// depth=0(远)-> 蓝;depth=255(近)-> 红
|
||||
const int r = d;
|
||||
const int g = 0;
|
||||
const int b = 255 - d;
|
||||
dst[x] = qRgba(r, g, b, a);
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace core
|
||||
|
||||
20
client/core/depth/DepthService.h
Normal file
20
client/core/depth/DepthService.h
Normal file
@@ -0,0 +1,20 @@
|
||||
#pragma once
|
||||
|
||||
#include <QImage>
|
||||
#include <QSize>
|
||||
|
||||
namespace core {
|
||||
|
||||
class DepthService final {
|
||||
public:
|
||||
// 生成 8-bit 深度图:0 最远,255 最近。当前实现为全 0(假深度)。
|
||||
static QImage computeFakeDepth(const QSize& size);
|
||||
static QImage computeFakeDepthFromBackground(const QImage& background);
|
||||
|
||||
// 把 8-bit 深度(Grayscale8)映射为伪彩色 ARGB32(带 alpha),用于叠加显示。
|
||||
// 约定:depth=0(最远)-> 蓝,depth=255(最近)-> 红(线性插值)。
|
||||
static QImage depthToColormapOverlay(const QImage& depth8, int alpha /*0-255*/);
|
||||
};
|
||||
|
||||
} // namespace core
|
||||
|
||||
5
client/core/domain/Project.cpp
Normal file
5
client/core/domain/Project.cpp
Normal file
@@ -0,0 +1,5 @@
|
||||
#include "domain/Project.h"
|
||||
|
||||
namespace core {
|
||||
|
||||
} // namespace core
|
||||
96
client/core/domain/Project.h
Normal file
96
client/core/domain/Project.h
Normal file
@@ -0,0 +1,96 @@
|
||||
#pragma once
|
||||
|
||||
#include <QString>
|
||||
#include <QPointF>
|
||||
#include <QVector>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace core {
|
||||
|
||||
class Project {
|
||||
public:
|
||||
void setName(const QString& name) { m_name = name; }
|
||||
const QString& name() const { return m_name; }
|
||||
|
||||
// 背景图在项目目录内的相对路径,例如 "assets/background.png"
|
||||
void setBackgroundImagePath(const QString& relativePath) { m_backgroundImagePath = relativePath; }
|
||||
const QString& backgroundImagePath() const { return m_backgroundImagePath; }
|
||||
|
||||
// 背景在视口/预览中的显隐(默认显示)
|
||||
void setBackgroundVisible(bool on) { m_backgroundVisible = on; }
|
||||
bool backgroundVisible() const { return m_backgroundVisible; }
|
||||
|
||||
void setDepthComputed(bool on) { m_depthComputed = on; }
|
||||
bool depthComputed() const { return m_depthComputed; }
|
||||
|
||||
// 深度图在项目目录内的相对路径,例如 "assets/depth.png"
|
||||
void setDepthMapPath(const QString& relativePath) { m_depthMapPath = relativePath; }
|
||||
const QString& depthMapPath() const { return m_depthMapPath; }
|
||||
|
||||
void setFrameStart(int f) { m_frameStart = f; }
|
||||
int frameStart() const { return m_frameStart; }
|
||||
void setFrameEnd(int f) { m_frameEnd = f; }
|
||||
int frameEnd() const { return m_frameEnd; }
|
||||
void setFps(int fps) { m_fps = std::max(1, fps); }
|
||||
int fps() const { return m_fps; }
|
||||
|
||||
struct Entity {
|
||||
QString id;
|
||||
QString displayName; // 显示名(空则界面用 id)
|
||||
bool visible = true; // Outliner 眼睛:默认显示
|
||||
// 可移动实体形状:存为局部坐标(相对 originWorld)
|
||||
QVector<QPointF> polygonLocal;
|
||||
// 从背景中抠洞的位置:固定在创建时的 world 坐标,不随实体移动
|
||||
QVector<QPointF> cutoutPolygonWorld;
|
||||
QPointF originWorld;
|
||||
int depth = 0; // 0..255
|
||||
QString imagePath; // 相对路径,例如 "assets/entities/entity-1.png"
|
||||
QPointF imageTopLeftWorld; // 贴图左上角 world 坐标
|
||||
// 人为整体缩放,与深度驱动的距离缩放相乘(画布中 visualScale = distanceScale * userScale)
|
||||
double userScale = 1.0;
|
||||
|
||||
struct KeyframeVec2 {
|
||||
int frame = 0;
|
||||
QPointF value;
|
||||
};
|
||||
struct KeyframeFloat01 {
|
||||
int frame = 0;
|
||||
double value = 0.5; // 0..1,默认 0.5 -> scale=1.0(0.5..1.5 映射)
|
||||
};
|
||||
struct KeyframeDouble {
|
||||
int frame = 0;
|
||||
double value = 1.0;
|
||||
};
|
||||
struct ImageFrame {
|
||||
int frame = 0;
|
||||
QString imagePath; // 相对路径
|
||||
};
|
||||
|
||||
// v2:project.json 仅存 id + payload,几何与动画在 entityPayloadPath(.hfe)中。
|
||||
QString entityPayloadPath; // 例如 "assets/entities/entity-1.hfe"
|
||||
// 仅打开 v1 项目时由 JSON 的 animationBundle 填入,用于合并旧 .anim;保存 v2 前应为空。
|
||||
QString legacyAnimSidecarPath;
|
||||
|
||||
QVector<KeyframeVec2> locationKeys;
|
||||
QVector<KeyframeFloat01> depthScaleKeys;
|
||||
QVector<KeyframeDouble> userScaleKeys;
|
||||
QVector<ImageFrame> imageFrames;
|
||||
};
|
||||
|
||||
void setEntities(const QVector<Entity>& entities) { m_entities = entities; }
|
||||
const QVector<Entity>& entities() const { return m_entities; }
|
||||
|
||||
private:
|
||||
QString m_name;
|
||||
QString m_backgroundImagePath;
|
||||
bool m_backgroundVisible = true;
|
||||
bool m_depthComputed = false;
|
||||
QString m_depthMapPath;
|
||||
int m_frameStart = 0;
|
||||
int m_frameEnd = 600;
|
||||
int m_fps = 60;
|
||||
QVector<Entity> m_entities;
|
||||
};
|
||||
|
||||
} // namespace core
|
||||
139
client/core/net/ModelServerClient.cpp
Normal file
139
client/core/net/ModelServerClient.cpp
Normal file
@@ -0,0 +1,139 @@
|
||||
#include "net/ModelServerClient.h"
|
||||
|
||||
#include <QEventLoop>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QNetworkAccessManager>
|
||||
#include <QNetworkReply>
|
||||
#include <QNetworkRequest>
|
||||
#include <QTimer>
|
||||
#include <QUrl>
|
||||
|
||||
namespace core {
|
||||
|
||||
ModelServerClient::ModelServerClient(QObject* parent)
|
||||
: QObject(parent)
|
||||
, m_nam(new QNetworkAccessManager(this)) {
|
||||
}
|
||||
|
||||
void ModelServerClient::setBaseUrl(const QUrl& baseUrl) {
|
||||
m_baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
QUrl ModelServerClient::baseUrl() const {
|
||||
return m_baseUrl;
|
||||
}
|
||||
|
||||
QNetworkReply* ModelServerClient::computeDepthPng8Async(const QByteArray& imageBytes, QString* outImmediateError) {
|
||||
if (outImmediateError) {
|
||||
outImmediateError->clear();
|
||||
}
|
||||
if (!m_baseUrl.isValid() || m_baseUrl.isEmpty()) {
|
||||
if (outImmediateError) *outImmediateError = QStringLiteral("后端地址无效。");
|
||||
return nullptr;
|
||||
}
|
||||
if (imageBytes.isEmpty()) {
|
||||
if (outImmediateError) *outImmediateError = QStringLiteral("输入图像为空。");
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
const QUrl url = m_baseUrl.resolved(QUrl(QStringLiteral("/depth")));
|
||||
QNetworkRequest req(url);
|
||||
req.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json"));
|
||||
|
||||
const QByteArray imageB64 = imageBytes.toBase64();
|
||||
const QJsonObject payload{
|
||||
{QStringLiteral("image_b64"), QString::fromLatin1(imageB64)},
|
||||
};
|
||||
const QByteArray body = QJsonDocument(payload).toJson(QJsonDocument::Compact);
|
||||
return m_nam->post(req, body);
|
||||
}
|
||||
|
||||
bool ModelServerClient::computeDepthPng8(
|
||||
const QByteArray& imageBytes,
|
||||
QByteArray& outPngBytes,
|
||||
QString& outError,
|
||||
int timeoutMs
|
||||
) {
|
||||
outPngBytes.clear();
|
||||
outError.clear();
|
||||
|
||||
if (!m_baseUrl.isValid() || m_baseUrl.isEmpty()) {
|
||||
outError = QStringLiteral("后端地址无效。");
|
||||
return false;
|
||||
}
|
||||
if (imageBytes.isEmpty()) {
|
||||
outError = QStringLiteral("输入图像为空。");
|
||||
return false;
|
||||
}
|
||||
|
||||
const QUrl url = m_baseUrl.resolved(QUrl(QStringLiteral("/depth")));
|
||||
|
||||
QNetworkRequest req(url);
|
||||
req.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json"));
|
||||
|
||||
const QByteArray imageB64 = imageBytes.toBase64();
|
||||
const QJsonObject payload{
|
||||
{QStringLiteral("image_b64"), QString::fromLatin1(imageB64)},
|
||||
};
|
||||
const QByteArray body = QJsonDocument(payload).toJson(QJsonDocument::Compact);
|
||||
|
||||
QNetworkReply* reply = m_nam->post(req, body);
|
||||
if (!reply) {
|
||||
outError = QStringLiteral("创建网络请求失败。");
|
||||
return false;
|
||||
}
|
||||
|
||||
QEventLoop loop;
|
||||
QTimer timer;
|
||||
timer.setSingleShot(true);
|
||||
const int t = (timeoutMs <= 0) ? 30000 : timeoutMs;
|
||||
|
||||
QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit);
|
||||
QObject::connect(&timer, &QTimer::timeout, &loop, &QEventLoop::quit);
|
||||
timer.start(t);
|
||||
loop.exec();
|
||||
|
||||
if (timer.isActive() == false && reply->isFinished() == false) {
|
||||
reply->abort();
|
||||
reply->deleteLater();
|
||||
outError = QStringLiteral("请求超时(%1ms)。").arg(t);
|
||||
return false;
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
if (netErr != QNetworkReply::NoError) {
|
||||
outError = QStringLiteral("网络错误:%1").arg(netErrStr);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (httpStatus != 200) {
|
||||
// FastAPI HTTPException 默认返回 {"detail": "..."}
|
||||
QString detail;
|
||||
const QJsonDocument jd = QJsonDocument::fromJson(raw);
|
||||
if (jd.isObject()) {
|
||||
const auto obj = jd.object();
|
||||
detail = obj.value(QStringLiteral("detail")).toString();
|
||||
}
|
||||
outError = detail.isEmpty()
|
||||
? QStringLiteral("后端返回HTTP %1。").arg(httpStatus)
|
||||
: QStringLiteral("后端错误(HTTP %1):%2").arg(httpStatus).arg(detail);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (raw.isEmpty()) {
|
||||
outError = QStringLiteral("后端返回空数据。");
|
||||
return false;
|
||||
}
|
||||
|
||||
outPngBytes = raw;
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace core
|
||||
|
||||
36
client/core/net/ModelServerClient.h
Normal file
36
client/core/net/ModelServerClient.h
Normal file
@@ -0,0 +1,36 @@
|
||||
#pragma once
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include <QUrl>
|
||||
#include <QNetworkReply>
|
||||
class QNetworkAccessManager;
|
||||
class QUrl;
|
||||
class QNetworkReply;
|
||||
|
||||
namespace core {
|
||||
|
||||
class ModelServerClient final : public QObject {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit ModelServerClient(QObject* parent = nullptr);
|
||||
|
||||
void setBaseUrl(const QUrl& baseUrl);
|
||||
QUrl baseUrl() const;
|
||||
|
||||
// 同步调用:向后端 POST /depth 发送背景图,成功返回 PNG(8-bit 灰度) 的二进制数据。
|
||||
// timeoutMs<=0 表示使用默认超时(30s)。
|
||||
bool computeDepthPng8(const QByteArray& imageBytes, QByteArray& outPngBytes, QString& outError, int timeoutMs = 30000);
|
||||
|
||||
// 异步调用:发起 POST /depth,返回 reply(由 Qt 管理生命周期;调用方负责连接 finished/错误处理)。
|
||||
// 返回 nullptr 表示参数/URL 非法导致无法发起。
|
||||
QNetworkReply* computeDepthPng8Async(const QByteArray& imageBytes, QString* outImmediateError = nullptr);
|
||||
|
||||
private:
|
||||
QNetworkAccessManager* m_nam = nullptr;
|
||||
QUrl m_baseUrl;
|
||||
};
|
||||
|
||||
} // namespace core
|
||||
|
||||
324
client/core/persistence/EntityPayloadBinary.cpp
Normal file
324
client/core/persistence/EntityPayloadBinary.cpp
Normal file
@@ -0,0 +1,324 @@
|
||||
#include "persistence/EntityPayloadBinary.h"
|
||||
|
||||
#include "persistence/PersistentBinaryObject.h"
|
||||
|
||||
#include "domain/Project.h"
|
||||
|
||||
#include <QDataStream>
|
||||
#include <QFile>
|
||||
#include <QtGlobal>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace core {
|
||||
|
||||
namespace {
|
||||
|
||||
void sortByFrame(QVector<Project::Entity::KeyframeVec2>& v) {
|
||||
std::sort(v.begin(), v.end(), [](const auto& a, const auto& b) { return a.frame < b.frame; });
|
||||
}
|
||||
|
||||
void sortByFrame(QVector<Project::Entity::KeyframeFloat01>& v) {
|
||||
std::sort(v.begin(), v.end(), [](const auto& a, const auto& b) { return a.frame < b.frame; });
|
||||
}
|
||||
|
||||
void sortByFrame(QVector<Project::Entity::KeyframeDouble>& v) {
|
||||
std::sort(v.begin(), v.end(), [](const auto& a, const auto& b) { return a.frame < b.frame; });
|
||||
}
|
||||
|
||||
void sortByFrame(QVector<Project::Entity::ImageFrame>& v) {
|
||||
std::sort(v.begin(), v.end(), [](const auto& a, const auto& b) { return a.frame < b.frame; });
|
||||
}
|
||||
|
||||
bool readAnimationBlock(QDataStream& ds, Project::Entity& out, bool hasUserScaleKeys) {
|
||||
out.locationKeys.clear();
|
||||
out.depthScaleKeys.clear();
|
||||
out.userScaleKeys.clear();
|
||||
out.imageFrames.clear();
|
||||
|
||||
qint32 nLoc = 0;
|
||||
ds >> nLoc;
|
||||
if (ds.status() != QDataStream::Ok || nLoc < 0 || nLoc > 1000000) {
|
||||
return false;
|
||||
}
|
||||
out.locationKeys.reserve(nLoc);
|
||||
for (qint32 i = 0; i < nLoc; ++i) {
|
||||
qint32 frame = 0;
|
||||
double x = 0.0;
|
||||
double y = 0.0;
|
||||
ds >> frame >> x >> y;
|
||||
if (ds.status() != QDataStream::Ok) {
|
||||
return false;
|
||||
}
|
||||
out.locationKeys.push_back(Project::Entity::KeyframeVec2{frame, QPointF(x, y)});
|
||||
}
|
||||
|
||||
qint32 nDepth = 0;
|
||||
ds >> nDepth;
|
||||
if (ds.status() != QDataStream::Ok || nDepth < 0 || nDepth > 1000000) {
|
||||
return false;
|
||||
}
|
||||
out.depthScaleKeys.reserve(nDepth);
|
||||
for (qint32 i = 0; i < nDepth; ++i) {
|
||||
qint32 frame = 0;
|
||||
double v = 0.5;
|
||||
ds >> frame >> v;
|
||||
if (ds.status() != QDataStream::Ok) {
|
||||
return false;
|
||||
}
|
||||
out.depthScaleKeys.push_back(Project::Entity::KeyframeFloat01{frame, v});
|
||||
}
|
||||
|
||||
if (hasUserScaleKeys) {
|
||||
qint32 nUser = 0;
|
||||
ds >> nUser;
|
||||
if (ds.status() != QDataStream::Ok || nUser < 0 || nUser > 1000000) {
|
||||
return false;
|
||||
}
|
||||
out.userScaleKeys.reserve(nUser);
|
||||
for (qint32 i = 0; i < nUser; ++i) {
|
||||
qint32 frame = 0;
|
||||
double v = 1.0;
|
||||
ds >> frame >> v;
|
||||
if (ds.status() != QDataStream::Ok) {
|
||||
return false;
|
||||
}
|
||||
out.userScaleKeys.push_back(Project::Entity::KeyframeDouble{frame, v});
|
||||
}
|
||||
}
|
||||
|
||||
qint32 nImg = 0;
|
||||
ds >> nImg;
|
||||
if (ds.status() != QDataStream::Ok || nImg < 0 || nImg > 1000000) {
|
||||
return false;
|
||||
}
|
||||
out.imageFrames.reserve(nImg);
|
||||
for (qint32 i = 0; i < nImg; ++i) {
|
||||
qint32 frame = 0;
|
||||
QString path;
|
||||
ds >> frame >> path;
|
||||
if (ds.status() != QDataStream::Ok) {
|
||||
return false;
|
||||
}
|
||||
if (!path.isEmpty()) {
|
||||
out.imageFrames.push_back(Project::Entity::ImageFrame{frame, path});
|
||||
}
|
||||
}
|
||||
|
||||
sortByFrame(out.locationKeys);
|
||||
sortByFrame(out.depthScaleKeys);
|
||||
sortByFrame(out.userScaleKeys);
|
||||
sortByFrame(out.imageFrames);
|
||||
return true;
|
||||
}
|
||||
|
||||
void writeAnimationBlock(QDataStream& ds, const Project::Entity& entity, bool writeUserScaleKeys) {
|
||||
ds << qint32(entity.locationKeys.size());
|
||||
for (const auto& k : entity.locationKeys) {
|
||||
ds << qint32(k.frame) << double(k.value.x()) << double(k.value.y());
|
||||
}
|
||||
|
||||
ds << qint32(entity.depthScaleKeys.size());
|
||||
for (const auto& k : entity.depthScaleKeys) {
|
||||
ds << qint32(k.frame) << double(k.value);
|
||||
}
|
||||
|
||||
if (writeUserScaleKeys) {
|
||||
ds << qint32(entity.userScaleKeys.size());
|
||||
for (const auto& k : entity.userScaleKeys) {
|
||||
ds << qint32(k.frame) << double(k.value);
|
||||
}
|
||||
}
|
||||
|
||||
ds << qint32(entity.imageFrames.size());
|
||||
for (const auto& k : entity.imageFrames) {
|
||||
ds << qint32(k.frame) << k.imagePath;
|
||||
}
|
||||
}
|
||||
|
||||
bool readEntityPayloadV1(QDataStream& ds, Project::Entity& tmp, bool hasUserScaleKeys) {
|
||||
ds >> tmp.id;
|
||||
qint32 depth = 0;
|
||||
ds >> depth;
|
||||
tmp.depth = static_cast<int>(depth);
|
||||
ds >> tmp.imagePath;
|
||||
double ox = 0.0;
|
||||
double oy = 0.0;
|
||||
double itlx = 0.0;
|
||||
double itly = 0.0;
|
||||
ds >> ox >> oy >> itlx >> itly;
|
||||
tmp.originWorld = QPointF(ox, oy);
|
||||
tmp.imageTopLeftWorld = QPointF(itlx, itly);
|
||||
|
||||
qint32 nLocal = 0;
|
||||
ds >> nLocal;
|
||||
if (ds.status() != QDataStream::Ok || nLocal < 0 || nLocal > 1000000) {
|
||||
return false;
|
||||
}
|
||||
tmp.polygonLocal.reserve(nLocal);
|
||||
for (qint32 i = 0; i < nLocal; ++i) {
|
||||
double x = 0.0;
|
||||
double y = 0.0;
|
||||
ds >> x >> y;
|
||||
if (ds.status() != QDataStream::Ok) {
|
||||
return false;
|
||||
}
|
||||
tmp.polygonLocal.push_back(QPointF(x, y));
|
||||
}
|
||||
|
||||
qint32 nCut = 0;
|
||||
ds >> nCut;
|
||||
if (ds.status() != QDataStream::Ok || nCut < 0 || nCut > 1000000) {
|
||||
return false;
|
||||
}
|
||||
tmp.cutoutPolygonWorld.reserve(nCut);
|
||||
for (qint32 i = 0; i < nCut; ++i) {
|
||||
double x = 0.0;
|
||||
double y = 0.0;
|
||||
ds >> x >> y;
|
||||
if (ds.status() != QDataStream::Ok) {
|
||||
return false;
|
||||
}
|
||||
tmp.cutoutPolygonWorld.push_back(QPointF(x, y));
|
||||
}
|
||||
|
||||
if (!readAnimationBlock(ds, tmp, hasUserScaleKeys)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (tmp.id.isEmpty() || tmp.polygonLocal.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
class EntityBinaryRecord final : public PersistentBinaryObject {
|
||||
public:
|
||||
explicit EntityBinaryRecord(const Project::Entity& e) : m_src(&e), m_dst(nullptr) {}
|
||||
explicit EntityBinaryRecord(Project::Entity& e) : m_src(nullptr), m_dst(&e) {}
|
||||
|
||||
quint32 recordMagic() const override { return EntityPayloadBinary::kMagicPayload; }
|
||||
quint32 recordFormatVersion() const override { return EntityPayloadBinary::kPayloadVersion; }
|
||||
|
||||
void writeBody(QDataStream& ds) const override {
|
||||
Q_ASSERT(m_src != nullptr);
|
||||
const Project::Entity& entity = *m_src;
|
||||
ds << entity.id;
|
||||
ds << qint32(entity.depth);
|
||||
ds << entity.imagePath;
|
||||
ds << double(entity.originWorld.x()) << double(entity.originWorld.y());
|
||||
ds << double(entity.imageTopLeftWorld.x()) << double(entity.imageTopLeftWorld.y());
|
||||
|
||||
ds << qint32(entity.polygonLocal.size());
|
||||
for (const auto& pt : entity.polygonLocal) {
|
||||
ds << double(pt.x()) << double(pt.y());
|
||||
}
|
||||
|
||||
ds << qint32(entity.cutoutPolygonWorld.size());
|
||||
for (const auto& pt : entity.cutoutPolygonWorld) {
|
||||
ds << double(pt.x()) << double(pt.y());
|
||||
}
|
||||
|
||||
writeAnimationBlock(ds, entity, true);
|
||||
ds << entity.displayName << double(entity.userScale);
|
||||
}
|
||||
|
||||
bool readBody(QDataStream& ds) override {
|
||||
Q_ASSERT(m_dst != nullptr);
|
||||
Project::Entity tmp;
|
||||
if (!readEntityPayloadV1(ds, tmp, true)) {
|
||||
return false;
|
||||
}
|
||||
QString dn;
|
||||
double us = 1.0;
|
||||
ds >> dn >> us;
|
||||
if (ds.status() != QDataStream::Ok) {
|
||||
return false;
|
||||
}
|
||||
tmp.displayName = dn;
|
||||
tmp.userScale = std::clamp(us, 1e-3, 1e3);
|
||||
*m_dst = std::move(tmp);
|
||||
return true;
|
||||
}
|
||||
|
||||
private:
|
||||
const Project::Entity* m_src;
|
||||
Project::Entity* m_dst;
|
||||
};
|
||||
|
||||
class LegacyAnimSidecarRecord final : public PersistentBinaryObject {
|
||||
public:
|
||||
explicit LegacyAnimSidecarRecord(Project::Entity& e) : m_entity(&e) {}
|
||||
|
||||
quint32 recordMagic() const override { return EntityPayloadBinary::kMagicLegacyAnim; }
|
||||
quint32 recordFormatVersion() const override { return EntityPayloadBinary::kLegacyAnimVersion; }
|
||||
|
||||
void writeBody(QDataStream& ds) const override { Q_UNUSED(ds); }
|
||||
|
||||
bool readBody(QDataStream& ds) override {
|
||||
Project::Entity tmp = *m_entity;
|
||||
if (!readAnimationBlock(ds, tmp, false)) {
|
||||
return false;
|
||||
}
|
||||
m_entity->locationKeys = std::move(tmp.locationKeys);
|
||||
m_entity->depthScaleKeys = std::move(tmp.depthScaleKeys);
|
||||
m_entity->userScaleKeys = std::move(tmp.userScaleKeys);
|
||||
m_entity->imageFrames = std::move(tmp.imageFrames);
|
||||
return true;
|
||||
}
|
||||
|
||||
private:
|
||||
Project::Entity* m_entity;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
bool EntityPayloadBinary::save(const QString& absolutePath, const Project::Entity& entity) {
|
||||
if (absolutePath.isEmpty() || entity.id.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
return EntityBinaryRecord(entity).saveToFile(absolutePath);
|
||||
}
|
||||
|
||||
bool EntityPayloadBinary::load(const QString& absolutePath, Project::Entity& entity) {
|
||||
QFile f(absolutePath);
|
||||
if (!f.open(QIODevice::ReadOnly)) {
|
||||
return false;
|
||||
}
|
||||
QDataStream ds(&f);
|
||||
ds.setVersion(QDataStream::Qt_5_15);
|
||||
quint32 magic = 0;
|
||||
quint32 ver = 0;
|
||||
ds >> magic >> ver;
|
||||
if (ds.status() != QDataStream::Ok || magic != kMagicPayload) {
|
||||
return false;
|
||||
}
|
||||
if (ver != 1 && ver != 2 && ver != 3) {
|
||||
return false;
|
||||
}
|
||||
Project::Entity tmp;
|
||||
if (!readEntityPayloadV1(ds, tmp, ver >= 3)) {
|
||||
return false;
|
||||
}
|
||||
if (ver >= 2) {
|
||||
QString dn;
|
||||
double us = 1.0;
|
||||
ds >> dn >> us;
|
||||
if (ds.status() != QDataStream::Ok) {
|
||||
return false;
|
||||
}
|
||||
tmp.displayName = dn;
|
||||
tmp.userScale = std::clamp(us, 1e-3, 1e3);
|
||||
} else {
|
||||
tmp.displayName.clear();
|
||||
tmp.userScale = 1.0;
|
||||
}
|
||||
entity = std::move(tmp);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool EntityPayloadBinary::loadLegacyAnimFile(const QString& absolutePath, Project::Entity& entity) {
|
||||
return LegacyAnimSidecarRecord(entity).loadFromFile(absolutePath);
|
||||
}
|
||||
|
||||
} // namespace core
|
||||
30
client/core/persistence/EntityPayloadBinary.h
Normal file
30
client/core/persistence/EntityPayloadBinary.h
Normal file
@@ -0,0 +1,30 @@
|
||||
#pragma once
|
||||
|
||||
#include "domain/Project.h"
|
||||
|
||||
#include <QString>
|
||||
|
||||
namespace core {
|
||||
|
||||
// 实体完整数据(几何 + 贴图路径 + 动画轨道)的二进制格式,与 project.json v2 的 payload 字段对应。
|
||||
// 贴图 PNG 仍单独存放在 assets/entities/,本文件不嵌入像素。
|
||||
// 具体读写通过继承 PersistentBinaryObject 的适配器类完成(见 EntityPayloadBinary.cpp)。
|
||||
class EntityPayloadBinary {
|
||||
public:
|
||||
static constexpr quint32 kMagicPayload = 0x48464550; // 'HFEP'
|
||||
static constexpr quint32 kPayloadVersion = 3; // v3:追加 userScaleKeys(动画轨道)
|
||||
|
||||
// 旧版独立动画文件(仍用于打开 v1 项目时合并)
|
||||
static constexpr quint32 kMagicLegacyAnim = 0x48465441; // 'HFTA'
|
||||
static constexpr quint32 kLegacyAnimVersion = 1;
|
||||
|
||||
static bool save(const QString& absolutePath, const Project::Entity& entity);
|
||||
|
||||
// 读入后覆盖 entity 中除调用方已校验外的字段;失败时尽量保持 entity 不变。
|
||||
static bool load(const QString& absolutePath, Project::Entity& entity);
|
||||
|
||||
// 仅读取旧 .anim(HFTA),写入 entity 的三条动画轨道。
|
||||
static bool loadLegacyAnimFile(const QString& absolutePath, Project::Entity& entity);
|
||||
};
|
||||
|
||||
} // namespace core
|
||||
57
client/core/persistence/PersistentBinaryObject.cpp
Normal file
57
client/core/persistence/PersistentBinaryObject.cpp
Normal file
@@ -0,0 +1,57 @@
|
||||
#include "persistence/PersistentBinaryObject.h"
|
||||
|
||||
#include <QDataStream>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
|
||||
namespace core {
|
||||
|
||||
bool PersistentBinaryObject::saveToFile(const QString& absolutePath) const {
|
||||
if (absolutePath.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
const auto parent = QFileInfo(absolutePath).absolutePath();
|
||||
if (!QFileInfo(parent).exists()) {
|
||||
QDir().mkpath(parent);
|
||||
}
|
||||
|
||||
const QString tmpPath = absolutePath + QStringLiteral(".tmp");
|
||||
QFile f(tmpPath);
|
||||
if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
QDataStream ds(&f);
|
||||
ds.setVersion(QDataStream::Qt_5_15);
|
||||
ds << quint32(recordMagic()) << quint32(recordFormatVersion());
|
||||
writeBody(ds);
|
||||
|
||||
f.close();
|
||||
if (f.error() != QFile::NoError) {
|
||||
QFile::remove(tmpPath);
|
||||
return false;
|
||||
}
|
||||
QFile::remove(absolutePath);
|
||||
return QFile::rename(tmpPath, absolutePath);
|
||||
}
|
||||
|
||||
bool PersistentBinaryObject::loadFromFile(const QString& absolutePath) {
|
||||
QFile f(absolutePath);
|
||||
if (!f.open(QIODevice::ReadOnly)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
QDataStream ds(&f);
|
||||
ds.setVersion(QDataStream::Qt_5_15);
|
||||
|
||||
quint32 magic = 0;
|
||||
quint32 version = 0;
|
||||
ds >> magic >> version;
|
||||
if (ds.status() != QDataStream::Ok || magic != recordMagic() || version != recordFormatVersion()) {
|
||||
return false;
|
||||
}
|
||||
return readBody(ds);
|
||||
}
|
||||
|
||||
} // namespace core
|
||||
27
client/core/persistence/PersistentBinaryObject.h
Normal file
27
client/core/persistence/PersistentBinaryObject.h
Normal file
@@ -0,0 +1,27 @@
|
||||
#pragma once
|
||||
|
||||
#include <QString>
|
||||
|
||||
class QDataStream;
|
||||
|
||||
namespace core {
|
||||
|
||||
// 二进制记录的统一持久化基类:魔数/版本、QDataStream 版本、.tmp 原子替换、父目录创建。
|
||||
//
|
||||
// 领域类型(如 Project::Entity)应保持为可拷贝的值类型,不要继承本类;为每种存储格式写一个
|
||||
// 小的适配器类(如 EntityBinaryRecord)继承本类并实现 writeBody/readBody 即可。
|
||||
class PersistentBinaryObject {
|
||||
public:
|
||||
virtual ~PersistentBinaryObject() = default;
|
||||
|
||||
[[nodiscard]] bool saveToFile(const QString& absolutePath) const;
|
||||
[[nodiscard]] bool loadFromFile(const QString& absolutePath);
|
||||
|
||||
protected:
|
||||
virtual quint32 recordMagic() const = 0;
|
||||
virtual quint32 recordFormatVersion() const = 0;
|
||||
virtual void writeBody(QDataStream& ds) const = 0;
|
||||
virtual bool readBody(QDataStream& ds) = 0;
|
||||
};
|
||||
|
||||
} // namespace core
|
||||
1629
client/core/workspace/ProjectWorkspace.cpp
Normal file
1629
client/core/workspace/ProjectWorkspace.cpp
Normal file
File diff suppressed because it is too large
Load Diff
148
client/core/workspace/ProjectWorkspace.h
Normal file
148
client/core/workspace/ProjectWorkspace.h
Normal file
@@ -0,0 +1,148 @@
|
||||
#pragma once
|
||||
|
||||
#include "domain/Project.h"
|
||||
|
||||
#include <QImage>
|
||||
#include <QJsonObject>
|
||||
#include <QRect>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <QVector>
|
||||
|
||||
namespace core {
|
||||
|
||||
class ProjectWorkspace {
|
||||
public:
|
||||
static constexpr const char* kProjectIndexFileName = "project.json";
|
||||
static constexpr const char* kAssetsDirName = "assets";
|
||||
// 写入 project.json 的 version 字段;仍可读 version 1(内嵌实体 + 可选 .anim)。
|
||||
static constexpr int kProjectIndexFormatVersion = 2;
|
||||
|
||||
ProjectWorkspace() = default;
|
||||
|
||||
// 新建项目:
|
||||
// - 传入的 parentDir 是“父目录”(你在文件对话框中选择的目录)
|
||||
// - 会在 parentDir 下创建一个新的项目目录(默认使用项目名做文件夹名;若重名会自动加后缀)
|
||||
// - 项目结构为(v2):
|
||||
// <projectDir>/project.json (索引:背景/深度路径 + 实体 id 与 .hfe 路径)
|
||||
// <projectDir>/assets/background.png
|
||||
// <projectDir>/assets/entities/*.png / *.hfe
|
||||
bool createNew(const QString& parentDir, const QString& name, const QString& backgroundImageSourcePath);
|
||||
bool createNew(const QString& parentDir, const QString& name, const QString& backgroundImageSourcePath,
|
||||
const QRect& cropRectInSourceImage);
|
||||
bool openExisting(const QString& projectDir);
|
||||
void close();
|
||||
|
||||
bool isOpen() const { return !m_projectDir.isEmpty(); }
|
||||
const QString& projectDir() const { return m_projectDir; }
|
||||
QString indexFilePath() const;
|
||||
QString assetsDirPath() const;
|
||||
bool hasBackground() const { return !m_project.backgroundImagePath().isEmpty(); }
|
||||
QString backgroundAbsolutePath() const;
|
||||
bool backgroundVisible() const { return m_project.backgroundVisible(); }
|
||||
bool setBackgroundVisible(bool on);
|
||||
|
||||
bool hasDepth() const;
|
||||
QString depthAbsolutePath() const;
|
||||
|
||||
// 写入 project.json 的 name 字段(可 undo)
|
||||
bool setProjectTitle(const QString& title);
|
||||
|
||||
Project& project() { return m_project; }
|
||||
const Project& project() const { return m_project; }
|
||||
|
||||
// 历史操作(最多 30 步),类似 Blender:维护 undo/redo 栈
|
||||
bool canUndo() const;
|
||||
bool canRedo() const;
|
||||
bool undo();
|
||||
bool redo();
|
||||
QStringList historyLabelsNewestFirst() const;
|
||||
|
||||
// 追加一次“导入并设置背景图”操作:把图片拷贝进 assets/,并作为背景写入项目(会进入历史)。
|
||||
bool importBackgroundImage(const QString& backgroundImageSourcePath);
|
||||
bool importBackgroundImage(const QString& backgroundImageSourcePath, const QRect& cropRectInSourceImage);
|
||||
|
||||
// 计算并写入假深度图:assets/depth.png,同时更新 project.json(depthComputed/depthMapPath)。
|
||||
bool computeFakeDepthForProject();
|
||||
|
||||
// 从后端计算深度并落盘:assets/depth.png,同时更新 project.json(depthComputed/depthMapPath)。
|
||||
// - serverBaseUrl 为空时:优先读环境变量 MODEL_SERVER_URL,否则默认 http://127.0.0.1:8000
|
||||
// - outError 可选:返回失败原因
|
||||
bool computeDepthForProjectFromServer(const QString& serverBaseUrl, QString* outError = nullptr, int timeoutMs = 30000);
|
||||
|
||||
// 直接保存深度图(PNG bytes)到 assets/depth.png,并更新 project.json。
|
||||
bool saveDepthMapPngBytes(const QByteArray& pngBytes, QString* outError = nullptr);
|
||||
|
||||
const QVector<Project::Entity>& entities() const { return m_project.entities(); }
|
||||
bool addEntity(const Project::Entity& entity, const QImage& image);
|
||||
bool setEntityVisible(const QString& id, bool on);
|
||||
bool setEntityDisplayName(const QString& id, const QString& displayName);
|
||||
bool setEntityUserScale(const QString& id, double userScale);
|
||||
// 将多边形质心平移到 targetCentroidWorld(整体平移);sTotal 须与画布一致
|
||||
bool moveEntityCentroidTo(const QString& id, int frame, const QPointF& targetCentroidWorld, double sTotal,
|
||||
bool autoKeyLocation);
|
||||
// 在保持外形不变的前提下移动枢轴点;sTotal 须与画布一致(距离缩放×整体缩放)
|
||||
bool reanchorEntityPivot(const QString& id, int frame, const QPointF& newPivotWorld, double sTotal);
|
||||
bool reorderEntitiesById(const QStringList& idsInOrder);
|
||||
// currentFrame:自动关键帧时写入位置曲线;autoKeyLocation 为 false 时忽略。
|
||||
bool moveEntityBy(const QString& id, const QPointF& delta, int currentFrame, bool autoKeyLocation);
|
||||
bool setEntityLocationKey(const QString& id, int frame, const QPointF& originWorld);
|
||||
bool setEntityDepthScaleKey(const QString& id, int frame, double value01);
|
||||
bool setEntityUserScaleKey(const QString& id, int frame, double userScale);
|
||||
bool setEntityImageFrame(const QString& id, int frame, const QImage& image, QString* outRelPath = nullptr);
|
||||
bool removeEntityLocationKey(const QString& id, int frame);
|
||||
bool removeEntityDepthScaleKey(const QString& id, int frame);
|
||||
bool removeEntityUserScaleKey(const QString& id, int frame);
|
||||
bool removeEntityImageFrame(const QString& id, int frame);
|
||||
|
||||
private:
|
||||
bool writeIndexJson();
|
||||
bool readIndexJson(const QString& indexPath);
|
||||
|
||||
bool syncEntityPayloadsToDisk();
|
||||
bool hydrateEntityPayloadsFromDisk();
|
||||
void loadV1LegacyAnimationSidecars();
|
||||
|
||||
static QJsonObject projectToJson(const Project& project);
|
||||
static bool projectFromJson(const QJsonObject& root, Project& outProject, int* outFileVersion);
|
||||
static QString asRelativeUnderProject(const QString& relativePath);
|
||||
static QString fileSuffixWithDot(const QString& path);
|
||||
static QString asOptionalRelativeUnderProject(const QString& relativePath);
|
||||
static QJsonObject entityToJson(const Project::Entity& e);
|
||||
static bool entityFromJsonV1(const QJsonObject& o, Project::Entity& out);
|
||||
static bool entityStubFromJsonV2(const QJsonObject& o, Project::Entity& out);
|
||||
|
||||
struct Operation {
|
||||
enum class Type { ImportBackground, SetEntities, SetProjectTitle };
|
||||
Type type {Type::ImportBackground};
|
||||
QString label;
|
||||
QString beforeBackgroundPath;
|
||||
QString afterBackgroundPath;
|
||||
QVector<Project::Entity> beforeEntities;
|
||||
QVector<Project::Entity> afterEntities;
|
||||
QString beforeProjectTitle;
|
||||
QString afterProjectTitle;
|
||||
};
|
||||
|
||||
static constexpr int kMaxHistorySteps = 30;
|
||||
void pushOperation(const Operation& op);
|
||||
|
||||
bool applyBackgroundPath(const QString& relativePath, bool recordHistory, const QString& label);
|
||||
bool applyEntities(const QVector<Project::Entity>& entities, bool recordHistory, const QString& label);
|
||||
QString copyIntoAssetsAsBackground(const QString& sourceFilePath, const QRect& cropRectInSourceImage);
|
||||
bool writeDepthMap(const QImage& depth8);
|
||||
bool writeDepthMapBytes(const QByteArray& pngBytes);
|
||||
QString ensureEntitiesDir() const;
|
||||
bool writeEntityImage(const QString& entityId, const QImage& image, QString& outRelPath);
|
||||
bool writeEntityFrameImage(const QString& entityId, int frame, const QImage& image, QString& outRelPath);
|
||||
|
||||
private:
|
||||
QString m_projectDir;
|
||||
Project m_project;
|
||||
|
||||
QVector<Operation> m_undoStack;
|
||||
QVector<Operation> m_redoStack;
|
||||
};
|
||||
|
||||
} // namespace core
|
||||
|
||||
62
client/gui/CMakeLists.txt
Normal file
62
client/gui/CMakeLists.txt
Normal 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
13
client/gui/app/main.cpp
Normal 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();
|
||||
}
|
||||
66
client/gui/dialogs/AboutWindow.cpp
Normal file
66
client/gui/dialogs/AboutWindow.cpp
Normal 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);
|
||||
}
|
||||
20
client/gui/dialogs/AboutWindow.h
Normal file
20
client/gui/dialogs/AboutWindow.h
Normal 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;
|
||||
};
|
||||
50
client/gui/dialogs/CancelableTaskDialog.cpp
Normal file
50
client/gui/dialogs/CancelableTaskDialog.cpp
Normal 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();
|
||||
}
|
||||
|
||||
35
client/gui/dialogs/CancelableTaskDialog.h
Normal file
35
client/gui/dialogs/CancelableTaskDialog.h
Normal 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;
|
||||
};
|
||||
|
||||
252
client/gui/dialogs/FrameAnimationDialog.cpp
Normal file
252
client/gui/dialogs/FrameAnimationDialog.cpp
Normal 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();
|
||||
}
|
||||
|
||||
52
client/gui/dialogs/FrameAnimationDialog.h
Normal file
52
client/gui/dialogs/FrameAnimationDialog.h
Normal 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;
|
||||
};
|
||||
|
||||
209
client/gui/dialogs/ImageCropDialog.cpp
Normal file
209
client/gui/dialogs/ImageCropDialog.cpp
Normal 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();
|
||||
}
|
||||
|
||||
34
client/gui/dialogs/ImageCropDialog.h
Normal file
34
client/gui/dialogs/ImageCropDialog.h
Normal 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;
|
||||
};
|
||||
|
||||
1327
client/gui/editor/EditorCanvas.cpp
Normal file
1327
client/gui/editor/EditorCanvas.cpp
Normal file
File diff suppressed because it is too large
Load Diff
179
client/gui/editor/EditorCanvas.h
Normal file
179
client/gui/editor/EditorCanvas.h
Normal 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;
|
||||
};
|
||||
|
||||
2097
client/gui/main_window/MainWindow.cpp
Normal file
2097
client/gui/main_window/MainWindow.cpp
Normal file
File diff suppressed because it is too large
Load Diff
177
client/gui/main_window/MainWindow.h
Normal file
177
client/gui/main_window/MainWindow.h
Normal 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;
|
||||
};
|
||||
100
client/gui/main_window/RecentProjectHistory.cpp
Normal file
100
client/gui/main_window/RecentProjectHistory.cpp
Normal 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);
|
||||
}
|
||||
21
client/gui/main_window/RecentProjectHistory.h
Normal file
21
client/gui/main_window/RecentProjectHistory.h
Normal 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);
|
||||
};
|
||||
127
client/gui/params/ParamControls.cpp
Normal file
127
client/gui/params/ParamControls.cpp
Normal 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
|
||||
|
||||
60
client/gui/params/ParamControls.h
Normal file
60
client/gui/params/ParamControls.h
Normal 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
|
||||
|
||||
77
client/gui/props/BackgroundPropertySection.cpp
Normal file
77
client/gui/props/BackgroundPropertySection.cpp
Normal 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
|
||||
32
client/gui/props/BackgroundPropertySection.h
Normal file
32
client/gui/props/BackgroundPropertySection.h
Normal 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
|
||||
108
client/gui/props/EntityPropertySection.cpp
Normal file
108
client/gui/props/EntityPropertySection.cpp
Normal 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
|
||||
52
client/gui/props/EntityPropertySection.h
Normal file
52
client/gui/props/EntityPropertySection.h
Normal 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
|
||||
13
client/gui/props/PropertySectionWidget.h
Normal file
13
client/gui/props/PropertySectionWidget.h
Normal 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
|
||||
310
client/gui/timeline/TimelineWidget.cpp
Normal file
310
client/gui/timeline/TimelineWidget.cpp
Normal 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();
|
||||
}
|
||||
|
||||
69
client/gui/timeline/TimelineWidget.h
Normal file
69
client/gui/timeline/TimelineWidget.h
Normal 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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user