4371 lines
149 KiB
C++
4371 lines
149 KiB
C++
/*
|
|
*/
|
|
|
|
#include "workspace/ProjectWorkspace.h"
|
|
#include "image/ImageDecodeConfig.h"
|
|
#include "image/ImageFileLoader.h"
|
|
#include "large_image/VipsBackend.h"
|
|
#include "animation/AnimationEvaluator.h"
|
|
#include "animation/AnimationSampling.h"
|
|
#include "eval/ProjectEvaluator.h"
|
|
#include "persistence/AnimationBinary.h"
|
|
#include "persistence/EntityPayloadBinary.h"
|
|
#include "persistence/AnimationBundleStore.h"
|
|
#include "persistence/EntityBundleStore.h"
|
|
#include "persistence/JsonFileAtomic.h"
|
|
#include "persistence/AsyncProjectWriter.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 <QBuffer>
|
|
#include <QImage>
|
|
#include <QImageWriter>
|
|
#include <QImageReader>
|
|
#include <QUrl>
|
|
#include <QHash>
|
|
#include <QSet>
|
|
#include <QPainter>
|
|
#include <QPainterPath>
|
|
#include <QTransform>
|
|
#include <QPolygonF>
|
|
#include <QRect>
|
|
|
|
#include <cmath>
|
|
#include <algorithm>
|
|
|
|
namespace core {
|
|
|
|
namespace {
|
|
|
|
static bool isNoneAnimationActive(const Project& project) {
|
|
return project.activeAnimationId() == QStringLiteral("none");
|
|
}
|
|
|
|
static bool presentationHotspotsEqual(const QVector<Project::PresentationHotspot>& a,
|
|
const QVector<Project::PresentationHotspot>& b) {
|
|
if (a.size() != b.size())
|
|
return false;
|
|
for (int i = 0; i < a.size(); ++i) {
|
|
if (a[i].id != b[i].id || a[i].targetAnimationId != b[i].targetAnimationId)
|
|
return false;
|
|
if (!qFuzzyCompare(static_cast<double>(a[i].centerWorld.x()),
|
|
static_cast<double>(b[i].centerWorld.x())) ||
|
|
!qFuzzyCompare(static_cast<double>(a[i].centerWorld.y()),
|
|
static_cast<double>(b[i].centerWorld.y()))) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
static QString firstNonNoneAnimationId(const Project& p) {
|
|
for (const auto& a : p.animations()) {
|
|
if (a.id != QStringLiteral("none"))
|
|
return a.id;
|
|
}
|
|
return {};
|
|
}
|
|
|
|
/// 移除热点里指向不存在动画方案的 targetAnimationId(回退为第一项非 none,若无则清空)
|
|
static void sanitizePresentationHotspotTargets(Project& p) {
|
|
const QString fb = firstNonNoneAnimationId(p);
|
|
QVector<Project::PresentationHotspot> hs = p.presentationHotspots();
|
|
bool changed = false;
|
|
for (auto& h : hs) {
|
|
if (h.targetAnimationId.isEmpty() || h.targetAnimationId == QStringLiteral("none"))
|
|
continue;
|
|
if (!p.findAnimationById(h.targetAnimationId)) {
|
|
h.targetAnimationId = fb;
|
|
changed = true;
|
|
}
|
|
}
|
|
if (changed)
|
|
p.setPresentationHotspots(hs);
|
|
}
|
|
|
|
QString sanitizedEntityIdForAsset(const QString& id) {
|
|
QString s = id;
|
|
const QString bad = QStringLiteral("\\/:*?\"<>|");
|
|
for (QChar c : bad) {
|
|
s.replace(c, QLatin1Char('_'));
|
|
}
|
|
return s.isEmpty() ? QStringLiteral("entity") : s;
|
|
}
|
|
|
|
bool writeBlackholeOverlayPng(const QString& projectDir, const QString& entityId,
|
|
const QString& previousOverlayRel, const QImage& patch,
|
|
const QRect& rectBg, QString* outRelativePath) {
|
|
if (projectDir.isEmpty() || patch.isNull() || !rectBg.isValid() || rectBg.width() <= 0 ||
|
|
rectBg.height() <= 0 || !outRelativePath) {
|
|
return false;
|
|
}
|
|
QImage p = patch;
|
|
if (p.size() != rectBg.size()) {
|
|
p = p.scaled(rectBg.size(), Qt::IgnoreAspectRatio, Qt::SmoothTransformation);
|
|
}
|
|
if (p.format() != QImage::Format_ARGB32_Premultiplied) {
|
|
p = p.convertToFormat(QImage::Format_ARGB32_Premultiplied);
|
|
}
|
|
|
|
const QString rel =
|
|
QStringLiteral("assets/blackhole_overlays/overlay-%1.png").arg(sanitizedEntityIdForAsset(entityId));
|
|
const QString abs = QDir(projectDir).filePath(rel);
|
|
QDir().mkpath(QFileInfo(abs).absolutePath());
|
|
|
|
if (!previousOverlayRel.isEmpty() && previousOverlayRel != rel) {
|
|
QFile::remove(QDir(projectDir).filePath(previousOverlayRel));
|
|
}
|
|
|
|
QImageWriter writer(abs);
|
|
writer.setFormat(QByteArrayLiteral("png"));
|
|
writer.setCompression(1);
|
|
if (!writer.write(p)) {
|
|
return false;
|
|
}
|
|
*outRelativePath = rel;
|
|
return true;
|
|
}
|
|
|
|
bool entityStaticEquals(const Project::Entity& a, const Project::Entity& b) {
|
|
return a.id == b.id &&
|
|
a.displayName == b.displayName &&
|
|
a.visible == b.visible &&
|
|
a.polygonLocal == b.polygonLocal &&
|
|
a.cutoutPolygonWorld == b.cutoutPolygonWorld &&
|
|
a.blackholeId == b.blackholeId &&
|
|
a.blackholeVisible == b.blackholeVisible &&
|
|
a.blackholeResolvedBy == b.blackholeResolvedBy &&
|
|
a.blackholeOverlayPath == b.blackholeOverlayPath &&
|
|
a.blackholeOverlayRect == b.blackholeOverlayRect &&
|
|
a.originWorld == b.originWorld &&
|
|
a.depth == b.depth &&
|
|
a.priority == b.priority &&
|
|
a.imagePath == b.imagePath &&
|
|
a.imageTopLeftWorld == b.imageTopLeftWorld &&
|
|
qFuzzyCompare(a.userScale + 1.0, b.userScale + 1.0) &&
|
|
qFuzzyCompare(a.distanceScaleCalibMult + 1.0, b.distanceScaleCalibMult + 1.0) &&
|
|
a.ignoreDistanceScale == b.ignoreDistanceScale &&
|
|
a.parentId == b.parentId &&
|
|
a.parentOffsetWorld == b.parentOffsetWorld &&
|
|
a.entityPayloadPath == b.entityPayloadPath &&
|
|
a.intro.title == b.intro.title &&
|
|
a.intro.bodyText == b.intro.bodyText &&
|
|
a.intro.imagePathsRelative == b.intro.imagePathsRelative &&
|
|
a.intro.videoPathRelative == b.intro.videoPathRelative;
|
|
}
|
|
|
|
//
|
|
void removeBlackholeOverlayFile(const QString& projectDir, const QString& rel) {
|
|
if (projectDir.isEmpty() || rel.isEmpty()) {
|
|
return;
|
|
}
|
|
QFile::remove(QDir(projectDir).filePath(rel));
|
|
}
|
|
|
|
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 resolvedOriginAtFrame(const Project& project, const QString& id, int frame) {
|
|
if (id.isEmpty()) {
|
|
return QPointF();
|
|
}
|
|
const auto rf = core::eval::evaluateAtFrame(project, frame, 10);
|
|
for (const auto& re : rf.entities) {
|
|
if (re.entity.id == id) {
|
|
return re.entity.originWorld;
|
|
}
|
|
}
|
|
for (const auto& rt : rf.tools) {
|
|
if (rt.tool.id == id) {
|
|
return rt.tool.originWorld;
|
|
}
|
|
}
|
|
return QPointF();
|
|
}
|
|
|
|
double depthToScale01Ws(int depthZ) {
|
|
const int d = std::clamp(depthZ, 0, 255);
|
|
return static_cast<double>(d) / 255.0;
|
|
}
|
|
|
|
double distanceScaleFromDepth01Ws(double depth01, double calibMult) {
|
|
const double d = std::clamp(depth01, 0.0, 1.0);
|
|
const double raw = 0.5 + d * 1.0;
|
|
if (calibMult > 0.0) {
|
|
return raw / std::max(calibMult, 1e-6);
|
|
}
|
|
return raw;
|
|
}
|
|
|
|
// scale = distanceScale * userScale
|
|
double combinedVisualScaleForEntity(const Project::Entity& e, int frame) {
|
|
const double ds01 = depthToScale01Ws(e.depth);
|
|
const double userScaleAnimated =
|
|
core::sampleUserScale(e.userScaleKeys, frame, e.userScale, core::KeyInterpolation::Linear);
|
|
const double distScale =
|
|
e.ignoreDistanceScale ? 1.0 : distanceScaleFromDepth01Ws(ds01, e.distanceScaleCalibMult);
|
|
return distScale * std::max(1e-6, userScaleAnimated);
|
|
}
|
|
|
|
QPointF entityPolygonCentroidWorld(const Project& project, const Project::Entity& e, int frame, double sTotal) {
|
|
const QPointF O = resolvedOriginAtFrame(project, e.id, frame);
|
|
QVector<QPointF> w;
|
|
w.reserve(e.polygonLocal.size());
|
|
for (const QPointF& lp : e.polygonLocal) {
|
|
w.push_back(O + lp * sTotal);
|
|
}
|
|
return polygonCentroidFromWorldPoints(w);
|
|
}
|
|
|
|
namespace {
|
|
|
|
/// 将采样原点与多边形形心对齐;保持世界几何与贴图不变(与 reanchorEntityPivot 同类变换)。
|
|
/// 有父级时跳过:基准量分布在 parentOffset/轨道上,需单独处理链式更新。
|
|
bool snapEntityOriginToCentroidInPlace(const Project& project, Project::Entity& e, int frame, double sTotal) {
|
|
if (!e.parentId.isEmpty()) {
|
|
return false;
|
|
}
|
|
if (e.polygonLocal.size() < 3 || sTotal <= 1e-9) {
|
|
return false;
|
|
}
|
|
const QPointF O_anim = resolvedOriginAtFrame(project, e.id, frame);
|
|
QVector<QPointF> polyWorld;
|
|
polyWorld.reserve(e.polygonLocal.size());
|
|
for (const QPointF& lp : e.polygonLocal) {
|
|
polyWorld.push_back(O_anim + lp * sTotal);
|
|
}
|
|
const QPointF C = polygonCentroidFromWorldPoints(polyWorld);
|
|
if (QLineF(O_anim, C).length() < 0.25) {
|
|
return false;
|
|
}
|
|
const QPointF I_disp = O_anim + (e.imageTopLeftWorld - e.originWorld) * sTotal;
|
|
const QPointF d = C - O_anim;
|
|
|
|
QVector<QPointF> newLocal;
|
|
newLocal.reserve(polyWorld.size());
|
|
for (const QPointF& p : polyWorld) {
|
|
newLocal.push_back((p - C) / sTotal);
|
|
}
|
|
|
|
for (auto& k : e.locationKeys) {
|
|
k.value += d;
|
|
}
|
|
e.originWorld += d;
|
|
e.polygonLocal = std::move(newLocal);
|
|
e.imageTopLeftWorld = e.originWorld + (I_disp - C) / sTotal;
|
|
return true;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
|
|
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 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 QString animationTrackId(Project::AnimationTargetKind kind,
|
|
const QString& targetId,
|
|
Project::AnimationProperty property) {
|
|
auto kindName = [](Project::AnimationTargetKind k) {
|
|
switch (k) {
|
|
case Project::AnimationTargetKind::Entity: return QStringLiteral("entity");
|
|
case Project::AnimationTargetKind::Tool: return QStringLiteral("tool");
|
|
case Project::AnimationTargetKind::Camera: return QStringLiteral("camera");
|
|
}
|
|
return QStringLiteral("entity");
|
|
};
|
|
auto propName = [](Project::AnimationProperty p) {
|
|
switch (p) {
|
|
case Project::AnimationProperty::Position: return QStringLiteral("position");
|
|
case Project::AnimationProperty::UserScale: return QStringLiteral("userScale");
|
|
case Project::AnimationProperty::Sprite: return QStringLiteral("sprite");
|
|
case Project::AnimationProperty::Visibility: return QStringLiteral("visibility");
|
|
case Project::AnimationProperty::DepthScale: return QStringLiteral("depthScale");
|
|
case Project::AnimationProperty::CameraScale: return QStringLiteral("cameraScale");
|
|
case Project::AnimationProperty::Priority: return QStringLiteral("priority");
|
|
}
|
|
return QStringLiteral("property");
|
|
};
|
|
return kindName(kind) + QStringLiteral(":") + targetId + QStringLiteral(":") + propName(property);
|
|
}
|
|
|
|
static Project::Animation* activeAnimationOrCreate(Project& project) {
|
|
if (Project::Animation* existing = project.activeAnimationOrNull()) {
|
|
return existing;
|
|
}
|
|
|
|
// 默认动画:无(静态),仅 1 帧。其存在用于“动画系统外”的静态编辑基底。
|
|
Project::Animation none;
|
|
none.id = QStringLiteral("none");
|
|
none.name = QStringLiteral("无");
|
|
none.fps = std::max(1, project.fps());
|
|
none.lengthFrames = 1;
|
|
none.loop = false;
|
|
project.setAnimations({none});
|
|
project.setActiveAnimationId(none.id);
|
|
return project.activeAnimationOrNull();
|
|
}
|
|
|
|
static Project::AnimationTrack* findOrCreateTrack(Project::Animation& anim,
|
|
Project::AnimationTargetKind kind,
|
|
const QString& targetId,
|
|
Project::AnimationProperty property,
|
|
Project::AnimationValueType valueType,
|
|
Project::AnimationInterpolation interpolation) {
|
|
for (auto& t : anim.tracks) {
|
|
if (t.targetKind == kind && t.targetId == targetId && t.property == property) {
|
|
t.valueType = valueType;
|
|
t.interpolation = interpolation;
|
|
return &t;
|
|
}
|
|
}
|
|
|
|
Project::AnimationTrack t;
|
|
t.id = animationTrackId(kind, targetId, property);
|
|
t.targetKind = kind;
|
|
t.targetId = targetId;
|
|
t.property = property;
|
|
t.valueType = valueType;
|
|
t.interpolation = interpolation;
|
|
anim.tracks.push_back(t);
|
|
return &anim.tracks.last();
|
|
}
|
|
|
|
static void upsertAnimKey(Project::AnimationTrack& track, int frame, const QPointF& value) {
|
|
for (auto& k : track.keys) {
|
|
if (k.frame == frame) {
|
|
k.vec2Value = value;
|
|
return;
|
|
}
|
|
}
|
|
Project::AnimationKey k;
|
|
k.frame = frame;
|
|
k.vec2Value = value;
|
|
track.keys.push_back(k);
|
|
}
|
|
|
|
static void upsertAnimKey(Project::AnimationTrack& track, int frame, double value) {
|
|
for (auto& k : track.keys) {
|
|
if (k.frame == frame) {
|
|
k.numberValue = value;
|
|
return;
|
|
}
|
|
}
|
|
Project::AnimationKey k;
|
|
k.frame = frame;
|
|
k.numberValue = value;
|
|
track.keys.push_back(k);
|
|
}
|
|
|
|
static void upsertAnimKey(Project::AnimationTrack& track, int frame, bool value) {
|
|
for (auto& k : track.keys) {
|
|
if (k.frame == frame) {
|
|
k.boolValue = value;
|
|
return;
|
|
}
|
|
}
|
|
Project::AnimationKey k;
|
|
k.frame = frame;
|
|
k.boolValue = value;
|
|
track.keys.push_back(k);
|
|
}
|
|
|
|
static void upsertAnimKey(Project::AnimationTrack& track, int frame, const QString& value) {
|
|
for (auto& k : track.keys) {
|
|
if (k.frame == frame) {
|
|
k.stringValue = value;
|
|
return;
|
|
}
|
|
}
|
|
Project::AnimationKey k;
|
|
k.frame = frame;
|
|
k.stringValue = value;
|
|
track.keys.push_back(k);
|
|
}
|
|
|
|
static void upsertAnimKey(Project::AnimationTrack& track, int frame, const QByteArray& value) {
|
|
for (auto& k : track.keys) {
|
|
if (k.frame == frame) {
|
|
k.bytesValue = value;
|
|
return;
|
|
}
|
|
}
|
|
Project::AnimationKey k;
|
|
k.frame = frame;
|
|
k.bytesValue = value;
|
|
track.keys.push_back(k);
|
|
}
|
|
|
|
static void pruneEmptyAnimationTracks(Project::Animation& anim) {
|
|
anim.tracks.erase(
|
|
std::remove_if(anim.tracks.begin(),
|
|
anim.tracks.end(),
|
|
[](const Project::AnimationTrack& track) {
|
|
return track.targetId.isEmpty() || track.keys.isEmpty();
|
|
}),
|
|
anim.tracks.end());
|
|
}
|
|
|
|
/// 平移轨道里存储的偏移:根实体为世界坐标增量,子实体为相对父坐标增量(父未动时与世界增量一致)。
|
|
static bool shiftEntityPositionAnimKeys(QVector<Project::Animation>& anims, const QString& entityId, const QPointF& delta) {
|
|
bool any = false;
|
|
for (auto& anim : anims) {
|
|
for (auto& t : anim.tracks) {
|
|
if (t.targetKind != Project::AnimationTargetKind::Entity || t.targetId != entityId) {
|
|
continue;
|
|
}
|
|
if (t.property != Project::AnimationProperty::Position) {
|
|
continue;
|
|
}
|
|
if (t.valueType != Project::AnimationValueType::Vec2 || t.keys.isEmpty()) {
|
|
continue;
|
|
}
|
|
for (auto& k : t.keys) {
|
|
k.vec2Value += delta;
|
|
}
|
|
any = true;
|
|
}
|
|
}
|
|
return any;
|
|
}
|
|
|
|
} // 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::ensureAnimationsDir() const {
|
|
const auto assets = assetsDirPath();
|
|
if (assets.isEmpty()) {
|
|
return {};
|
|
}
|
|
return ensureDir(QDir(assets).filePath(QStringLiteral("animations")));
|
|
}
|
|
|
|
QString ProjectWorkspace::defaultAnimationPayloadPath(const QString& animationId) const {
|
|
const QString id = sanitizedEntityIdForAsset(animationId.isEmpty() ? QStringLiteral("animation") : animationId);
|
|
return core::persistence::AnimationBundleStore::bundleRelativeDir(id);
|
|
}
|
|
|
|
void ProjectWorkspace::markIndexDirty() {
|
|
m_indexDirty = true;
|
|
}
|
|
|
|
void ProjectWorkspace::markEntityDirty(const QString& id, const Project::Entity* snapshot) {
|
|
if (!id.isEmpty()) {
|
|
m_dirtyEntityIds.insert(id);
|
|
}
|
|
// 后台线程落盘 bundle(与 project.json 解耦);显式 save/close 仍会 flush + 写索引。
|
|
if (m_projectDir.isEmpty() || id.isEmpty()) {
|
|
return;
|
|
}
|
|
if (!m_asyncWriter) {
|
|
m_asyncWriter = new core::persistence::AsyncProjectWriter();
|
|
m_asyncWriter->setProjectDir(m_projectDir);
|
|
}
|
|
if (snapshot && snapshot->id == id) {
|
|
m_asyncWriter->requestWriteEntity(*snapshot);
|
|
return;
|
|
}
|
|
for (const auto& e : m_project.entities()) {
|
|
if (e.id == id) {
|
|
m_asyncWriter->requestWriteEntity(e);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
void ProjectWorkspace::markAnimationDirty(const QString& id) {
|
|
if (!id.isEmpty()) {
|
|
m_dirtyAnimationIds.insert(id);
|
|
}
|
|
if (m_projectDir.isEmpty() || id.isEmpty()) {
|
|
return;
|
|
}
|
|
if (!m_asyncWriter) {
|
|
m_asyncWriter = new core::persistence::AsyncProjectWriter();
|
|
m_asyncWriter->setProjectDir(m_projectDir);
|
|
}
|
|
for (const auto& a : m_project.animations()) {
|
|
if (a.id == id) {
|
|
m_asyncWriter->requestWriteAnimation(a);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
void ProjectWorkspace::markAllEntitiesDirty() {
|
|
for (const auto& e : m_project.entities()) {
|
|
markEntityDirty(e.id);
|
|
}
|
|
}
|
|
|
|
void ProjectWorkspace::markAllAnimationsDirty() {
|
|
for (const auto& a : m_project.animations()) {
|
|
markAnimationDirty(a.id);
|
|
}
|
|
}
|
|
|
|
void ProjectWorkspace::schedulePayloadSync() {
|
|
if (m_projectDir.isEmpty()) {
|
|
return;
|
|
}
|
|
if (!m_payloadSyncTimer) {
|
|
m_payloadSyncTimer = new QTimer();
|
|
m_payloadSyncTimer->setSingleShot(true);
|
|
QObject::connect(m_payloadSyncTimer, &QTimer::timeout, [this]() {
|
|
(void)syncPayloadsToDiskOnce();
|
|
});
|
|
}
|
|
m_payloadSyncTimer->start(400);
|
|
}
|
|
|
|
bool ProjectWorkspace::syncPayloadsToDiskOnce() {
|
|
if (m_projectDir.isEmpty()) {
|
|
return false;
|
|
}
|
|
const bool okEnt = syncEntityPayloadsToDisk();
|
|
const bool okAnim = syncAnimationPayloadsToDisk();
|
|
if (!okEnt || !okAnim) {
|
|
return false;
|
|
}
|
|
if (m_asyncWriter) {
|
|
m_asyncWriter->flushBlocking(60000);
|
|
}
|
|
// payload sync 可能修正 payloadPath/entityPayloadPath,需要再写一次 index
|
|
return writeIndexJsonWithoutPayloadSync();
|
|
}
|
|
|
|
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;
|
|
}
|
|
if (!m_asyncWriter) {
|
|
m_asyncWriter = new core::persistence::AsyncProjectWriter();
|
|
}
|
|
m_asyncWriter->setProjectDir(m_projectDir);
|
|
|
|
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;
|
|
}
|
|
|
|
// 默认只创建“无”(静态)动画作为基底:仅 1 帧
|
|
Project::Animation none;
|
|
none.id = QStringLiteral("none");
|
|
none.name = QStringLiteral("无");
|
|
none.payloadPath = core::persistence::AnimationBundleStore::bundleRelativeDir(none.id);
|
|
none.fps = std::max(1, m_project.fps());
|
|
none.lengthFrames = 1;
|
|
none.loop = false;
|
|
m_project.setAnimations({none});
|
|
m_project.setActiveAnimationId(none.id);
|
|
markAnimationDirty(none.id);
|
|
markIndexDirty();
|
|
|
|
if (!ensureDefaultCameraIfMissing(nullptr)) {
|
|
return false;
|
|
}
|
|
markAllEntitiesDirty();
|
|
return writeIndexJson();
|
|
}
|
|
|
|
bool ProjectWorkspace::ensureDefaultCameraIfMissing(bool* outAdded) {
|
|
if (m_projectDir.isEmpty()) {
|
|
return false;
|
|
}
|
|
if (!m_project.cameras().isEmpty()) {
|
|
if (outAdded) {
|
|
*outAdded = false;
|
|
}
|
|
return true;
|
|
}
|
|
Project::Camera c;
|
|
c.id = QStringLiteral("camera-default");
|
|
c.displayName = QStringLiteral("主摄像机");
|
|
c.visible = true;
|
|
const QString bgAbs = backgroundAbsolutePath();
|
|
QSize sz;
|
|
if (!bgAbs.isEmpty() && QFileInfo::exists(bgAbs)) {
|
|
if (!image_file::probeImagePixelSize(bgAbs, &sz)) {
|
|
sz = QSize();
|
|
}
|
|
}
|
|
if (!sz.isValid() || sz.width() < 1 || sz.height() < 1) {
|
|
c.centerWorld = QPointF(512.0, 384.0);
|
|
c.viewScale = 1.0;
|
|
} else {
|
|
c.centerWorld = QPointF(sz.width() * 0.5, sz.height() * 0.5);
|
|
// 参考视口下「适配整张背景」的像素/世界单位比,与画布 zoomToFit 语义一致
|
|
constexpr double kRefViewportW = 1600.0;
|
|
constexpr double kRefViewportH = 900.0;
|
|
const double sw = kRefViewportW / static_cast<double>(sz.width());
|
|
const double sh = kRefViewportH / static_cast<double>(sz.height());
|
|
c.viewScale = std::clamp(std::min(sw, sh), 1e-6, 1e3);
|
|
}
|
|
m_project.setCameras({c});
|
|
if (m_project.activeCameraId().isEmpty()) {
|
|
m_project.setActiveCameraId(c.id);
|
|
}
|
|
if (outAdded) {
|
|
*outAdded = true;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
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 (!m_asyncWriter) {
|
|
m_asyncWriter = new core::persistence::AsyncProjectWriter();
|
|
}
|
|
m_asyncWriter->setProjectDir(m_projectDir);
|
|
if (!readIndexJson(indexPath)) {
|
|
m_projectDir = prevDir;
|
|
m_project = prevProject;
|
|
return false;
|
|
}
|
|
|
|
// 不做任何自动迁移/兼容:工程必须是当前版本(v7:目录 bundle payload + index)。
|
|
|
|
// readIndexJson 会写入 m_project;这里补齐历史初始化
|
|
ensureDir(assetsDirPath());
|
|
m_undoStack.clear();
|
|
m_redoStack.clear();
|
|
|
|
bool cameraAdded = false;
|
|
if (!ensureDefaultCameraIfMissing(&cameraAdded)) {
|
|
return false;
|
|
}
|
|
if (cameraAdded && !writeIndexJson()) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
void ProjectWorkspace::close() {
|
|
if (m_asyncWriter) {
|
|
m_asyncWriter->flushBlocking(15000);
|
|
}
|
|
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::saveAll() {
|
|
if (m_projectDir.isEmpty()) {
|
|
return false;
|
|
}
|
|
markAllEntitiesDirty();
|
|
markAllAnimationsDirty();
|
|
markIndexDirty();
|
|
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::SetCameras) {
|
|
if (!applyCameras(op.beforeCameras, false, QString())) {
|
|
m_undoStack.push_back(op);
|
|
return false;
|
|
}
|
|
} else if (op.type == Operation::Type::SetAnimations) {
|
|
const auto snapAnims = m_project.animations();
|
|
const auto snapHs = m_project.presentationHotspots();
|
|
m_project.setAnimations(op.beforeAnimations);
|
|
if (op.animBundlesPresentationHotspots) {
|
|
m_project.setPresentationHotspots(op.animSnapshotBeforePresentationHotspots);
|
|
}
|
|
markIndexDirty();
|
|
markAllAnimationsDirty();
|
|
if (!writeIndexJsonWithoutPayloadSync()) {
|
|
m_project.setAnimations(snapAnims);
|
|
m_project.setPresentationHotspots(snapHs);
|
|
m_undoStack.push_back(op);
|
|
return false;
|
|
}
|
|
} else if (op.type == Operation::Type::SetPresentationHotspots) {
|
|
const auto cur = m_project.presentationHotspots();
|
|
m_project.setPresentationHotspots(op.beforePresentationHotspots);
|
|
markIndexDirty();
|
|
if (!writeIndexJsonWithoutPayloadSync()) {
|
|
m_project.setPresentationHotspots(cur);
|
|
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::SetCameras) {
|
|
if (!applyCameras(op.afterCameras, false, QString())) {
|
|
m_redoStack.push_back(op);
|
|
return false;
|
|
}
|
|
} else if (op.type == Operation::Type::SetAnimations) {
|
|
const auto snapAnims = m_project.animations();
|
|
const auto snapHs = m_project.presentationHotspots();
|
|
m_project.setAnimations(op.afterAnimations);
|
|
if (op.animBundlesPresentationHotspots) {
|
|
m_project.setPresentationHotspots(op.animSnapshotAfterPresentationHotspots);
|
|
}
|
|
markIndexDirty();
|
|
markAllAnimationsDirty();
|
|
if (!writeIndexJsonWithoutPayloadSync()) {
|
|
m_project.setAnimations(snapAnims);
|
|
m_project.setPresentationHotspots(snapHs);
|
|
m_redoStack.push_back(op);
|
|
return false;
|
|
}
|
|
} else if (op.type == Operation::Type::SetPresentationHotspots) {
|
|
const auto cur = m_project.presentationHotspots();
|
|
m_project.setPresentationHotspots(op.afterPresentationHotspots);
|
|
markIndexDirty();
|
|
if (!writeIndexJsonWithoutPayloadSync()) {
|
|
m_project.setPresentationHotspots(cur);
|
|
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() {
|
|
// 显式保存:入队 payload → flush → 原子写索引,避免队列未刷完就与索引不一致。
|
|
if (!m_projectDir.isEmpty() && !syncEntityPayloadsToDisk()) {
|
|
return false;
|
|
}
|
|
if (!m_projectDir.isEmpty() && !syncAnimationPayloadsToDisk()) {
|
|
return false;
|
|
}
|
|
if (m_asyncWriter) {
|
|
m_asyncWriter->flushBlocking(60000);
|
|
}
|
|
return writeIndexJsonWithoutPayloadSync();
|
|
}
|
|
|
|
bool ProjectWorkspace::writeIndexJsonWithoutPayloadSync() {
|
|
const auto root = projectToJson(m_project);
|
|
QJsonDocument doc(root);
|
|
|
|
const QString destAbs = indexFilePath();
|
|
if (!core::persistence::writeBytesAtomic(destAbs, doc.toJson(QJsonDocument::Indented))) {
|
|
return false;
|
|
}
|
|
m_indexDirty = false;
|
|
return true;
|
|
}
|
|
|
|
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 (!hydrateEntityPayloadsFromDisk()) {
|
|
return false;
|
|
}
|
|
if (!hydrateAnimationPayloadsFromDisk()) {
|
|
return false;
|
|
}
|
|
m_dirtyEntityIds.clear();
|
|
m_dirtyAnimationIds.clear();
|
|
m_indexDirty = 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);
|
|
|
|
QJsonArray cams;
|
|
for (const auto& c : project.cameras()) {
|
|
cams.append(cameraToJson(c));
|
|
}
|
|
root.insert("cameras", cams);
|
|
root.insert("activeCameraId", project.activeCameraId());
|
|
|
|
root.insert("activeAnimationId", project.activeAnimationId());
|
|
QJsonArray animations;
|
|
for (const auto& a : project.animations()) {
|
|
QJsonObject ao;
|
|
ao.insert("id", a.id);
|
|
ao.insert("name", a.name);
|
|
ao.insert("path", a.payloadPath);
|
|
ao.insert("fps", a.fps);
|
|
ao.insert("lengthFrames", a.lengthFrames);
|
|
ao.insert("loop", a.loop);
|
|
animations.append(ao);
|
|
}
|
|
root.insert("animations", animations);
|
|
|
|
QJsonArray hots;
|
|
for (const auto& h : project.presentationHotspots()) {
|
|
QJsonObject ho;
|
|
ho.insert(QStringLiteral("id"), h.id);
|
|
ho.insert(QStringLiteral("cx"), h.centerWorld.x());
|
|
ho.insert(QStringLiteral("cy"), h.centerWorld.y());
|
|
ho.insert(QStringLiteral("targetAnimationId"), h.targetAnimationId);
|
|
hots.append(ho);
|
|
}
|
|
root.insert(QStringLiteral("presentationHotspots"), hots);
|
|
|
|
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 != 7) {
|
|
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 (!entityStubFromJsonV2(v.toObject(), e)) {
|
|
return false;
|
|
}
|
|
entities.push_back(e);
|
|
}
|
|
}
|
|
outProject.setEntities(entities);
|
|
|
|
QVector<Project::Tool> tools;
|
|
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);
|
|
|
|
QVector<Project::Camera> cameras;
|
|
const auto camsVal = root.value("cameras");
|
|
if (camsVal.isArray()) {
|
|
const QJsonArray arr = camsVal.toArray();
|
|
cameras.reserve(arr.size());
|
|
for (const auto& v : arr) {
|
|
if (!v.isObject()) continue;
|
|
Project::Camera c;
|
|
if (cameraFromJsonV4(v.toObject(), c)) {
|
|
cameras.push_back(c);
|
|
}
|
|
}
|
|
}
|
|
outProject.setCameras(cameras);
|
|
outProject.setActiveCameraId(root.value("activeCameraId").toString());
|
|
|
|
QVector<Project::Animation> animations;
|
|
const auto animVal = root.value("animations");
|
|
if (animVal.isArray()) {
|
|
for (const auto& av : animVal.toArray()) {
|
|
if (!av.isObject()) continue;
|
|
const QJsonObject ao = av.toObject();
|
|
Project::Animation a;
|
|
a.id = ao.value("id").toString();
|
|
a.name = ao.value("name").toString();
|
|
a.payloadPath = asRelativeUnderProject(ao.value("path").toString());
|
|
a.fps = std::max(1, ao.value("fps").toInt(30));
|
|
a.lengthFrames = std::max(1, ao.value("lengthFrames").toInt(Project::kClipFixedFrames));
|
|
a.loop = ao.value("loop").toBool(true);
|
|
if (a.id.isEmpty() || a.payloadPath.isEmpty()) {
|
|
return false;
|
|
}
|
|
animations.push_back(a);
|
|
}
|
|
}
|
|
outProject.setAnimations(animations);
|
|
outProject.setActiveAnimationId(root.value("activeAnimationId").toString());
|
|
|
|
QVector<Project::PresentationHotspot> hotspots;
|
|
const auto hotVal = root.value(QStringLiteral("presentationHotspots"));
|
|
if (hotVal.isArray()) {
|
|
const QJsonArray arr = hotVal.toArray();
|
|
hotspots.reserve(arr.size());
|
|
for (const auto& v : arr) {
|
|
if (!v.isObject())
|
|
continue;
|
|
const QJsonObject ho = v.toObject();
|
|
Project::PresentationHotspot ph;
|
|
ph.id = ho.value(QStringLiteral("id")).toString();
|
|
ph.centerWorld =
|
|
QPointF(ho.value(QStringLiteral("cx")).toDouble(), ho.value(QStringLiteral("cy")).toDouble());
|
|
ph.targetAnimationId = ho.value(QStringLiteral("targetAnimationId")).toString();
|
|
if (!ph.id.isEmpty()) {
|
|
hotspots.push_back(ph);
|
|
}
|
|
}
|
|
}
|
|
outProject.setPresentationHotspots(hotspots);
|
|
|
|
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("priority", t.priority);
|
|
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));
|
|
t.priority = o.value("priority").toInt(10);
|
|
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;
|
|
}
|
|
|
|
QJsonObject ProjectWorkspace::cameraToJson(const Project::Camera& c) {
|
|
QJsonObject o;
|
|
o.insert("id", c.id);
|
|
o.insert("displayName", c.displayName);
|
|
o.insert("visible", c.visible);
|
|
o.insert("centerX", c.centerWorld.x());
|
|
o.insert("centerY", c.centerWorld.y());
|
|
o.insert("viewScale", c.viewScale);
|
|
QJsonArray loc;
|
|
for (const auto& k : c.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);
|
|
QJsonArray sc;
|
|
for (const auto& k : c.scaleKeys) {
|
|
QJsonObject ko;
|
|
ko.insert("frame", k.frame);
|
|
ko.insert("value", k.value);
|
|
sc.append(ko);
|
|
}
|
|
o.insert("scaleKeys", sc);
|
|
return o;
|
|
}
|
|
|
|
bool ProjectWorkspace::cameraFromJsonV4(const QJsonObject& o, Project::Camera& out) {
|
|
Project::Camera c;
|
|
c.id = o.value("id").toString();
|
|
if (c.id.isEmpty()) {
|
|
return false;
|
|
}
|
|
c.displayName = o.value("displayName").toString();
|
|
c.visible = o.value("visible").toBool(true);
|
|
c.centerWorld = QPointF(o.value("centerX").toDouble(0.0), o.value("centerY").toDouble(0.0));
|
|
c.viewScale = std::clamp(o.value("viewScale").toDouble(1.0), 1e-6, 1e3);
|
|
c.locationKeys.clear();
|
|
const auto locVal = o.value("locationKeys");
|
|
if (locVal.isArray()) {
|
|
const QJsonArray arr = locVal.toArray();
|
|
c.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));
|
|
c.locationKeys.push_back(k);
|
|
}
|
|
}
|
|
c.scaleKeys.clear();
|
|
const auto scVal = o.value("scaleKeys");
|
|
if (scVal.isArray()) {
|
|
const QJsonArray arr = scVal.toArray();
|
|
c.scaleKeys.reserve(arr.size());
|
|
for (const auto& v : arr) {
|
|
if (!v.isObject()) continue;
|
|
const auto ko = v.toObject();
|
|
Project::Entity::KeyframeDouble k;
|
|
k.frame = ko.value("frame").toInt(0);
|
|
k.value = std::clamp(ko.value("value").toDouble(1.0), 1e-6, 1e3);
|
|
c.scaleKeys.push_back(k);
|
|
}
|
|
}
|
|
out = std::move(c);
|
|
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);
|
|
out.blackholeVisible = true;
|
|
out.blackholeId = out.id.isEmpty() ? QString() : QStringLiteral("blackhole-%1").arg(out.id);
|
|
out.blackholeResolvedBy = QStringLiteral("pending");
|
|
if (out.id.isEmpty() || out.entityPayloadPath.isEmpty()) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// 不再支持 v1 entity JSON(无兼容/无迁移)。
|
|
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);
|
|
markIndexDirty();
|
|
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();
|
|
QHash<QString, Project::Entity> beforeById;
|
|
beforeById.reserve(before.size());
|
|
for (const auto& e : before) {
|
|
beforeById.insert(e.id, e);
|
|
}
|
|
for (const auto& e : entities) {
|
|
if (!beforeById.contains(e.id) || !entityStaticEquals(beforeById.value(e.id), e)) {
|
|
markEntityDirty(e.id, &e);
|
|
}
|
|
}
|
|
if (before.size() != entities.size()) {
|
|
markIndexDirty();
|
|
}
|
|
m_project.setEntities(entities);
|
|
if (!writeIndexJsonWithoutPayloadSync()) {
|
|
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);
|
|
markIndexDirty();
|
|
if (!writeIndexJsonWithoutPayloadSync()) {
|
|
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;
|
|
}
|
|
|
|
bool ProjectWorkspace::applyCameras(const QVector<Project::Camera>& cameras,
|
|
bool recordHistory,
|
|
const QString& label) {
|
|
const auto before = m_project.cameras();
|
|
m_project.setCameras(cameras);
|
|
markIndexDirty();
|
|
if (!writeIndexJsonWithoutPayloadSync()) {
|
|
m_project.setCameras(before);
|
|
return false;
|
|
}
|
|
if (recordHistory) {
|
|
Operation op;
|
|
op.type = Operation::Type::SetCameras;
|
|
op.label = label;
|
|
op.beforeCameras = before;
|
|
op.afterCameras = cameras;
|
|
pushOperation(op);
|
|
m_redoStack.clear();
|
|
}
|
|
return true;
|
|
}
|
|
|
|
bool ProjectWorkspace::applyAnimations(const QVector<Project::Animation>& animations,
|
|
bool recordHistory,
|
|
const QString& label) {
|
|
const auto beforeAnims = m_project.animations();
|
|
const auto beforeHs = m_project.presentationHotspots();
|
|
m_project.setAnimations(animations);
|
|
sanitizePresentationHotspotTargets(m_project);
|
|
const auto afterHs = m_project.presentationHotspots();
|
|
const bool hotspotsTouched = !presentationHotspotsEqual(beforeHs, afterHs);
|
|
markIndexDirty();
|
|
markAllAnimationsDirty();
|
|
if (!writeIndexJsonWithoutPayloadSync()) {
|
|
m_project.setAnimations(beforeAnims);
|
|
m_project.setPresentationHotspots(beforeHs);
|
|
return false;
|
|
}
|
|
if (recordHistory) {
|
|
Operation op;
|
|
op.type = Operation::Type::SetAnimations;
|
|
op.label = label;
|
|
op.beforeAnimations = beforeAnims;
|
|
op.afterAnimations = animations;
|
|
op.animBundlesPresentationHotspots = hotspotsTouched;
|
|
if (hotspotsTouched) {
|
|
op.animSnapshotBeforePresentationHotspots = beforeHs;
|
|
op.animSnapshotAfterPresentationHotspots = afterHs;
|
|
}
|
|
pushOperation(op);
|
|
m_redoStack.clear();
|
|
}
|
|
return true;
|
|
}
|
|
|
|
bool ProjectWorkspace::applyPresentationHotspots(const QVector<Project::PresentationHotspot>& hotspots,
|
|
bool recordHistory,
|
|
const QString& label) {
|
|
if (!isOpen())
|
|
return false;
|
|
const auto before = m_project.presentationHotspots();
|
|
m_project.setPresentationHotspots(hotspots);
|
|
markIndexDirty();
|
|
if (!writeIndexJsonWithoutPayloadSync()) {
|
|
m_project.setPresentationHotspots(before);
|
|
return false;
|
|
}
|
|
if (recordHistory) {
|
|
Operation op;
|
|
op.type = Operation::Type::SetPresentationHotspots;
|
|
op.label = label;
|
|
op.beforePresentationHotspots = before;
|
|
op.afterPresentationHotspots = hotspots;
|
|
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 pathsChanged = false;
|
|
if (!m_asyncWriter && !m_projectDir.isEmpty()) {
|
|
m_asyncWriter = new core::persistence::AsyncProjectWriter();
|
|
m_asyncWriter->setProjectDir(m_projectDir);
|
|
}
|
|
for (auto& e : ents) {
|
|
if (e.entityPayloadPath.isEmpty()) {
|
|
e.entityPayloadPath = core::persistence::EntityBundleStore::bundleRelativeDir(e.id);
|
|
pathsChanged = true;
|
|
} else if (e.entityPayloadPath.endsWith(QStringLiteral(".hfe"), Qt::CaseInsensitive)) {
|
|
// 与 v7 bundle 目录布局对齐(曾误写为单文件 .hfe)
|
|
e.entityPayloadPath = core::persistence::EntityBundleStore::bundleRelativeDir(e.id);
|
|
pathsChanged = true;
|
|
}
|
|
const QString rel = asRelativeUnderProject(e.entityPayloadPath);
|
|
if (rel.isEmpty()) {
|
|
return false;
|
|
}
|
|
if (rel != e.entityPayloadPath) {
|
|
e.entityPayloadPath = rel;
|
|
pathsChanged = true;
|
|
}
|
|
|
|
const bool dirty = m_dirtyEntityIds.contains(e.id);
|
|
if (dirty && m_asyncWriter) {
|
|
m_asyncWriter->requestWriteEntity(e);
|
|
}
|
|
}
|
|
if (pathsChanged) {
|
|
m_project.setEntities(ents);
|
|
markIndexDirty();
|
|
}
|
|
m_dirtyEntityIds.clear();
|
|
return true;
|
|
}
|
|
|
|
bool ProjectWorkspace::syncAnimationPayloadsToDisk() {
|
|
if (ensureAnimationsDir().isEmpty()) {
|
|
return false;
|
|
}
|
|
auto animations = m_project.animations();
|
|
bool pathsChanged = false;
|
|
if (!m_asyncWriter && !m_projectDir.isEmpty()) {
|
|
m_asyncWriter = new core::persistence::AsyncProjectWriter();
|
|
m_asyncWriter->setProjectDir(m_projectDir);
|
|
}
|
|
for (auto& a : animations) {
|
|
if (a.id.isEmpty()) {
|
|
return false;
|
|
}
|
|
if (a.payloadPath.isEmpty()) {
|
|
a.payloadPath = core::persistence::AnimationBundleStore::bundleRelativeDir(a.id);
|
|
pathsChanged = true;
|
|
m_dirtyAnimationIds.insert(a.id);
|
|
}
|
|
const QString rel = asRelativeUnderProject(a.payloadPath);
|
|
if (rel.isEmpty()) {
|
|
return false;
|
|
}
|
|
if (rel != a.payloadPath) {
|
|
a.payloadPath = rel;
|
|
pathsChanged = true;
|
|
m_dirtyAnimationIds.insert(a.id);
|
|
}
|
|
if (m_dirtyAnimationIds.contains(a.id) && m_asyncWriter) {
|
|
m_asyncWriter->requestWriteAnimation(a);
|
|
}
|
|
}
|
|
if (pathsChanged) {
|
|
m_project.setAnimations(animations);
|
|
markIndexDirty();
|
|
}
|
|
m_dirtyAnimationIds.clear();
|
|
return true;
|
|
}
|
|
|
|
bool ProjectWorkspace::saveSingleEntityPayload(Project::Entity& entity) {
|
|
if (m_projectDir.isEmpty()) {
|
|
return false;
|
|
}
|
|
if (ensureEntitiesDir().isEmpty()) {
|
|
return false;
|
|
}
|
|
if (entity.entityPayloadPath.isEmpty()) {
|
|
entity.entityPayloadPath = core::persistence::EntityBundleStore::bundleRelativeDir(entity.id);
|
|
}
|
|
const QString rel = asRelativeUnderProject(entity.entityPayloadPath);
|
|
if (rel.isEmpty()) {
|
|
return false;
|
|
}
|
|
entity.entityPayloadPath = rel;
|
|
return core::persistence::EntityBundleStore::save(m_projectDir, entity);
|
|
}
|
|
|
|
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;
|
|
}
|
|
e.entityPayloadPath = rel;
|
|
|
|
// 曾错误地把 payload 写成 *.hfe 单文件路径,而 bundle 实际写在 assets/entities/<id>/ 目录下。
|
|
QString tryRel = rel;
|
|
if (!core::persistence::EntityBundleStore::load(m_projectDir, e)) {
|
|
if (tryRel.endsWith(QStringLiteral(".hfe"), Qt::CaseInsensitive)) {
|
|
tryRel = core::persistence::EntityBundleStore::bundleRelativeDir(expectId);
|
|
tryRel = asRelativeUnderProject(tryRel);
|
|
if (!tryRel.isEmpty()) {
|
|
e.entityPayloadPath = tryRel;
|
|
if (!core::persistence::EntityBundleStore::load(m_projectDir, e)) {
|
|
return false;
|
|
}
|
|
} else {
|
|
return false;
|
|
}
|
|
} else {
|
|
return false;
|
|
}
|
|
}
|
|
if (e.id != expectId) {
|
|
return false;
|
|
}
|
|
}
|
|
m_project.setEntities(ents);
|
|
return true;
|
|
}
|
|
|
|
bool ProjectWorkspace::hydrateAnimationPayloadsFromDisk() {
|
|
if (m_projectDir.isEmpty()) {
|
|
return true;
|
|
}
|
|
QVector<Project::Animation> animations = m_project.animations();
|
|
for (auto& a : animations) {
|
|
const QString expectId = a.id;
|
|
QString rel = asRelativeUnderProject(a.payloadPath);
|
|
if (expectId.isEmpty() || rel.isEmpty()) {
|
|
return false;
|
|
}
|
|
a.payloadPath = rel;
|
|
if (!core::persistence::AnimationBundleStore::load(m_projectDir, a)) {
|
|
return false;
|
|
}
|
|
if (a.id != expectId) {
|
|
return false;
|
|
}
|
|
}
|
|
m_project.setAnimations(animations);
|
|
m_dirtyAnimationIds.clear();
|
|
return true;
|
|
}
|
|
|
|
// 不再支持 v1 legacy .anim sidecar(无兼容/无迁移)。
|
|
|
|
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()) {
|
|
QByteArray png;
|
|
QBuffer buf(&png);
|
|
if (!buf.open(QIODevice::WriteOnly) || !image.save(&buf, "PNG")) {
|
|
return false;
|
|
}
|
|
e.imagePath.clear();
|
|
e.defaultImagePng = png;
|
|
}
|
|
if (e.entityPayloadPath.isEmpty()) {
|
|
e.entityPayloadPath = core::persistence::EntityBundleStore::bundleRelativeDir(e.id);
|
|
}
|
|
if (e.blackholeId.isEmpty()) {
|
|
e.blackholeId = QStringLiteral("blackhole-%1").arg(e.id);
|
|
}
|
|
if (e.blackholeResolvedBy.isEmpty()) {
|
|
e.blackholeResolvedBy = QStringLiteral("pending");
|
|
}
|
|
|
|
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::setEntityBlackholeVisible(const QString& id, bool on) {
|
|
if (m_projectDir.isEmpty() || id.isEmpty()) {
|
|
return false;
|
|
}
|
|
const auto before = m_project.entities();
|
|
auto ents = before;
|
|
bool found = false;
|
|
bool changed = false;
|
|
int hit = -1;
|
|
for (auto& e : ents) {
|
|
if (e.id != id) {
|
|
continue;
|
|
}
|
|
found = true;
|
|
hit = static_cast<int>(&e - ents.data());
|
|
if (e.blackholeVisible != on) {
|
|
e.blackholeVisible = on;
|
|
changed = true;
|
|
}
|
|
if (e.blackholeId.isEmpty()) {
|
|
e.blackholeId = QStringLiteral("blackhole-%1").arg(e.id);
|
|
changed = true;
|
|
}
|
|
if (on) {
|
|
if (e.blackholeResolvedBy.isEmpty()) {
|
|
e.blackholeResolvedBy = QStringLiteral("pending");
|
|
changed = true;
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
if (!found) {
|
|
return false;
|
|
}
|
|
if (!changed || hit < 0) {
|
|
return true;
|
|
}
|
|
m_project.setEntities(ents);
|
|
if (!saveSingleEntityPayload(ents[hit]) || !writeIndexJsonWithoutPayloadSync()) {
|
|
m_project.setEntities(before);
|
|
return false;
|
|
}
|
|
Operation op;
|
|
op.type = Operation::Type::SetEntities;
|
|
op.label = on ? QStringLiteral("显示黑洞") : QStringLiteral("隐藏黑洞");
|
|
op.beforeEntities = before;
|
|
op.afterEntities = ents;
|
|
pushOperation(op);
|
|
m_redoStack.clear();
|
|
return true;
|
|
}
|
|
|
|
bool ProjectWorkspace::resolveBlackholeByUseOriginalBackground(const QString& id) {
|
|
if (m_projectDir.isEmpty() || id.isEmpty()) {
|
|
return false;
|
|
}
|
|
const auto before = m_project.entities();
|
|
auto ents = before;
|
|
int hit = -1;
|
|
for (int i = 0; i < ents.size(); ++i) {
|
|
if (ents[i].id == id) {
|
|
hit = i;
|
|
break;
|
|
}
|
|
}
|
|
if (hit < 0) {
|
|
return false;
|
|
}
|
|
auto& e = ents[hit];
|
|
e.blackholeVisible = false;
|
|
if (e.blackholeId.isEmpty()) {
|
|
e.blackholeId = QStringLiteral("blackhole-%1").arg(e.id);
|
|
}
|
|
e.blackholeResolvedBy = QStringLiteral("use_original_background");
|
|
removeBlackholeOverlayFile(m_projectDir, e.blackholeOverlayPath);
|
|
e.blackholeOverlayPath.clear();
|
|
e.blackholeOverlayRect = QRect();
|
|
|
|
m_project.setEntities(ents);
|
|
if (!saveSingleEntityPayload(ents[hit]) || !writeIndexJsonWithoutPayloadSync()) {
|
|
m_project.setEntities(before);
|
|
return false;
|
|
}
|
|
Operation op;
|
|
op.type = Operation::Type::SetEntities;
|
|
op.label = QStringLiteral("黑洞使用原始背景");
|
|
op.beforeEntities = before;
|
|
op.afterEntities = ents;
|
|
pushOperation(op);
|
|
m_redoStack.clear();
|
|
return true;
|
|
}
|
|
|
|
bool ProjectWorkspace::resolveBlackholeByCopyBackground(const QString& id, const QPoint& sourceOffsetPx,
|
|
bool hideBlackholeAfterFill) {
|
|
if (m_projectDir.isEmpty() || id.isEmpty()) {
|
|
return false;
|
|
}
|
|
const QString bgAbs = backgroundAbsolutePath();
|
|
if (bgAbs.isEmpty() || !QFileInfo::exists(bgAbs)) {
|
|
return false;
|
|
}
|
|
|
|
auto ents = m_project.entities();
|
|
int hit = -1;
|
|
for (int i = 0; i < ents.size(); ++i) {
|
|
if (ents[i].id == id) {
|
|
hit = i;
|
|
break;
|
|
}
|
|
}
|
|
if (hit < 0) {
|
|
return false;
|
|
}
|
|
const auto& ent = ents[hit];
|
|
if (ent.cutoutPolygonWorld.size() < 3) {
|
|
return false;
|
|
}
|
|
|
|
QImage bg = image_file::loadImageLimited(bgAbs, image_decode::kWorkspaceMaxPixels);
|
|
if (bg.isNull()) {
|
|
return false;
|
|
}
|
|
if (bg.format() != QImage::Format_ARGB32_Premultiplied) {
|
|
bg = bg.convertToFormat(QImage::Format_ARGB32_Premultiplied);
|
|
}
|
|
const QImage srcSnapshot = bg;
|
|
|
|
QSize logicalSz;
|
|
if (!image_file::probeImagePixelSize(bgAbs, &logicalSz) || !logicalSz.isValid() || logicalSz.width() < 1 ||
|
|
logicalSz.height() < 1) {
|
|
return false;
|
|
}
|
|
|
|
QPainterPath holePathWorld;
|
|
holePathWorld.addPolygon(QPolygonF(ent.cutoutPolygonWorld));
|
|
holePathWorld.closeSubpath();
|
|
const QRect targetWorld =
|
|
holePathWorld.boundingRect().toAlignedRect().intersected(QRect(0, 0, logicalSz.width(), logicalSz.height()));
|
|
if (!targetWorld.isValid() || targetWorld.width() <= 0 || targetWorld.height() <= 0) {
|
|
return false;
|
|
}
|
|
|
|
QRect srcWorld(targetWorld.topLeft() + sourceOffsetPx, targetWorld.size());
|
|
if (srcWorld.left() < 0) srcWorld.moveLeft(0);
|
|
if (srcWorld.top() < 0) srcWorld.moveTop(0);
|
|
if (srcWorld.right() >= logicalSz.width()) srcWorld.moveRight(logicalSz.width() - 1);
|
|
if (srcWorld.bottom() >= logicalSz.height()) srcWorld.moveBottom(logicalSz.height() - 1);
|
|
srcWorld = srcWorld.intersected(QRect(0, 0, logicalSz.width(), logicalSz.height()));
|
|
if (srcWorld.width() != targetWorld.width() || srcWorld.height() != targetWorld.height()) {
|
|
return false;
|
|
}
|
|
|
|
const qreal sx = bg.width() / qreal(logicalSz.width());
|
|
const qreal sy = bg.height() / qreal(logicalSz.height());
|
|
QTransform worldToBg;
|
|
worldToBg.scale(sx, sy);
|
|
|
|
const QRect targetImg =
|
|
worldToBg.mapRect(QRectF(targetWorld)).toAlignedRect().intersected(QRect(0, 0, bg.width(), bg.height()));
|
|
const QRect srcImg =
|
|
worldToBg.mapRect(QRectF(srcWorld)).toAlignedRect().intersected(QRect(0, 0, bg.width(), bg.height()));
|
|
if (!targetImg.isValid() || !srcImg.isValid() || targetImg.width() != srcImg.width() ||
|
|
targetImg.height() != srcImg.height()) {
|
|
return false;
|
|
}
|
|
|
|
const QPainterPath holePathImg = worldToBg.map(holePathWorld);
|
|
|
|
{
|
|
QPainter p(&bg);
|
|
p.setRenderHint(QPainter::Antialiasing, true);
|
|
p.setClipPath(holePathImg);
|
|
p.drawImage(targetImg.topLeft(), srcSnapshot, srcImg);
|
|
p.end();
|
|
}
|
|
|
|
const QImage patch = bg.copy(targetImg);
|
|
const auto before = m_project.entities();
|
|
QString overlayRel;
|
|
if (!writeBlackholeOverlayPng(m_projectDir, id, ents[hit].blackholeOverlayPath, patch, targetWorld,
|
|
&overlayRel)) {
|
|
return false;
|
|
}
|
|
ents[hit].blackholeOverlayPath = overlayRel;
|
|
ents[hit].blackholeOverlayRect = targetWorld;
|
|
ents[hit].blackholeVisible = hideBlackholeAfterFill ? false : ents[hit].blackholeVisible;
|
|
if (ents[hit].blackholeId.isEmpty()) {
|
|
ents[hit].blackholeId = QStringLiteral("blackhole-%1").arg(ents[hit].id);
|
|
}
|
|
ents[hit].blackholeResolvedBy = QStringLiteral("copy_background");
|
|
|
|
m_project.setEntities(ents);
|
|
if (!saveSingleEntityPayload(ents[hit]) || !writeIndexJsonWithoutPayloadSync()) {
|
|
m_project.setEntities(before);
|
|
return false;
|
|
}
|
|
Operation op;
|
|
op.type = Operation::Type::SetEntities;
|
|
op.label = QStringLiteral("黑洞复制填充");
|
|
op.beforeEntities = before;
|
|
op.afterEntities = ents;
|
|
pushOperation(op);
|
|
m_redoStack.clear();
|
|
return true;
|
|
}
|
|
|
|
bool ProjectWorkspace::resolveBlackholeByModelInpaint(const QString& id, const QImage& overlayPatch,
|
|
const QRect& overlayRectInBackground,
|
|
bool hideBlackholeAfterFill) {
|
|
if (m_projectDir.isEmpty() || id.isEmpty()) {
|
|
return false;
|
|
}
|
|
if (overlayPatch.isNull() || !overlayRectInBackground.isValid() || overlayRectInBackground.width() <= 0 ||
|
|
overlayRectInBackground.height() <= 0) {
|
|
return false;
|
|
}
|
|
|
|
// 更新实体黑洞状态 + 记录历史
|
|
const auto before = m_project.entities();
|
|
auto ents = before;
|
|
int hit = -1;
|
|
for (int i = 0; i < ents.size(); ++i) {
|
|
if (ents[i].id == id) {
|
|
hit = i;
|
|
break;
|
|
}
|
|
}
|
|
if (hit < 0) {
|
|
return false;
|
|
}
|
|
|
|
QString overlayRel;
|
|
if (!writeBlackholeOverlayPng(m_projectDir, id, ents[hit].blackholeOverlayPath, overlayPatch,
|
|
overlayRectInBackground, &overlayRel)) {
|
|
return false;
|
|
}
|
|
ents[hit].blackholeOverlayPath = overlayRel;
|
|
ents[hit].blackholeOverlayRect = overlayRectInBackground;
|
|
ents[hit].blackholeVisible = hideBlackholeAfterFill ? false : ents[hit].blackholeVisible;
|
|
if (ents[hit].blackholeId.isEmpty()) {
|
|
ents[hit].blackholeId = QStringLiteral("blackhole-%1").arg(ents[hit].id);
|
|
}
|
|
ents[hit].blackholeResolvedBy = QStringLiteral("model_inpaint");
|
|
|
|
m_project.setEntities(ents);
|
|
if (!saveSingleEntityPayload(ents[hit]) || !writeIndexJsonWithoutPayloadSync()) {
|
|
m_project.setEntities(before);
|
|
return false;
|
|
}
|
|
|
|
Operation op;
|
|
op.type = Operation::Type::SetEntities;
|
|
op.label = QStringLiteral("黑洞模型补全");
|
|
op.beforeEntities = before;
|
|
op.afterEntities = ents;
|
|
pushOperation(op);
|
|
m_redoStack.clear();
|
|
return true;
|
|
}
|
|
|
|
bool ProjectWorkspace::setEntityVisibilityKey(const QString& id, int frame, bool visible) {
|
|
if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) {
|
|
return false;
|
|
}
|
|
if (isNoneAnimationActive(m_project)) {
|
|
return false;
|
|
}
|
|
const auto beforeAnimations = m_project.animations();
|
|
Project::Animation* anim = activeAnimationOrCreate(m_project);
|
|
if (!anim) return false;
|
|
auto* track = findOrCreateTrack(*anim,
|
|
Project::AnimationTargetKind::Entity,
|
|
id,
|
|
Project::AnimationProperty::Visibility,
|
|
Project::AnimationValueType::Bool,
|
|
Project::AnimationInterpolation::Hold);
|
|
if (!track) return false;
|
|
upsertAnimKey(*track, frame, visible);
|
|
markAnimationDirty(anim->id);
|
|
const auto afterAnimations = m_project.animations();
|
|
m_project.setAnimations(beforeAnimations);
|
|
return applyAnimations(afterAnimations, true, QStringLiteral("插入关键帧(显隐)"));
|
|
}
|
|
|
|
bool ProjectWorkspace::removeEntityVisibilityKey(const QString& id, int frame) {
|
|
if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) {
|
|
return false;
|
|
}
|
|
if (isNoneAnimationActive(m_project)) {
|
|
return false;
|
|
}
|
|
const auto beforeAnimations = m_project.animations();
|
|
bool changed = false;
|
|
if (Project::Animation* anim = activeAnimationOrCreate(m_project)) {
|
|
for (auto& t : anim->tracks) {
|
|
if (t.targetKind != Project::AnimationTargetKind::Entity ||
|
|
t.targetId != id ||
|
|
t.property != Project::AnimationProperty::Visibility) {
|
|
continue;
|
|
}
|
|
for (int i = 0; i < t.keys.size(); ++i) {
|
|
if (t.keys[i].frame == frame) {
|
|
t.keys.removeAt(i);
|
|
changed = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (changed) {
|
|
pruneEmptyAnimationTracks(*anim);
|
|
markAnimationDirty(anim->id);
|
|
}
|
|
}
|
|
if (!changed) return true;
|
|
const auto afterAnimations = m_project.animations();
|
|
m_project.setAnimations(beforeAnimations);
|
|
return applyAnimations(afterAnimations, true, 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);
|
|
const bool writeAnimated = keyframeAtFrame >= 0 && !isNoneAnimationActive(m_project);
|
|
if (writeAnimated) {
|
|
return setEntityUserScaleKey(id, keyframeAtFrame, u);
|
|
}
|
|
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);
|
|
if (baseSame) {
|
|
return true;
|
|
}
|
|
e.userScale = u;
|
|
break;
|
|
}
|
|
if (!found) {
|
|
return false;
|
|
}
|
|
return applyEntities(ents, true, QStringLiteral("整体缩放"));
|
|
}
|
|
|
|
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;
|
|
}
|
|
const int frameStart = std::max(0, m_project.frameStart());
|
|
|
|
// 父子关系切换时:需要把 location 关键帧在“绝对世界坐标”与“相对父对象偏移”之间互转,
|
|
// 否则同一组 key 会被用不同坐标系解释,造成跳跃。
|
|
auto fetchOrigin = [](const core::eval::ResolvedProjectFrame& rf, const QString& anyId) -> QPointF {
|
|
if (anyId.isEmpty()) return QPointF();
|
|
for (const auto& re : rf.entities) {
|
|
if (re.entity.id == anyId) return re.entity.originWorld;
|
|
}
|
|
for (const auto& rt : rf.tools) {
|
|
if (rt.tool.id == anyId) return rt.tool.originWorld;
|
|
}
|
|
return QPointF();
|
|
};
|
|
|
|
auto parentOriginAt = [&](const QString& pid, int f) -> QPointF {
|
|
if (pid.isEmpty()) return QPointF();
|
|
const auto rf = core::eval::evaluateAtFrame(m_project, f, 10);
|
|
return fetchOrigin(rf, pid);
|
|
};
|
|
|
|
auto convertKeys = [&](QVector<Project::Entity::KeyframeVec2>& keys,
|
|
const QString& oldPid,
|
|
const QString& newPid) {
|
|
if (keys.isEmpty()) return;
|
|
for (auto& k : keys) {
|
|
const QPointF oldParentO = oldPid.isEmpty() ? QPointF() : parentOriginAt(oldPid, k.frame);
|
|
const QPointF world = oldPid.isEmpty() ? k.value : (oldParentO + k.value);
|
|
const QPointF newParentO = newPid.isEmpty() ? QPointF() : parentOriginAt(newPid, k.frame);
|
|
k.value = newPid.isEmpty() ? world : (world - newParentO);
|
|
}
|
|
};
|
|
|
|
auto convertAnimTrackVec2 = [&](Project::Animation& anim,
|
|
Project::AnimationTargetKind kind,
|
|
const QString& targetId,
|
|
Project::AnimationProperty prop,
|
|
const QString& oldPid,
|
|
const QString& newPid) {
|
|
for (auto& t : anim.tracks) {
|
|
if (t.targetKind != kind || t.targetId != targetId || t.property != prop) continue;
|
|
if (t.valueType != Project::AnimationValueType::Vec2) continue;
|
|
if (t.keys.isEmpty()) continue;
|
|
for (auto& k : t.keys) {
|
|
const QPointF oldParentO = oldPid.isEmpty() ? QPointF() : parentOriginAt(oldPid, k.frame);
|
|
const QPointF world = oldPid.isEmpty() ? k.vec2Value : (oldParentO + k.vec2Value);
|
|
const QPointF newParentO = newPid.isEmpty() ? QPointF() : parentOriginAt(newPid, k.frame);
|
|
k.vec2Value = newPid.isEmpty() ? world : (world - newParentO);
|
|
}
|
|
}
|
|
};
|
|
|
|
bool found = false;
|
|
for (auto& e : ents) {
|
|
if (e.id != id) continue;
|
|
found = true;
|
|
|
|
const QString oldPid = e.parentId;
|
|
const QPointF oldBaseStored = oldPid.isEmpty() ? e.originWorld : e.parentOffsetWorld;
|
|
const QPointF oldParentOStart = oldPid.isEmpty() ? QPointF() : parentOriginAt(oldPid, frameStart);
|
|
const QPointF baseWorldAtStart = oldPid.isEmpty() ? oldBaseStored : (oldParentOStart + oldBaseStored);
|
|
|
|
// 转换内嵌 key(兼容旧数据来源/工具逻辑)
|
|
convertKeys(e.locationKeys, oldPid, parentId);
|
|
// 转换新动画轨道(作为运行时求值的真相源)
|
|
if (Project::Animation* anim = activeAnimationOrCreate(m_project)) {
|
|
convertAnimTrackVec2(*anim,
|
|
Project::AnimationTargetKind::Entity,
|
|
e.id,
|
|
Project::AnimationProperty::Position,
|
|
oldPid,
|
|
parentId);
|
|
markAnimationDirty(anim->id);
|
|
}
|
|
|
|
// 更新父子信息
|
|
e.parentId = parentId;
|
|
|
|
// 更新基准值:无 key/或 key 覆盖不到的区间仍应保持世界位置连续
|
|
if (parentId.isEmpty()) {
|
|
e.originWorld = baseWorldAtStart;
|
|
e.parentOffsetWorld = QPointF();
|
|
} else {
|
|
const QPointF newParentOStart = parentOriginAt(parentId, frameStart);
|
|
e.parentOffsetWorld = baseWorldAtStart - newParentOStart;
|
|
// cycle/parent missing 时 resolve 会回退到 sampledOriginForEntity;令其也尽量不跳
|
|
e.originWorld = baseWorldAtStart;
|
|
}
|
|
|
|
// 若调用方传入了当前帧下的 parentOffsetWorld(来自 UI 计算),在“绑定父对象”场景下优先采用,
|
|
// 保证操作当下立刻不跳(关键帧已整体转换,后续帧也保持一致)。
|
|
if (!parentId.isEmpty()) {
|
|
e.parentOffsetWorld = parentOffsetWorld;
|
|
}
|
|
break;
|
|
}
|
|
if (!found) return false;
|
|
return applyEntities(ents, true, QStringLiteral("设置父实体"));
|
|
}
|
|
|
|
bool ProjectWorkspace::normalizeAllEntityOriginsToCentroids(int frame) {
|
|
if (m_projectDir.isEmpty() || frame < 0) {
|
|
return false;
|
|
}
|
|
auto ents = m_project.entities();
|
|
bool changed = false;
|
|
for (auto& e : ents) {
|
|
const double s = combinedVisualScaleForEntity(e, frame);
|
|
if (snapEntityOriginToCentroidInPlace(m_project, e, frame, s)) {
|
|
changed = true;
|
|
}
|
|
}
|
|
if (!changed) {
|
|
return true;
|
|
}
|
|
return applyEntities(ents, true, QStringLiteral("对齐实体中心"));
|
|
}
|
|
|
|
QPointF ProjectWorkspace::entityCentroidWorldAtFrame(const QString& id, int frame) const {
|
|
if (id.isEmpty() || frame < 0) {
|
|
return {};
|
|
}
|
|
const auto rf = core::eval::evaluateAtFrame(m_project, frame, 10);
|
|
for (const auto& re : rf.entities) {
|
|
if (re.entity.id != id) {
|
|
continue;
|
|
}
|
|
const double s = combinedVisualScaleForEntity(re.entity, frame);
|
|
return entityPolygonCentroidWorld(m_project, re.entity, frame, s);
|
|
}
|
|
for (const auto& rt : rf.tools) {
|
|
if (rt.tool.id == id) {
|
|
return rt.tool.originWorld;
|
|
}
|
|
}
|
|
return {};
|
|
}
|
|
|
|
bool ProjectWorkspace::setEntityParentOffsetWorld(const QString& id, const QPointF& offsetWorld) {
|
|
if (m_projectDir.isEmpty() || id.isEmpty()) {
|
|
return false;
|
|
}
|
|
auto ents = m_project.entities();
|
|
for (auto& e : ents) {
|
|
if (e.id != id) {
|
|
continue;
|
|
}
|
|
if (e.parentId.isEmpty()) {
|
|
return false;
|
|
}
|
|
e.parentOffsetWorld = offsetWorld;
|
|
return applyEntities(ents, true, QStringLiteral("相对父中心"));
|
|
}
|
|
return false;
|
|
}
|
|
|
|
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(m_project, 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();
|
|
QPointF pivotDelta;
|
|
bool found = false;
|
|
for (auto& e : ents) {
|
|
if (e.id != id) {
|
|
continue;
|
|
}
|
|
found = true;
|
|
const QPointF O_anim = resolvedOriginAtFrame(m_project, e.id, frame);
|
|
|
|
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;
|
|
}
|
|
|
|
// 按目标枢轴精确重锚:外形世界坐标不变;形心不变,仅改变换原点
|
|
const QPointF O_new = newPivotWorld;
|
|
|
|
const QPointF I_disp = O_anim + (e.imageTopLeftWorld - e.originWorld) * sTotal;
|
|
const QPointF d = O_new - O_anim;
|
|
pivotDelta = d;
|
|
|
|
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;
|
|
}
|
|
if (!e.parentId.isEmpty()) {
|
|
e.parentOffsetWorld += d;
|
|
}
|
|
e.originWorld += d;
|
|
e.polygonLocal = std::move(newLocal);
|
|
e.imageTopLeftWorld = e.originWorld + (I_disp - O_new) / sTotal;
|
|
break;
|
|
}
|
|
if (!found) {
|
|
return false;
|
|
}
|
|
|
|
QVector<Project::Animation> anims = m_project.animations();
|
|
const bool shiftedAnim = shiftEntityPositionAnimKeys(anims, id, pivotDelta);
|
|
if (!applyEntities(ents, true, QStringLiteral("属性:枢轴"))) {
|
|
return false;
|
|
}
|
|
if (shiftedAnim && !applyAnimations(anims, true, QStringLiteral("属性:枢轴(位移轨)"))) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
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;
|
|
bool changedStatic = false;
|
|
for (auto& e : ents) {
|
|
if (e.id != id) {
|
|
continue;
|
|
}
|
|
found = true;
|
|
// 「无」动画为静态基底,不写位置关键帧。
|
|
if (autoKeyLocation && currentFrame >= 0 && !isNoneAnimationActive(m_project)) {
|
|
Project::Animation* anim = activeAnimationOrCreate(m_project);
|
|
if (!anim) return false;
|
|
// 关键:用“当前帧求值后的基准”写回关键帧,避免拖拽预览(求值态)与落盘(静态态)不一致导致松手瞬移。
|
|
const QPointF worldBase = resolvedOriginAtFrame(m_project, e.id, currentFrame);
|
|
QPointF base = worldBase;
|
|
if (!e.parentId.isEmpty()) {
|
|
const QPointF parentWorld = resolvedOriginAtFrame(m_project, e.parentId, currentFrame);
|
|
base = worldBase - parentWorld; // 相对父对象偏移
|
|
}
|
|
auto* track = findOrCreateTrack(*anim,
|
|
Project::AnimationTargetKind::Entity,
|
|
e.id,
|
|
Project::AnimationProperty::Position,
|
|
Project::AnimationValueType::Vec2,
|
|
Project::AnimationInterpolation::Linear);
|
|
upsertAnimKey(*track, currentFrame, base + delta);
|
|
markAnimationDirty(anim->id);
|
|
return writeIndexJson();
|
|
}
|
|
// 父子关系:绑定父对象时,位置曲线表示“相对父对象偏移”。
|
|
if (!e.parentId.isEmpty()) {
|
|
e.parentOffsetWorld += delta;
|
|
changedStatic = true;
|
|
break;
|
|
}
|
|
e.originWorld += delta;
|
|
e.imageTopLeftWorld += delta;
|
|
changedStatic = true;
|
|
break;
|
|
}
|
|
if (!found) {
|
|
return false;
|
|
}
|
|
if (!changedStatic) {
|
|
return true;
|
|
}
|
|
return applyEntities(ents, true, QStringLiteral("移动实体"));
|
|
}
|
|
|
|
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::setCameraVisible(const QString& id, bool on) {
|
|
if (m_projectDir.isEmpty() || id.isEmpty()) {
|
|
return false;
|
|
}
|
|
auto cams = m_project.cameras();
|
|
bool found = false;
|
|
bool changed = false;
|
|
for (auto& c : cams) {
|
|
if (c.id != id) continue;
|
|
found = true;
|
|
if (c.visible != on) {
|
|
c.visible = on;
|
|
changed = true;
|
|
}
|
|
break;
|
|
}
|
|
if (!found) return false;
|
|
if (!changed) return true;
|
|
return applyCameras(cams, true, on ? QStringLiteral("显示摄像机") : QStringLiteral("隐藏摄像机"));
|
|
}
|
|
|
|
bool ProjectWorkspace::setActiveCameraId(const QString& id) {
|
|
if (m_projectDir.isEmpty()) {
|
|
return false;
|
|
}
|
|
if (!id.isEmpty()) {
|
|
bool ok = false;
|
|
for (const auto& c : m_project.cameras()) {
|
|
if (c.id == id) {
|
|
ok = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!ok) {
|
|
return false;
|
|
}
|
|
}
|
|
if (m_project.activeCameraId() == id) {
|
|
return true;
|
|
}
|
|
m_project.setActiveCameraId(id);
|
|
return writeIndexJson();
|
|
}
|
|
|
|
bool ProjectWorkspace::setCameraDisplayName(const QString& id, const QString& displayName) {
|
|
if (m_projectDir.isEmpty() || id.isEmpty()) {
|
|
return false;
|
|
}
|
|
auto cams = m_project.cameras();
|
|
bool found = false;
|
|
for (auto& c : cams) {
|
|
if (c.id != id) continue;
|
|
found = true;
|
|
c.displayName = displayName;
|
|
break;
|
|
}
|
|
if (!found) return false;
|
|
return applyCameras(cams, true, QStringLiteral("摄像机名称"));
|
|
}
|
|
|
|
bool ProjectWorkspace::setCameraCenterWorld(const QString& id, const QPointF& centerWorld) {
|
|
if (m_projectDir.isEmpty() || id.isEmpty()) {
|
|
return false;
|
|
}
|
|
auto cams = m_project.cameras();
|
|
bool found = false;
|
|
for (auto& c : cams) {
|
|
if (c.id != id) continue;
|
|
found = true;
|
|
c.centerWorld = centerWorld;
|
|
break;
|
|
}
|
|
if (!found) return false;
|
|
return applyCameras(cams, true, QStringLiteral("摄像机位置"));
|
|
}
|
|
|
|
bool ProjectWorkspace::setCameraViewScaleValue(const QString& id, double viewScale, int keyframeAtFrame) {
|
|
if (m_projectDir.isEmpty() || id.isEmpty()) {
|
|
return false;
|
|
}
|
|
const double v = std::clamp(viewScale, 1e-6, 1e3);
|
|
const bool writeAnimated = keyframeAtFrame >= 0 && !isNoneAnimationActive(m_project);
|
|
if (writeAnimated) {
|
|
return setCameraScaleKey(id, keyframeAtFrame, v);
|
|
}
|
|
auto cams = m_project.cameras();
|
|
bool found = false;
|
|
for (auto& c : cams) {
|
|
if (c.id != id) continue;
|
|
found = true;
|
|
const bool baseSame = qFuzzyCompare(c.viewScale + 1.0, v + 1.0);
|
|
if (baseSame) {
|
|
return true;
|
|
}
|
|
c.viewScale = v;
|
|
break;
|
|
}
|
|
if (!found) return false;
|
|
return applyCameras(cams, true, QStringLiteral("摄像机缩放"));
|
|
}
|
|
|
|
bool ProjectWorkspace::moveCameraBy(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 cams = m_project.cameras();
|
|
bool found = false;
|
|
bool changedStatic = false;
|
|
for (auto& c : cams) {
|
|
if (c.id != id) continue;
|
|
found = true;
|
|
if (autoKeyLocation && currentFrame >= 0 && !isNoneAnimationActive(m_project)) {
|
|
Project::Animation* anim = activeAnimationOrCreate(m_project);
|
|
if (!anim) return false;
|
|
const QPointF base = resolvedOriginAtFrame(m_project, c.id, currentFrame);
|
|
auto* track = findOrCreateTrack(*anim,
|
|
Project::AnimationTargetKind::Camera,
|
|
c.id,
|
|
Project::AnimationProperty::Position,
|
|
Project::AnimationValueType::Vec2,
|
|
Project::AnimationInterpolation::Linear);
|
|
upsertAnimKey(*track, currentFrame, base + delta);
|
|
markAnimationDirty(anim->id);
|
|
// 同步到摄像机内嵌 keys(兼容旧工程/工具逻辑)
|
|
upsertKey(c.locationKeys, currentFrame, base + delta);
|
|
return writeIndexJson();
|
|
}
|
|
c.centerWorld += delta;
|
|
changedStatic = true;
|
|
break;
|
|
}
|
|
if (!found) return false;
|
|
if (!changedStatic) return true;
|
|
return applyCameras(cams, true, QStringLiteral("移动摄像机"));
|
|
}
|
|
|
|
bool ProjectWorkspace::setCameraLocationKey(const QString& id, int frame, const QPointF& centerWorld) {
|
|
if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) {
|
|
return false;
|
|
}
|
|
if (isNoneAnimationActive(m_project)) {
|
|
return false;
|
|
}
|
|
const auto beforeAnimations = m_project.animations();
|
|
Project::Animation* anim = activeAnimationOrCreate(m_project);
|
|
if (!anim) return false;
|
|
auto* track = findOrCreateTrack(*anim,
|
|
Project::AnimationTargetKind::Camera,
|
|
id,
|
|
Project::AnimationProperty::Position,
|
|
Project::AnimationValueType::Vec2,
|
|
Project::AnimationInterpolation::Linear);
|
|
upsertAnimKey(*track, frame, centerWorld);
|
|
markAnimationDirty(anim->id);
|
|
const auto afterAnimations = m_project.animations();
|
|
m_project.setAnimations(beforeAnimations);
|
|
return applyAnimations(afterAnimations, true, QStringLiteral("插入关键帧(摄像机位置)"));
|
|
}
|
|
|
|
bool ProjectWorkspace::setCameraScaleKey(const QString& id, int frame, double viewScale) {
|
|
if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) {
|
|
return false;
|
|
}
|
|
if (isNoneAnimationActive(m_project)) {
|
|
return false;
|
|
}
|
|
const double v = std::clamp(viewScale, 1e-6, 1e3);
|
|
const auto beforeAnimations = m_project.animations();
|
|
Project::Animation* anim = activeAnimationOrCreate(m_project);
|
|
if (!anim) return false;
|
|
auto* track = findOrCreateTrack(*anim,
|
|
Project::AnimationTargetKind::Camera,
|
|
id,
|
|
Project::AnimationProperty::CameraScale,
|
|
Project::AnimationValueType::Double,
|
|
Project::AnimationInterpolation::Linear);
|
|
upsertAnimKey(*track, frame, v);
|
|
markAnimationDirty(anim->id);
|
|
const auto afterAnimations = m_project.animations();
|
|
m_project.setAnimations(beforeAnimations);
|
|
return applyAnimations(afterAnimations, true, QStringLiteral("插入关键帧(摄像机缩放)"));
|
|
}
|
|
|
|
bool ProjectWorkspace::removeCameraLocationKey(const QString& id, int frame) {
|
|
if (m_projectDir.isEmpty() || id.isEmpty()) {
|
|
return false;
|
|
}
|
|
if (isNoneAnimationActive(m_project)) {
|
|
return false;
|
|
}
|
|
const auto beforeAnimations = m_project.animations();
|
|
bool changed = false;
|
|
if (Project::Animation* anim = activeAnimationOrCreate(m_project)) {
|
|
for (auto& t : anim->tracks) {
|
|
if (t.targetKind != Project::AnimationTargetKind::Camera ||
|
|
t.targetId != id ||
|
|
t.property != Project::AnimationProperty::Position) {
|
|
continue;
|
|
}
|
|
for (int i = 0; i < t.keys.size(); ++i) {
|
|
if (t.keys[i].frame == frame) {
|
|
t.keys.removeAt(i);
|
|
changed = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (changed) {
|
|
pruneEmptyAnimationTracks(*anim);
|
|
markAnimationDirty(anim->id);
|
|
}
|
|
}
|
|
if (!changed) return true;
|
|
const auto afterAnimations = m_project.animations();
|
|
m_project.setAnimations(beforeAnimations);
|
|
return applyAnimations(afterAnimations, true, QStringLiteral("删除关键帧(摄像机位置)"));
|
|
}
|
|
|
|
bool ProjectWorkspace::removeCameraScaleKey(const QString& id, int frame) {
|
|
if (m_projectDir.isEmpty() || id.isEmpty()) {
|
|
return false;
|
|
}
|
|
if (isNoneAnimationActive(m_project)) {
|
|
return false;
|
|
}
|
|
const auto beforeAnimations = m_project.animations();
|
|
bool changed = false;
|
|
if (Project::Animation* anim = activeAnimationOrCreate(m_project)) {
|
|
for (auto& t : anim->tracks) {
|
|
if (t.targetKind != Project::AnimationTargetKind::Camera ||
|
|
t.targetId != id ||
|
|
t.property != Project::AnimationProperty::CameraScale) {
|
|
continue;
|
|
}
|
|
for (int i = 0; i < t.keys.size(); ++i) {
|
|
if (t.keys[i].frame == frame) {
|
|
t.keys.removeAt(i);
|
|
changed = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (changed) {
|
|
pruneEmptyAnimationTracks(*anim);
|
|
markAnimationDirty(anim->id);
|
|
}
|
|
}
|
|
if (!changed) return true;
|
|
const auto afterAnimations = m_project.animations();
|
|
m_project.setAnimations(beforeAnimations);
|
|
return applyAnimations(afterAnimations, 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;
|
|
}
|
|
if (isNoneAnimationActive(m_project)) {
|
|
return false;
|
|
}
|
|
const auto beforeAnimations = m_project.animations();
|
|
Project::Animation* anim = activeAnimationOrCreate(m_project);
|
|
if (!anim) return false;
|
|
auto* track = findOrCreateTrack(*anim,
|
|
Project::AnimationTargetKind::Tool,
|
|
id,
|
|
Project::AnimationProperty::Visibility,
|
|
Project::AnimationValueType::Bool,
|
|
Project::AnimationInterpolation::Hold);
|
|
upsertAnimKey(*track, frame, visible);
|
|
markAnimationDirty(anim->id);
|
|
const auto afterAnimations = m_project.animations();
|
|
m_project.setAnimations(beforeAnimations);
|
|
return applyAnimations(afterAnimations, true, QStringLiteral("插入关键帧(工具显隐)"));
|
|
}
|
|
|
|
bool ProjectWorkspace::setToolPriorityKey(const QString& id, int frame, int priority) {
|
|
if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) {
|
|
return false;
|
|
}
|
|
if (isNoneAnimationActive(m_project)) {
|
|
auto tools = m_project.tools();
|
|
for (auto& t : tools) {
|
|
if (t.id != id) continue;
|
|
t.priority = priority;
|
|
return applyTools(tools, true, QStringLiteral("工具优先级"));
|
|
}
|
|
return false;
|
|
}
|
|
const double v = std::clamp<double>(double(priority), -1e6, 1e6);
|
|
const auto beforeAnimations = m_project.animations();
|
|
Project::Animation* anim = activeAnimationOrCreate(m_project);
|
|
if (!anim) return false;
|
|
auto* track = findOrCreateTrack(*anim,
|
|
Project::AnimationTargetKind::Tool,
|
|
id,
|
|
Project::AnimationProperty::Priority,
|
|
Project::AnimationValueType::Double,
|
|
Project::AnimationInterpolation::Hold);
|
|
upsertAnimKey(*track, frame, v);
|
|
markAnimationDirty(anim->id);
|
|
const auto afterAnimations = m_project.animations();
|
|
m_project.setAnimations(beforeAnimations);
|
|
return applyAnimations(afterAnimations, true, QStringLiteral("插入关键帧(工具优先级)"));
|
|
}
|
|
|
|
bool ProjectWorkspace::removeToolVisibilityKey(const QString& id, int frame) {
|
|
if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) {
|
|
return false;
|
|
}
|
|
if (isNoneAnimationActive(m_project)) {
|
|
return false;
|
|
}
|
|
const auto beforeAnimations = m_project.animations();
|
|
bool changed = false;
|
|
if (Project::Animation* anim = activeAnimationOrCreate(m_project)) {
|
|
for (auto& t : anim->tracks) {
|
|
if (t.targetKind != Project::AnimationTargetKind::Tool ||
|
|
t.targetId != id ||
|
|
t.property != Project::AnimationProperty::Visibility) {
|
|
continue;
|
|
}
|
|
for (int i = 0; i < t.keys.size(); ++i) {
|
|
if (t.keys[i].frame == frame) {
|
|
t.keys.removeAt(i);
|
|
changed = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (changed) {
|
|
pruneEmptyAnimationTracks(*anim);
|
|
markAnimationDirty(anim->id);
|
|
}
|
|
}
|
|
if (!changed) return true;
|
|
const auto afterAnimations = m_project.animations();
|
|
m_project.setAnimations(beforeAnimations);
|
|
return applyAnimations(afterAnimations, true, QStringLiteral("删除关键帧(工具显隐)"));
|
|
}
|
|
|
|
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();
|
|
const int frameStart = std::max(0, m_project.frameStart());
|
|
|
|
auto fetchOrigin = [](const core::eval::ResolvedProjectFrame& rf, const QString& anyId) -> QPointF {
|
|
if (anyId.isEmpty()) return QPointF();
|
|
for (const auto& re : rf.entities) {
|
|
if (re.entity.id == anyId) return re.entity.originWorld;
|
|
}
|
|
for (const auto& rt : rf.tools) {
|
|
if (rt.tool.id == anyId) return rt.tool.originWorld;
|
|
}
|
|
return QPointF();
|
|
};
|
|
|
|
auto parentOriginAt = [&](const QString& pid, int f) -> QPointF {
|
|
if (pid.isEmpty()) return QPointF();
|
|
const auto rf = core::eval::evaluateAtFrame(m_project, f, 10);
|
|
return fetchOrigin(rf, pid);
|
|
};
|
|
|
|
auto convertKeys = [&](QVector<Project::Entity::KeyframeVec2>& keys,
|
|
const QString& oldPid,
|
|
const QString& newPid) {
|
|
if (keys.isEmpty()) return;
|
|
for (auto& k : keys) {
|
|
const QPointF oldParentO = oldPid.isEmpty() ? QPointF() : parentOriginAt(oldPid, k.frame);
|
|
const QPointF world = oldPid.isEmpty() ? k.value : (oldParentO + k.value);
|
|
const QPointF newParentO = newPid.isEmpty() ? QPointF() : parentOriginAt(newPid, k.frame);
|
|
k.value = newPid.isEmpty() ? world : (world - newParentO);
|
|
}
|
|
};
|
|
|
|
bool found = false;
|
|
for (auto& t : tools) {
|
|
if (t.id != id) continue;
|
|
found = true;
|
|
|
|
const QString oldPid = t.parentId;
|
|
const QPointF oldBaseStored = oldPid.isEmpty() ? t.originWorld : t.parentOffsetWorld;
|
|
const QPointF oldParentOStart = oldPid.isEmpty() ? QPointF() : parentOriginAt(oldPid, frameStart);
|
|
const QPointF baseWorldAtStart = oldPid.isEmpty() ? oldBaseStored : (oldParentOStart + oldBaseStored);
|
|
|
|
convertKeys(t.locationKeys, oldPid, parentId);
|
|
|
|
if (Project::Animation* anim = activeAnimationOrCreate(m_project)) {
|
|
for (auto& tr : anim->tracks) {
|
|
if (tr.targetKind != Project::AnimationTargetKind::Tool ||
|
|
tr.targetId != t.id ||
|
|
tr.property != Project::AnimationProperty::Position ||
|
|
tr.valueType != Project::AnimationValueType::Vec2) {
|
|
continue;
|
|
}
|
|
for (auto& k : tr.keys) {
|
|
const QPointF oldParentO = oldPid.isEmpty() ? QPointF() : parentOriginAt(oldPid, k.frame);
|
|
const QPointF world = oldPid.isEmpty() ? k.vec2Value : (oldParentO + k.vec2Value);
|
|
const QPointF newParentO = parentId.isEmpty() ? QPointF() : parentOriginAt(parentId, k.frame);
|
|
k.vec2Value = parentId.isEmpty() ? world : (world - newParentO);
|
|
}
|
|
}
|
|
markAnimationDirty(anim->id);
|
|
}
|
|
|
|
t.parentId = parentId;
|
|
if (parentId.isEmpty()) {
|
|
t.originWorld = baseWorldAtStart;
|
|
t.parentOffsetWorld = QPointF();
|
|
} else {
|
|
const QPointF newParentOStart = parentOriginAt(parentId, frameStart);
|
|
t.parentOffsetWorld = baseWorldAtStart - newParentOStart;
|
|
t.originWorld = baseWorldAtStart;
|
|
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;
|
|
bool changedStatic = false;
|
|
for (auto& t : tools) {
|
|
if (t.id != id) continue;
|
|
found = true;
|
|
if (autoKeyLocation && currentFrame >= 0 && !isNoneAnimationActive(m_project)) {
|
|
Project::Animation* anim = activeAnimationOrCreate(m_project);
|
|
if (!anim) return false;
|
|
const QPointF worldBase = resolvedOriginAtFrame(m_project, t.id, currentFrame);
|
|
QPointF base = worldBase;
|
|
if (!t.parentId.isEmpty()) {
|
|
const QPointF parentWorld = resolvedOriginAtFrame(m_project, t.parentId, currentFrame);
|
|
base = worldBase - parentWorld;
|
|
}
|
|
auto* track = findOrCreateTrack(*anim,
|
|
Project::AnimationTargetKind::Tool,
|
|
t.id,
|
|
Project::AnimationProperty::Position,
|
|
Project::AnimationValueType::Vec2,
|
|
Project::AnimationInterpolation::Linear);
|
|
upsertAnimKey(*track, currentFrame, base + delta);
|
|
markAnimationDirty(anim->id);
|
|
// 同步到工具内嵌 keys(兼容旧工程/工具逻辑)
|
|
upsertKey(t.locationKeys, currentFrame, base + delta);
|
|
return writeIndexJson();
|
|
}
|
|
if (!t.parentId.isEmpty()) {
|
|
t.parentOffsetWorld += delta;
|
|
changedStatic = true;
|
|
break;
|
|
}
|
|
t.originWorld += delta;
|
|
changedStatic = true;
|
|
break;
|
|
}
|
|
if (!found) return false;
|
|
if (!changedStatic) return true;
|
|
return applyTools(tools, true, QStringLiteral("移动工具"));
|
|
}
|
|
|
|
bool ProjectWorkspace::setEntityLocationKey(const QString& id, int frame, const QPointF& originWorld) {
|
|
if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) {
|
|
return false;
|
|
}
|
|
if (isNoneAnimationActive(m_project)) {
|
|
return false;
|
|
}
|
|
QPointF keyValue = originWorld;
|
|
for (const auto& e : m_project.entities()) {
|
|
if (e.id == id && !e.parentId.isEmpty()) {
|
|
const QPointF parentWorld = resolvedOriginAtFrame(m_project, e.parentId, frame);
|
|
keyValue = originWorld - parentWorld;
|
|
break;
|
|
}
|
|
}
|
|
const auto beforeAnimations = m_project.animations();
|
|
Project::Animation* anim = activeAnimationOrCreate(m_project);
|
|
if (!anim) return false;
|
|
auto* track = findOrCreateTrack(*anim,
|
|
Project::AnimationTargetKind::Entity,
|
|
id,
|
|
Project::AnimationProperty::Position,
|
|
Project::AnimationValueType::Vec2,
|
|
Project::AnimationInterpolation::Linear);
|
|
if (!track) return false;
|
|
upsertAnimKey(*track, frame, keyValue);
|
|
markAnimationDirty(anim->id);
|
|
const auto afterAnimations = m_project.animations();
|
|
m_project.setAnimations(beforeAnimations);
|
|
return applyAnimations(afterAnimations, true, QStringLiteral("插入关键帧(位置)"));
|
|
}
|
|
|
|
bool ProjectWorkspace::setEntityDepthScaleKey(const QString& id, int frame, double value01) {
|
|
if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) {
|
|
return false;
|
|
}
|
|
if (isNoneAnimationActive(m_project)) {
|
|
return false;
|
|
}
|
|
const double v = std::clamp(value01, 0.0, 1.0);
|
|
const auto beforeAnimations = m_project.animations();
|
|
Project::Animation* anim = activeAnimationOrCreate(m_project);
|
|
if (!anim) return false;
|
|
auto* track = findOrCreateTrack(*anim,
|
|
Project::AnimationTargetKind::Entity,
|
|
id,
|
|
Project::AnimationProperty::DepthScale,
|
|
Project::AnimationValueType::Double,
|
|
Project::AnimationInterpolation::Linear);
|
|
if (!track) return false;
|
|
upsertAnimKey(*track, frame, v);
|
|
markAnimationDirty(anim->id);
|
|
const auto afterAnimations = m_project.animations();
|
|
m_project.setAnimations(beforeAnimations);
|
|
return applyAnimations(afterAnimations, true, QStringLiteral("插入关键帧(深度缩放)"));
|
|
}
|
|
|
|
bool ProjectWorkspace::setEntityUserScaleKey(const QString& id, int frame, double userScale) {
|
|
if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) {
|
|
return false;
|
|
}
|
|
if (isNoneAnimationActive(m_project)) {
|
|
return false;
|
|
}
|
|
const double v = std::clamp(userScale, 1e-6, 1e3);
|
|
const auto beforeAnimations = m_project.animations();
|
|
Project::Animation* anim = activeAnimationOrCreate(m_project);
|
|
if (!anim) return false;
|
|
auto* track = findOrCreateTrack(*anim,
|
|
Project::AnimationTargetKind::Entity,
|
|
id,
|
|
Project::AnimationProperty::UserScale,
|
|
Project::AnimationValueType::Double,
|
|
Project::AnimationInterpolation::Linear);
|
|
if (!track) return false;
|
|
upsertAnimKey(*track, frame, v);
|
|
markAnimationDirty(anim->id);
|
|
const auto afterAnimations = m_project.animations();
|
|
m_project.setAnimations(beforeAnimations);
|
|
return applyAnimations(afterAnimations, true, QStringLiteral("插入关键帧(整体缩放)"));
|
|
}
|
|
|
|
bool ProjectWorkspace::setEntityPriorityKey(const QString& id, int frame, int priority) {
|
|
if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) {
|
|
return false;
|
|
}
|
|
if (isNoneAnimationActive(m_project)) {
|
|
// “无”动画:直接写静态值
|
|
auto ents = m_project.entities();
|
|
for (auto& e : ents) {
|
|
if (e.id != id) continue;
|
|
e.priority = priority;
|
|
return applyEntities(ents, true, QStringLiteral("优先级"));
|
|
}
|
|
return false;
|
|
}
|
|
const double v = std::clamp<double>(double(priority), -1e6, 1e6);
|
|
const auto beforeAnimations = m_project.animations();
|
|
Project::Animation* anim = activeAnimationOrCreate(m_project);
|
|
if (!anim) return false;
|
|
auto* track = findOrCreateTrack(*anim,
|
|
Project::AnimationTargetKind::Entity,
|
|
id,
|
|
Project::AnimationProperty::Priority,
|
|
Project::AnimationValueType::Double,
|
|
Project::AnimationInterpolation::Hold);
|
|
if (!track) return false;
|
|
upsertAnimKey(*track, frame, v);
|
|
markAnimationDirty(anim->id);
|
|
const auto afterAnimations = m_project.animations();
|
|
m_project.setAnimations(beforeAnimations);
|
|
return applyAnimations(afterAnimations, true, QStringLiteral("插入关键帧(优先级)"));
|
|
}
|
|
|
|
bool ProjectWorkspace::setEntityDefaultImage(const QString& id, const QImage& image, QString* outRelPath) {
|
|
if (outRelPath) outRelPath->clear();
|
|
if (m_projectDir.isEmpty() || id.isEmpty() || image.isNull()) {
|
|
return false;
|
|
}
|
|
QByteArray png;
|
|
{
|
|
QBuffer buf(&png);
|
|
if (!buf.open(QIODevice::WriteOnly) || !image.save(&buf, "PNG")) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
auto ents = m_project.entities();
|
|
bool found = false;
|
|
for (auto& e : ents) {
|
|
if (e.id != id) continue;
|
|
found = true;
|
|
e.imagePath.clear();
|
|
e.defaultImagePng = png;
|
|
break;
|
|
}
|
|
if (!found) return false;
|
|
if (!applyEntities(ents, true, QStringLiteral("更换实体本体图"))) {
|
|
return false;
|
|
}
|
|
if (outRelPath) *outRelPath = QString();
|
|
return true;
|
|
}
|
|
|
|
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;
|
|
}
|
|
if (isNoneAnimationActive(m_project)) {
|
|
return false;
|
|
}
|
|
// 逐帧动画帧:按用户要求存入二进制(.hfa),不写出单独的图片文件。
|
|
QByteArray png;
|
|
{
|
|
QBuffer buf(&png);
|
|
if (!buf.open(QIODevice::WriteOnly) || !image.save(&buf, "PNG")) {
|
|
return false;
|
|
}
|
|
}
|
|
const auto beforeAnimations = m_project.animations();
|
|
Project::Animation* anim = activeAnimationOrCreate(m_project);
|
|
if (!anim) return false;
|
|
Project::AnimationTrack* track = findOrCreateTrack(
|
|
*anim,
|
|
Project::AnimationTargetKind::Entity,
|
|
id,
|
|
Project::AnimationProperty::Sprite,
|
|
Project::AnimationValueType::PngBytes,
|
|
Project::AnimationInterpolation::Hold);
|
|
if (!track) return false;
|
|
upsertAnimKey(*track, frame, png);
|
|
markAnimationDirty(anim->id);
|
|
const auto afterAnimations = m_project.animations();
|
|
m_project.setAnimations(beforeAnimations);
|
|
if (!applyAnimations(afterAnimations, true, QStringLiteral("插入关键帧(图像)"))) return false;
|
|
if (outRelPath) *outRelPath = QString(); // 不再产生资源路径
|
|
return true;
|
|
}
|
|
|
|
bool ProjectWorkspace::setEntityImageFramePath(const QString& id, int frame, const QString& relativePath) {
|
|
if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) {
|
|
return false;
|
|
}
|
|
if (isNoneAnimationActive(m_project)) {
|
|
return false;
|
|
}
|
|
Q_UNUSED(relativePath);
|
|
// 不再支持路径型贴图关键帧(仅支持二进制 PNG bytes)。
|
|
return false;
|
|
}
|
|
|
|
QVector<Project::Entity::ImageFrame> ProjectWorkspace::entitySpriteFrames(const QString& id) const {
|
|
const Project::Animation* anim = m_project.activeAnimationOrNull();
|
|
if (anim) {
|
|
return spriteFramesForEntity(*anim, id);
|
|
}
|
|
return {};
|
|
}
|
|
|
|
QString ProjectWorkspace::entityImagePathAt(const QString& id, int frame) const {
|
|
const Project::Entity* entity = nullptr;
|
|
for (const auto& e : m_project.entities()) {
|
|
if (e.id == id) {
|
|
entity = &e;
|
|
break;
|
|
}
|
|
}
|
|
if (!entity) return {};
|
|
const QByteArray png = entityImagePngAt(id, frame);
|
|
if (png.isEmpty()) return {};
|
|
return QStringLiteral("pngb64:") + QString::fromLatin1(png.toBase64());
|
|
}
|
|
|
|
QByteArray ProjectWorkspace::entityImagePngAt(const QString& id, int frame) const {
|
|
const Project::Entity* entity = nullptr;
|
|
for (const auto& e : m_project.entities()) {
|
|
if (e.id == id) {
|
|
entity = &e;
|
|
break;
|
|
}
|
|
}
|
|
if (!entity) return {};
|
|
if (const Project::Animation* anim = m_project.activeAnimationOrNull()) {
|
|
for (const auto& t : anim->tracks) {
|
|
if (t.targetKind != Project::AnimationTargetKind::Entity ||
|
|
t.targetId != id ||
|
|
t.property != Project::AnimationProperty::Sprite) {
|
|
continue;
|
|
}
|
|
if (t.valueType != Project::AnimationValueType::PngBytes) {
|
|
continue;
|
|
}
|
|
QByteArray best;
|
|
int bestFrame = -1;
|
|
for (const auto& k : t.keys) {
|
|
if (k.frame <= frame && k.frame >= bestFrame && !k.bytesValue.isEmpty()) {
|
|
bestFrame = k.frame;
|
|
best = k.bytesValue;
|
|
}
|
|
}
|
|
if (!best.isEmpty()) return best;
|
|
}
|
|
}
|
|
return entity->defaultImagePng;
|
|
}
|
|
|
|
bool ProjectWorkspace::clearEntityImageFramesInRange(const QString& id, int startFrame, int endFrame) {
|
|
if (m_projectDir.isEmpty() || id.isEmpty()) return false;
|
|
if (isNoneAnimationActive(m_project)) {
|
|
return false;
|
|
}
|
|
const int a = std::min(startFrame, endFrame);
|
|
const int b = std::max(startFrame, endFrame);
|
|
const auto beforeAnimations = m_project.animations();
|
|
bool changed = false;
|
|
|
|
if (Project::Animation* anim = activeAnimationOrCreate(m_project)) {
|
|
for (auto& t : anim->tracks) {
|
|
if (t.targetKind != Project::AnimationTargetKind::Entity ||
|
|
t.targetId != id ||
|
|
t.property != Project::AnimationProperty::Sprite) {
|
|
continue;
|
|
}
|
|
const int before = static_cast<int>(t.keys.size());
|
|
for (int i = static_cast<int>(t.keys.size()) - 1; i >= 0; --i) {
|
|
if (t.keys[i].frame >= a && t.keys[i].frame <= b) {
|
|
t.keys.removeAt(i);
|
|
}
|
|
}
|
|
changed = changed || (before != t.keys.size());
|
|
}
|
|
if (changed) {
|
|
pruneEmptyAnimationTracks(*anim);
|
|
markAnimationDirty(anim->id);
|
|
}
|
|
}
|
|
|
|
if (!changed) return true;
|
|
const auto afterAnimations = m_project.animations();
|
|
m_project.setAnimations(beforeAnimations);
|
|
return applyAnimations(afterAnimations, true, QStringLiteral("清除区间图像帧"));
|
|
}
|
|
|
|
int ProjectWorkspace::activeAnimationLengthFrames() const {
|
|
if (const Project::Animation* anim = m_project.activeAnimationOrNull()) {
|
|
return std::max(1, anim->lengthFrames);
|
|
}
|
|
return Project::kClipFixedFrames;
|
|
}
|
|
|
|
int ProjectWorkspace::activeAnimationFps() const {
|
|
if (const Project::Animation* anim = m_project.activeAnimationOrNull()) {
|
|
return std::max(1, anim->fps);
|
|
}
|
|
return std::max(1, m_project.fps());
|
|
}
|
|
|
|
int ProjectWorkspace::animationLengthFramesForId(const QString& animationId) const {
|
|
if (const Project::Animation* anim = m_project.findAnimationById(animationId)) {
|
|
return std::max(1, anim->lengthFrames);
|
|
}
|
|
return Project::kClipFixedFrames;
|
|
}
|
|
|
|
int ProjectWorkspace::animationFpsForId(const QString& animationId) const {
|
|
if (const Project::Animation* anim = m_project.findAnimationById(animationId)) {
|
|
return std::max(1, anim->fps);
|
|
}
|
|
return std::max(1, m_project.fps());
|
|
}
|
|
|
|
bool ProjectWorkspace::animationLoopsForId(const QString& animationId) const {
|
|
if (const Project::Animation* anim = m_project.findAnimationById(animationId)) {
|
|
return anim->loop;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
static void pruneAnimationTracksToLength(Project::Animation& anim, int lengthFramesCap) {
|
|
const int cap = std::max(1, lengthFramesCap);
|
|
for (auto& t : anim.tracks) {
|
|
QVector<Project::AnimationKey> kept;
|
|
kept.reserve(t.keys.size());
|
|
for (const auto& k : t.keys) {
|
|
if (k.frame >= 0 && k.frame < cap) {
|
|
kept.push_back(k);
|
|
}
|
|
}
|
|
t.keys = std::move(kept);
|
|
}
|
|
}
|
|
|
|
static QString nextUniqueAnimationId(const QVector<Project::Animation>& anims, const QString& prefix) {
|
|
QStringList ids;
|
|
ids.reserve(anims.size());
|
|
for (const auto& a : anims) {
|
|
ids.push_back(a.id);
|
|
}
|
|
int n = 1;
|
|
while (ids.contains(prefix + QString::number(n))) {
|
|
++n;
|
|
}
|
|
return prefix + QString::number(n);
|
|
}
|
|
|
|
bool ProjectWorkspace::createAnimationScheme(const QString& displayName, int lengthFrames, bool loop, int fps,
|
|
QString* outNewId) {
|
|
if (!isOpen() || lengthFrames < 1) {
|
|
return false;
|
|
}
|
|
const int fp = std::max(1, fps);
|
|
const QString nm = displayName.trimmed().isEmpty() ? QStringLiteral("方案_%1").arg(project().animations().size())
|
|
: displayName.trimmed();
|
|
|
|
Project::Animation a;
|
|
a.id = nextUniqueAnimationId(project().animations(), QStringLiteral("animation-"));
|
|
a.name = nm;
|
|
a.payloadPath = core::persistence::AnimationBundleStore::bundleRelativeDir(a.id);
|
|
a.fps = fp;
|
|
a.lengthFrames = lengthFrames;
|
|
a.loop = loop;
|
|
|
|
QVector<Project::Animation> next = project().animations();
|
|
next.push_back(a);
|
|
if (!applyAnimations(next, true, QStringLiteral("新建动画方案"))) {
|
|
return false;
|
|
}
|
|
project().setActiveAnimationId(a.id);
|
|
if (!writeIndexJson()) {
|
|
return false;
|
|
}
|
|
if (outNewId) {
|
|
*outNewId = a.id;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
bool ProjectWorkspace::updateAnimationSchemeSettings(const QString& id, const QString& displayName,
|
|
int lengthFrames, bool loop, int fps) {
|
|
if (!isOpen() || id.isEmpty() || lengthFrames < 1) {
|
|
return false;
|
|
}
|
|
QVector<Project::Animation> next = project().animations();
|
|
int ix = -1;
|
|
for (int i = 0; i < next.size(); ++i) {
|
|
if (next[i].id == id) {
|
|
ix = i;
|
|
break;
|
|
}
|
|
}
|
|
if (ix < 0) {
|
|
return false;
|
|
}
|
|
Project::Animation a = next[ix];
|
|
a.name = displayName.trimmed().isEmpty() ? a.name : displayName.trimmed();
|
|
a.fps = std::max(1, fps);
|
|
a.loop = loop;
|
|
a.lengthFrames = std::max(1, lengthFrames);
|
|
pruneAnimationTracksToLength(a, a.lengthFrames);
|
|
next[ix] = a;
|
|
|
|
if (!applyAnimations(next, true, QStringLiteral("动画方案设置"))) {
|
|
return false;
|
|
}
|
|
return writeIndexJson();
|
|
}
|
|
|
|
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;
|
|
}
|
|
if (isNoneAnimationActive(m_project)) {
|
|
return false;
|
|
}
|
|
const auto beforeAnimations = m_project.animations();
|
|
bool changed = false;
|
|
if (Project::Animation* anim = activeAnimationOrCreate(m_project)) {
|
|
for (auto& t : anim->tracks) {
|
|
if (t.targetKind != Project::AnimationTargetKind::Entity ||
|
|
t.targetId != id ||
|
|
t.property != Project::AnimationProperty::Position) {
|
|
continue;
|
|
}
|
|
for (int i = 0; i < t.keys.size(); ++i) {
|
|
if (t.keys[i].frame == frame) {
|
|
t.keys.removeAt(i);
|
|
changed = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (changed) {
|
|
pruneEmptyAnimationTracks(*anim);
|
|
markAnimationDirty(anim->id);
|
|
}
|
|
}
|
|
if (!changed) return true;
|
|
const auto afterAnimations = m_project.animations();
|
|
m_project.setAnimations(beforeAnimations);
|
|
return applyAnimations(afterAnimations, true, QStringLiteral("删除关键帧(位置)"));
|
|
}
|
|
|
|
bool ProjectWorkspace::removeEntityDepthScaleKey(const QString& id, int frame) {
|
|
if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) {
|
|
return false;
|
|
}
|
|
if (isNoneAnimationActive(m_project)) {
|
|
return false;
|
|
}
|
|
const auto beforeAnimations = m_project.animations();
|
|
bool changed = false;
|
|
if (Project::Animation* anim = activeAnimationOrCreate(m_project)) {
|
|
for (auto& t : anim->tracks) {
|
|
if (t.targetKind != Project::AnimationTargetKind::Entity ||
|
|
t.targetId != id ||
|
|
t.property != Project::AnimationProperty::DepthScale) {
|
|
continue;
|
|
}
|
|
for (int i = 0; i < t.keys.size(); ++i) {
|
|
if (t.keys[i].frame == frame) {
|
|
t.keys.removeAt(i);
|
|
changed = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (changed) {
|
|
pruneEmptyAnimationTracks(*anim);
|
|
markAnimationDirty(anim->id);
|
|
}
|
|
}
|
|
if (!changed) return true;
|
|
const auto afterAnimations = m_project.animations();
|
|
m_project.setAnimations(beforeAnimations);
|
|
return applyAnimations(afterAnimations, true, QStringLiteral("删除关键帧(深度缩放)"));
|
|
}
|
|
|
|
bool ProjectWorkspace::removeEntityUserScaleKey(const QString& id, int frame) {
|
|
if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) {
|
|
return false;
|
|
}
|
|
if (isNoneAnimationActive(m_project)) {
|
|
return false;
|
|
}
|
|
const auto beforeAnimations = m_project.animations();
|
|
bool changed = false;
|
|
if (Project::Animation* anim = activeAnimationOrCreate(m_project)) {
|
|
for (auto& t : anim->tracks) {
|
|
if (t.targetKind != Project::AnimationTargetKind::Entity ||
|
|
t.targetId != id ||
|
|
t.property != Project::AnimationProperty::UserScale) {
|
|
continue;
|
|
}
|
|
for (int i = 0; i < t.keys.size(); ++i) {
|
|
if (t.keys[i].frame == frame) {
|
|
t.keys.removeAt(i);
|
|
changed = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (changed) {
|
|
pruneEmptyAnimationTracks(*anim);
|
|
markAnimationDirty(anim->id);
|
|
}
|
|
}
|
|
if (!changed) return true;
|
|
const auto afterAnimations = m_project.animations();
|
|
m_project.setAnimations(beforeAnimations);
|
|
return applyAnimations(afterAnimations, true, QStringLiteral("删除关键帧(整体缩放)"));
|
|
}
|
|
|
|
bool ProjectWorkspace::removeEntityImageFrame(const QString& id, int frame) {
|
|
if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) {
|
|
return false;
|
|
}
|
|
if (isNoneAnimationActive(m_project)) {
|
|
return false;
|
|
}
|
|
const auto beforeAnimations = m_project.animations();
|
|
bool removedAny = false;
|
|
if (Project::Animation* anim = activeAnimationOrCreate(m_project)) {
|
|
for (auto& t : anim->tracks) {
|
|
if (t.targetKind != Project::AnimationTargetKind::Entity ||
|
|
t.targetId != id ||
|
|
t.property != Project::AnimationProperty::Sprite) {
|
|
continue;
|
|
}
|
|
for (int i = 0; i < t.keys.size(); ++i) {
|
|
if (t.keys[i].frame == frame) {
|
|
t.keys.removeAt(i);
|
|
removedAny = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (removedAny) {
|
|
pruneEmptyAnimationTracks(*anim);
|
|
markAnimationDirty(anim->id);
|
|
}
|
|
}
|
|
if (!removedAny) return false;
|
|
const auto afterAnimations = m_project.animations();
|
|
m_project.setAnimations(beforeAnimations);
|
|
return applyAnimations(afterAnimations, 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;
|
|
|
|
const auto tmpAbs = destAbs + ".tmp";
|
|
|
|
#ifdef LT_HAVE_VIPS
|
|
if (VipsBackend::isAvailable()) {
|
|
QSize srcSz;
|
|
if (VipsBackend::probeSize(sourceFilePath, &srcSz) && srcSz.isValid()) {
|
|
const QRect full(0, 0, srcSz.width(), srcSz.height());
|
|
const QRect clamped = cropRectInSourceImage.isNull()
|
|
? full
|
|
: clampRectToImage(cropRectInSourceImage, srcSz);
|
|
if (!clamped.isNull() && clamped.width() > 0 && clamped.height() > 0) {
|
|
if (QFileInfo::exists(tmpAbs)) {
|
|
QFile::remove(tmpAbs);
|
|
}
|
|
if (VipsBackend::saveRectToPng(sourceFilePath, clamped, tmpAbs)) {
|
|
QFile::remove(destAbs);
|
|
if (QFile::rename(tmpAbs, destAbs)) {
|
|
return destRel;
|
|
}
|
|
}
|
|
QFile::remove(tmpAbs);
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
|
|
// 回退:Qt 解码(像素上限),裁剪框需映射到缩放后的 QImage
|
|
core::image_decode::prepareLargeImageReader();
|
|
QImageReader reader(sourceFilePath);
|
|
reader.setAutoTransform(true);
|
|
const QSize srcLogicalSize = reader.size();
|
|
core::image_decode::downscaleReaderIfExceeds(reader, core::image_decode::kWorkspaceMaxPixels);
|
|
QImage img = reader.read();
|
|
if (img.isNull()) {
|
|
return {};
|
|
}
|
|
|
|
QRect crop;
|
|
if (cropRectInSourceImage.isNull()) {
|
|
crop = QRect(0, 0, img.width(), img.height());
|
|
} else if (srcLogicalSize.isValid() && srcLogicalSize.width() > 0 && srcLogicalSize.height() > 0 &&
|
|
img.size() != srcLogicalSize) {
|
|
const QRect clampedSrc = clampRectToImage(cropRectInSourceImage, srcLogicalSize);
|
|
crop = core::image_decode::mapRectBetweenSizes(clampedSrc, srcLogicalSize, img.size());
|
|
} else {
|
|
crop = clampRectToImage(cropRectInSourceImage, img.size());
|
|
}
|
|
crop = crop.intersected(img.rect());
|
|
if (crop.isNull() || crop.width() <= 0 || crop.height() <= 0) {
|
|
return {};
|
|
}
|
|
const QImage cropped = img.copy(crop);
|
|
if (cropped.isNull()) {
|
|
return {};
|
|
}
|
|
|
|
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 = image_file::loadImageLimited(bgAbs, image_decode::kWorkspaceMaxPixels);
|
|
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
|
|
|