1714 lines
53 KiB
C++
1714 lines
53 KiB
C++
#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)));
|
||
}
|
||
}
|
||
|
||
// 兼容旧字段:polygon(world)
|
||
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::setEntityIntroContent(const QString& id, const EntityIntroContent& intro) {
|
||
if (m_projectDir.isEmpty() || id.isEmpty()) {
|
||
return false;
|
||
}
|
||
auto ents = m_project.entities();
|
||
bool found = false;
|
||
for (auto& e : ents) {
|
||
if (e.id != id) {
|
||
continue;
|
||
}
|
||
found = true;
|
||
e.intro = intro;
|
||
break;
|
||
}
|
||
if (!found) {
|
||
return false;
|
||
}
|
||
return applyEntities(ents, true, QStringLiteral("实体介绍"));
|
||
}
|
||
|
||
bool ProjectWorkspace::importEntityIntroImageFromFile(const QString& id, const QString& absoluteImagePath,
|
||
QString* outRelativePath) {
|
||
if (m_projectDir.isEmpty() || id.isEmpty() || absoluteImagePath.isEmpty()) {
|
||
return false;
|
||
}
|
||
const QFileInfo srcFi(absoluteImagePath);
|
||
if (!srcFi.exists() || !srcFi.isFile()) {
|
||
return false;
|
||
}
|
||
const QString entsDir = ensureEntitiesDir();
|
||
if (entsDir.isEmpty()) {
|
||
return false;
|
||
}
|
||
const QString suf = fileSuffixWithDot(absoluteImagePath);
|
||
for (int n = 0; n < 100000; ++n) {
|
||
const QString base = id + QStringLiteral("-intro-%1").arg(n) + suf;
|
||
const QString destAbs = QDir(entsDir).filePath(base);
|
||
if (!QFileInfo::exists(destAbs)) {
|
||
if (!QFile::copy(absoluteImagePath, destAbs)) {
|
||
return false;
|
||
}
|
||
const QString rel =
|
||
QString::fromUtf8(kAssetsDirName) + QStringLiteral("/entities/") + base;
|
||
if (outRelativePath) {
|
||
*outRelativePath = rel;
|
||
}
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
bool ProjectWorkspace::setEntityUserScale(const QString& id, double userScale, int keyframeAtFrame) {
|
||
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;
|
||
const bool baseSame = qFuzzyCompare(e.userScale + 1.0, u + 1.0);
|
||
e.userScale = u;
|
||
if (keyframeAtFrame >= 0) {
|
||
upsertKey(e.userScaleKeys, keyframeAtFrame, std::clamp(u, 1e-6, 1e3));
|
||
} else if (baseSame) {
|
||
return true;
|
||
}
|
||
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 if (e.locationKeys.isEmpty()) {
|
||
e.originWorld += delta;
|
||
e.imageTopLeftWorld += delta;
|
||
} else if (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;
|
||
}
|
||
|
||
bool ProjectWorkspace::setEntityImageFramePath(const QString& id, int frame, const QString& relativePath) {
|
||
if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) {
|
||
return false;
|
||
}
|
||
const QString rel = relativePath.trimmed();
|
||
if (rel.isEmpty()) {
|
||
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;
|
||
return applyEntities(ents, true, QStringLiteral("插入关键帧(图像)"));
|
||
}
|
||
|
||
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
|
||
|