update
This commit is contained in:
@@ -53,6 +53,12 @@ public:
|
||||
QVector<QPointF> polygonLocal;
|
||||
// 从背景中抠洞的位置:固定在创建时的 world 坐标,不随实体移动
|
||||
QVector<QPointF> cutoutPolygonWorld;
|
||||
// 背景空缺标识:用于项目树节点和交互,空则按 id 生成默认值
|
||||
QString blackholeId;
|
||||
// 背景空缺可见性:与实体可见性轨道解耦,控制背景空缺是否显示
|
||||
bool blackholeVisible = true;
|
||||
// 背景空缺修复方案:copy_background / use_original_background / model_inpaint(预留)
|
||||
QString blackholeResolvedBy;
|
||||
QPointF originWorld;
|
||||
int depth = 0; // 0..255
|
||||
QString imagePath; // 相对路径,例如 "assets/entities/entity-1.png"
|
||||
|
||||
@@ -25,6 +25,16 @@ QPointF sampledOriginForEntity(const core::Project::Entity& e,
|
||||
return core::sampleLocation(e.locationKeys, localFrame, e.originWorld, core::KeyInterpolation::Linear);
|
||||
}
|
||||
|
||||
QPointF sampledRelativeForEntity(const core::Project::Entity& e,
|
||||
const core::Project::AnimationClip* clipOrNull,
|
||||
int localFrame) {
|
||||
if (clipOrNull && clipOrNull->entityLocationKeys.contains(e.id)) {
|
||||
const auto& keys = clipOrNull->entityLocationKeys.value(e.id);
|
||||
return core::sampleLocation(keys, localFrame, e.parentOffsetWorld, core::KeyInterpolation::Linear);
|
||||
}
|
||||
return core::sampleLocation(e.locationKeys, localFrame, e.parentOffsetWorld, core::KeyInterpolation::Linear);
|
||||
}
|
||||
|
||||
QPointF sampledOriginForTool(const core::Project::Tool& t,
|
||||
const core::Project::AnimationClip* clipOrNull,
|
||||
int localFrame) {
|
||||
@@ -35,6 +45,16 @@ QPointF sampledOriginForTool(const core::Project::Tool& t,
|
||||
return core::sampleLocation(t.locationKeys, localFrame, t.originWorld, core::KeyInterpolation::Linear);
|
||||
}
|
||||
|
||||
QPointF sampledRelativeForTool(const core::Project::Tool& t,
|
||||
const core::Project::AnimationClip* clipOrNull,
|
||||
int localFrame) {
|
||||
if (clipOrNull && clipOrNull->toolLocationKeys.contains(t.id)) {
|
||||
const auto& keys = clipOrNull->toolLocationKeys.value(t.id);
|
||||
return core::sampleLocation(keys, localFrame, t.parentOffsetWorld, core::KeyInterpolation::Linear);
|
||||
}
|
||||
return core::sampleLocation(t.locationKeys, localFrame, t.parentOffsetWorld, core::KeyInterpolation::Linear);
|
||||
}
|
||||
|
||||
struct VisKey {
|
||||
int frame = 0;
|
||||
bool value = true;
|
||||
@@ -227,24 +247,24 @@ ResolvedProjectFrame evaluateAtFrame(const core::Project& project, int frame, in
|
||||
|
||||
const NodeRef r = index.value(id);
|
||||
QString parentId;
|
||||
QPointF off;
|
||||
QPointF selfSampled;
|
||||
if (r.kind == NodeRef::Kind::Entity) {
|
||||
const auto& e = ents[r.index];
|
||||
parentId = e.parentId;
|
||||
off = e.parentOffsetWorld;
|
||||
selfSampled = sampledOriginForEntity(e, clip, localFrame);
|
||||
} else {
|
||||
const auto& t = tools[r.index];
|
||||
parentId = t.parentId;
|
||||
off = t.parentOffsetWorld;
|
||||
selfSampled = sampledOriginForTool(t, clip, localFrame);
|
||||
}
|
||||
|
||||
QPointF outO = selfSampled;
|
||||
if (!parentId.isEmpty() && index.contains(parentId)) {
|
||||
const QPointF po = resolve(parentId);
|
||||
outO = po + off;
|
||||
const QPointF rel = (r.kind == NodeRef::Kind::Entity)
|
||||
? sampledRelativeForEntity(ents[r.index], clip, localFrame)
|
||||
: sampledRelativeForTool(tools[r.index], clip, localFrame);
|
||||
outO = po + rel;
|
||||
}
|
||||
|
||||
resolving.insert(id, false);
|
||||
|
||||
@@ -67,6 +67,9 @@ QJsonObject entityToJson(const core::Project::Entity& e) {
|
||||
o.insert(QStringLiteral("visible"), e.visible);
|
||||
o.insert(QStringLiteral("polygonLocal"), pointsToJson(e.polygonLocal));
|
||||
o.insert(QStringLiteral("cutoutPolygonWorld"), pointsToJson(e.cutoutPolygonWorld));
|
||||
o.insert(QStringLiteral("blackholeId"), e.blackholeId);
|
||||
o.insert(QStringLiteral("blackholeVisible"), e.blackholeVisible);
|
||||
o.insert(QStringLiteral("blackholeResolvedBy"), e.blackholeResolvedBy);
|
||||
o.insert(QStringLiteral("originWorld"), pointToJson(e.originWorld));
|
||||
o.insert(QStringLiteral("depth"), e.depth);
|
||||
o.insert(QStringLiteral("imagePath"), e.imagePath);
|
||||
@@ -152,6 +155,12 @@ bool entityFromJson(const QJsonObject& o, core::Project::Entity& out) {
|
||||
// cutout 允许不存在:按空处理
|
||||
e.cutoutPolygonWorld.clear();
|
||||
}
|
||||
e.blackholeId = o.value(QStringLiteral("blackholeId")).toString();
|
||||
if (e.blackholeId.isEmpty() && !e.id.isEmpty()) {
|
||||
e.blackholeId = QStringLiteral("blackhole-%1").arg(e.id);
|
||||
}
|
||||
e.blackholeVisible = o.value(QStringLiteral("blackholeVisible")).toBool(true);
|
||||
e.blackholeResolvedBy = o.value(QStringLiteral("blackholeResolvedBy")).toString();
|
||||
{
|
||||
QPointF p;
|
||||
if (!pointFromJson(o.value(QStringLiteral("originWorld")), p)) {
|
||||
|
||||
@@ -260,6 +260,13 @@ public:
|
||||
ds << qint32(k.frame) << bool(k.value);
|
||||
}
|
||||
writeIntroBlock(ds, entity);
|
||||
// v8/v9:黑洞元数据(与实体可见性解耦)
|
||||
ds << bool(entity.blackholeVisible);
|
||||
const QString holeId = entity.blackholeId.isEmpty()
|
||||
? QStringLiteral("blackhole-%1").arg(entity.id)
|
||||
: entity.blackholeId;
|
||||
ds << holeId;
|
||||
ds << entity.blackholeResolvedBy;
|
||||
}
|
||||
|
||||
bool readBody(QDataStream& ds) override {
|
||||
@@ -315,6 +322,16 @@ public:
|
||||
if (!readIntroBlock(ds, tmp.intro)) {
|
||||
return false;
|
||||
}
|
||||
bool holeVisible = true;
|
||||
QString holeId;
|
||||
QString resolvedBy;
|
||||
ds >> holeVisible >> holeId >> resolvedBy;
|
||||
if (ds.status() != QDataStream::Ok) {
|
||||
return false;
|
||||
}
|
||||
tmp.blackholeVisible = holeVisible;
|
||||
tmp.blackholeId = holeId.isEmpty() ? QStringLiteral("blackhole-%1").arg(tmp.id) : holeId;
|
||||
tmp.blackholeResolvedBy = resolvedBy;
|
||||
*m_dst = std::move(tmp);
|
||||
return true;
|
||||
}
|
||||
@@ -371,7 +388,7 @@ bool EntityPayloadBinary::load(const QString& absolutePath, Project::Entity& ent
|
||||
if (ds.status() != QDataStream::Ok || magic != kMagicPayload) {
|
||||
return false;
|
||||
}
|
||||
if (ver != 1 && ver != 2 && ver != 3 && ver != 4 && ver != 5 && ver != 6 && ver != 7) {
|
||||
if (ver != 1 && ver != 2 && ver != 3 && ver != 4 && ver != 5 && ver != 6 && ver != 7 && ver != 8 && ver != 9) {
|
||||
return false;
|
||||
}
|
||||
Project::Entity tmp;
|
||||
@@ -442,6 +459,30 @@ bool EntityPayloadBinary::load(const QString& absolutePath, Project::Entity& ent
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (ver >= 8) {
|
||||
bool holeVisible = true;
|
||||
QString holeId;
|
||||
ds >> holeVisible >> holeId;
|
||||
if (ds.status() != QDataStream::Ok) {
|
||||
return false;
|
||||
}
|
||||
tmp.blackholeVisible = holeVisible;
|
||||
tmp.blackholeId = holeId.isEmpty() ? QStringLiteral("blackhole-%1").arg(tmp.id) : holeId;
|
||||
if (ver >= 9) {
|
||||
QString resolvedBy;
|
||||
ds >> resolvedBy;
|
||||
if (ds.status() != QDataStream::Ok) {
|
||||
return false;
|
||||
}
|
||||
tmp.blackholeResolvedBy = resolvedBy;
|
||||
} else {
|
||||
tmp.blackholeResolvedBy = QStringLiteral("pending");
|
||||
}
|
||||
} else {
|
||||
tmp.blackholeVisible = true;
|
||||
tmp.blackholeId = QStringLiteral("blackhole-%1").arg(tmp.id);
|
||||
tmp.blackholeResolvedBy = QStringLiteral("pending");
|
||||
}
|
||||
} else {
|
||||
tmp.displayName.clear();
|
||||
tmp.userScale = 1.0;
|
||||
@@ -449,6 +490,9 @@ bool EntityPayloadBinary::load(const QString& absolutePath, Project::Entity& ent
|
||||
tmp.parentId.clear();
|
||||
tmp.parentOffsetWorld = QPointF();
|
||||
tmp.visibilityKeys.clear();
|
||||
tmp.blackholeVisible = true;
|
||||
tmp.blackholeId = QStringLiteral("blackhole-%1").arg(tmp.id);
|
||||
tmp.blackholeResolvedBy = QStringLiteral("pending");
|
||||
}
|
||||
entity = std::move(tmp);
|
||||
return true;
|
||||
|
||||
@@ -12,7 +12,7 @@ namespace core {
|
||||
class EntityPayloadBinary {
|
||||
public:
|
||||
static constexpr quint32 kMagicPayload = 0x48464550; // 'HFEP'
|
||||
static constexpr quint32 kPayloadVersion = 7; // v7:追加 visibilityKeys
|
||||
static constexpr quint32 kPayloadVersion = 9; // v9:追加 blackholeResolvedBy
|
||||
|
||||
// 旧版独立动画文件(仍用于打开 v1 项目时合并)
|
||||
static constexpr quint32 kMagicLegacyAnim = 0x48465441; // 'HFTA'
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "workspace/ProjectWorkspace.h"
|
||||
#include "animation/AnimationSampling.h"
|
||||
#include "eval/ProjectEvaluator.h"
|
||||
#include "persistence/EntityPayloadBinary.h"
|
||||
#include "depth/DepthService.h"
|
||||
#include "net/ModelServerClient.h"
|
||||
@@ -13,9 +14,14 @@
|
||||
#include <QJsonObject>
|
||||
#include <QJsonValue>
|
||||
#include <QImage>
|
||||
#include <QImageWriter>
|
||||
#include <QImageReader>
|
||||
#include <QUrl>
|
||||
#include <QHash>
|
||||
#include <QSet>
|
||||
#include <QPainter>
|
||||
#include <QPainterPath>
|
||||
#include <QPolygonF>
|
||||
|
||||
#include <cmath>
|
||||
|
||||
@@ -67,6 +73,24 @@ QPointF entityPolygonCentroidWorld(const Project::Entity& e, int frame, double s
|
||||
return polygonCentroidFromWorldPoints(w);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
QString ensureDir(const QString& path) {
|
||||
QDir dir(path);
|
||||
@@ -632,6 +656,10 @@ bool ProjectWorkspace::writeIndexJson() {
|
||||
if (!m_projectDir.isEmpty() && !syncEntityPayloadsToDisk()) {
|
||||
return false;
|
||||
}
|
||||
return writeIndexJsonWithoutPayloadSync();
|
||||
}
|
||||
|
||||
bool ProjectWorkspace::writeIndexJsonWithoutPayloadSync() {
|
||||
const auto root = projectToJson(m_project);
|
||||
QJsonDocument doc(root);
|
||||
|
||||
@@ -1153,6 +1181,9 @@ bool ProjectWorkspace::entityStubFromJsonV2(const QJsonObject& o, Project::Entit
|
||||
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;
|
||||
}
|
||||
@@ -1238,6 +1269,9 @@ bool ProjectWorkspace::entityFromJsonV1(const QJsonObject& o, Project::Entity& o
|
||||
out.cutoutPolygonWorld.push_back(out.originWorld + lp);
|
||||
}
|
||||
}
|
||||
out.blackholeVisible = true;
|
||||
out.blackholeId = QStringLiteral("blackhole-%1").arg(out.id);
|
||||
out.blackholeResolvedBy = QStringLiteral("pending");
|
||||
|
||||
// 旧版:关键帧内嵌在 project.json;若存在对应 .anim 文件,打开项目时会被二进制数据覆盖。
|
||||
const auto lk = o.value("locationKeys");
|
||||
@@ -1419,6 +1453,27 @@ bool ProjectWorkspace::syncEntityPayloadsToDisk() {
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ProjectWorkspace::saveSingleEntityPayload(Project::Entity& entity) {
|
||||
if (m_projectDir.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
if (ensureEntitiesDir().isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
entity.legacyAnimSidecarPath.clear();
|
||||
if (entity.entityPayloadPath.isEmpty()) {
|
||||
entity.entityPayloadPath =
|
||||
QString::fromUtf8(kAssetsDirName) + QStringLiteral("/entities/") + entity.id + QStringLiteral(".hfe");
|
||||
}
|
||||
const QString rel = asRelativeUnderProject(entity.entityPayloadPath);
|
||||
if (rel.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
entity.entityPayloadPath = rel;
|
||||
const QString abs = QDir(m_projectDir).filePath(rel);
|
||||
return EntityPayloadBinary::save(abs, entity);
|
||||
}
|
||||
|
||||
bool ProjectWorkspace::hydrateEntityPayloadsFromDisk() {
|
||||
if (m_projectDir.isEmpty()) {
|
||||
return true;
|
||||
@@ -1602,6 +1657,12 @@ bool ProjectWorkspace::addEntity(const Project::Entity& entity, const QImage& im
|
||||
e.entityPayloadPath =
|
||||
QString::fromUtf8(kAssetsDirName) + QStringLiteral("/entities/") + e.id + QStringLiteral(".hfe");
|
||||
}
|
||||
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);
|
||||
@@ -1629,6 +1690,185 @@ bool ProjectWorkspace::setEntityVisible(const QString& id, bool on) {
|
||||
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");
|
||||
|
||||
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(bgAbs);
|
||||
if (bg.isNull()) {
|
||||
return false;
|
||||
}
|
||||
if (bg.format() != QImage::Format_ARGB32_Premultiplied) {
|
||||
bg = bg.convertToFormat(QImage::Format_ARGB32_Premultiplied);
|
||||
}
|
||||
const QImage srcSnapshot = bg;
|
||||
|
||||
QPainterPath holePath;
|
||||
holePath.addPolygon(QPolygonF(ent.cutoutPolygonWorld));
|
||||
holePath.closeSubpath();
|
||||
const QRect targetRect = holePath.boundingRect().toAlignedRect().intersected(QRect(QPoint(0, 0), bg.size()));
|
||||
if (!targetRect.isValid() || targetRect.width() <= 0 || targetRect.height() <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
QRect srcRect(targetRect.topLeft() + sourceOffsetPx, targetRect.size());
|
||||
if (srcRect.left() < 0) srcRect.moveLeft(0);
|
||||
if (srcRect.top() < 0) srcRect.moveTop(0);
|
||||
if (srcRect.right() >= bg.width()) srcRect.moveRight(bg.width() - 1);
|
||||
if (srcRect.bottom() >= bg.height()) srcRect.moveBottom(bg.height() - 1);
|
||||
srcRect = srcRect.intersected(QRect(QPoint(0, 0), bg.size()));
|
||||
if (srcRect.width() != targetRect.width() || srcRect.height() != targetRect.height()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
{
|
||||
QPainter p(&bg);
|
||||
p.setRenderHint(QPainter::Antialiasing, true);
|
||||
p.setClipPath(holePath);
|
||||
p.drawImage(targetRect.topLeft(), srcSnapshot, srcRect);
|
||||
p.end();
|
||||
}
|
||||
QImageWriter writer(bgAbs);
|
||||
writer.setFormat("png");
|
||||
writer.setCompression(1);
|
||||
if (!writer.write(bg)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const auto before = m_project.entities();
|
||||
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::setEntityVisibilityKey(const QString& id, int frame, bool visible) {
|
||||
if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) {
|
||||
return false;
|
||||
@@ -1804,12 +2044,78 @@ bool ProjectWorkspace::setEntityParent(const QString& id, const QString& parentI
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
// 转换 clip 与内嵌 key(兼容 v1/v2/v3 数据来源)
|
||||
if (Project::AnimationClip* clip = activeClipOrNull(m_project)) {
|
||||
if (clip->entityLocationKeys.contains(e.id)) {
|
||||
auto k = clip->entityLocationKeys.value(e.id);
|
||||
convertKeys(k, oldPid, parentId);
|
||||
clip->entityLocationKeys.insert(e.id, k);
|
||||
}
|
||||
}
|
||||
convertKeys(e.locationKeys, oldPid, parentId);
|
||||
|
||||
// 更新父子信息
|
||||
e.parentId = parentId;
|
||||
e.parentOffsetWorld = parentOffsetWorld;
|
||||
|
||||
// 更新基准值:无 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;
|
||||
@@ -1951,16 +2257,35 @@ bool ProjectWorkspace::moveEntityBy(const QString& id, const QPointF& delta, int
|
||||
continue;
|
||||
}
|
||||
found = true;
|
||||
// 父子关系:若该实体绑定父实体,则移动应修改相对偏移(保持“跟随父实体”语义)。
|
||||
if (!e.parentId.isEmpty()) {
|
||||
e.parentOffsetWorld += delta;
|
||||
break;
|
||||
}
|
||||
Project::AnimationClip* clip = activeClipOrNull(m_project);
|
||||
QVector<Project::Entity::KeyframeVec2>* keys = nullptr;
|
||||
if (clip) {
|
||||
keys = &clip->entityLocationKeys[e.id];
|
||||
}
|
||||
// 父子关系:绑定父对象时,位置曲线表示“相对父对象偏移”。
|
||||
if (!e.parentId.isEmpty()) {
|
||||
const bool hasCurve = (keys && !keys->isEmpty()) || (!e.locationKeys.isEmpty());
|
||||
if (autoKeyLocation && currentFrame >= 0) {
|
||||
const QPointF sampled =
|
||||
(keys)
|
||||
? sampleLocation(*keys, currentFrame, e.parentOffsetWorld, KeyInterpolation::Linear)
|
||||
: sampleLocation(e.locationKeys, currentFrame, e.parentOffsetWorld, KeyInterpolation::Linear);
|
||||
if (keys) upsertKey(*keys, currentFrame, sampled + delta);
|
||||
else upsertKey(e.locationKeys, currentFrame, sampled + delta);
|
||||
} else if (!hasCurve) {
|
||||
e.parentOffsetWorld += delta;
|
||||
} else if (currentFrame >= 0) {
|
||||
const QPointF sampled =
|
||||
(keys)
|
||||
? sampleLocation(*keys, currentFrame, e.parentOffsetWorld, KeyInterpolation::Linear)
|
||||
: sampleLocation(e.locationKeys, currentFrame, e.parentOffsetWorld, KeyInterpolation::Linear);
|
||||
if (keys) upsertKey(*keys, currentFrame, sampled + delta);
|
||||
else upsertKey(e.locationKeys, currentFrame, sampled + delta);
|
||||
} else {
|
||||
e.parentOffsetWorld += delta;
|
||||
}
|
||||
break;
|
||||
}
|
||||
const bool hasCurve = (keys && !keys->isEmpty()) || (!e.locationKeys.isEmpty());
|
||||
if (autoKeyLocation && currentFrame >= 0) {
|
||||
const QPointF sampled =
|
||||
@@ -2139,12 +2464,66 @@ bool ProjectWorkspace::setToolParent(const QString& id, const QString& 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);
|
||||
|
||||
if (Project::AnimationClip* clip = activeClipOrNull(m_project)) {
|
||||
if (clip->toolLocationKeys.contains(t.id)) {
|
||||
auto k = clip->toolLocationKeys.value(t.id);
|
||||
convertKeys(k, oldPid, parentId);
|
||||
clip->toolLocationKeys.insert(t.id, k);
|
||||
}
|
||||
}
|
||||
convertKeys(t.locationKeys, oldPid, parentId);
|
||||
|
||||
t.parentId = parentId;
|
||||
t.parentOffsetWorld = parentOffsetWorld;
|
||||
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;
|
||||
@@ -2163,15 +2542,34 @@ bool ProjectWorkspace::moveToolBy(const QString& id, const QPointF& delta, int c
|
||||
for (auto& t : tools) {
|
||||
if (t.id != id) continue;
|
||||
found = true;
|
||||
if (!t.parentId.isEmpty()) {
|
||||
t.parentOffsetWorld += delta;
|
||||
break;
|
||||
}
|
||||
Project::AnimationClip* clip = activeClipOrNull(m_project);
|
||||
QVector<Project::Entity::KeyframeVec2>* keys = nullptr;
|
||||
if (clip) {
|
||||
keys = &clip->toolLocationKeys[t.id];
|
||||
}
|
||||
if (!t.parentId.isEmpty()) {
|
||||
const bool hasCurve = (keys && !keys->isEmpty()) || (!t.locationKeys.isEmpty());
|
||||
if (autoKeyLocation && currentFrame >= 0) {
|
||||
const QPointF sampled =
|
||||
(keys)
|
||||
? sampleLocation(*keys, currentFrame, t.parentOffsetWorld, KeyInterpolation::Linear)
|
||||
: sampleLocation(t.locationKeys, currentFrame, t.parentOffsetWorld, KeyInterpolation::Linear);
|
||||
if (keys) upsertKey(*keys, currentFrame, sampled + delta);
|
||||
else upsertKey(t.locationKeys, currentFrame, sampled + delta);
|
||||
} else if (!hasCurve) {
|
||||
t.parentOffsetWorld += delta;
|
||||
} else if (currentFrame >= 0) {
|
||||
const QPointF sampled =
|
||||
(keys)
|
||||
? sampleLocation(*keys, currentFrame, t.parentOffsetWorld, KeyInterpolation::Linear)
|
||||
: sampleLocation(t.locationKeys, currentFrame, t.parentOffsetWorld, KeyInterpolation::Linear);
|
||||
if (keys) upsertKey(*keys, currentFrame, sampled + delta);
|
||||
else upsertKey(t.locationKeys, currentFrame, sampled + delta);
|
||||
} else {
|
||||
t.parentOffsetWorld += delta;
|
||||
}
|
||||
break;
|
||||
}
|
||||
const bool hasCurve = (keys && !keys->isEmpty()) || (!t.locationKeys.isEmpty());
|
||||
if (autoKeyLocation && currentFrame >= 0) {
|
||||
const QPointF sampled =
|
||||
@@ -2207,8 +2605,16 @@ bool ProjectWorkspace::setEntityLocationKey(const QString& id, int frame, const
|
||||
}
|
||||
Project::AnimationClip* clip = activeClipOrNull(m_project);
|
||||
if (!clip) 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;
|
||||
}
|
||||
}
|
||||
auto& keys = clip->entityLocationKeys[id];
|
||||
upsertKey(keys, frame, originWorld);
|
||||
upsertKey(keys, frame, keyValue);
|
||||
return writeIndexJson();
|
||||
}
|
||||
|
||||
@@ -2406,7 +2812,25 @@ QString ProjectWorkspace::copyIntoAssetsAsBackground(const QString& sourceFilePa
|
||||
const auto destAbs = QDir(assetsDir).filePath(fileName);
|
||||
const auto destRel = QString::fromUtf8(kAssetsDirName) + "/" + fileName;
|
||||
|
||||
QImage img(sourceFilePath);
|
||||
// Qt 默认的 image allocation limit 较小,超大分辨率背景可能会被拒绝。
|
||||
// 这里提高 limit,并对极端大图按像素数上限自动缩放后再裁剪落盘。
|
||||
#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)
|
||||
QImageReader::setAllocationLimit(1024); // MB
|
||||
#endif
|
||||
QImageReader reader(sourceFilePath);
|
||||
reader.setAutoTransform(true);
|
||||
const QSize sz = reader.size();
|
||||
if (sz.isValid()) {
|
||||
constexpr qint64 kMaxPixels = 160LL * 1000LL * 1000LL; // 160MP
|
||||
const qint64 pixels = qint64(sz.width()) * qint64(sz.height());
|
||||
if (pixels > kMaxPixels) {
|
||||
const double s = std::sqrt(double(kMaxPixels) / std::max<double>(1.0, double(pixels)));
|
||||
const int nw = std::max(1, int(std::lround(sz.width() * s)));
|
||||
const int nh = std::max(1, int(std::lround(sz.height() * s)));
|
||||
reader.setScaledSize(QSize(nw, nh));
|
||||
}
|
||||
}
|
||||
QImage img = reader.read();
|
||||
if (img.isNull()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <QVector>
|
||||
#include <QPoint>
|
||||
|
||||
namespace core {
|
||||
|
||||
@@ -95,6 +96,11 @@ public:
|
||||
bool moveToolBy(const QString& id, const QPointF& delta, int currentFrame, bool autoKeyLocation);
|
||||
bool addEntity(const Project::Entity& entity, const QImage& image);
|
||||
bool setEntityVisible(const QString& id, bool on);
|
||||
bool setEntityBlackholeVisible(const QString& id, bool on);
|
||||
bool resolveBlackholeByUseOriginalBackground(const QString& id);
|
||||
// 复制背景其他区域填充黑洞(sourceOffsetPx 以黑洞包围盒左上角为基准偏移)
|
||||
bool resolveBlackholeByCopyBackground(const QString& id, const QPoint& sourceOffsetPx,
|
||||
bool hideBlackholeAfterFill);
|
||||
bool setEntityVisibilityKey(const QString& id, int frame, bool visible);
|
||||
bool removeEntityVisibilityKey(const QString& id, int frame);
|
||||
bool setEntityDisplayName(const QString& id, const QString& displayName);
|
||||
@@ -133,6 +139,8 @@ private:
|
||||
bool syncEntityPayloadsToDisk();
|
||||
bool hydrateEntityPayloadsFromDisk();
|
||||
void loadV1LegacyAnimationSidecars();
|
||||
bool writeIndexJsonWithoutPayloadSync();
|
||||
bool saveSingleEntityPayload(Project::Entity& entity);
|
||||
|
||||
static QJsonObject projectToJson(const Project& project);
|
||||
static bool projectFromJson(const QJsonObject& root, Project& outProject, int* outFileVersion);
|
||||
|
||||
Reference in New Issue
Block a user