2604 lines
86 KiB
C++
2604 lines
86 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;
|
||
}
|
||
|
||
static const Project::NlaStrip* findStripById(const Project::AnimationScheme& scheme, const QString& stripId) {
|
||
if (stripId.isEmpty()) return nullptr;
|
||
for (const auto& tr : scheme.tracks) {
|
||
for (const auto& st : tr.strips) {
|
||
if (st.id == stripId) return &st;
|
||
}
|
||
}
|
||
return nullptr;
|
||
}
|
||
|
||
static void upsertBoolKey(QVector<Project::ToolKeyframeBool>& keys, int frame, bool value) {
|
||
for (auto& k : keys) {
|
||
if (k.frame == frame) {
|
||
k.value = value;
|
||
return;
|
||
}
|
||
}
|
||
Project::ToolKeyframeBool k;
|
||
k.frame = frame;
|
||
k.value = value;
|
||
keys.push_back(k);
|
||
}
|
||
|
||
static Project::AnimationClip* activeClipOrNull(Project& project) {
|
||
auto* scheme = project.activeSchemeOrNull();
|
||
if (!scheme) return nullptr;
|
||
const Project::NlaStrip* st = findStripById(*scheme, project.selectedStripId());
|
||
if (!st) {
|
||
for (const auto& tr : scheme->tracks) {
|
||
for (const auto& s : tr.strips) {
|
||
if (s.enabled && !s.muted) {
|
||
st = &s;
|
||
break;
|
||
}
|
||
}
|
||
if (st) break;
|
||
}
|
||
}
|
||
if (!st) return nullptr;
|
||
return project.findClipById(st->clipId);
|
||
}
|
||
|
||
} // 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;
|
||
}
|
||
|
||
// 初始化默认动画方案/片段(600 帧固定片段)
|
||
if (m_project.animationSchemes().isEmpty()) {
|
||
Project::AnimationClip clip;
|
||
clip.id = QStringLiteral("clip-1");
|
||
clip.name = QStringLiteral("Clip_001");
|
||
m_project.setAnimationClips({clip});
|
||
|
||
Project::NlaStrip strip;
|
||
strip.id = QStringLiteral("strip-1");
|
||
strip.clipId = clip.id;
|
||
strip.startSlot = 0;
|
||
strip.slotLen = 1;
|
||
|
||
Project::NlaTrack track;
|
||
track.id = QStringLiteral("track-1");
|
||
track.name = QStringLiteral("Track");
|
||
track.strips = {strip};
|
||
|
||
Project::AnimationScheme scheme;
|
||
scheme.id = QStringLiteral("scheme-1");
|
||
scheme.name = QStringLiteral("方案_001");
|
||
scheme.tracks = {track};
|
||
|
||
m_project.setAnimationSchemes({scheme});
|
||
m_project.setActiveSchemeId(scheme.id);
|
||
m_project.setSelectedStripId(strip.id);
|
||
}
|
||
|
||
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();
|
||
|
||
// 旧项目迁移:若无动画方案/片段,则将实体/工具的关键帧迁移到默认 clip,并创建默认 scheme/track/strip
|
||
if (m_project.animationSchemes().isEmpty()) {
|
||
Project::AnimationClip clip;
|
||
clip.id = QStringLiteral("clip-1");
|
||
clip.name = QStringLiteral("Clip_001");
|
||
|
||
auto ents = m_project.entities();
|
||
for (auto& e : ents) {
|
||
if (e.id.isEmpty()) continue;
|
||
if (!e.locationKeys.isEmpty()) clip.entityLocationKeys.insert(e.id, e.locationKeys);
|
||
if (!e.userScaleKeys.isEmpty()) clip.entityUserScaleKeys.insert(e.id, e.userScaleKeys);
|
||
if (!e.imageFrames.isEmpty()) clip.entityImageFrames.insert(e.id, e.imageFrames);
|
||
if (!e.visibilityKeys.isEmpty()) clip.entityVisibilityKeys.insert(e.id, e.visibilityKeys);
|
||
e.locationKeys.clear();
|
||
e.userScaleKeys.clear();
|
||
e.imageFrames.clear();
|
||
e.visibilityKeys.clear();
|
||
}
|
||
m_project.setEntities(ents);
|
||
|
||
auto tools = m_project.tools();
|
||
for (auto& t : tools) {
|
||
if (t.id.isEmpty()) continue;
|
||
if (!t.locationKeys.isEmpty()) clip.toolLocationKeys.insert(t.id, t.locationKeys);
|
||
if (!t.visibilityKeys.isEmpty()) clip.toolVisibilityKeys.insert(t.id, t.visibilityKeys);
|
||
t.locationKeys.clear();
|
||
t.visibilityKeys.clear();
|
||
}
|
||
m_project.setTools(tools);
|
||
|
||
m_project.setAnimationClips({clip});
|
||
|
||
Project::NlaStrip strip;
|
||
strip.id = QStringLiteral("strip-1");
|
||
strip.clipId = clip.id;
|
||
strip.startSlot = 0;
|
||
strip.slotLen = 1;
|
||
|
||
Project::NlaTrack track;
|
||
track.id = QStringLiteral("track-1");
|
||
track.name = QStringLiteral("Track");
|
||
track.strips = {strip};
|
||
|
||
Project::AnimationScheme scheme;
|
||
scheme.id = QStringLiteral("scheme-1");
|
||
scheme.name = QStringLiteral("方案_001");
|
||
scheme.tracks = {track};
|
||
|
||
m_project.setAnimationSchemes({scheme});
|
||
m_project.setActiveSchemeId(scheme.id);
|
||
m_project.setSelectedStripId(strip.id);
|
||
|
||
// 迁移后立即落盘,避免后续求值出现双来源
|
||
writeIndexJson();
|
||
}
|
||
return true;
|
||
}
|
||
|
||
void ProjectWorkspace::close() {
|
||
m_projectDir.clear();
|
||
m_project = Project();
|
||
m_undoStack.clear();
|
||
m_redoStack.clear();
|
||
}
|
||
|
||
bool ProjectWorkspace::save() {
|
||
if (m_projectDir.isEmpty()) {
|
||
return false;
|
||
}
|
||
return writeIndexJson();
|
||
}
|
||
|
||
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::SetTools) {
|
||
if (!applyTools(op.beforeTools, 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;
|
||
}
|
||
} else if (op.type == Operation::Type::SetProjectFrameRange) {
|
||
m_project.setFrameStart(std::max(0, op.beforeFrameStart));
|
||
m_project.setFrameEnd(std::max(m_project.frameStart(), op.beforeFrameEnd));
|
||
if (!writeIndexJson()) {
|
||
m_project.setFrameStart(std::max(0, op.afterFrameStart));
|
||
m_project.setFrameEnd(std::max(m_project.frameStart(), op.afterFrameEnd));
|
||
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::SetTools) {
|
||
if (!applyTools(op.afterTools, 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;
|
||
}
|
||
} else if (op.type == Operation::Type::SetProjectFrameRange) {
|
||
m_project.setFrameStart(std::max(0, op.afterFrameStart));
|
||
m_project.setFrameEnd(std::max(m_project.frameStart(), op.afterFrameEnd));
|
||
if (!writeIndexJson()) {
|
||
m_project.setFrameStart(std::max(0, op.beforeFrameStart));
|
||
m_project.setFrameEnd(std::max(m_project.frameStart(), op.beforeFrameEnd));
|
||
m_redoStack.push_back(op);
|
||
return false;
|
||
}
|
||
}
|
||
m_undoStack.push_back(undoOp);
|
||
return true;
|
||
}
|
||
|
||
bool ProjectWorkspace::setProjectFrameRange(int start, int end) {
|
||
if (m_projectDir.isEmpty()) {
|
||
return false;
|
||
}
|
||
const int s = std::max(0, start);
|
||
const int e = std::max(end, s);
|
||
if (m_project.frameStart() == s && m_project.frameEnd() == e) {
|
||
return true;
|
||
}
|
||
const int beforeS = m_project.frameStart();
|
||
const int beforeE = m_project.frameEnd();
|
||
m_project.setFrameStart(s);
|
||
m_project.setFrameEnd(e);
|
||
if (!writeIndexJson()) {
|
||
m_project.setFrameStart(beforeS);
|
||
m_project.setFrameEnd(beforeE);
|
||
return false;
|
||
}
|
||
Operation op;
|
||
op.type = Operation::Type::SetProjectFrameRange;
|
||
op.label = QStringLiteral("时间轴范围");
|
||
op.beforeFrameStart = beforeS;
|
||
op.beforeFrameEnd = beforeE;
|
||
op.afterFrameStart = s;
|
||
op.afterFrameEnd = e;
|
||
pushOperation(op);
|
||
m_redoStack.clear();
|
||
return true;
|
||
}
|
||
|
||
bool ProjectWorkspace::ensureProjectFrameEndAtLeast(int end, bool recordHistory) {
|
||
if (m_projectDir.isEmpty()) {
|
||
return false;
|
||
}
|
||
const int s = std::max(0, m_project.frameStart());
|
||
const int want = std::max(end, s);
|
||
if (want <= m_project.frameEnd()) {
|
||
return true;
|
||
}
|
||
// 按“页/片段长度”扩展:避免 scrub 时每帧都写盘,并使时间轴增长呈分段语义
|
||
// 约定:默认一页 600 帧(历史工程默认 frameEnd=600),后续可做成可配置。
|
||
constexpr int kPageLen = 600;
|
||
const int beforeE = m_project.frameEnd();
|
||
const int spanFromStart = std::max(0, want - s);
|
||
const int pages = (spanFromStart + kPageLen - 1) / kPageLen; // ceil
|
||
const int newEnd = s + pages * kPageLen;
|
||
m_project.setFrameEnd(std::max(newEnd, want));
|
||
if (!writeIndexJson()) {
|
||
m_project.setFrameEnd(beforeE);
|
||
return false;
|
||
}
|
||
if (recordHistory) {
|
||
Operation op;
|
||
op.type = Operation::Type::SetProjectFrameRange;
|
||
op.label = QStringLiteral("扩展时间轴");
|
||
op.beforeFrameStart = m_project.frameStart();
|
||
op.afterFrameStart = m_project.frameStart();
|
||
op.beforeFrameEnd = beforeE;
|
||
op.afterFrameEnd = m_project.frameEnd();
|
||
pushOperation(op);
|
||
m_redoStack.clear();
|
||
}
|
||
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);
|
||
|
||
QJsonArray tools;
|
||
for (const auto& t : project.tools()) {
|
||
tools.append(toolToJson(t));
|
||
}
|
||
root.insert("tools", tools);
|
||
|
||
// —— 动画(v3)——
|
||
root.insert("activeSchemeId", project.activeSchemeId());
|
||
root.insert("selectedStripId", project.selectedStripId());
|
||
|
||
QJsonArray clips;
|
||
for (const auto& c : project.animationClips()) {
|
||
QJsonObject co;
|
||
co.insert("id", c.id);
|
||
co.insert("name", c.name);
|
||
|
||
auto encodeVec2 = [](const QVector<Project::Entity::KeyframeVec2>& keys) {
|
||
QJsonArray a;
|
||
for (const auto& k : keys) {
|
||
QJsonObject o;
|
||
o.insert("frame", k.frame);
|
||
o.insert("x", k.value.x());
|
||
o.insert("y", k.value.y());
|
||
a.append(o);
|
||
}
|
||
return a;
|
||
};
|
||
auto encodeDouble = [](const QVector<Project::Entity::KeyframeDouble>& keys) {
|
||
QJsonArray a;
|
||
for (const auto& k : keys) {
|
||
QJsonObject o;
|
||
o.insert("frame", k.frame);
|
||
o.insert("value", k.value);
|
||
a.append(o);
|
||
}
|
||
return a;
|
||
};
|
||
auto encodeImage = [](const QVector<Project::Entity::ImageFrame>& keys) {
|
||
QJsonArray a;
|
||
for (const auto& k : keys) {
|
||
QJsonObject o;
|
||
o.insert("frame", k.frame);
|
||
o.insert("path", k.imagePath);
|
||
a.append(o);
|
||
}
|
||
return a;
|
||
};
|
||
auto encodeBool = [](const QVector<Project::ToolKeyframeBool>& keys) {
|
||
QJsonArray a;
|
||
for (const auto& k : keys) {
|
||
QJsonObject o;
|
||
o.insert("frame", k.frame);
|
||
o.insert("value", k.value);
|
||
a.append(o);
|
||
}
|
||
return a;
|
||
};
|
||
|
||
auto encodeHash = [&](const auto& h, auto encoder) {
|
||
QJsonObject out;
|
||
for (auto it = h.constBegin(); it != h.constEnd(); ++it) {
|
||
out.insert(it.key(), encoder(it.value()));
|
||
}
|
||
return out;
|
||
};
|
||
|
||
co.insert("entityLocationKeys", encodeHash(c.entityLocationKeys, encodeVec2));
|
||
co.insert("entityUserScaleKeys", encodeHash(c.entityUserScaleKeys, encodeDouble));
|
||
co.insert("entityImageFrames", encodeHash(c.entityImageFrames, encodeImage));
|
||
co.insert("entityVisibilityKeys", encodeHash(c.entityVisibilityKeys, encodeBool));
|
||
co.insert("toolLocationKeys", encodeHash(c.toolLocationKeys, encodeVec2));
|
||
co.insert("toolVisibilityKeys", encodeHash(c.toolVisibilityKeys, encodeBool));
|
||
|
||
clips.append(co);
|
||
}
|
||
root.insert("animationClips", clips);
|
||
|
||
QJsonArray schemes;
|
||
for (const auto& s : project.animationSchemes()) {
|
||
QJsonObject so;
|
||
so.insert("id", s.id);
|
||
so.insert("name", s.name);
|
||
QJsonArray tracks;
|
||
for (const auto& t : s.tracks) {
|
||
QJsonObject to;
|
||
to.insert("id", t.id);
|
||
to.insert("name", t.name);
|
||
to.insert("muted", t.muted);
|
||
to.insert("solo", t.solo);
|
||
QJsonArray strips;
|
||
for (const auto& st : t.strips) {
|
||
QJsonObject sto;
|
||
sto.insert("id", st.id);
|
||
sto.insert("clipId", st.clipId);
|
||
sto.insert("startSlot", st.startSlot);
|
||
sto.insert("slotLen", st.slotLen);
|
||
sto.insert("enabled", st.enabled);
|
||
sto.insert("muted", st.muted);
|
||
strips.append(sto);
|
||
}
|
||
to.insert("strips", strips);
|
||
tracks.append(to);
|
||
}
|
||
so.insert("tracks", tracks);
|
||
schemes.append(so);
|
||
}
|
||
root.insert("animationSchemes", schemes);
|
||
|
||
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 && version != 3) {
|
||
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);
|
||
|
||
QVector<Project::Tool> tools;
|
||
if (version >= 2) {
|
||
const auto toolsVal = root.value("tools");
|
||
if (toolsVal.isArray()) {
|
||
const QJsonArray arr = toolsVal.toArray();
|
||
tools.reserve(arr.size());
|
||
for (const auto& v : arr) {
|
||
if (!v.isObject()) continue;
|
||
Project::Tool t;
|
||
if (toolFromJsonV2(v.toObject(), t)) {
|
||
tools.push_back(t);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
outProject.setTools(tools);
|
||
|
||
// —— 动画(v3,可选)——
|
||
if (version >= 3) {
|
||
outProject.setActiveSchemeId(root.value("activeSchemeId").toString());
|
||
outProject.setSelectedStripId(root.value("selectedStripId").toString());
|
||
|
||
QVector<Project::AnimationClip> clips;
|
||
const auto clipsVal = root.value("animationClips");
|
||
if (clipsVal.isArray()) {
|
||
const QJsonArray arr = clipsVal.toArray();
|
||
clips.reserve(arr.size());
|
||
for (const auto& v : arr) {
|
||
if (!v.isObject()) continue;
|
||
const QJsonObject co = v.toObject();
|
||
Project::AnimationClip c;
|
||
c.id = co.value("id").toString();
|
||
c.name = co.value("name").toString();
|
||
|
||
auto decodeVec2 = [](const QJsonValue& val) {
|
||
QVector<Project::Entity::KeyframeVec2> out;
|
||
if (!val.isArray()) return out;
|
||
const QJsonArray karr = val.toArray();
|
||
out.reserve(karr.size());
|
||
for (const auto& kv : karr) {
|
||
if (!kv.isObject()) continue;
|
||
const QJsonObject o = kv.toObject();
|
||
Project::Entity::KeyframeVec2 k;
|
||
k.frame = o.value("frame").toInt(0);
|
||
k.value = QPointF(o.value("x").toDouble(0.0), o.value("y").toDouble(0.0));
|
||
out.push_back(k);
|
||
}
|
||
return out;
|
||
};
|
||
auto decodeDouble = [](const QJsonValue& val) {
|
||
QVector<Project::Entity::KeyframeDouble> out;
|
||
if (!val.isArray()) return out;
|
||
const QJsonArray karr = val.toArray();
|
||
out.reserve(karr.size());
|
||
for (const auto& kv : karr) {
|
||
if (!kv.isObject()) continue;
|
||
const QJsonObject o = kv.toObject();
|
||
Project::Entity::KeyframeDouble k;
|
||
k.frame = o.value("frame").toInt(0);
|
||
k.value = o.value("value").toDouble(1.0);
|
||
out.push_back(k);
|
||
}
|
||
return out;
|
||
};
|
||
auto decodeImage = [](const QJsonValue& val) {
|
||
QVector<Project::Entity::ImageFrame> out;
|
||
if (!val.isArray()) return out;
|
||
const QJsonArray karr = val.toArray();
|
||
out.reserve(karr.size());
|
||
for (const auto& kv : karr) {
|
||
if (!kv.isObject()) continue;
|
||
const QJsonObject o = kv.toObject();
|
||
Project::Entity::ImageFrame k;
|
||
k.frame = o.value("frame").toInt(0);
|
||
k.imagePath = asOptionalRelativeUnderProject(o.value("path").toString());
|
||
out.push_back(k);
|
||
}
|
||
return out;
|
||
};
|
||
auto decodeBool = [](const QJsonValue& val) {
|
||
QVector<Project::ToolKeyframeBool> out;
|
||
if (!val.isArray()) return out;
|
||
const QJsonArray karr = val.toArray();
|
||
out.reserve(karr.size());
|
||
for (const auto& kv : karr) {
|
||
if (!kv.isObject()) continue;
|
||
const QJsonObject o = kv.toObject();
|
||
Project::ToolKeyframeBool k;
|
||
k.frame = o.value("frame").toInt(0);
|
||
k.value = o.value("value").toBool(true);
|
||
out.push_back(k);
|
||
}
|
||
return out;
|
||
};
|
||
|
||
auto decodeHash = [&](const QJsonValue& val, auto decoder, auto& dst) {
|
||
dst.clear();
|
||
if (!val.isObject()) return;
|
||
const QJsonObject ho = val.toObject();
|
||
for (auto it = ho.begin(); it != ho.end(); ++it) {
|
||
dst.insert(it.key(), decoder(it.value()));
|
||
}
|
||
};
|
||
|
||
decodeHash(co.value("entityLocationKeys"), decodeVec2, c.entityLocationKeys);
|
||
decodeHash(co.value("entityUserScaleKeys"), decodeDouble, c.entityUserScaleKeys);
|
||
decodeHash(co.value("entityImageFrames"), decodeImage, c.entityImageFrames);
|
||
decodeHash(co.value("entityVisibilityKeys"), decodeBool, c.entityVisibilityKeys);
|
||
decodeHash(co.value("toolLocationKeys"), decodeVec2, c.toolLocationKeys);
|
||
decodeHash(co.value("toolVisibilityKeys"), decodeBool, c.toolVisibilityKeys);
|
||
|
||
clips.push_back(std::move(c));
|
||
}
|
||
}
|
||
outProject.setAnimationClips(clips);
|
||
|
||
QVector<Project::AnimationScheme> schemes;
|
||
const auto schemesVal = root.value("animationSchemes");
|
||
if (schemesVal.isArray()) {
|
||
const QJsonArray arr = schemesVal.toArray();
|
||
schemes.reserve(arr.size());
|
||
for (const auto& v : arr) {
|
||
if (!v.isObject()) continue;
|
||
const QJsonObject so = v.toObject();
|
||
Project::AnimationScheme s;
|
||
s.id = so.value("id").toString();
|
||
s.name = so.value("name").toString();
|
||
const auto tracksVal = so.value("tracks");
|
||
if (tracksVal.isArray()) {
|
||
const QJsonArray tarr = tracksVal.toArray();
|
||
s.tracks.reserve(tarr.size());
|
||
for (const auto& tv : tarr) {
|
||
if (!tv.isObject()) continue;
|
||
const QJsonObject to = tv.toObject();
|
||
Project::NlaTrack t;
|
||
t.id = to.value("id").toString();
|
||
t.name = to.value("name").toString();
|
||
t.muted = to.value("muted").toBool(false);
|
||
t.solo = to.value("solo").toBool(false);
|
||
const auto stripsVal = to.value("strips");
|
||
if (stripsVal.isArray()) {
|
||
const QJsonArray sarr = stripsVal.toArray();
|
||
t.strips.reserve(sarr.size());
|
||
for (const auto& sv : sarr) {
|
||
if (!sv.isObject()) continue;
|
||
const QJsonObject sto = sv.toObject();
|
||
Project::NlaStrip st;
|
||
st.id = sto.value("id").toString();
|
||
st.clipId = sto.value("clipId").toString();
|
||
st.startSlot = sto.value("startSlot").toInt(0);
|
||
st.slotLen = std::max(1, sto.value("slotLen").toInt(1));
|
||
st.enabled = sto.value("enabled").toBool(true);
|
||
st.muted = sto.value("muted").toBool(false);
|
||
t.strips.push_back(std::move(st));
|
||
}
|
||
}
|
||
s.tracks.push_back(std::move(t));
|
||
}
|
||
}
|
||
schemes.push_back(std::move(s));
|
||
}
|
||
}
|
||
outProject.setAnimationSchemes(schemes);
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
QJsonObject ProjectWorkspace::toolToJson(const Project::Tool& t) {
|
||
QJsonObject o;
|
||
o.insert("id", t.id);
|
||
o.insert("displayName", t.displayName);
|
||
o.insert("visible", t.visible);
|
||
o.insert("parentId", t.parentId);
|
||
o.insert("parentOffsetX", t.parentOffsetWorld.x());
|
||
o.insert("parentOffsetY", t.parentOffsetWorld.y());
|
||
o.insert("originX", t.originWorld.x());
|
||
o.insert("originY", t.originWorld.y());
|
||
o.insert("type", (t.type == Project::Tool::Type::Bubble) ? QStringLiteral("bubble") : QStringLiteral("bubble"));
|
||
o.insert("text", t.text);
|
||
o.insert("fontPx", t.fontPx);
|
||
const QString align =
|
||
(t.align == Project::Tool::TextAlign::Left) ? QStringLiteral("left")
|
||
: (t.align == Project::Tool::TextAlign::Right) ? QStringLiteral("right")
|
||
: QStringLiteral("center");
|
||
o.insert("align", align);
|
||
o.insert("pointerT", t.bubblePointerT01);
|
||
QJsonArray vis;
|
||
for (const auto& k : t.visibilityKeys) {
|
||
QJsonObject ko;
|
||
ko.insert("frame", k.frame);
|
||
ko.insert("value", k.value);
|
||
vis.append(ko);
|
||
}
|
||
o.insert("visibilityKeys", vis);
|
||
QJsonArray loc;
|
||
for (const auto& k : t.locationKeys) {
|
||
QJsonObject ko;
|
||
ko.insert("frame", k.frame);
|
||
ko.insert("x", k.value.x());
|
||
ko.insert("y", k.value.y());
|
||
loc.append(ko);
|
||
}
|
||
o.insert("locationKeys", loc);
|
||
return o;
|
||
}
|
||
|
||
bool ProjectWorkspace::toolFromJsonV2(const QJsonObject& o, Project::Tool& out) {
|
||
Project::Tool t;
|
||
t.id = o.value("id").toString();
|
||
if (t.id.isEmpty()) {
|
||
return false;
|
||
}
|
||
t.displayName = o.value("displayName").toString();
|
||
t.visible = o.value("visible").toBool(true);
|
||
t.parentId = o.value("parentId").toString();
|
||
t.parentOffsetWorld = QPointF(o.value("parentOffsetX").toDouble(0.0), o.value("parentOffsetY").toDouble(0.0));
|
||
t.originWorld = QPointF(o.value("originX").toDouble(0.0), o.value("originY").toDouble(0.0));
|
||
const QString type = o.value("type").toString();
|
||
t.type = (type == QStringLiteral("bubble")) ? Project::Tool::Type::Bubble : Project::Tool::Type::Bubble;
|
||
t.text = o.value("text").toString();
|
||
t.fontPx = std::clamp(o.value("fontPx").toInt(18), 8, 120);
|
||
const QString align = o.value("align").toString(QStringLiteral("center"));
|
||
if (align == QStringLiteral("left")) t.align = Project::Tool::TextAlign::Left;
|
||
else if (align == QStringLiteral("right")) t.align = Project::Tool::TextAlign::Right;
|
||
else t.align = Project::Tool::TextAlign::Center;
|
||
if (o.contains(QStringLiteral("pointerT"))) {
|
||
t.bubblePointerT01 = std::clamp(o.value(QStringLiteral("pointerT")).toDouble(0.5), 0.0, 1.0);
|
||
} else {
|
||
const QString ptr = o.value(QStringLiteral("pointer")).toString();
|
||
if (ptr == QStringLiteral("left")) t.bubblePointerT01 = 0.12;
|
||
else if (ptr == QStringLiteral("right")) t.bubblePointerT01 = 0.88;
|
||
else t.bubblePointerT01 = 0.5;
|
||
}
|
||
|
||
t.visibilityKeys.clear();
|
||
const auto visVal = o.value("visibilityKeys");
|
||
if (visVal.isArray()) {
|
||
const QJsonArray arr = visVal.toArray();
|
||
t.visibilityKeys.reserve(arr.size());
|
||
for (const auto& v : arr) {
|
||
if (!v.isObject()) continue;
|
||
const auto ko = v.toObject();
|
||
Project::ToolKeyframeBool k;
|
||
k.frame = ko.value("frame").toInt(0);
|
||
k.value = ko.value("value").toBool(true);
|
||
t.visibilityKeys.push_back(k);
|
||
}
|
||
}
|
||
t.locationKeys.clear();
|
||
const auto locVal = o.value("locationKeys");
|
||
if (locVal.isArray()) {
|
||
const QJsonArray arr = locVal.toArray();
|
||
t.locationKeys.reserve(arr.size());
|
||
for (const auto& v : arr) {
|
||
if (!v.isObject()) continue;
|
||
const auto ko = v.toObject();
|
||
Project::Entity::KeyframeVec2 k;
|
||
k.frame = ko.value("frame").toInt(0);
|
||
k.value = QPointF(ko.value("x").toDouble(0.0), ko.value("y").toDouble(0.0));
|
||
t.locationKeys.push_back(k);
|
||
}
|
||
}
|
||
|
||
out = std::move(t);
|
||
return true;
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
bool ProjectWorkspace::applyTools(const QVector<Project::Tool>& tools,
|
||
bool recordHistory,
|
||
const QString& label) {
|
||
const auto before = m_project.tools();
|
||
m_project.setTools(tools);
|
||
if (!writeIndexJson()) {
|
||
m_project.setTools(before);
|
||
return false;
|
||
}
|
||
if (recordHistory) {
|
||
Operation op;
|
||
op.type = Operation::Type::SetTools;
|
||
op.label = label;
|
||
op.beforeTools = before;
|
||
op.afterTools = tools;
|
||
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::setEntityVisibilityKey(const QString& id, int frame, bool visible) {
|
||
if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) {
|
||
return false;
|
||
}
|
||
Project::AnimationClip* clip = activeClipOrNull(m_project);
|
||
if (!clip) return false;
|
||
auto& keys = clip->entityVisibilityKeys[id];
|
||
upsertBoolKey(keys, frame, visible);
|
||
return writeIndexJson();
|
||
}
|
||
|
||
bool ProjectWorkspace::removeEntityVisibilityKey(const QString& id, int frame) {
|
||
if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) {
|
||
return false;
|
||
}
|
||
Project::AnimationClip* clip = activeClipOrNull(m_project);
|
||
if (!clip) return false;
|
||
if (!clip->entityVisibilityKeys.contains(id)) return true;
|
||
auto keys = clip->entityVisibilityKeys.value(id);
|
||
bool changed = false;
|
||
for (int i = 0; i < keys.size(); ++i) {
|
||
if (keys[i].frame == frame) {
|
||
keys.removeAt(i);
|
||
changed = true;
|
||
break;
|
||
}
|
||
}
|
||
if (!changed) return true;
|
||
clip->entityVisibilityKeys.insert(id, keys);
|
||
return writeIndexJson();
|
||
}
|
||
|
||
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) {
|
||
Project::AnimationClip* clip = activeClipOrNull(m_project);
|
||
if (!clip) return false;
|
||
auto& keys = clip->entityUserScaleKeys[id];
|
||
upsertKey(keys, keyframeAtFrame, std::clamp(u, 1e-6, 1e3));
|
||
} else if (baseSame) {
|
||
return true;
|
||
}
|
||
break;
|
||
}
|
||
if (!found) {
|
||
return false;
|
||
}
|
||
if (!applyEntities(ents, true, QStringLiteral("整体缩放"))) {
|
||
return false;
|
||
}
|
||
// 缩放关键帧已写入 clip,此处只需保证索引落盘
|
||
return writeIndexJson();
|
||
}
|
||
|
||
bool ProjectWorkspace::setEntityIgnoreDistanceScale(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.ignoreDistanceScale != on) {
|
||
e.ignoreDistanceScale = on;
|
||
changed = true;
|
||
}
|
||
break;
|
||
}
|
||
if (!found) return false;
|
||
if (!changed) return true;
|
||
return applyEntities(ents, true, QStringLiteral("距离缩放开关"));
|
||
}
|
||
|
||
bool ProjectWorkspace::setEntityParent(const QString& id, const QString& parentId, const QPointF& parentOffsetWorld) {
|
||
if (m_projectDir.isEmpty() || id.isEmpty()) {
|
||
return false;
|
||
}
|
||
if (id == parentId) {
|
||
return false;
|
||
}
|
||
auto ents = m_project.entities();
|
||
QSet<QString> ids;
|
||
for (const auto& e : ents) ids.insert(e.id);
|
||
if (!parentId.isEmpty() && !ids.contains(parentId)) {
|
||
return false;
|
||
}
|
||
bool found = false;
|
||
for (auto& e : ents) {
|
||
if (e.id != id) continue;
|
||
found = true;
|
||
e.parentId = parentId;
|
||
e.parentOffsetWorld = parentOffsetWorld;
|
||
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 (!e.parentId.isEmpty()) {
|
||
e.parentOffsetWorld += delta;
|
||
break;
|
||
}
|
||
Project::AnimationClip* clip = activeClipOrNull(m_project);
|
||
QVector<Project::Entity::KeyframeVec2>* keys = nullptr;
|
||
if (clip) {
|
||
keys = &clip->entityLocationKeys[e.id];
|
||
}
|
||
const bool hasCurve = (keys && !keys->isEmpty()) || (!e.locationKeys.isEmpty());
|
||
if (autoKeyLocation && currentFrame >= 0) {
|
||
const QPointF sampled =
|
||
(keys)
|
||
? sampleLocation(*keys, currentFrame, e.originWorld, KeyInterpolation::Linear)
|
||
: sampleLocation(e.locationKeys, currentFrame, e.originWorld, KeyInterpolation::Linear);
|
||
if (keys) upsertKey(*keys, currentFrame, sampled + delta);
|
||
else upsertKey(e.locationKeys, currentFrame, sampled + delta);
|
||
} else if (!hasCurve) {
|
||
e.originWorld += delta;
|
||
e.imageTopLeftWorld += delta;
|
||
} else if (currentFrame >= 0) {
|
||
const QPointF sampled =
|
||
(keys)
|
||
? sampleLocation(*keys, currentFrame, e.originWorld, KeyInterpolation::Linear)
|
||
: sampleLocation(e.locationKeys, currentFrame, e.originWorld, KeyInterpolation::Linear);
|
||
if (keys) upsertKey(*keys, currentFrame, sampled + delta);
|
||
else upsertKey(e.locationKeys, currentFrame, sampled + delta);
|
||
} else {
|
||
e.originWorld += delta;
|
||
e.imageTopLeftWorld += delta;
|
||
}
|
||
break;
|
||
}
|
||
if (!found) {
|
||
return false;
|
||
}
|
||
if (!applyEntities(ents, true, QStringLiteral("移动实体"))) {
|
||
return false;
|
||
}
|
||
// clip 曲线与选中条带存于 project.json,需要保证落盘
|
||
return writeIndexJson();
|
||
}
|
||
|
||
bool ProjectWorkspace::addTool(const Project::Tool& tool) {
|
||
if (m_projectDir.isEmpty()) {
|
||
return false;
|
||
}
|
||
if (tool.id.isEmpty()) {
|
||
return false;
|
||
}
|
||
auto tools = m_project.tools();
|
||
for (const auto& t : tools) {
|
||
if (t.id == tool.id) {
|
||
return false;
|
||
}
|
||
}
|
||
tools.push_back(tool);
|
||
return applyTools(tools, true, QStringLiteral("添加工具"));
|
||
}
|
||
|
||
bool ProjectWorkspace::setToolVisible(const QString& id, bool on) {
|
||
if (m_projectDir.isEmpty() || id.isEmpty()) {
|
||
return false;
|
||
}
|
||
auto tools = m_project.tools();
|
||
bool found = false;
|
||
bool changed = false;
|
||
for (auto& t : tools) {
|
||
if (t.id != id) continue;
|
||
found = true;
|
||
if (t.visible != on) {
|
||
t.visible = on;
|
||
changed = true;
|
||
}
|
||
break;
|
||
}
|
||
if (!found) return false;
|
||
if (!changed) return true;
|
||
return applyTools(tools, true, on ? QStringLiteral("显示工具") : QStringLiteral("隐藏工具"));
|
||
}
|
||
|
||
bool ProjectWorkspace::setToolText(const QString& id, const QString& text) {
|
||
if (m_projectDir.isEmpty() || id.isEmpty()) {
|
||
return false;
|
||
}
|
||
auto tools = m_project.tools();
|
||
bool found = false;
|
||
for (auto& t : tools) {
|
||
if (t.id != id) continue;
|
||
found = true;
|
||
t.text = text;
|
||
break;
|
||
}
|
||
if (!found) return false;
|
||
return applyTools(tools, true, QStringLiteral("工具文本"));
|
||
}
|
||
|
||
bool ProjectWorkspace::setToolBubblePointerT01(const QString& id, double t01) {
|
||
if (m_projectDir.isEmpty() || id.isEmpty()) {
|
||
return false;
|
||
}
|
||
const double tClamped = std::clamp(t01, 0.0, 1.0);
|
||
auto tools = m_project.tools();
|
||
bool found = false;
|
||
for (auto& tool : tools) {
|
||
if (tool.id != id) continue;
|
||
found = true;
|
||
tool.bubblePointerT01 = tClamped;
|
||
break;
|
||
}
|
||
if (!found) return false;
|
||
return applyTools(tools, true, QStringLiteral("气泡指向位置"));
|
||
}
|
||
|
||
bool ProjectWorkspace::setToolFontPx(const QString& id, int fontPx) {
|
||
if (m_projectDir.isEmpty() || id.isEmpty()) {
|
||
return false;
|
||
}
|
||
const int px = std::clamp(fontPx, 8, 120);
|
||
auto tools = m_project.tools();
|
||
bool found = false;
|
||
for (auto& t : tools) {
|
||
if (t.id != id) continue;
|
||
found = true;
|
||
t.fontPx = px;
|
||
break;
|
||
}
|
||
if (!found) return false;
|
||
return applyTools(tools, true, QStringLiteral("工具字号"));
|
||
}
|
||
|
||
bool ProjectWorkspace::setToolAlign(const QString& id, core::Project::Tool::TextAlign align) {
|
||
if (m_projectDir.isEmpty() || id.isEmpty()) {
|
||
return false;
|
||
}
|
||
auto tools = m_project.tools();
|
||
bool found = false;
|
||
for (auto& t : tools) {
|
||
if (t.id != id) continue;
|
||
found = true;
|
||
t.align = align;
|
||
break;
|
||
}
|
||
if (!found) return false;
|
||
return applyTools(tools, true, QStringLiteral("工具对齐"));
|
||
}
|
||
|
||
bool ProjectWorkspace::setToolVisibilityKey(const QString& id, int frame, bool visible) {
|
||
if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) {
|
||
return false;
|
||
}
|
||
Project::AnimationClip* clip = activeClipOrNull(m_project);
|
||
if (!clip) return false;
|
||
auto& keys = clip->toolVisibilityKeys[id];
|
||
upsertBoolKey(keys, frame, visible);
|
||
return writeIndexJson();
|
||
}
|
||
|
||
bool ProjectWorkspace::removeToolVisibilityKey(const QString& id, int frame) {
|
||
if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) {
|
||
return false;
|
||
}
|
||
Project::AnimationClip* clip = activeClipOrNull(m_project);
|
||
if (!clip) return false;
|
||
if (!clip->toolVisibilityKeys.contains(id)) return true;
|
||
auto keys = clip->toolVisibilityKeys.value(id);
|
||
bool changed = false;
|
||
for (int i = 0; i < keys.size(); ++i) {
|
||
if (keys[i].frame == frame) {
|
||
keys.removeAt(i);
|
||
changed = true;
|
||
break;
|
||
}
|
||
}
|
||
if (!changed) return true;
|
||
clip->toolVisibilityKeys.insert(id, keys);
|
||
return writeIndexJson();
|
||
}
|
||
|
||
bool ProjectWorkspace::setToolParent(const QString& id, const QString& parentId, const QPointF& parentOffsetWorld) {
|
||
if (m_projectDir.isEmpty() || id.isEmpty()) {
|
||
return false;
|
||
}
|
||
if (id == parentId) {
|
||
return false;
|
||
}
|
||
auto tools = m_project.tools();
|
||
bool found = false;
|
||
for (auto& t : tools) {
|
||
if (t.id != id) continue;
|
||
found = true;
|
||
t.parentId = parentId;
|
||
t.parentOffsetWorld = parentOffsetWorld;
|
||
break;
|
||
}
|
||
if (!found) return false;
|
||
return applyTools(tools, true, QStringLiteral("设置工具父对象"));
|
||
}
|
||
|
||
bool ProjectWorkspace::moveToolBy(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 tools = m_project.tools();
|
||
bool found = false;
|
||
for (auto& t : tools) {
|
||
if (t.id != id) continue;
|
||
found = true;
|
||
if (!t.parentId.isEmpty()) {
|
||
t.parentOffsetWorld += delta;
|
||
break;
|
||
}
|
||
Project::AnimationClip* clip = activeClipOrNull(m_project);
|
||
QVector<Project::Entity::KeyframeVec2>* keys = nullptr;
|
||
if (clip) {
|
||
keys = &clip->toolLocationKeys[t.id];
|
||
}
|
||
const bool hasCurve = (keys && !keys->isEmpty()) || (!t.locationKeys.isEmpty());
|
||
if (autoKeyLocation && currentFrame >= 0) {
|
||
const QPointF sampled =
|
||
(keys)
|
||
? sampleLocation(*keys, currentFrame, t.originWorld, KeyInterpolation::Linear)
|
||
: sampleLocation(t.locationKeys, currentFrame, t.originWorld, KeyInterpolation::Linear);
|
||
if (keys) upsertKey(*keys, currentFrame, sampled + delta);
|
||
else upsertKey(t.locationKeys, currentFrame, sampled + delta);
|
||
} else if (!hasCurve) {
|
||
t.originWorld += delta;
|
||
} else if (currentFrame >= 0) {
|
||
const QPointF sampled =
|
||
(keys)
|
||
? sampleLocation(*keys, currentFrame, t.originWorld, KeyInterpolation::Linear)
|
||
: sampleLocation(t.locationKeys, currentFrame, t.originWorld, KeyInterpolation::Linear);
|
||
if (keys) upsertKey(*keys, currentFrame, sampled + delta);
|
||
else upsertKey(t.locationKeys, currentFrame, sampled + delta);
|
||
} else {
|
||
t.originWorld += delta;
|
||
}
|
||
break;
|
||
}
|
||
if (!found) return false;
|
||
if (!applyTools(tools, true, QStringLiteral("移动工具"))) {
|
||
return false;
|
||
}
|
||
return writeIndexJson();
|
||
}
|
||
|
||
bool ProjectWorkspace::setEntityLocationKey(const QString& id, int frame, const QPointF& originWorld) {
|
||
if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) {
|
||
return false;
|
||
}
|
||
Project::AnimationClip* clip = activeClipOrNull(m_project);
|
||
if (!clip) return false;
|
||
auto& keys = clip->entityLocationKeys[id];
|
||
upsertKey(keys, frame, originWorld);
|
||
return writeIndexJson();
|
||
}
|
||
|
||
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);
|
||
Project::AnimationClip* clip = activeClipOrNull(m_project);
|
||
if (!clip) return false;
|
||
auto& keys = clip->entityUserScaleKeys[id];
|
||
upsertKey(keys, frame, v);
|
||
return writeIndexJson();
|
||
}
|
||
|
||
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;
|
||
}
|
||
Project::AnimationClip* clip = activeClipOrNull(m_project);
|
||
if (!clip) return false;
|
||
auto& frames = clip->entityImageFrames[id];
|
||
upsertFrame(frames, frame, rel);
|
||
if (!writeIndexJson()) 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;
|
||
}
|
||
Project::AnimationClip* clip = activeClipOrNull(m_project);
|
||
if (!clip) return false;
|
||
if (!clip->entityLocationKeys.contains(id)) return false;
|
||
auto keys = clip->entityLocationKeys.value(id);
|
||
const bool removed = removeLocationKeyAtFrame(keys, frame);
|
||
if (!removed) return false;
|
||
clip->entityLocationKeys.insert(id, keys);
|
||
return writeIndexJson();
|
||
}
|
||
|
||
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;
|
||
}
|
||
Project::AnimationClip* clip = activeClipOrNull(m_project);
|
||
if (!clip) return false;
|
||
if (!clip->entityUserScaleKeys.contains(id)) return false;
|
||
auto keys = clip->entityUserScaleKeys.value(id);
|
||
const bool removed = removeUserScaleKeyAtFrame(keys, frame);
|
||
if (!removed) return false;
|
||
clip->entityUserScaleKeys.insert(id, keys);
|
||
return writeIndexJson();
|
||
}
|
||
|
||
bool ProjectWorkspace::removeEntityImageFrame(const QString& id, int frame) {
|
||
if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) {
|
||
return false;
|
||
}
|
||
Project::AnimationClip* clip = activeClipOrNull(m_project);
|
||
if (!clip) return false;
|
||
if (!clip->entityImageFrames.contains(id)) return false;
|
||
auto keys = clip->entityImageFrames.value(id);
|
||
const bool removed = removeImageKeyAtFrame(keys, frame);
|
||
if (!removed) return false;
|
||
clip->entityImageFrames.insert(id, keys);
|
||
return writeIndexJson();
|
||
}
|
||
|
||
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
|
||
|