Files
hfut-bishe/client/core/workspace/ProjectWorkspace.cpp
2026-04-07 20:55:30 +08:00

1630 lines
50 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include "workspace/ProjectWorkspace.h"
#include "animation/AnimationSampling.h"
#include "persistence/EntityPayloadBinary.h"
#include "depth/DepthService.h"
#include "net/ModelServerClient.h"
#include <QDateTime>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QJsonDocument>
#include <QJsonArray>
#include <QJsonObject>
#include <QJsonValue>
#include <QImage>
#include <QUrl>
#include <QHash>
#include <QSet>
#include <cmath>
namespace core {
namespace {
QPointF polygonCentroidFromWorldPoints(const QVector<QPointF>& poly) {
if (poly.size() < 3) {
return poly.isEmpty() ? QPointF() : poly.front();
}
double a2 = 0.0;
double cx6a = 0.0;
double cy6a = 0.0;
for (int i = 0; i < poly.size(); ++i) {
const QPointF p0 = poly[i];
const QPointF p1 = poly[(i + 1) % poly.size()];
const double cross = static_cast<double>(p0.x()) * static_cast<double>(p1.y()) -
static_cast<double>(p1.x()) * static_cast<double>(p0.y());
a2 += cross;
cx6a += (static_cast<double>(p0.x()) + static_cast<double>(p1.x())) * cross;
cy6a += (static_cast<double>(p0.y()) + static_cast<double>(p1.y())) * cross;
}
if (std::abs(a2) < 1e-6) {
double minX = poly[0].x();
double minY = poly[0].y();
double maxX = minX;
double maxY = minY;
for (const QPointF& p : poly) {
minX = std::min(minX, static_cast<double>(p.x()));
minY = std::min(minY, static_cast<double>(p.y()));
maxX = std::max(maxX, static_cast<double>(p.x()));
maxY = std::max(maxY, static_cast<double>(p.y()));
}
return QPointF(0.5 * (minX + maxX), 0.5 * (minY + maxY));
}
const double inv6a = 1.0 / (3.0 * a2);
return QPointF(cx6a * inv6a, cy6a * inv6a);
}
QPointF entityPolygonCentroidWorld(const Project::Entity& e, int frame, double sTotal) {
const QPointF O =
sampleLocation(e.locationKeys, frame, e.originWorld, KeyInterpolation::Linear);
QVector<QPointF> w;
w.reserve(e.polygonLocal.size());
for (const QPointF& lp : e.polygonLocal) {
w.push_back(O + lp * sTotal);
}
return polygonCentroidFromWorldPoints(w);
}
QString ensureDir(const QString& path) {
QDir dir(path);
if (dir.exists()) {
return dir.absolutePath();
}
if (dir.mkpath(".")) {
return dir.absolutePath();
}
return {};
}
QString normalizedProjectDir(const QString& projectDir) {
QFileInfo fi(projectDir);
return QDir(fi.absoluteFilePath()).absolutePath();
}
QString sanitizeFolderName(QString name) {
name = name.trimmed();
if (name.isEmpty()) {
return QStringLiteral("project");
}
// 简单做法:把明显不适合作为文件夹名的字符替换为下划线
static const QString badChars = QStringLiteral("\\/:*?\"<>|");
for (const QChar& ch : badChars) {
name.replace(ch, QChar('_'));
}
return name;
}
QString pickUniqueSubdirPath(const QString& parentDir, const QString& baseName) {
const auto cleanedBase = sanitizeFolderName(baseName);
QDir parent(parentDir);
if (!parent.exists()) {
return {};
}
const QString first = parent.filePath(cleanedBase);
if (!QFileInfo::exists(first)) {
return first;
}
for (int i = 1; i < 10000; ++i) {
const QString cand = parent.filePath(QStringLiteral("%1_%2").arg(cleanedBase).arg(i));
if (!QFileInfo::exists(cand)) {
return cand;
}
}
return {};
}
QRect clampRectToImage(const QRect& rect, const QSize& size) {
QRect r = rect.normalized();
if (r.isNull() || r.width() <= 0 || r.height() <= 0) {
return {};
}
r.setLeft(std::max(0, r.left()));
r.setTop(std::max(0, r.top()));
r.setRight(std::min(size.width() - 1, r.right()));
r.setBottom(std::min(size.height() - 1, r.bottom()));
if (r.width() <= 0 || r.height() <= 0) {
return {};
}
return r;
}
} // namespace
QString ProjectWorkspace::indexFilePath() const {
if (m_projectDir.isEmpty()) {
return {};
}
return QDir(m_projectDir).filePath(QString::fromUtf8(kProjectIndexFileName));
}
QString ProjectWorkspace::assetsDirPath() const {
if (m_projectDir.isEmpty()) {
return {};
}
return QDir(m_projectDir).filePath(QString::fromUtf8(kAssetsDirName));
}
QString ProjectWorkspace::backgroundAbsolutePath() const {
if (m_projectDir.isEmpty() || m_project.backgroundImagePath().isEmpty()) {
return {};
}
return QDir(m_projectDir).filePath(m_project.backgroundImagePath());
}
bool ProjectWorkspace::setBackgroundVisible(bool on) {
if (m_projectDir.isEmpty()) {
return false;
}
if (m_project.backgroundVisible() == on) {
return true;
}
m_project.setBackgroundVisible(on);
if (!writeIndexJson()) {
m_project.setBackgroundVisible(!on);
return false;
}
return true;
}
bool ProjectWorkspace::hasDepth() const {
if (m_projectDir.isEmpty()) {
return false;
}
if (!m_project.depthComputed() || m_project.depthMapPath().isEmpty()) {
return false;
}
const auto abs = depthAbsolutePath();
return !abs.isEmpty() && QFileInfo::exists(abs);
}
QString ProjectWorkspace::depthAbsolutePath() const {
if (m_projectDir.isEmpty() || m_project.depthMapPath().isEmpty()) {
return {};
}
return QDir(m_projectDir).filePath(m_project.depthMapPath());
}
bool ProjectWorkspace::setProjectTitle(const QString& title) {
if (m_projectDir.isEmpty()) {
return false;
}
const QString t = title.trimmed();
if (t.isEmpty()) {
return false;
}
if (t == m_project.name()) {
return true;
}
const QString before = m_project.name();
m_project.setName(t);
if (!writeIndexJson()) {
m_project.setName(before);
return false;
}
Operation op;
op.type = Operation::Type::SetProjectTitle;
op.label = QStringLiteral("重命名项目");
op.beforeProjectTitle = before;
op.afterProjectTitle = t;
pushOperation(op);
m_redoStack.clear();
return true;
}
bool ProjectWorkspace::createNew(const QString& projectDir, const QString& name,
const QString& backgroundImageSourcePath) {
return createNew(projectDir, name, backgroundImageSourcePath, QRect());
}
bool ProjectWorkspace::createNew(const QString& projectDir, const QString& name,
const QString& backgroundImageSourcePath,
const QRect& cropRectInSourceImage) {
// 约束:新建项目必须选择背景;裁剪可选(为空时取整张图)
if (backgroundImageSourcePath.isEmpty()) {
return false;
}
// 这里的 projectDir 实际是“父目录”,我们在其下创建新的项目文件夹
const auto parentAbs = normalizedProjectDir(projectDir);
if (parentAbs.isEmpty()) {
return false;
}
const auto newProjectDir = pickUniqueSubdirPath(parentAbs, name);
if (newProjectDir.isEmpty()) {
return false;
}
m_projectDir = normalizedProjectDir(newProjectDir);
if (m_projectDir.isEmpty()) {
return false;
}
const auto rootOk = ensureDir(m_projectDir);
if (rootOk.isEmpty()) {
m_projectDir.clear();
return false;
}
const auto assetsOk = ensureDir(assetsDirPath());
if (assetsOk.isEmpty()) {
m_projectDir.clear();
return false;
}
m_project.setName(name);
m_project.setBackgroundImagePath(QString());
m_undoStack.clear();
m_redoStack.clear();
if (!importBackgroundImage(backgroundImageSourcePath, cropRectInSourceImage)) {
m_projectDir.clear();
m_project = Project();
m_undoStack.clear();
m_redoStack.clear();
return false;
}
return writeIndexJson();
}
bool ProjectWorkspace::openExisting(const QString& projectDir) {
const auto dir = normalizedProjectDir(projectDir);
const auto indexPath = QDir(dir).filePath(QString::fromUtf8(kProjectIndexFileName));
if (!QFileInfo::exists(indexPath)) {
return false;
}
// 重要readIndexJson(v2) 会尝试从磁盘 hydrate 实体 payload需要 m_projectDir 已就绪
const QString prevDir = m_projectDir;
const Project prevProject = m_project;
m_projectDir = dir;
if (!readIndexJson(indexPath)) {
m_projectDir = prevDir;
m_project = prevProject;
return false;
}
// readIndexJson 会写入 m_project这里补齐历史初始化
ensureDir(assetsDirPath());
m_undoStack.clear();
m_redoStack.clear();
return true;
}
void ProjectWorkspace::close() {
m_projectDir.clear();
m_project = Project();
m_undoStack.clear();
m_redoStack.clear();
}
bool ProjectWorkspace::canUndo() const {
return !m_undoStack.isEmpty();
}
bool ProjectWorkspace::canRedo() const {
return !m_redoStack.isEmpty();
}
bool ProjectWorkspace::undo() {
if (!canUndo() || m_projectDir.isEmpty()) {
return false;
}
const auto op = m_undoStack.takeLast();
Operation redoOp = op;
redoOp.beforeBackgroundPath = op.beforeBackgroundPath;
redoOp.afterBackgroundPath = op.afterBackgroundPath;
if (op.type == Operation::Type::ImportBackground) {
if (!applyBackgroundPath(op.beforeBackgroundPath, false, QString())) {
m_undoStack.push_back(op);
return false;
}
} else if (op.type == Operation::Type::SetEntities) {
if (!applyEntities(op.beforeEntities, false, QString())) {
m_undoStack.push_back(op);
return false;
}
} else if (op.type == Operation::Type::SetProjectTitle) {
m_project.setName(op.beforeProjectTitle);
if (!writeIndexJson()) {
m_project.setName(op.afterProjectTitle);
m_undoStack.push_back(op);
return false;
}
}
m_redoStack.push_back(redoOp);
return true;
}
bool ProjectWorkspace::redo() {
if (!canRedo() || m_projectDir.isEmpty()) {
return false;
}
const auto op = m_redoStack.takeLast();
Operation undoOp = op;
if (op.type == Operation::Type::ImportBackground) {
if (!applyBackgroundPath(op.afterBackgroundPath, false, QString())) {
m_redoStack.push_back(op);
return false;
}
} else if (op.type == Operation::Type::SetEntities) {
if (!applyEntities(op.afterEntities, false, QString())) {
m_redoStack.push_back(op);
return false;
}
} else if (op.type == Operation::Type::SetProjectTitle) {
m_project.setName(op.afterProjectTitle);
if (!writeIndexJson()) {
m_project.setName(op.beforeProjectTitle);
m_redoStack.push_back(op);
return false;
}
}
m_undoStack.push_back(undoOp);
return true;
}
QStringList ProjectWorkspace::historyLabelsNewestFirst() const {
QStringList out;
out.reserve(m_undoStack.size());
for (auto it = m_undoStack.crbegin(); it != m_undoStack.crend(); ++it) {
out.push_back(it->label);
}
return out;
}
bool ProjectWorkspace::importBackgroundImage(const QString& backgroundImageSourcePath) {
return importBackgroundImage(backgroundImageSourcePath, QRect());
}
bool ProjectWorkspace::importBackgroundImage(const QString& backgroundImageSourcePath,
const QRect& cropRectInSourceImage) {
if (m_projectDir.isEmpty()) {
return false;
}
// 约束:项目创建成功后不允许再更换/裁剪背景
if (!m_project.backgroundImagePath().isEmpty()) {
return false;
}
// 背景变化会使深度失效:这里先直接清空深度状态(后续若允许更换背景,再完善历史记录)
m_project.setDepthComputed(false);
m_project.setDepthMapPath(QString());
const auto rel = copyIntoAssetsAsBackground(backgroundImageSourcePath, cropRectInSourceImage);
if (rel.isEmpty()) {
return false;
}
const auto label = cropRectInSourceImage.isNull() ? QStringLiteral("导入背景")
: QStringLiteral("导入背景(裁剪)");
return applyBackgroundPath(rel, true, label);
}
bool ProjectWorkspace::writeIndexJson() {
if (!m_projectDir.isEmpty() && !syncEntityPayloadsToDisk()) {
return false;
}
const auto root = projectToJson(m_project);
QJsonDocument doc(root);
QFile f(indexFilePath());
if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
return false;
}
const auto bytes = doc.toJson(QJsonDocument::Indented);
return f.write(bytes) == bytes.size();
}
bool ProjectWorkspace::readIndexJson(const QString& indexPath) {
QFile f(indexPath);
if (!f.open(QIODevice::ReadOnly)) {
return false;
}
const auto data = f.readAll();
QJsonParseError err;
const auto doc = QJsonDocument::fromJson(data, &err);
if (err.error != QJsonParseError::NoError || !doc.isObject()) {
return false;
}
Project p;
int fileVer = 0;
if (!projectFromJson(doc.object(), p, &fileVer)) {
return false;
}
m_project = p;
if (fileVer == 1) {
loadV1LegacyAnimationSidecars();
} else {
if (!hydrateEntityPayloadsFromDisk()) {
return false;
}
}
return true;
}
QJsonObject ProjectWorkspace::projectToJson(const Project& project) {
QJsonObject root;
root.insert("format", "hfut-bishe-project");
root.insert("version", kProjectIndexFormatVersion);
root.insert("name", project.name());
root.insert("savedAt", QDateTime::currentDateTimeUtc().toString(Qt::ISODate));
root.insert("backgroundImagePath", project.backgroundImagePath());
root.insert("backgroundVisible", project.backgroundVisible());
root.insert("depthComputed", project.depthComputed());
root.insert("depthMapPath", project.depthMapPath());
root.insert("frameStart", project.frameStart());
root.insert("frameEnd", project.frameEnd());
root.insert("fps", project.fps());
QJsonArray ents;
for (const auto& e : project.entities()) {
ents.append(entityToJson(e));
}
root.insert("entities", ents);
return root;
}
bool ProjectWorkspace::projectFromJson(const QJsonObject& root, Project& outProject, int* outFileVersion) {
if (root.value("format").toString() != QStringLiteral("hfut-bishe-project")) {
return false;
}
const int version = root.value("version").toInt();
if (version != 1 && version != 2) {
return false;
}
if (outFileVersion) {
*outFileVersion = version;
}
outProject.setName(root.value("name").toString());
outProject.setBackgroundImagePath(asRelativeUnderProject(root.value("backgroundImagePath").toString()));
outProject.setBackgroundVisible(root.value("backgroundVisible").toBool(true));
outProject.setDepthComputed(root.value("depthComputed").toBool(false));
outProject.setDepthMapPath(asOptionalRelativeUnderProject(root.value("depthMapPath").toString()));
outProject.setFrameStart(root.value("frameStart").toInt(0));
outProject.setFrameEnd(root.value("frameEnd").toInt(600));
outProject.setFps(root.value("fps").toInt(60));
if (outProject.frameEnd() < outProject.frameStart()) {
outProject.setFrameEnd(outProject.frameStart());
}
QVector<Project::Entity> entities;
const auto entsVal = root.value("entities");
if (entsVal.isArray()) {
const QJsonArray arr = entsVal.toArray();
entities.reserve(arr.size());
for (const auto& v : arr) {
if (!v.isObject()) {
continue;
}
Project::Entity e;
if (version == 1) {
if (entityFromJsonV1(v.toObject(), e)) {
entities.push_back(e);
}
} else {
if (!entityStubFromJsonV2(v.toObject(), e)) {
return false;
}
entities.push_back(e);
}
}
}
outProject.setEntities(entities);
return true;
}
QString ProjectWorkspace::asRelativeUnderProject(const QString& relativePath) {
if (relativePath.isEmpty()) {
return {};
}
QString p = relativePath;
while (p.startsWith("./")) {
p = p.mid(2);
}
if (QDir::isAbsolutePath(p)) {
// 不允许绝对路径写入索引,避免项目不可迁移
return {};
}
return QDir::cleanPath(p);
}
QString ProjectWorkspace::asOptionalRelativeUnderProject(const QString& relativePath) {
if (relativePath.isEmpty()) {
return {};
}
return asRelativeUnderProject(relativePath);
}
QJsonObject ProjectWorkspace::entityToJson(const Project::Entity& e) {
QJsonObject o;
o.insert("id", e.id);
o.insert("payload", e.entityPayloadPath);
o.insert("visible", e.visible);
return o;
}
bool ProjectWorkspace::entityStubFromJsonV2(const QJsonObject& o, Project::Entity& out) {
out = Project::Entity{};
out.id = o.value("id").toString();
out.entityPayloadPath = asOptionalRelativeUnderProject(o.value("payload").toString());
out.visible = o.value("visible").toBool(true);
if (out.id.isEmpty() || out.entityPayloadPath.isEmpty()) {
return false;
}
return true;
}
bool ProjectWorkspace::entityFromJsonV1(const QJsonObject& o, Project::Entity& out) {
out.id = o.value("id").toString();
if (out.id.isEmpty()) {
return false;
}
out.depth = o.value("depth").toInt(0);
out.imagePath = asOptionalRelativeUnderProject(o.value("imagePath").toString());
out.imageTopLeftWorld = QPointF(o.value("imageTopLeftX").toDouble(0.0), o.value("imageTopLeftY").toDouble(0.0));
out.originWorld = QPointF(o.value("originX").toDouble(0.0), o.value("originY").toDouble(0.0));
out.polygonLocal.clear();
out.cutoutPolygonWorld.clear();
out.entityPayloadPath.clear();
out.legacyAnimSidecarPath = asOptionalRelativeUnderProject(o.value("animationBundle").toString());
out.locationKeys.clear();
out.depthScaleKeys.clear();
out.imageFrames.clear();
const auto localVal = o.value("polygonLocal");
if (localVal.isArray()) {
const QJsonArray arr = localVal.toArray();
out.polygonLocal.reserve(arr.size());
for (const auto& v : arr) {
if (!v.isObject()) {
continue;
}
const QJsonObject p = v.toObject();
out.polygonLocal.push_back(QPointF(p.value("x").toDouble(0.0), p.value("y").toDouble(0.0)));
}
}
const auto cutoutVal = o.value("cutoutPolygon");
if (cutoutVal.isArray()) {
const QJsonArray arr = cutoutVal.toArray();
out.cutoutPolygonWorld.reserve(arr.size());
for (const auto& v : arr) {
if (!v.isObject()) {
continue;
}
const QJsonObject p = v.toObject();
out.cutoutPolygonWorld.push_back(QPointF(p.value("x").toDouble(0.0), p.value("y").toDouble(0.0)));
}
}
// 兼容旧字段polygonworld
if (out.polygonLocal.isEmpty()) {
const auto legacy = o.value("polygon");
if (legacy.isArray()) {
const QJsonArray arr = legacy.toArray();
QVector<QPointF> polyWorld;
polyWorld.reserve(arr.size());
for (const auto& v : arr) {
if (!v.isObject()) continue;
const QJsonObject p = v.toObject();
polyWorld.push_back(QPointF(p.value("x").toDouble(0.0), p.value("y").toDouble(0.0)));
}
if (!polyWorld.isEmpty()) {
// 若没给 origin则用包围盒中心近似
if (qFuzzyIsNull(out.originWorld.x()) && qFuzzyIsNull(out.originWorld.y())) {
QRectF bb;
for (const auto& pt : polyWorld) {
bb = bb.isNull() ? QRectF(pt, QSizeF(1, 1)) : bb.united(QRectF(pt, QSizeF(1, 1)));
}
out.originWorld = bb.center();
}
out.cutoutPolygonWorld = polyWorld;
out.polygonLocal.reserve(polyWorld.size());
for (const auto& pt : polyWorld) {
out.polygonLocal.push_back(pt - out.originWorld);
}
}
}
}
if (out.cutoutPolygonWorld.isEmpty()) {
// 没有 cutout 就默认用当前实体形状origin+local
for (const auto& lp : out.polygonLocal) {
out.cutoutPolygonWorld.push_back(out.originWorld + lp);
}
}
// 旧版:关键帧内嵌在 project.json若存在对应 .anim 文件,打开项目时会被二进制数据覆盖。
const auto lk = o.value("locationKeys");
if (lk.isArray()) {
for (const auto& v : lk.toArray()) {
if (!v.isObject()) continue;
const auto ko = v.toObject();
Project::Entity::KeyframeVec2 kf;
kf.frame = ko.value("frame").toInt(0);
kf.value = QPointF(ko.value("x").toDouble(0.0), ko.value("y").toDouble(0.0));
out.locationKeys.push_back(kf);
}
}
const auto dk = o.value("depthScaleKeys");
if (dk.isArray()) {
for (const auto& v : dk.toArray()) {
if (!v.isObject()) continue;
const auto ko = v.toObject();
Project::Entity::KeyframeFloat01 kf;
kf.frame = ko.value("frame").toInt(0);
kf.value = ko.value("v").toDouble(0.5);
out.depthScaleKeys.push_back(kf);
}
}
const auto ik = o.value("imageFrames");
if (ik.isArray()) {
for (const auto& v : ik.toArray()) {
if (!v.isObject()) continue;
const auto ko = v.toObject();
Project::Entity::ImageFrame kf;
kf.frame = ko.value("frame").toInt(0);
kf.imagePath = asOptionalRelativeUnderProject(ko.value("imagePath").toString());
if (!kf.imagePath.isEmpty()) {
out.imageFrames.push_back(kf);
}
}
}
return !out.polygonLocal.isEmpty();
}
QString ProjectWorkspace::fileSuffixWithDot(const QString& path) {
QFileInfo fi(path);
const auto suf = fi.suffix();
if (suf.isEmpty()) {
return {};
}
return "." + suf;
}
void ProjectWorkspace::pushOperation(const Operation& op) {
m_undoStack.push_back(op);
if (m_undoStack.size() > kMaxHistorySteps) {
m_undoStack.remove(0, m_undoStack.size() - kMaxHistorySteps);
}
}
bool ProjectWorkspace::applyBackgroundPath(const QString& relativePath,
bool recordHistory,
const QString& label) {
const auto rel = asRelativeUnderProject(relativePath);
if (relativePath.isEmpty()) {
// 允许清空背景
if (recordHistory) {
Operation op;
op.type = Operation::Type::ImportBackground;
op.label = label;
op.beforeBackgroundPath = m_project.backgroundImagePath();
op.afterBackgroundPath = QString();
pushOperation(op);
m_redoStack.clear();
}
m_project.setBackgroundImagePath(QString());
return writeIndexJson();
}
if (rel.isEmpty()) {
return false;
}
const auto before = m_project.backgroundImagePath();
m_project.setBackgroundImagePath(rel);
if (!writeIndexJson()) {
m_project.setBackgroundImagePath(before);
return false;
}
if (recordHistory) {
Operation op;
op.type = Operation::Type::ImportBackground;
op.label = label;
op.beforeBackgroundPath = before;
op.afterBackgroundPath = rel;
pushOperation(op);
m_redoStack.clear();
}
return true;
}
bool ProjectWorkspace::applyEntities(const QVector<Project::Entity>& entities,
bool recordHistory,
const QString& label) {
const auto before = m_project.entities();
m_project.setEntities(entities);
if (!writeIndexJson()) {
m_project.setEntities(before);
return false;
}
if (recordHistory) {
Operation op;
op.type = Operation::Type::SetEntities;
op.label = label;
op.beforeEntities = before;
op.afterEntities = entities;
pushOperation(op);
m_redoStack.clear();
}
return true;
}
QString ProjectWorkspace::ensureEntitiesDir() const {
const auto assets = assetsDirPath();
if (assets.isEmpty()) {
return {};
}
const auto dir = QDir(assets).filePath(QStringLiteral("entities"));
return ensureDir(dir);
}
bool ProjectWorkspace::syncEntityPayloadsToDisk() {
if (ensureEntitiesDir().isEmpty()) {
return false;
}
QVector<Project::Entity> ents = m_project.entities();
bool changed = false;
for (auto& e : ents) {
e.legacyAnimSidecarPath.clear();
if (e.entityPayloadPath.isEmpty()) {
e.entityPayloadPath =
QString::fromUtf8(kAssetsDirName) + QStringLiteral("/entities/") + e.id + QStringLiteral(".hfe");
changed = true;
}
const QString rel = asRelativeUnderProject(e.entityPayloadPath);
if (rel.isEmpty()) {
return false;
}
if (rel != e.entityPayloadPath) {
e.entityPayloadPath = rel;
changed = true;
}
const QString abs = QDir(m_projectDir).filePath(rel);
if (!EntityPayloadBinary::save(abs, e)) {
return false;
}
}
if (changed) {
m_project.setEntities(ents);
}
return true;
}
bool ProjectWorkspace::hydrateEntityPayloadsFromDisk() {
if (m_projectDir.isEmpty()) {
return true;
}
QVector<Project::Entity> ents = m_project.entities();
for (auto& e : ents) {
const QString expectId = e.id;
QString rel = e.entityPayloadPath;
if (rel.isEmpty()) {
return false;
}
rel = asRelativeUnderProject(rel);
if (rel.isEmpty()) {
return false;
}
const QString abs = QDir(m_projectDir).filePath(rel);
if (!QFileInfo::exists(abs)) {
return false;
}
if (!EntityPayloadBinary::load(abs, e)) {
return false;
}
if (e.id != expectId) {
return false;
}
e.entityPayloadPath = rel;
}
m_project.setEntities(ents);
return true;
}
void ProjectWorkspace::loadV1LegacyAnimationSidecars() {
if (m_projectDir.isEmpty()) {
return;
}
QVector<Project::Entity> ents = m_project.entities();
for (auto& e : ents) {
QString rel = e.legacyAnimSidecarPath;
if (rel.isEmpty()) {
rel = QString::fromUtf8(kAssetsDirName) + QStringLiteral("/anim/") + e.id + QStringLiteral(".anim");
}
rel = asRelativeUnderProject(rel);
if (!rel.isEmpty()) {
const QString abs = QDir(m_projectDir).filePath(rel);
if (QFileInfo::exists(abs)) {
EntityPayloadBinary::loadLegacyAnimFile(abs, e);
}
}
e.legacyAnimSidecarPath.clear();
}
m_project.setEntities(ents);
}
bool ProjectWorkspace::writeEntityImage(const QString& entityId, const QImage& image, QString& outRelPath) {
outRelPath.clear();
if (m_projectDir.isEmpty() || entityId.isEmpty() || image.isNull()) {
return false;
}
const auto entsDir = ensureEntitiesDir();
if (entsDir.isEmpty()) {
return false;
}
const auto fileName = QStringLiteral("%1.png").arg(entityId);
const auto destAbs = QDir(entsDir).filePath(fileName);
const auto destRel = QString::fromUtf8(kAssetsDirName) + "/entities/" + fileName;
const auto tmpAbs = destAbs + ".tmp";
if (QFileInfo::exists(tmpAbs)) {
QFile::remove(tmpAbs);
}
if (!image.save(tmpAbs, "PNG")) {
QFile::remove(tmpAbs);
return false;
}
QFile::remove(destAbs);
if (!QFile::rename(tmpAbs, destAbs)) {
QFile::remove(tmpAbs);
return false;
}
outRelPath = destRel;
return true;
}
bool ProjectWorkspace::writeEntityFrameImage(const QString& entityId, int frame, const QImage& image, QString& outRelPath) {
outRelPath.clear();
if (m_projectDir.isEmpty() || entityId.isEmpty() || image.isNull() || frame < 0) {
return false;
}
const auto entsDir = ensureEntitiesDir();
if (entsDir.isEmpty()) {
return false;
}
const auto fileName = QStringLiteral("%1_f%2.png").arg(entityId).arg(frame);
const auto destAbs = QDir(entsDir).filePath(fileName);
const auto destRel = QString::fromUtf8(kAssetsDirName) + "/entities/" + fileName;
const auto tmpAbs = destAbs + ".tmp";
if (QFileInfo::exists(tmpAbs)) {
QFile::remove(tmpAbs);
}
if (!image.save(tmpAbs, "PNG")) {
QFile::remove(tmpAbs);
return false;
}
QFile::remove(destAbs);
if (!QFile::rename(tmpAbs, destAbs)) {
QFile::remove(tmpAbs);
return false;
}
outRelPath = destRel;
return true;
}
static void upsertKey(QVector<Project::Entity::KeyframeVec2>& keys, int frame, const QPointF& v) {
for (auto& k : keys) {
if (k.frame == frame) {
k.value = v;
return;
}
}
keys.push_back(Project::Entity::KeyframeVec2{frame, v});
}
static void upsertKey(QVector<Project::Entity::KeyframeFloat01>& keys, int frame, double v) {
for (auto& k : keys) {
if (k.frame == frame) {
k.value = v;
return;
}
}
Project::Entity::KeyframeFloat01 kf;
kf.frame = frame;
kf.value = v;
keys.push_back(kf);
}
static void upsertKey(QVector<Project::Entity::KeyframeDouble>& keys, int frame, double v) {
for (auto& k : keys) {
if (k.frame == frame) {
k.value = v;
return;
}
}
Project::Entity::KeyframeDouble kf;
kf.frame = frame;
kf.value = v;
keys.push_back(kf);
}
static void upsertFrame(QVector<Project::Entity::ImageFrame>& frames, int frame, const QString& path) {
for (auto& k : frames) {
if (k.frame == frame) {
k.imagePath = path;
return;
}
}
Project::Entity::ImageFrame kf;
kf.frame = frame;
kf.imagePath = path;
frames.push_back(kf);
}
bool ProjectWorkspace::addEntity(const Project::Entity& entity, const QImage& image) {
// 允许在“只有背景、尚未计算深度”的情况下创建实体depth 会退化为 0。
if (m_projectDir.isEmpty() || !hasBackground()) {
return false;
}
if (entity.id.isEmpty() || entity.polygonLocal.isEmpty()) {
return false;
}
Project::Entity e = entity;
if (!image.isNull()) {
QString rel;
if (!writeEntityImage(e.id, image, rel)) {
return false;
}
e.imagePath = rel;
}
if (e.entityPayloadPath.isEmpty()) {
e.entityPayloadPath =
QString::fromUtf8(kAssetsDirName) + QStringLiteral("/entities/") + e.id + QStringLiteral(".hfe");
}
auto ents = m_project.entities();
ents.push_back(e);
return applyEntities(ents, true, QStringLiteral("添加实体"));
}
bool ProjectWorkspace::setEntityVisible(const QString& id, bool on) {
if (m_projectDir.isEmpty() || id.isEmpty()) {
return false;
}
auto ents = m_project.entities();
bool found = false;
bool changed = false;
for (auto& e : ents) {
if (e.id != id) continue;
found = true;
if (e.visible != on) {
e.visible = on;
changed = true;
}
break;
}
if (!found) return false;
if (!changed) return true;
return applyEntities(ents, true, on ? QStringLiteral("显示实体") : QStringLiteral("隐藏实体"));
}
bool ProjectWorkspace::setEntityDisplayName(const QString& id, const QString& displayName) {
if (m_projectDir.isEmpty() || id.isEmpty()) {
return false;
}
const QString trimmed = displayName.trimmed();
const QString stored =
(trimmed.isEmpty() || trimmed == id) ? QString() : trimmed;
auto ents = m_project.entities();
bool found = false;
for (auto& e : ents) {
if (e.id != id) {
continue;
}
found = true;
e.displayName = stored;
break;
}
if (!found) {
return false;
}
return applyEntities(ents, true, QStringLiteral("重命名实体"));
}
bool ProjectWorkspace::setEntityUserScale(const QString& id, double userScale) {
if (m_projectDir.isEmpty() || id.isEmpty()) {
return false;
}
const double u = std::clamp(userScale, 0.05, 20.0);
auto ents = m_project.entities();
bool found = false;
for (auto& e : ents) {
if (e.id != id) {
continue;
}
found = true;
if (qFuzzyCompare(e.userScale + 1.0, u + 1.0)) {
return true;
}
e.userScale = u;
break;
}
if (!found) {
return false;
}
return applyEntities(ents, true, QStringLiteral("整体缩放"));
}
bool ProjectWorkspace::moveEntityCentroidTo(const QString& id, int frame, const QPointF& targetCentroidWorld,
double sTotal, bool autoKeyLocation) {
if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0 || sTotal <= 1e-9) {
return false;
}
auto ents = m_project.entities();
for (const auto& e : ents) {
if (e.id == id) {
const QPointF c = entityPolygonCentroidWorld(e, frame, sTotal);
const QPointF delta = targetCentroidWorld - c;
return moveEntityBy(id, delta, frame, autoKeyLocation);
}
}
return false;
}
bool ProjectWorkspace::reanchorEntityPivot(const QString& id, int frame, const QPointF& newPivotWorld, double sTotal) {
if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0 || sTotal <= 1e-9) {
return false;
}
auto ents = m_project.entities();
bool found = false;
for (auto& e : ents) {
if (e.id != id) {
continue;
}
found = true;
const QPointF O_anim =
sampleLocation(e.locationKeys, frame, e.originWorld, KeyInterpolation::Linear);
QVector<QPointF> polyWorld;
polyWorld.reserve(e.polygonLocal.size());
for (const QPointF& lp : e.polygonLocal) {
polyWorld.push_back(O_anim + lp * sTotal);
}
if (polyWorld.size() < 3) {
return false;
}
double minX = polyWorld[0].x();
double minY = polyWorld[0].y();
double maxX = minX;
double maxY = minY;
for (const QPointF& p : polyWorld) {
minX = std::min(minX, p.x());
minY = std::min(minY, p.y());
maxX = std::max(maxX, p.x());
maxY = std::max(maxY, p.y());
}
QPointF O_new(newPivotWorld);
O_new.setX(std::clamp(O_new.x(), minX, maxX));
O_new.setY(std::clamp(O_new.y(), minY, maxY));
const QPointF I_disp = O_anim + (e.imageTopLeftWorld - e.originWorld) * sTotal;
const QPointF d = O_new - O_anim;
QVector<QPointF> newLocal;
newLocal.reserve(polyWorld.size());
for (const QPointF& p : polyWorld) {
newLocal.push_back((p - O_new) / sTotal);
}
for (auto& k : e.locationKeys) {
k.value += d;
}
e.originWorld += d;
e.polygonLocal = std::move(newLocal);
e.imageTopLeftWorld = e.originWorld + (I_disp - O_new) / sTotal;
break;
}
if (!found) {
return false;
}
return applyEntities(ents, true, QStringLiteral("属性:枢轴"));
}
bool ProjectWorkspace::reorderEntitiesById(const QStringList& idsInOrder) {
if (m_projectDir.isEmpty()) {
return false;
}
auto ents = m_project.entities();
if (ents.isEmpty()) {
return true;
}
if (idsInOrder.isEmpty()) {
return false;
}
// 构建 id->entity 映射,并确保 ids 覆盖全部实体且无重复
QHash<QString, Project::Entity> map;
map.reserve(ents.size());
for (const auto& e : ents) {
map.insert(e.id, e);
}
if (map.size() != ents.size()) {
return false;
}
if (idsInOrder.size() != ents.size()) {
return false;
}
QVector<Project::Entity> reordered;
reordered.reserve(ents.size());
QSet<QString> seen;
for (const auto& id : idsInOrder) {
if (id.isEmpty() || seen.contains(id) || !map.contains(id)) {
return false;
}
seen.insert(id);
reordered.push_back(map.value(id));
}
// 若顺序没变,直接返回
bool same = true;
for (int i = 0; i < ents.size(); ++i) {
if (ents[i].id != reordered[i].id) {
same = false;
break;
}
}
if (same) return true;
return applyEntities(reordered, true, QStringLiteral("排序实体"));
}
bool ProjectWorkspace::moveEntityBy(const QString& id, const QPointF& delta, int currentFrame, bool autoKeyLocation) {
if (m_projectDir.isEmpty() || id.isEmpty()) {
return false;
}
if (qFuzzyIsNull(delta.x()) && qFuzzyIsNull(delta.y())) {
return true;
}
auto ents = m_project.entities();
bool found = false;
for (auto& e : ents) {
if (e.id != id) {
continue;
}
found = true;
if (autoKeyLocation && currentFrame >= 0) {
const QPointF sampled = sampleLocation(e.locationKeys,
currentFrame,
e.originWorld,
KeyInterpolation::Linear);
upsertKey(e.locationKeys, currentFrame, sampled + delta);
} else {
// 无自动关键帧时,直接修改“基准”位置
e.originWorld += delta;
e.imageTopLeftWorld += delta;
}
break;
}
if (!found) {
return false;
}
return applyEntities(ents, true, QStringLiteral("移动实体"));
}
bool ProjectWorkspace::setEntityLocationKey(const QString& id, int frame, const QPointF& originWorld) {
if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) {
return false;
}
auto ents = m_project.entities();
bool found = false;
for (auto& e : ents) {
if (e.id != id) continue;
found = true;
upsertKey(e.locationKeys, frame, originWorld);
break;
}
if (!found) return false;
return applyEntities(ents, true, QStringLiteral("插入关键帧(位置)"));
}
bool ProjectWorkspace::setEntityDepthScaleKey(const QString& id, int frame, double value01) {
if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) {
return false;
}
const double v = std::clamp(value01, 0.0, 1.0);
auto ents = m_project.entities();
bool found = false;
for (auto& e : ents) {
if (e.id != id) continue;
found = true;
upsertKey(e.depthScaleKeys, frame, v);
break;
}
if (!found) return false;
return applyEntities(ents, true, QStringLiteral("插入关键帧(缩放)"));
}
bool ProjectWorkspace::setEntityUserScaleKey(const QString& id, int frame, double userScale) {
if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) {
return false;
}
const double v = std::clamp(userScale, 1e-6, 1e3);
auto ents = m_project.entities();
bool found = false;
for (auto& e : ents) {
if (e.id != id) continue;
found = true;
upsertKey(e.userScaleKeys, frame, v);
break;
}
if (!found) return false;
return applyEntities(ents, true, QStringLiteral("插入关键帧(缩放)"));
}
bool ProjectWorkspace::setEntityImageFrame(const QString& id, int frame, const QImage& image, QString* outRelPath) {
if (outRelPath) outRelPath->clear();
if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0 || image.isNull()) {
return false;
}
QString rel;
if (!writeEntityFrameImage(id, frame, image, rel)) {
return false;
}
auto ents = m_project.entities();
bool found = false;
for (auto& e : ents) {
if (e.id != id) continue;
found = true;
upsertFrame(e.imageFrames, frame, rel);
break;
}
if (!found) return false;
if (!applyEntities(ents, true, QStringLiteral("插入关键帧(图像)"))) {
return false;
}
if (outRelPath) *outRelPath = rel;
return true;
}
namespace {
bool removeLocationKeyAtFrame(QVector<Project::Entity::KeyframeVec2>& keys, int frame) {
for (int i = 0; i < keys.size(); ++i) {
if (keys[i].frame == frame) {
keys.remove(i);
return true;
}
}
return false;
}
bool removeDepthKeyAtFrame(QVector<Project::Entity::KeyframeFloat01>& keys, int frame) {
for (int i = 0; i < keys.size(); ++i) {
if (keys[i].frame == frame) {
keys.remove(i);
return true;
}
}
return false;
}
bool removeUserScaleKeyAtFrame(QVector<Project::Entity::KeyframeDouble>& keys, int frame) {
for (int i = 0; i < keys.size(); ++i) {
if (keys[i].frame == frame) {
keys.remove(i);
return true;
}
}
return false;
}
bool removeImageKeyAtFrame(QVector<Project::Entity::ImageFrame>& keys, int frame) {
for (int i = 0; i < keys.size(); ++i) {
if (keys[i].frame == frame) {
keys.remove(i);
return true;
}
}
return false;
}
} // namespace
bool ProjectWorkspace::removeEntityLocationKey(const QString& id, int frame) {
if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) {
return false;
}
auto ents = m_project.entities();
bool found = false;
bool removed = false;
for (auto& e : ents) {
if (e.id != id) {
continue;
}
found = true;
removed = removeLocationKeyAtFrame(e.locationKeys, frame);
break;
}
if (!found || !removed) {
return false;
}
return applyEntities(ents, true, QStringLiteral("删除关键帧(位置)"));
}
bool ProjectWorkspace::removeEntityDepthScaleKey(const QString& id, int frame) {
if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) {
return false;
}
auto ents = m_project.entities();
bool found = false;
bool removed = false;
for (auto& e : ents) {
if (e.id != id) {
continue;
}
found = true;
removed = removeDepthKeyAtFrame(e.depthScaleKeys, frame);
break;
}
if (!found || !removed) {
return false;
}
return applyEntities(ents, true, QStringLiteral("删除关键帧(缩放)"));
}
bool ProjectWorkspace::removeEntityUserScaleKey(const QString& id, int frame) {
if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) {
return false;
}
auto ents = m_project.entities();
bool found = false;
bool removed = false;
for (auto& e : ents) {
if (e.id != id) {
continue;
}
found = true;
removed = removeUserScaleKeyAtFrame(e.userScaleKeys, frame);
break;
}
if (!found || !removed) {
return false;
}
return applyEntities(ents, true, QStringLiteral("删除关键帧(缩放)"));
}
bool ProjectWorkspace::removeEntityImageFrame(const QString& id, int frame) {
if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) {
return false;
}
auto ents = m_project.entities();
bool found = false;
bool removed = false;
for (auto& e : ents) {
if (e.id != id) {
continue;
}
found = true;
removed = removeImageKeyAtFrame(e.imageFrames, frame);
break;
}
if (!found || !removed) {
return false;
}
return applyEntities(ents, true, QStringLiteral("删除关键帧(图像)"));
}
QString ProjectWorkspace::copyIntoAssetsAsBackground(const QString& sourceFilePath,
const QRect& cropRectInSourceImage) {
if (m_projectDir.isEmpty()) {
return {};
}
QFileInfo srcInfo(sourceFilePath);
if (!srcInfo.exists() || !srcInfo.isFile()) {
return {};
}
const auto assetsDir = assetsDirPath();
if (assetsDir.isEmpty()) {
return {};
}
// 统一落盘为 png避免后续读取处理复杂化
const auto fileName = QStringLiteral("background.png");
const auto destAbs = QDir(assetsDir).filePath(fileName);
const auto destRel = QString::fromUtf8(kAssetsDirName) + "/" + fileName;
QImage img(sourceFilePath);
if (img.isNull()) {
return {};
}
const QRect crop = cropRectInSourceImage.isNull() ? QRect(0, 0, img.width(), img.height())
: clampRectToImage(cropRectInSourceImage, img.size());
if (crop.isNull()) {
return {};
}
const QImage cropped = img.copy(crop);
if (cropped.isNull()) {
return {};
}
// 覆盖式更新背景:先写临时文件,再替换,避免中间态损坏
const auto tmpAbs = destAbs + ".tmp";
if (QFileInfo::exists(tmpAbs)) {
QFile::remove(tmpAbs);
}
if (!cropped.save(tmpAbs, "PNG")) {
QFile::remove(tmpAbs);
return {};
}
QFile::remove(destAbs);
if (!QFile::rename(tmpAbs, destAbs)) {
QFile::remove(tmpAbs);
return {};
}
return destRel;
}
bool ProjectWorkspace::writeDepthMap(const QImage& depth8) {
if (m_projectDir.isEmpty() || depth8.isNull()) {
return false;
}
const auto assetsDir = assetsDirPath();
if (assetsDir.isEmpty()) {
return false;
}
const auto fileName = QStringLiteral("depth.png");
const auto destAbs = QDir(assetsDir).filePath(fileName);
const auto destRel = QString::fromUtf8(kAssetsDirName) + "/" + fileName;
const auto tmpAbs = destAbs + ".tmp";
if (QFileInfo::exists(tmpAbs)) {
QFile::remove(tmpAbs);
}
if (!depth8.save(tmpAbs, "PNG")) {
QFile::remove(tmpAbs);
return false;
}
QFile::remove(destAbs);
if (!QFile::rename(tmpAbs, destAbs)) {
QFile::remove(tmpAbs);
return false;
}
m_project.setDepthComputed(true);
m_project.setDepthMapPath(destRel);
return writeIndexJson();
}
bool ProjectWorkspace::writeDepthMapBytes(const QByteArray& pngBytes) {
if (m_projectDir.isEmpty() || pngBytes.isEmpty()) {
return false;
}
const auto assetsDir = assetsDirPath();
if (assetsDir.isEmpty()) {
return false;
}
const auto fileName = QStringLiteral("depth.png");
const auto destAbs = QDir(assetsDir).filePath(fileName);
const auto destRel = QString::fromUtf8(kAssetsDirName) + "/" + fileName;
const auto tmpAbs = destAbs + ".tmp";
if (QFileInfo::exists(tmpAbs)) {
QFile::remove(tmpAbs);
}
QFile f(tmpAbs);
if (!f.open(QIODevice::WriteOnly)) {
QFile::remove(tmpAbs);
return false;
}
const qint64 n = f.write(pngBytes);
f.close();
if (n != pngBytes.size()) {
QFile::remove(tmpAbs);
return false;
}
QFile::remove(destAbs);
if (!QFile::rename(tmpAbs, destAbs)) {
QFile::remove(tmpAbs);
return false;
}
m_project.setDepthComputed(true);
m_project.setDepthMapPath(destRel);
return writeIndexJson();
}
bool ProjectWorkspace::computeFakeDepthForProject() {
if (m_projectDir.isEmpty()) {
return false;
}
const auto bgAbs = backgroundAbsolutePath();
if (bgAbs.isEmpty() || !QFileInfo::exists(bgAbs)) {
return false;
}
QImage bg(bgAbs);
if (bg.isNull()) {
return false;
}
const QImage depth8 = DepthService::computeFakeDepthFromBackground(bg);
if (depth8.isNull()) {
return false;
}
return writeDepthMap(depth8);
}
bool ProjectWorkspace::computeDepthForProjectFromServer(const QString& serverBaseUrl, QString* outError, int timeoutMs) {
if (outError) {
outError->clear();
}
if (m_projectDir.isEmpty()) {
if (outError) *outError = QStringLiteral("项目未打开。");
return false;
}
const auto bgAbs = backgroundAbsolutePath();
if (bgAbs.isEmpty() || !QFileInfo::exists(bgAbs)) {
if (outError) *outError = QStringLiteral("背景不存在。");
return false;
}
QFile bgFile(bgAbs);
if (!bgFile.open(QIODevice::ReadOnly)) {
if (outError) *outError = QStringLiteral("读取背景失败。");
return false;
}
const QByteArray bgBytes = bgFile.readAll();
bgFile.close();
if (bgBytes.isEmpty()) {
if (outError) *outError = QStringLiteral("背景文件为空。");
return false;
}
QString base = serverBaseUrl.trimmed();
if (base.isEmpty()) {
const QByteArray env = qgetenv("MODEL_SERVER_URL");
base = env.isEmpty() ? QStringLiteral("http://127.0.0.1:8000") : QString::fromUtf8(env);
}
ModelServerClient client;
client.setBaseUrl(QUrl(base));
QByteArray depthPngBytes;
QString err;
if (!client.computeDepthPng8(bgBytes, depthPngBytes, err, timeoutMs)) {
if (outError) *outError = err.isEmpty() ? QStringLiteral("后端计算深度失败。") : err;
return false;
}
if (!writeDepthMapBytes(depthPngBytes)) {
if (outError) *outError = QStringLiteral("写入深度图失败。");
return false;
}
return true;
}
bool ProjectWorkspace::saveDepthMapPngBytes(const QByteArray& pngBytes, QString* outError) {
if (outError) {
outError->clear();
}
if (m_projectDir.isEmpty()) {
if (outError) *outError = QStringLiteral("项目未打开。");
return false;
}
if (pngBytes.isEmpty()) {
if (outError) *outError = QStringLiteral("深度数据为空。");
return false;
}
if (!writeDepthMapBytes(pngBytes)) {
if (outError) *outError = QStringLiteral("写入深度图失败。");
return false;
}
return true;
}
} // namespace core