initial commit
This commit is contained in:
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
|
||||
|
||||
Reference in New Issue
Block a user