diff --git a/client/core/domain/Project.h b/client/core/domain/Project.h index 6e70d14..1fb4163 100644 --- a/client/core/domain/Project.h +++ b/client/core/domain/Project.h @@ -53,6 +53,12 @@ public: QVector polygonLocal; // 从背景中抠洞的位置:固定在创建时的 world 坐标,不随实体移动 QVector 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" diff --git a/client/core/eval/ProjectEvaluator.cpp b/client/core/eval/ProjectEvaluator.cpp index 7b0fca5..cc288d8 100644 --- a/client/core/eval/ProjectEvaluator.cpp +++ b/client/core/eval/ProjectEvaluator.cpp @@ -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); diff --git a/client/core/library/EntityJson.cpp b/client/core/library/EntityJson.cpp index da3c8c7..35c9fbd 100644 --- a/client/core/library/EntityJson.cpp +++ b/client/core/library/EntityJson.cpp @@ -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)) { diff --git a/client/core/persistence/EntityPayloadBinary.cpp b/client/core/persistence/EntityPayloadBinary.cpp index d152fa7..f6de3ef 100644 --- a/client/core/persistence/EntityPayloadBinary.cpp +++ b/client/core/persistence/EntityPayloadBinary.cpp @@ -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; diff --git a/client/core/persistence/EntityPayloadBinary.h b/client/core/persistence/EntityPayloadBinary.h index 19f898c..aa716b0 100644 --- a/client/core/persistence/EntityPayloadBinary.h +++ b/client/core/persistence/EntityPayloadBinary.h @@ -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' diff --git a/client/core/workspace/ProjectWorkspace.cpp b/client/core/workspace/ProjectWorkspace.cpp index 6689e04..3a6611d 100644 --- a/client/core/workspace/ProjectWorkspace.cpp +++ b/client/core/workspace/ProjectWorkspace.cpp @@ -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 #include #include +#include +#include #include #include #include +#include +#include +#include #include @@ -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(&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& 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* 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& 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* 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(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 {}; } diff --git a/client/core/workspace/ProjectWorkspace.h b/client/core/workspace/ProjectWorkspace.h index df83b9c..80a8cf3 100644 --- a/client/core/workspace/ProjectWorkspace.h +++ b/client/core/workspace/ProjectWorkspace.h @@ -8,6 +8,7 @@ #include #include #include +#include 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); diff --git a/client/gui/CMakeLists.txt b/client/gui/CMakeLists.txt index 7c84fc9..693ddee 100644 --- a/client/gui/CMakeLists.txt +++ b/client/gui/CMakeLists.txt @@ -13,11 +13,13 @@ set(GUI_SOURCES ${GUI_ROOT}/dialogs/FrameAnimationDialog.cpp ${GUI_ROOT}/dialogs/CancelableTaskDialog.cpp ${GUI_ROOT}/dialogs/EntityFinalizeDialog.cpp + ${GUI_ROOT}/dialogs/BlackholeResolveDialog.cpp ${GUI_ROOT}/dialogs/EntityIntroPopup.cpp ${GUI_ROOT}/editor/EditorCanvas.cpp ${GUI_ROOT}/editor/EntityCutoutUtils.cpp ${GUI_ROOT}/params/ParamControls.cpp ${GUI_ROOT}/props/BackgroundPropertySection.cpp + ${GUI_ROOT}/props/BlackholePropertySection.cpp ${GUI_ROOT}/props/EntityPropertySection.cpp ${GUI_ROOT}/props/ToolPropertySection.cpp ${GUI_ROOT}/timeline/TimelineWidget.cpp @@ -34,11 +36,13 @@ set(GUI_HEADERS ${GUI_ROOT}/dialogs/FrameAnimationDialog.h ${GUI_ROOT}/dialogs/CancelableTaskDialog.h ${GUI_ROOT}/dialogs/EntityFinalizeDialog.h + ${GUI_ROOT}/dialogs/BlackholeResolveDialog.h ${GUI_ROOT}/dialogs/EntityIntroPopup.h ${GUI_ROOT}/editor/EditorCanvas.h ${GUI_ROOT}/editor/EntityCutoutUtils.h ${GUI_ROOT}/params/ParamControls.h ${GUI_ROOT}/props/BackgroundPropertySection.h + ${GUI_ROOT}/props/BlackholePropertySection.h ${GUI_ROOT}/props/EntityPropertySection.h ${GUI_ROOT}/props/ToolPropertySection.h ${GUI_ROOT}/props/PropertySectionWidget.h diff --git a/client/gui/app/main.cpp b/client/gui/app/main.cpp index fe0918b..2eacf72 100644 --- a/client/gui/app/main.cpp +++ b/client/gui/app/main.cpp @@ -1,11 +1,17 @@ #include "main_window/MainWindow.h" #include +#include int main(int argc, char* argv[]) { QApplication app(argc, argv); app.setApplicationName(QStringLiteral("landscape tool")); + // 全局放宽 Qt 图片分配限制(默认常见为 256MB),否则超大分辨率背景/深度可能在任意加载路径被拒绝。 +#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) + QImageReader::setAllocationLimit(1024); // MB +#endif + MainWindow window; window.show(); diff --git a/client/gui/dialogs/BlackholeResolveDialog.cpp b/client/gui/dialogs/BlackholeResolveDialog.cpp new file mode 100644 index 0000000..bf8d261 --- /dev/null +++ b/client/gui/dialogs/BlackholeResolveDialog.cpp @@ -0,0 +1,175 @@ +#include "dialogs/BlackholeResolveDialog.h" + +#include +#include +#include +#include +#include +#include + +namespace { + +QPushButton* makeAlgoButton(const QString& title, const QString& subtitle, QWidget* parent) { + auto* btn = new QPushButton(parent); + btn->setCheckable(false); + btn->setCursor(Qt::PointingHandCursor); + btn->setMinimumHeight(86); + btn->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + btn->setText(title + QStringLiteral("\n") + subtitle); + btn->setStyleSheet( + "QPushButton { text-align: left; padding: 10px 12px; border: 1px solid palette(mid); border-radius: 8px; }" + "QPushButton:hover { border-color: palette(highlight); }"); + return btn; +} + +} // namespace + +BlackholeResolveDialog::BlackholeResolveDialog(const QString& blackholeName, QWidget* parent) + : QDialog(parent), + m_blackholeName(blackholeName) { + setModal(true); + setMinimumSize(560, 420); + setWindowTitle(QStringLiteral("黑洞解决")); + + auto* root = new QVBoxLayout(this); + m_pages = new QStackedWidget(this); + root->addWidget(m_pages, 1); + + buildSelectPage(); + buildDetailPage(); + m_pages->setCurrentWidget(m_pageSelect); +} + +void BlackholeResolveDialog::buildSelectPage() { + m_pageSelect = new QWidget(this); + auto* layout = new QVBoxLayout(m_pageSelect); + layout->setContentsMargins(8, 8, 8, 8); + layout->setSpacing(12); + + auto* title = new QLabel(QStringLiteral("第 1 步:选择黑洞解决算法"), m_pageSelect); + auto* sub = new QLabel(QStringLiteral("当前黑洞:%1").arg(m_blackholeName), m_pageSelect); + title->setStyleSheet("font-size: 18px; font-weight: 600;"); + sub->setStyleSheet("color: palette(mid);"); + layout->addWidget(title); + layout->addWidget(sub); + + auto* btnCopy = makeAlgoButton( + QStringLiteral("复制背景其他区域"), + QStringLiteral("进入画布拖动取样框,直观选择复制来源。"), + m_pageSelect); + auto* btnOriginal = makeAlgoButton( + QStringLiteral("使用原始背景"), + QStringLiteral("撤销黑洞显示,恢复抠图前背景区域。"), + m_pageSelect); + layout->addWidget(btnCopy); + layout->addWidget(btnOriginal); + + auto* modelNote = new QLabel( + QStringLiteral("模型补全:已预留接口,本版本暂不实现。"), + m_pageSelect); + modelNote->setStyleSheet("color: palette(mid);"); + layout->addWidget(modelNote); + layout->addStretch(1); + + auto* btns = new QDialogButtonBox(QDialogButtonBox::Cancel, m_pageSelect); + connect(btns, &QDialogButtonBox::rejected, this, &QDialog::reject); + layout->addWidget(btns); + + connect(btnCopy, &QPushButton::clicked, this, [this]() { + enterAlgorithmPage(Algorithm::CopyBackgroundRegion); + }); + connect(btnOriginal, &QPushButton::clicked, this, [this]() { + enterAlgorithmPage(Algorithm::UseOriginalBackground); + }); + + m_pages->addWidget(m_pageSelect); +} + +void BlackholeResolveDialog::buildDetailPage() { + m_pageDetail = new QWidget(this); + auto* layout = new QVBoxLayout(m_pageDetail); + layout->setContentsMargins(8, 8, 8, 8); + layout->setSpacing(10); + + m_detailTitle = new QLabel(m_pageDetail); + m_detailTitle->setStyleSheet("font-size: 18px; font-weight: 600;"); + m_detailHint = new QLabel(m_pageDetail); + m_detailHint->setWordWrap(true); + m_detailHint->setStyleSheet("color: palette(mid);"); + layout->addWidget(m_detailTitle); + layout->addWidget(m_detailHint); + + m_algoDetails = new QStackedWidget(m_pageDetail); + + // 详情页 A:复制背景其他区域(交互布局) + m_copyDetail = new QWidget(m_algoDetails); + { + auto* cLay = new QVBoxLayout(m_copyDetail); + cLay->setSpacing(8); + + auto* panel = new QFrame(m_copyDetail); + panel->setFrameShape(QFrame::StyledPanel); + auto* pLay = new QVBoxLayout(panel); + pLay->setSpacing(8); + + auto* tip = new QLabel( + QStringLiteral("说明:点击“应用”后进入画布拖动模式。\n在画布中拖动青色取样框,松开鼠标即可将该区域复制到黑洞位置并自动移除黑洞。"), + panel); + tip->setWordWrap(true); + tip->setStyleSheet("color: palette(mid);"); + pLay->addWidget(tip); + cLay->addWidget(panel); + cLay->addStretch(1); + } + + // 详情页 B:使用原始背景(确认布局) + m_originalDetail = new QWidget(m_algoDetails); + { + auto* oLay = new QVBoxLayout(m_originalDetail); + oLay->setSpacing(8); + auto* desc = new QLabel( + QStringLiteral("该方案不会改动背景像素文件,只会将黑洞切换为不显示,从而恢复原始背景区域。"), + m_originalDetail); + desc->setWordWrap(true); + auto* note = new QLabel( + QStringLiteral("适用场景:当前黑洞区域无需二次修补,只需恢复抠图前背景;应用后黑洞会自动移除。"), + m_originalDetail); + note->setWordWrap(true); + note->setStyleSheet("color: palette(mid);"); + oLay->addWidget(desc); + oLay->addWidget(note); + oLay->addStretch(1); + } + + m_algoDetails->addWidget(m_copyDetail); + m_algoDetails->addWidget(m_originalDetail); + layout->addWidget(m_algoDetails, 1); + + auto* btns = new QDialogButtonBox(m_pageDetail); + auto* btnBack = btns->addButton(QStringLiteral("上一步"), QDialogButtonBox::ActionRole); + auto* btnApply = btns->addButton(QStringLiteral("应用"), QDialogButtonBox::AcceptRole); + auto* btnCancel = btns->addButton(QDialogButtonBox::Cancel); + connect(btnBack, &QPushButton::clicked, this, [this]() { + m_pages->setCurrentWidget(m_pageSelect); + }); + connect(btnApply, &QPushButton::clicked, this, &QDialog::accept); + connect(btnCancel, &QPushButton::clicked, this, &QDialog::reject); + layout->addWidget(btns); + + m_pages->addWidget(m_pageDetail); +} + +void BlackholeResolveDialog::enterAlgorithmPage(Algorithm algo) { + m_selectedAlgorithm = algo; + if (algo == Algorithm::CopyBackgroundRegion) { + m_detailTitle->setText(QStringLiteral("第 2 步:复制背景其他区域")); + m_detailHint->setText(QStringLiteral("准备进入画布拖动取样框模式。")); + m_algoDetails->setCurrentWidget(m_copyDetail); + } else { + m_detailTitle->setText(QStringLiteral("第 2 步:使用原始背景")); + m_detailHint->setText(QStringLiteral("确认后将切换为原始背景显示。")); + m_algoDetails->setCurrentWidget(m_originalDetail); + } + m_pages->setCurrentWidget(m_pageDetail); +} + diff --git a/client/gui/dialogs/BlackholeResolveDialog.h b/client/gui/dialogs/BlackholeResolveDialog.h new file mode 100644 index 0000000..d477f8b --- /dev/null +++ b/client/gui/dialogs/BlackholeResolveDialog.h @@ -0,0 +1,42 @@ +#pragma once + +#include + +class QLabel; +class QStackedWidget; + +class BlackholeResolveDialog final : public QDialog { + Q_OBJECT +public: + enum class Algorithm { + CopyBackgroundRegion, + UseOriginalBackground, + }; + + explicit BlackholeResolveDialog(const QString& blackholeName, QWidget* parent = nullptr); + + Algorithm selectedAlgorithm() const { return m_selectedAlgorithm; } + +private: + void buildSelectPage(); + void buildDetailPage(); + void enterAlgorithmPage(Algorithm algo); + +private: + QString m_blackholeName; + Algorithm m_selectedAlgorithm = Algorithm::UseOriginalBackground; + + QStackedWidget* m_pages = nullptr; + + QWidget* m_pageSelect = nullptr; + QWidget* m_pageDetail = nullptr; + + QLabel* m_detailTitle = nullptr; + QLabel* m_detailHint = nullptr; + QStackedWidget* m_algoDetails = nullptr; + + QWidget* m_copyDetail = nullptr; + + QWidget* m_originalDetail = nullptr; +}; + diff --git a/client/gui/dialogs/FrameAnimationDialog.cpp b/client/gui/dialogs/FrameAnimationDialog.cpp index 65f2a88..747fe0e 100644 --- a/client/gui/dialogs/FrameAnimationDialog.cpp +++ b/client/gui/dialogs/FrameAnimationDialog.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -171,7 +172,25 @@ void FrameAnimationDialog::updatePreviewForFrame(int frame) { } bool FrameAnimationDialog::applyImageToFrame(int frame, const QString& absImagePath) { - QImage img(absImagePath); + // Qt 默认的 image allocation limit 较小,超大分辨率图可能会被拒绝。 + // 这里提高 limit,并对极端大图按像素数上限自动缩放,避免 OOM。 +#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) + QImageReader::setAllocationLimit(1024); // MB +#endif + QImageReader reader(absImagePath); + reader.setAutoTransform(true); + const QSize sz = reader.size(); + if (sz.isValid()) { + constexpr qint64 kMaxPixels = 120LL * 1000LL * 1000LL; // 120MP + const qint64 pixels = qint64(sz.width()) * qint64(sz.height()); + if (pixels > kMaxPixels) { + const double s = std::sqrt(double(kMaxPixels) / std::max(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 false; } diff --git a/client/gui/dialogs/ImageCropDialog.cpp b/client/gui/dialogs/ImageCropDialog.cpp index fb5e3bf..9d7cf38 100644 --- a/client/gui/dialogs/ImageCropDialog.cpp +++ b/client/gui/dialogs/ImageCropDialog.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include class ImageCropDialog::CropView final : public QWidget { @@ -159,7 +160,25 @@ ImageCropDialog::ImageCropDialog(const QString& imagePath, QWidget* parent) } void ImageCropDialog::loadImageOrClose() { - m_image = QImage(m_imagePath); + // Qt 默认的 image allocation limit 较小(常见为 256MB),超大分辨率图会被拒绝。 + // 这里用 QImageReader 并提高 limit;同时对极端大图按像素数上限自动缩放,避免 OOM。 +#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) + QImageReader::setAllocationLimit(1024); // MB +#endif + QImageReader reader(m_imagePath); + reader.setAutoTransform(true); + const QSize sz = reader.size(); + if (sz.isValid()) { + constexpr qint64 kMaxPixels = 120LL * 1000LL * 1000LL; // 120MP + const qint64 pixels = qint64(sz.width()) * qint64(sz.height()); + if (pixels > kMaxPixels) { + const double s = std::sqrt(double(kMaxPixels) / std::max(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)); + } + } + m_image = reader.read(); if (m_image.isNull()) { reject(); } diff --git a/client/gui/editor/EditorCanvas.cpp b/client/gui/editor/EditorCanvas.cpp index 4846f10..e3aa164 100644 --- a/client/gui/editor/EditorCanvas.cpp +++ b/client/gui/editor/EditorCanvas.cpp @@ -19,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -37,6 +38,51 @@ constexpr int kMinStrokePointsSam = 4; constexpr int kMinStrokePointsManual = 8; constexpr int kMaxSamPointPrompts = 32; +static QImage readImageTolerant(const QString& absPath) { + if (absPath.isEmpty()) { + return {}; + } +#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) + // Qt 默认限制常见为 256MB;超大分辨率背景/深度可能会被拒绝。 + QImageReader::setAllocationLimit(1024); // MB +#endif + QImageReader reader(absPath); + 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(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)); + } + } + return reader.read(); +} + +QRectF clampRectTopLeftToBounds(const QRectF& rect, const QRectF& bounds) { + if (rect.isNull() || bounds.isNull()) { + return rect; + } + QRectF out = rect; + if (out.width() > bounds.width()) { + out.setWidth(bounds.width()); + } + if (out.height() > bounds.height()) { + out.setHeight(bounds.height()); + } + QPointF tl = out.topLeft(); + if (tl.x() < bounds.left()) tl.setX(bounds.left()); + if (tl.y() < bounds.top()) tl.setY(bounds.top()); + if (tl.x() + out.width() > bounds.right()) tl.setX(bounds.right() - out.width()); + if (tl.y() + out.height() > bounds.bottom()) tl.setY(bounds.bottom() - out.height()); + out.moveTopLeft(tl); + return out; +} + QVector snapStrokeToEdges(const QVector& strokeWorld, const QImage& bgImage, int searchRadiusPx) { if (strokeWorld.size() < 3 || bgImage.isNull()) { return strokeWorld; @@ -100,6 +146,12 @@ bool buildSamSegmentPayloadFromStroke( if (strokeWorld.size() < kMinStrokePointsSam || bgImage.isNull()) { return false; } + outCropPng.clear(); + outOverlayPng.clear(); + outPointCoords = QJsonArray{}; + outPointLabels = QJsonArray{}; + outBoxXyxy = QJsonArray{}; + const QRectF polyBr = QPolygonF(strokeWorld).boundingRect(); if (polyBr.isEmpty()) { return false; @@ -148,30 +200,35 @@ bool buildSamSegmentPayloadFromStroke( } bufOv.close(); + // 关键修复: + // 用户“圈选”通常是在实体外侧画一圈。原实现把笔画点全当作前景点(1), + // 会让 SAM 倾向于把圈线/裁剪边缘当成前景,从而出现“沿小块图像边缘贴边”的 mask。 + // 新策略:圈内给一个前景点(1),圈线采样一些背景点(0)抑制外侧区域。 + auto clampD = [](double v, double lo, double hi) { return std::clamp(v, lo, hi); }; + + // 前景点:取笔画包围盒中心(通常落在圈内),并限制在裁剪范围内。 + const QPointF centerWorld = QPolygonF(strokeWorld).boundingRect().center(); + const QPointF centerLocal = centerWorld - origin; + const double fgx = clampD(centerLocal.x(), 0.0, static_cast(cw - 1)); + const double fgy = clampD(centerLocal.y(), 0.0, static_cast(ch - 1)); + outPointCoords.append(QJsonArray{fgx, fgy}); + outPointLabels.append(1); + + // 背景点:在圈线(polyline)上均匀采样(最多 kMaxSamPointPrompts-1 个)。 const int n = static_cast(strokeWorld.size()); - const int step = std::max(1, (n + kMaxSamPointPrompts - 1) / kMaxSamPointPrompts); - for (int i = 0; i < n; i += step) { - const QPointF L = strokeWorld[i] - origin; - outPointCoords.append(QJsonArray{L.x(), L.y()}); - outPointLabels.append(1); - } - if (n > 1) { - const QPointF Llast = strokeWorld.last() - origin; - if (!outPointCoords.isEmpty()) { - const QJsonArray prev = outPointCoords.last().toArray(); - if (prev.size() >= 2) { - const double dx = Llast.x() - prev.at(0).toDouble(); - const double dy = Llast.y() - prev.at(1).toDouble(); - if (dx * dx + dy * dy > 4.0) { - outPointCoords.append(QJsonArray{Llast.x(), Llast.y()}); - outPointLabels.append(1); - } - } + const int maxBg = std::max(0, kMaxSamPointPrompts - 1); + if (n >= 2 && maxBg > 0) { + const int step = std::max(1, (n + maxBg - 1) / maxBg); + for (int i = 0; i < n; i += step) { + const QPointF L = strokeWorld[i] - origin; + const double bx = clampD(L.x(), 0.0, static_cast(cw - 1)); + const double by = clampD(L.y(), 0.0, static_cast(ch - 1)); + outPointCoords.append(QJsonArray{bx, by}); + outPointLabels.append(0); } } const QRectF tight = QPolygonF(strokeWorld).boundingRect(); - auto clampD = [](double v, double lo, double hi) { return std::clamp(v, lo, hi); }; double x1 = clampD(tight.left() - origin.x(), 0.0, static_cast(cw - 1)); double y1 = clampD(tight.top() - origin.y(), 0.0, static_cast(ch - 1)); double x2 = clampD(tight.right() - origin.x(), 0.0, static_cast(cw - 1)); @@ -479,9 +536,11 @@ void EditorCanvas::setPresentationPreviewMode(bool on) { m_presZoomFinishingRestore = false; m_presBgPanSession = false; m_presBgDragDist = 0.0; + cancelBlackholeCopyResolve(); if (on) { m_tool = Tool::Move; m_selectedEntity = -1; + m_selectedBlackholeEntityId.clear(); m_draggingEntity = false; m_drawingEntity = false; m_dragMode = DragMode::None; @@ -505,7 +564,7 @@ void EditorCanvas::setEntities(const QVector& entities, if (!m_depthAbsPath.isEmpty()) { if (m_depthDirty) { m_depthDirty = false; - QImage img(m_depthAbsPath); + QImage img = readImageTolerant(m_depthAbsPath); m_depthImage8 = img.isNull() ? QImage() : img.convertToFormat(QImage::Format_Grayscale8); } } @@ -521,6 +580,7 @@ void EditorCanvas::setEntities(const QVector& entities, const QPointF originWorld = e.originWorld; v.animatedOriginWorld = originWorld; v.cutoutPolygonWorld = e.cutoutPolygonWorld; + v.blackholeVisible = e.blackholeVisible; v.distanceScaleCalibMult = e.distanceScaleCalibMult; v.ignoreDistanceScale = e.ignoreDistanceScale; @@ -597,6 +657,31 @@ void EditorCanvas::setEntities(const QVector& entities, emit selectedEntityChanged(false, QString(), 0, QPointF()); } + if (!m_selectedBlackholeEntityId.isEmpty()) { + bool exists = false; + for (const auto& ent : m_entities) { + if (ent.id == m_selectedBlackholeEntityId && !ent.cutoutPolygonWorld.isEmpty()) { + exists = true; + break; + } + } + if (!exists) { + m_selectedBlackholeEntityId.clear(); + } + } + if (m_blackholeCopyResolveActive) { + bool exists = false; + for (const auto& ent : m_entities) { + if (ent.id == m_blackholeCopyEntityId && !ent.cutoutPolygonWorld.isEmpty()) { + exists = true; + break; + } + } + if (!exists) { + cancelBlackholeCopyResolve(); + } + } + m_bgCutoutDirty = true; update(); } @@ -815,10 +900,119 @@ void EditorCanvas::selectEntityById(const QString& id) { clearEntitySelection(); } +void EditorCanvas::selectBlackholeByEntityId(const QString& entityId) { + if (entityId.isEmpty()) { + clearBlackholeSelection(); + return; + } + for (const auto& ent : m_entities) { + if (ent.id == entityId && !ent.cutoutPolygonWorld.isEmpty()) { + if (m_selectedBlackholeEntityId == entityId) { + update(); + return; + } + m_selectedBlackholeEntityId = entityId; + update(); + return; + } + } + clearBlackholeSelection(); +} + +void EditorCanvas::clearBlackholeSelection() { + if (m_blackholeCopyResolveActive) { + cancelBlackholeCopyResolve(); + } + if (m_selectedBlackholeEntityId.isEmpty()) { + return; + } + m_selectedBlackholeEntityId.clear(); + update(); +} + +bool EditorCanvas::startBlackholeCopyResolve(const QString& entityId) { + if (entityId.isEmpty()) { + return false; + } + const Entity* hit = nullptr; + for (const auto& ent : m_entities) { + if (ent.id == entityId && !ent.cutoutPolygonWorld.isEmpty()) { + hit = &ent; + break; + } + } + if (!hit) { + return false; + } + ensurePixmapLoaded(); + if (m_bgImageDirty) { + m_bgImageDirty = false; + m_bgImage = readImageTolerant(m_bgAbsPath); + if (m_bgImage.format() != QImage::Format_ARGB32_Premultiplied && !m_bgImage.isNull()) { + m_bgImage = m_bgImage.convertToFormat(QImage::Format_ARGB32_Premultiplied); + } + } + const QRectF bg = worldRectOfBackground(); + if (bg.isNull()) { + return false; + } + + QRectF holeRect = entity_cutout::pathFromWorldPolygon(hit->cutoutPolygonWorld).boundingRect(); + if (holeRect.isNull()) { + return false; + } + if (holeRect.width() < 1.0 || holeRect.height() < 1.0) { + return false; + } + holeRect = holeRect.intersected(bg); + if (holeRect.isNull()) { + return false; + } + + QRectF srcRect(holeRect); + const qreal shift = std::max(24.0, holeRect.width() * 0.6); + srcRect.translate(shift, 0.0); + srcRect = clampRectTopLeftToBounds(srcRect, bg); + + m_selectedBlackholeEntityId = entityId; + m_blackholeCopyResolveActive = true; + m_blackholeCopyEntityId = entityId; + m_blackholeCopyHoleRect = holeRect; + m_blackholeCopySourceRect = srcRect; + m_blackholeCopyDragging = false; + m_blackholeCopyDragOffset = QPointF(); + updateCursor(); + update(); + return true; +} + +void EditorCanvas::cancelBlackholeCopyResolve() { + if (!m_blackholeCopyResolveActive) { + return; + } + m_blackholeCopyResolveActive = false; + m_blackholeCopyEntityId.clear(); + m_blackholeCopyHoleRect = QRectF(); + m_blackholeCopySourceRect = QRectF(); + m_blackholeCopyDragging = false; + m_blackholeCopyDragOffset = QPointF(); + updateCursor(); + update(); +} + +void EditorCanvas::notifyBackgroundContentChanged() { + // 背景路径不变时,setBackgroundImagePath 不会触发刷新;这里显式让 pixmap/image 缓存失效并重载。 + invalidatePixmap(); + m_bgImageDirty = true; + m_bgCutoutDirty = true; + update(); +} + void EditorCanvas::setBackgroundImagePath(const QString& absolutePath) { if (m_bgAbsPath == absolutePath) { return; } + cancelBlackholeCopyResolve(); m_bgAbsPath = absolutePath; invalidatePixmap(); m_bgImageDirty = true; @@ -997,13 +1191,21 @@ void EditorCanvas::ensurePixmapLoaded() const { m_pixmapDirty = false; m_bgPixmap = QPixmap(); if (!m_bgAbsPath.isEmpty()) { - m_bgPixmap = QPixmap(m_bgAbsPath); + // 避免直接 QPixmap(path) 走默认 imageio 限制(超大图可能被 256MB 上限拒绝) + const QImage img = readImageTolerant(m_bgAbsPath); + if (!img.isNull()) { + m_bgPixmap = QPixmap::fromImage(img); + } } m_bgImageDirty = true; m_bgCutoutDirty = true; } void EditorCanvas::updateCursor() { + if (m_blackholeCopyResolveActive) { + setCursor(m_blackholeCopyDragging ? Qt::ClosedHandCursor : Qt::OpenHandCursor); + return; + } if (m_presentationPreviewMode) { if (m_presHoverEntityIndex >= 0) { setCursor(Qt::PointingHandCursor); @@ -1105,7 +1307,7 @@ void EditorCanvas::paintEvent(QPaintEvent* e) { if (showBg) { if (m_bgImageDirty) { m_bgImageDirty = false; - m_bgImage = QImage(m_bgAbsPath); + m_bgImage = readImageTolerant(m_bgAbsPath); if (m_bgImage.format() != QImage::Format_ARGB32_Premultiplied && !m_bgImage.isNull()) { m_bgImage = m_bgImage.convertToFormat(QImage::Format_ARGB32_Premultiplied); } @@ -1115,7 +1317,7 @@ void EditorCanvas::paintEvent(QPaintEvent* e) { m_bgCutoutDirty = false; m_bgImageCutout = m_bgImage; for (const auto& ent : m_entities) { - if (ent.opacity > 0.001 && !ent.cutoutPolygonWorld.isEmpty() && !m_bgImageCutout.isNull()) { + if (ent.blackholeVisible && !ent.cutoutPolygonWorld.isEmpty() && !m_bgImageCutout.isNull()) { entity_cutout::applyBlackFillToBackground(m_bgImageCutout, ent.cutoutPolygonWorld); } } @@ -1156,7 +1358,7 @@ void EditorCanvas::paintEvent(QPaintEvent* e) { if (wantDepth) { if (m_depthDirty) { m_depthDirty = false; - QImage img(m_depthAbsPath); + QImage img = readImageTolerant(m_depthAbsPath); m_depthImage8 = img.isNull() ? QImage() : img.convertToFormat(QImage::Format_Grayscale8); } if (!m_depthImage8.isNull()) { @@ -1173,6 +1375,48 @@ void EditorCanvas::paintEvent(QPaintEvent* e) { } } + if (!m_presentationPreviewMode && m_blackholeCopyResolveActive && + !m_blackholeCopyHoleRect.isNull() && !m_blackholeCopySourceRect.isNull()) { + if (!m_bgImage.isNull()) { + const QRect srcRect = m_blackholeCopySourceRect.toAlignedRect(); + const QRect dstRect = m_blackholeCopyHoleRect.toAlignedRect(); + if (srcRect.isValid() && dstRect.isValid()) { + QPainterPath holePath; + for (const auto& ent : m_entities) { + if (ent.id == m_blackholeCopyEntityId && !ent.cutoutPolygonWorld.isEmpty()) { + holePath = entity_cutout::pathFromWorldPolygon(ent.cutoutPolygonWorld); + break; + } + } + p.save(); + if (!holePath.isEmpty()) { + p.setClipPath(holePath); + } + p.setOpacity(0.75); + p.drawImage(dstRect.topLeft(), m_bgImage, srcRect); + p.setOpacity(1.0); + p.restore(); + } + } + + p.setBrush(Qt::NoBrush); + QPen holePen(QColor(255, 120, 0, 220), 2.0 / std::max(m_scale, 0.001)); + holePen.setStyle(Qt::DashLine); + p.setPen(holePen); + p.drawRect(m_blackholeCopyHoleRect); + + QPen srcPen(QColor(70, 200, 255, 230), 2.0 / std::max(m_scale, 0.001)); + p.setPen(srcPen); + p.drawRect(m_blackholeCopySourceRect); + + const QPen textPen(QColor(70, 200, 255, 230)); + p.setPen(textPen); + const QPointF textPos = m_blackholeCopySourceRect.topLeft() + + QPointF(4.0 / std::max(m_scale, 0.001), + 14.0 / std::max(m_scale, 0.001)); + p.drawText(textPos, QStringLiteral("拖动取样框,松手应用")); + } + // 实体元素(占位):后续可替换为真实数据 for (int i = 0; i < m_entities.size(); ++i) { const auto& ent = m_entities[i]; @@ -1280,6 +1524,13 @@ void EditorCanvas::paintEvent(QPaintEvent* e) { p.drawRect(ent.rect.adjusted(-2, -2, 2, 2)); } } + if (!m_presentationPreviewMode && ent.id == m_selectedBlackholeEntityId && !ent.cutoutPolygonWorld.isEmpty()) { + p.setBrush(Qt::NoBrush); + QPen holePen(QColor(70, 200, 255, 230), 2.2 / std::max(m_scale, 0.001)); + holePen.setStyle(Qt::DashLine); + p.setPen(holePen); + p.drawPath(entity_cutout::pathFromWorldPolygon(ent.cutoutPolygonWorld)); + } if (m_presentationPreviewMode && ent.opacity > 0.001) { const bool showHover = (i == m_presHoverEntityIndex); const bool showFocus = (i == m_presFocusedEntityIndex); @@ -1398,7 +1649,47 @@ void EditorCanvas::paintEvent(QPaintEvent* e) { p.drawLine(QPointF(0, axisY), QPointF(width(), axisY)); // X 轴(水平) p.drawLine(QPointF(axisX, 0), QPointF(axisX, height())); // Y 轴(垂直) - const int step = 100; + // 根据缩放与视口大小动态调整刻度密度:使相邻刻度在屏幕上保持“够密但不挤”的间距 + auto niceStep = [](double raw) -> double { + if (!(raw > 0.0) || !std::isfinite(raw)) { + return 1.0; + } + const double p10 = std::pow(10.0, std::floor(std::log10(raw))); + const double m = raw / p10; // 1..10 + double n = 1.0; + if (m <= 1.0) n = 1.0; + else if (m <= 2.0) n = 2.0; + else if (m <= 5.0) n = 5.0; + else n = 10.0; + return n * p10; + }; + + const double sView = std::max(0.001, double(m_scale)); + const double targetTickPx = 86.0; // 目标:相邻刻度约 86px + const double minTickPx = 44.0; // 太密则自动放大 step + const double stepWorld = std::max(1.0, niceStep(targetTickPx / sView)); + const double stepPx = stepWorld * sView; + const int labelEvery = (stepPx < minTickPx) ? int(std::ceil(minTickPx / std::max(1.0, stepPx))) : 1; + + auto visibleWorldXRange = [&]() -> std::pair { + const QPointF w0 = viewToWorld(QPointF(0, 0)); + const QPointF w1 = viewToWorld(QPointF(width(), height())); + double a = std::min(w0.x(), w1.x()); + double b = std::max(w0.x(), w1.x()); + a = std::clamp(a, 0.0, double(m_bgPixmap.width())); + b = std::clamp(b, 0.0, double(m_bgPixmap.width())); + return {a, b}; + }; + auto visibleWorldYRange = [&]() -> std::pair { + const QPointF w0 = viewToWorld(QPointF(0, 0)); + const QPointF w1 = viewToWorld(QPointF(width(), height())); + double a = std::min(w0.y(), w1.y()); + double b = std::max(w0.y(), w1.y()); + a = std::clamp(a, 0.0, double(m_bgPixmap.height())); + b = std::clamp(b, 0.0, double(m_bgPixmap.height())); + return {a, b}; + }; + QPen tickPen(QColor(20, 20, 20, 140)); tickPen.setWidth(1); p.setPen(tickPen); @@ -1409,28 +1700,44 @@ void EditorCanvas::paintEvent(QPaintEvent* e) { } // X 轴:用 y=0 的世界线映射到 view-x,并把刻度画在 axisY 上 - for (int x = 0; x <= m_bgPixmap.width(); x += step) { - const QPointF vx = worldToView(QPointF(x, 0)); + { + const auto [xmin, xmax] = visibleWorldXRange(); + const double start = std::floor(xmin / stepWorld) * stepWorld; + int iTick = 0; + for (double x = start; x <= xmax + 1e-9; x += stepWorld, ++iTick) { + const double xc = std::clamp(x, 0.0, double(m_bgPixmap.width())); + const QPointF vx = worldToView(QPointF(xc, 0)); if (vx.x() < -50 || vx.x() > width() + 50) { continue; } const qreal tx = std::clamp(vx.x(), 0.0, static_cast(width())); p.drawLine(QPointF(tx, axisY), QPointF(tx, axisY + 6)); if (m_axisLabelsVisible) { - p.drawText(QPointF(tx + 2, axisY + 18), QString::number(x)); + if (labelEvery <= 1 || (iTick % labelEvery) == 0) { + p.drawText(QPointF(tx + 2, axisY + 18), QString::number(int(std::lround(xc)))); + } + } } } // Y 轴:用 x=0 的世界线映射到 view-y,并把刻度画在 axisX 上 - for (int y = 0; y <= m_bgPixmap.height(); y += step) { - const QPointF vy = worldToView(QPointF(0, y)); + { + const auto [ymin, ymax] = visibleWorldYRange(); + const double start = std::floor(ymin / stepWorld) * stepWorld; + int iTick = 0; + for (double y = start; y <= ymax + 1e-9; y += stepWorld, ++iTick) { + const double yc = std::clamp(y, 0.0, double(m_bgPixmap.height())); + const QPointF vy = worldToView(QPointF(0, yc)); if (vy.y() < -50 || vy.y() > height() + 50) { continue; } const qreal ty = std::clamp(vy.y(), 0.0, static_cast(height())); p.drawLine(QPointF(axisX, ty), QPointF(axisX + 6, ty)); if (m_axisLabelsVisible) { - p.drawText(QPointF(axisX + 10, ty - 2), QString::number(y)); + if (labelEvery <= 1 || (iTick % labelEvery) == 0) { + p.drawText(QPointF(axisX + 10, ty - 2), QString::number(int(std::lround(yc)))); + } + } } } } @@ -1489,7 +1796,7 @@ void EditorCanvas::mousePressEvent(QMouseEvent* e) { if (!m_depthAbsPath.isEmpty()) { if (m_depthDirty) { m_depthDirty = false; - QImage img(m_depthAbsPath); + QImage img = readImageTolerant(m_depthAbsPath); m_depthImage8 = img.isNull() ? QImage() : img.convertToFormat(QImage::Format_Grayscale8); } if (!m_depthImage8.isNull()) { @@ -1502,6 +1809,25 @@ void EditorCanvas::mousePressEvent(QMouseEvent* e) { } emit hoveredWorldPosDepthChanged(wp0, z0); + if (m_blackholeCopyResolveActive) { + if (e->button() == Qt::LeftButton) { + QRectF src = m_blackholeCopySourceRect; + if (!src.contains(wp0)) { + src.moveCenter(wp0); + src = clampRectTopLeftToBounds(src, worldRectOfBackground()); + m_blackholeCopySourceRect = src; + } + m_blackholeCopyDragging = true; + m_blackholeCopyDragOffset = wp0 - m_blackholeCopySourceRect.topLeft(); + updateCursor(); + update(); + e->accept(); + return; + } + e->accept(); + return; + } + if (m_presentationPreviewMode) { if (e->button() == Qt::LeftButton) { const int hit = hitTestEntity(wp0); @@ -1734,7 +2060,7 @@ void EditorCanvas::mouseMoveEvent(QMouseEvent* e) { if (!m_depthAbsPath.isEmpty()) { if (m_depthDirty) { m_depthDirty = false; - QImage img(m_depthAbsPath); + QImage img = readImageTolerant(m_depthAbsPath); m_depthImage8 = img.isNull() ? QImage() : img.convertToFormat(QImage::Format_Grayscale8); } if (!m_depthImage8.isNull()) { @@ -1747,6 +2073,18 @@ void EditorCanvas::mouseMoveEvent(QMouseEvent* e) { } emit hoveredWorldPosDepthChanged(wp, z); + if (m_blackholeCopyResolveActive) { + if (m_blackholeCopyDragging && (e->buttons() & Qt::LeftButton)) { + QRectF src = m_blackholeCopySourceRect; + src.moveTopLeft(wp - m_blackholeCopyDragOffset); + src = clampRectTopLeftToBounds(src, worldRectOfBackground()); + m_blackholeCopySourceRect = src; + update(); + } + e->accept(); + return; + } + if (m_presentationPreviewMode) { const int h = hitTestEntity(wp); if (h != m_presHoverEntityIndex) { @@ -1849,7 +2187,7 @@ void EditorCanvas::mouseMoveEvent(QMouseEvent* e) { if (!m_depthAbsPath.isEmpty() && m_depthDirty) { m_depthDirty = false; - QImage img(m_depthAbsPath); + QImage img = readImageTolerant(m_depthAbsPath); m_depthImage8 = img.isNull() ? QImage() : img.convertToFormat(QImage::Format_Grayscale8); } if (!m_depthImage8.isNull()) { @@ -1901,6 +2239,18 @@ void EditorCanvas::mouseMoveEvent(QMouseEvent* e) { } void EditorCanvas::mouseReleaseEvent(QMouseEvent* e) { + if (m_blackholeCopyResolveActive && e->button() == Qt::LeftButton) { + if (m_blackholeCopyDragging) { + m_blackholeCopyDragging = false; + const QPointF delta = m_blackholeCopySourceRect.topLeft() - m_blackholeCopyHoleRect.topLeft(); + emit requestResolveBlackholeCopy( + m_blackholeCopyEntityId, + QPoint(static_cast(std::round(delta.x())), static_cast(std::round(delta.y())))); + } + cancelBlackholeCopyResolve(); + e->accept(); + return; + } if (e->button() == Qt::LeftButton || e->button() == Qt::MiddleButton) { if (m_presentationPreviewMode && e->button() == Qt::LeftButton) { if (m_presBgPanSession && m_presBgDragDist < 8.0) { @@ -2016,6 +2366,11 @@ void EditorCanvas::wheelEvent(QWheelEvent* e) { } void EditorCanvas::keyPressEvent(QKeyEvent* e) { + if (m_blackholeCopyResolveActive && e->key() == Qt::Key_Escape) { + cancelBlackholeCopyResolve(); + e->accept(); + return; + } if (m_presentationPreviewMode && e->key() == Qt::Key_Escape) { clearPresentationEntityFocus(); e->accept(); diff --git a/client/gui/editor/EditorCanvas.h b/client/gui/editor/EditorCanvas.h index 53a8b40..dc59d51 100644 --- a/client/gui/editor/EditorCanvas.h +++ b/client/gui/editor/EditorCanvas.h @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -85,6 +86,12 @@ public: void selectEntityById(const QString& id); void clearEntitySelection(); + void selectBlackholeByEntityId(const QString& entityId); + void clearBlackholeSelection(); + bool startBlackholeCopyResolve(const QString& entityId); + void cancelBlackholeCopyResolve(); + /// 背景图片文件内容被外部写盘更新(路径未变)时,强制重新加载缓存 + void notifyBackgroundContentChanged(); // 与动画求值一致的原点/缩放(用于 K 帧与自动关键帧) QPointF selectedAnimatedOriginWorld() const; @@ -115,6 +122,7 @@ signals: void requestFinalizePendingEntity(const QVector& polyWorld); void requestMoveEntity(const QString& id, const QPointF& delta); void requestMoveTool(const QString& id, const QPointF& delta); + void requestResolveBlackholeCopy(const QString& entityId, const QPoint& sourceOffsetPx); void entityDragActiveChanged(bool on); void selectedEntityPreviewChanged(const QString& id, int depth, const QPointF& originWorld); /// 预览模式下点击实体:anchorView 为实体质心在视图中的位置,用于摆放介绍浮层 @@ -171,6 +179,7 @@ private: QPointF animatedOriginWorld; double animatedDepthScale01 = 0.5; double opacity = 1.0; // 0..1(由可见性轨道求值) + bool blackholeVisible = true; }; int hitTestEntity(const QPointF& worldPos) const; @@ -186,7 +195,7 @@ private: mutable QPixmap m_bgPixmap; mutable bool m_pixmapDirty = true; mutable QImage m_bgImage; // 原背景(用于抠图/填充) - mutable QImage m_bgImageCutout; // 抠图后的背景(实体区域填黑) + mutable QImage m_bgImageCutout; // 抠图后的背景(黑洞区域填黑) mutable bool m_bgImageDirty = true; mutable bool m_bgCutoutDirty = true; @@ -229,6 +238,13 @@ private: qint64 m_lastPreviewEmitMs = 0; qint64 m_lastDepthScaleRecalcMs = 0; int m_selectedEntity = -1; + QString m_selectedBlackholeEntityId; + bool m_blackholeCopyResolveActive = false; + QString m_blackholeCopyEntityId; + QRectF m_blackholeCopyHoleRect; + QRectF m_blackholeCopySourceRect; + bool m_blackholeCopyDragging = false; + QPointF m_blackholeCopyDragOffset; int m_selectedTool = -1; bool m_draggingTool = false; QPointF m_toolDragOffsetOriginWorld; diff --git a/client/gui/main_window/MainWindow.cpp b/client/gui/main_window/MainWindow.cpp index 8897fba..b28c29b 100644 --- a/client/gui/main_window/MainWindow.cpp +++ b/client/gui/main_window/MainWindow.cpp @@ -2,6 +2,7 @@ #include "dialogs/AboutWindow.h" #include "dialogs/CancelableTaskDialog.h" #include "dialogs/EntityFinalizeDialog.h" +#include "dialogs/BlackholeResolveDialog.h" #include "editor/EditorCanvas.h" #include "editor/EntityCutoutUtils.h" #include "dialogs/ImageCropDialog.h" @@ -10,6 +11,7 @@ #include "widgets/ToolOptionPopup.h" #include "params/ParamControls.h" #include "props/BackgroundPropertySection.h" +#include "props/BlackholePropertySection.h" #include "props/EntityPropertySection.h" #include "props/ToolPropertySection.h" #include "timeline/TimelineWidget.h" @@ -87,9 +89,9 @@ constexpr int kRightDockMinimumWidth = 80; /// 列宽小于此值时自动隐藏右侧两 dock constexpr int kRightDockAutoHideBelow = 92; /// 右侧 dock 列最大宽度,避免过宽挤占画布 -constexpr int kRightDockMaximumWidth = 288; +constexpr int kRightDockMaximumWidth = 252; /// 属性区表单内容最大宽度(dock 仍可略宽,两侧留白,避免 SpinBox 被拉得过开) -constexpr int kPropertyPanelContentMaxWidth = 268; +constexpr int kPropertyPanelContentMaxWidth = 232; /// 启动时垂直分割高度:项目树较矮、属性区较高 constexpr int kProjectTreeDockStartupHeight = 148; constexpr int kPropertiesDockStartupHeight = 392; @@ -1185,6 +1187,14 @@ void MainWindow::createProjectTreeDock() { showBackgroundContextMenu(m_projectTree->viewport()->mapToGlobal(pos)); return; } + const QString kind = item->data(0, Qt::UserRole).toString(); + if (kind == QStringLiteral("blackhole")) { + const QString id = item->data(0, Qt::UserRole + 1).toString(); + if (!id.isEmpty()) { + showBlackholeContextMenu(m_projectTree->viewport()->mapToGlobal(pos), id); + } + return; + } }); connect(m_projectTree, &QTreeWidget::itemClicked, this, &MainWindow::onProjectTreeItemClicked); static_cast(m_projectTree)->onNodeParentDropRequested = @@ -1280,15 +1290,17 @@ void MainWindow::createProjectTreeDock() { Qt::BottomDockWidgetArea | Qt::TopDockWidgetArea); m_dockProperties->setFeatures(QDockWidget::DockWidgetMovable | QDockWidget::DockWidgetFloatable | QDockWidget::DockWidgetClosable); - m_dockProperties->setMinimumWidth(236); + m_dockProperties->setMinimumWidth(200); m_bgPropertySection = new gui::BackgroundPropertySection(); + m_blackholePropertySection = new gui::BlackholePropertySection(); m_entityPropertySection = new gui::EntityPropertySection(); m_toolPropertySection = new gui::ToolPropertySection(); m_propertyStack = new QStackedWidget(); m_propertyStack->setContentsMargins(4, 4, 4, 4); m_propertyStack->setMaximumWidth(kPropertyPanelContentMaxWidth); m_propertyStack->addWidget(m_bgPropertySection); + m_propertyStack->addWidget(m_blackholePropertySection); m_propertyStack->addWidget(m_entityPropertySection); m_propertyStack->addWidget(m_toolPropertySection); @@ -1336,7 +1348,30 @@ void MainWindow::createProjectTreeDock() { if (m_selectedEntityId.isEmpty() || !m_editorCanvas) return; const double s = m_editorCanvas->selectedCombinedScale(); if (s <= 1e-9) return; - if (!m_workspace.reanchorEntityPivot(m_selectedEntityId, m_currentFrame, QPointF(x, y), s)) return; + QPointF targetPivot(x, y); + QString parentId; + for (const auto& e : m_workspace.entities()) { + if (e.id == m_selectedEntityId) { + parentId = e.parentId; + break; + } + } + if (!parentId.isEmpty()) { + const auto rf = core::eval::evaluateAtFrame(m_workspace.project(), m_currentFrame, 10); + for (const auto& pe : rf.entities) { + if (pe.entity.id == parentId) { + targetPivot += pe.entity.originWorld; + break; + } + } + for (const auto& pt : rf.tools) { + if (pt.tool.id == parentId) { + targetPivot += pt.tool.originWorld; + break; + } + } + } + if (!m_workspace.reanchorEntityPivot(m_selectedEntityId, m_currentFrame, targetPivot, s)) return; refreshEditorPage(); refreshDopeSheet(); }); @@ -1344,8 +1379,31 @@ void MainWindow::createProjectTreeDock() { if (m_selectedEntityId.isEmpty() || !m_editorCanvas) return; const double s = m_editorCanvas->selectedCombinedScale(); if (s <= 1e-9) return; + QPointF targetCentroid(x, y); + QString parentId; + for (const auto& e : m_workspace.entities()) { + if (e.id == m_selectedEntityId) { + parentId = e.parentId; + break; + } + } + if (!parentId.isEmpty()) { + const auto rf = core::eval::evaluateAtFrame(m_workspace.project(), m_currentFrame, 10); + for (const auto& pe : rf.entities) { + if (pe.entity.id == parentId) { + targetCentroid += pe.entity.originWorld; + break; + } + } + for (const auto& pt : rf.tools) { + if (pt.tool.id == parentId) { + targetCentroid += pt.tool.originWorld; + break; + } + } + } const bool autoKey = m_chkAutoKeyframe && m_chkAutoKeyframe->isChecked(); - if (!m_workspace.moveEntityCentroidTo(m_selectedEntityId, m_currentFrame, QPointF(x, y), s, autoKey)) return; + if (!m_workspace.moveEntityCentroidTo(m_selectedEntityId, m_currentFrame, targetCentroid, s, autoKey)) return; refreshEditorPage(); refreshDopeSheet(); }); @@ -1410,6 +1468,46 @@ void MainWindow::createProjectTreeDock() { m_workspace.setToolAlign(m_selectedToolId, a); refreshEditorPage(); }); + connect(m_toolPropertySection, &gui::ToolPropertySection::positionEdited, this, [this](double x, double y) { + if (m_selectedToolId.isEmpty() || !m_workspace.isOpen()) return; + const int f = std::clamp(m_currentFrame, 0, core::Project::kClipFixedFrames - 1); + const auto rf = core::eval::evaluateAtFrame(m_workspace.project(), f, 10); + QPointF currentWorld; + QPointF parentWorld; + QString parentId; + bool found = false; + for (const auto& t : rf.tools) { + if (t.tool.id == m_selectedToolId) { + currentWorld = t.tool.originWorld; + parentId = t.tool.parentId; + found = true; + break; + } + } + if (!found) return; + if (!parentId.isEmpty()) { + for (const auto& e : rf.entities) { + if (e.entity.id == parentId) { + parentWorld = e.entity.originWorld; + break; + } + } + if (qFuzzyIsNull(parentWorld.x()) && qFuzzyIsNull(parentWorld.y())) { + for (const auto& t : rf.tools) { + if (t.tool.id == parentId) { + parentWorld = t.tool.originWorld; + break; + } + } + } + } + const QPointF targetWorld = parentId.isEmpty() ? QPointF(x, y) : (parentWorld + QPointF(x, y)); + const QPointF delta = targetWorld - currentWorld; + if (qFuzzyIsNull(delta.x()) && qFuzzyIsNull(delta.y())) return; + if (!m_workspace.moveToolBy(m_selectedToolId, delta, f, true)) return; + refreshEditorPage(); + refreshDopeSheet(); + }); connect(m_toolPropertySection, &gui::ToolPropertySection::visibleToggled, this, [this](bool on) { if (m_selectedToolId.isEmpty() || !m_workspace.isOpen()) return; const int f = std::clamp(m_currentFrame, 0, core::Project::kClipFixedFrames - 1); @@ -1468,7 +1566,8 @@ void MainWindow::createProjectTreeDock() { } void MainWindow::refreshPropertyPanel() { - if (!m_bgPropertySection || !m_entityPropertySection || !m_toolPropertySection || !m_propertyStack) { + if (!m_bgPropertySection || !m_blackholePropertySection || !m_entityPropertySection || + !m_toolPropertySection || !m_propertyStack) { return; } @@ -1556,6 +1655,15 @@ void MainWindow::refreshPropertyPanel() { gui::ToolPropertyUiState st; const int f = std::clamp(m_currentFrame, 0, core::Project::kClipFixedFrames - 1); const auto* clip = activeClipForUi(); + QString parentId; + const auto rf = core::eval::evaluateAtFrame(m_workspace.project(), m_currentFrame, 10); + for (const auto& rt : rf.tools) { + if (rt.tool.id == m_selectedToolId) { + st.position = rt.tool.originWorld; + parentId = rt.tool.parentId; + break; + } + } for (const auto& t : m_workspace.tools()) { if (t.id == m_selectedToolId) { st.displayName = t.displayName.isEmpty() ? t.id : t.displayName; @@ -1576,12 +1684,59 @@ void MainWindow::refreshPropertyPanel() { break; } } + if (!parentId.isEmpty()) { + QPointF parentWorld; + for (const auto& pe : rf.entities) { + if (pe.entity.id == parentId) { + parentWorld = pe.entity.originWorld; + break; + } + } + if (qFuzzyIsNull(parentWorld.x()) && qFuzzyIsNull(parentWorld.y())) { + for (const auto& pt : rf.tools) { + if (pt.tool.id == parentId) { + parentWorld = pt.tool.originWorld; + break; + } + } + } + st.position -= parentWorld; + st.parentRelativeMode = true; + } m_toolPropertySection->applyState(st); m_propertyStack->setCurrentWidget(m_toolPropertySection); m_dockProperties->setWindowTitle(QStringLiteral("属性 — 工具")); return; } + const bool holeUi = m_workspace.isOpen() && !m_selectedBlackholeEntityId.isEmpty(); + if (holeUi) { + gui::BlackholePropertyUiState st; + for (const auto& e : m_workspace.entities()) { + if (e.id != m_selectedBlackholeEntityId) { + continue; + } + st.blackholeName = e.blackholeId.isEmpty() ? QStringLiteral("blackhole-%1").arg(e.id) : e.blackholeId; + st.statusText = e.blackholeVisible ? QStringLiteral("否") : QStringLiteral("是"); + if (e.blackholeResolvedBy == QStringLiteral("copy_background")) { + st.methodText = QStringLiteral("复制背景其他区域"); + } else if (e.blackholeResolvedBy == QStringLiteral("use_original_background")) { + st.methodText = QStringLiteral("使用原始背景"); + } else if (e.blackholeResolvedBy == QStringLiteral("model_inpaint")) { + st.methodText = QStringLiteral("模型补全"); + } else if (e.blackholeResolvedBy == QStringLiteral("pending")) { + st.methodText = QStringLiteral("待选择"); + } else { + st.methodText = QStringLiteral("未选择"); + } + break; + } + m_blackholePropertySection->applyState(st); + m_propertyStack->setCurrentWidget(m_blackholePropertySection); + m_dockProperties->setWindowTitle(QStringLiteral("属性 — 黑洞")); + return; + } + const bool entUi = m_hasSelectedEntity && m_workspace.isOpen() && !m_selectedEntityId.isEmpty() && m_editorCanvas; if (!entUi) { m_entityPropertySection->clearDisconnected(); @@ -1595,6 +1750,7 @@ void MainWindow::refreshPropertyPanel() { double userScale = 1.0; bool ignoreDist = false; bool entVisible = true; + QString parentId; core::EntityIntroContent intro; const int f = std::clamp(m_currentFrame, 0, core::Project::kClipFixedFrames - 1); const auto* clip = activeClipForUi(); @@ -1604,6 +1760,7 @@ void MainWindow::refreshPropertyPanel() { userScale = e.userScale; intro = e.intro; ignoreDist = e.ignoreDistanceScale; + parentId = e.parentId; const QVector keys = (clip && clip->entityVisibilityKeys.contains(e.id)) ? clip->entityVisibilityKeys.value(e.id) @@ -1621,6 +1778,27 @@ void MainWindow::refreshPropertyPanel() { QStringLiteral("%1(自动)").arg(m_editorCanvas->selectedDistanceScaleMultiplier(), 0, 'f', 3); st.pivot = m_editorCanvas->selectedAnimatedOriginWorld(); st.centroid = m_editorCanvas->selectedEntityCentroidWorld(); + if (!parentId.isEmpty()) { + QPointF parentWorld; + const auto rf = core::eval::evaluateAtFrame(m_workspace.project(), m_currentFrame, 10); + for (const auto& pe : rf.entities) { + if (pe.entity.id == parentId) { + parentWorld = pe.entity.originWorld; + break; + } + } + if (qFuzzyIsNull(parentWorld.x()) && qFuzzyIsNull(parentWorld.y())) { + for (const auto& pt : rf.tools) { + if (pt.tool.id == parentId) { + parentWorld = pt.tool.originWorld; + break; + } + } + } + st.pivot -= parentWorld; + st.centroid -= parentWorld; + st.parentRelativeMode = true; + } st.userScale = userScale; st.ignoreDistanceScale = ignoreDist; st.visible = entVisible; @@ -1646,6 +1824,34 @@ void MainWindow::refreshEntityPropertyPanelFast() { QStringLiteral("%1(自动)").arg(m_editorCanvas->selectedDistanceScaleMultiplier(), 0, 'f', 3); st.pivot = m_editorCanvas->selectedAnimatedOriginWorld(); st.centroid = m_editorCanvas->selectedEntityCentroidWorld(); + QString parentId; + for (const auto& e : m_workspace.entities()) { + if (e.id == m_selectedEntityId) { + parentId = e.parentId; + break; + } + } + if (!parentId.isEmpty()) { + QPointF parentWorld; + const auto rf = core::eval::evaluateAtFrame(m_workspace.project(), m_currentFrame, 10); + for (const auto& pe : rf.entities) { + if (pe.entity.id == parentId) { + parentWorld = pe.entity.originWorld; + break; + } + } + if (qFuzzyIsNull(parentWorld.x()) && qFuzzyIsNull(parentWorld.y())) { + for (const auto& pt : rf.tools) { + if (pt.tool.id == parentId) { + parentWorld = pt.tool.originWorld; + break; + } + } + } + st.pivot -= parentWorld; + st.centroid -= parentWorld; + st.parentRelativeMode = true; + } st.userScale = m_editorCanvas->selectedUserScale(); // ignoreDistanceScale 在拖动中不变更,fast path 不必更新(避免再遍历 entities) m_entityPropertySection->applyState(st); @@ -1742,6 +1948,33 @@ void MainWindow::refreshProjectTree() { }); } + // 黑洞节点:挂在“背景”下,和实体渲染解耦(黑洞可见性独立于实体可见性) + QVector blackholeEnts; + blackholeEnts.reserve(sortedEnts.size()); + for (const auto& e : sortedEnts) { + if (!e.cutoutPolygonWorld.isEmpty()) { + blackholeEnts.push_back(&e); + } + } + std::stable_sort(blackholeEnts.begin(), blackholeEnts.end(), + [](const core::Project::Entity* a, const core::Project::Entity* b) { + const QString an = a->displayName.isEmpty() ? a->id : a->displayName; + const QString bn = b->displayName.isEmpty() ? b->id : b->displayName; + return an < bn; + }); + for (const auto* e : blackholeEnts) { + auto* it = new QTreeWidgetItem(m_itemBackground); + const QString base = e->displayName.isEmpty() ? e->id : e->displayName; + const QString holeName = + e->blackholeId.isEmpty() ? QStringLiteral("blackhole-%1").arg(e->id) : e->blackholeId; + it->setText(1, QStringLiteral("黑洞 · %1").arg(base)); + it->setToolTip(1, QStringLiteral("节点:%1").arg(holeName)); + it->setTextAlignment(1, Qt::AlignRight | Qt::AlignVCenter); + it->setData(0, Qt::UserRole, QStringLiteral("blackhole")); + it->setData(0, Qt::UserRole + 1, e->id); // 绑定实体 id,便于定位 cutout 多边形 + it->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled); + } + struct NodeRef { QString kind; // "entity" / "tool" QString id; @@ -1845,7 +2078,9 @@ void MainWindow::syncProjectTreeFromCanvasSelection() { } m_syncingTreeSelection = true; m_projectTree->blockSignals(true); - if ((!m_hasSelectedEntity || m_selectedEntityId.isEmpty()) && (!m_hasSelectedTool || m_selectedToolId.isEmpty())) { + if ((!m_hasSelectedEntity || m_selectedEntityId.isEmpty()) && + (!m_hasSelectedTool || m_selectedToolId.isEmpty()) && + m_selectedBlackholeEntityId.isEmpty()) { m_projectTree->clearSelection(); } else { QTreeWidgetItem* found = nullptr; @@ -1861,6 +2096,11 @@ void MainWindow::syncProjectTreeFromCanvasSelection() { found = node; break; } + if (!m_selectedBlackholeEntityId.isEmpty() && kind == QStringLiteral("blackhole") && + id == m_selectedBlackholeEntityId) { + found = node; + break; + } } if (found) { m_projectTree->setCurrentItem(found); @@ -1882,6 +2122,10 @@ void MainWindow::onProjectTreeItemClicked(QTreeWidgetItem* item, int column) { if (kind == QStringLiteral("entity")) { const QString id = item->data(0, Qt::UserRole + 1).toString(); if (!id.isEmpty()) { + m_selectedBlackholeEntityId.clear(); + if (m_editorCanvas) { + m_editorCanvas->clearBlackholeSelection(); + } m_hasSelectedTool = false; m_selectedToolId.clear(); if (m_timeline) { @@ -1892,6 +2136,10 @@ void MainWindow::onProjectTreeItemClicked(QTreeWidgetItem* item, int column) { } else if (kind == QStringLiteral("tool")) { const QString id = item->data(0, Qt::UserRole + 1).toString(); if (!id.isEmpty()) { + m_selectedBlackholeEntityId.clear(); + if (m_editorCanvas) { + m_editorCanvas->clearBlackholeSelection(); + } m_hasSelectedTool = true; m_selectedToolId = id; m_hasSelectedEntity = false; @@ -1904,10 +2152,28 @@ void MainWindow::onProjectTreeItemClicked(QTreeWidgetItem* item, int column) { } refreshPropertyPanel(); } + } else if (kind == QStringLiteral("blackhole")) { + const QString entityId = item->data(0, Qt::UserRole + 1).toString(); + if (!entityId.isEmpty()) { + m_selectedBlackholeEntityId = entityId; + m_hasSelectedTool = false; + m_selectedToolId.clear(); + m_hasSelectedEntity = false; + m_selectedEntityId.clear(); + m_selectedEntityDisplayNameCache.clear(); + if (m_editorCanvas) { + m_editorCanvas->clearEntitySelection(); + m_editorCanvas->selectBlackholeByEntityId(entityId); + } + updateTimelineTracks(); + refreshPropertyPanel(); + } } else if (kind == QStringLiteral("background")) { + m_selectedBlackholeEntityId.clear(); m_hasSelectedTool = false; m_selectedToolId.clear(); m_editorCanvas->clearEntitySelection(); + m_editorCanvas->clearBlackholeSelection(); updateTimelineTracks(); } } @@ -2394,6 +2660,7 @@ void MainWindow::rebuildCentralPages() { connect(m_editorCanvas, &EditorCanvas::selectedEntityChanged, this, [this](bool hasSel, const QString& id, int depth, const QPointF& origin) { m_hasSelectedEntity = hasSel; m_selectedEntityId = id; + m_selectedBlackholeEntityId.clear(); m_selectedEntityDepth = depth; m_selectedEntityOrigin = origin; m_hasSelectedTool = false; @@ -2418,6 +2685,7 @@ void MainWindow::rebuildCentralPages() { Q_UNUSED(origin); m_hasSelectedTool = hasSel; m_selectedToolId = id; + m_selectedBlackholeEntityId.clear(); if (hasSel) { m_hasSelectedEntity = false; m_selectedEntityId.clear(); @@ -2475,6 +2743,11 @@ void MainWindow::rebuildCentralPages() { } } } + ent.blackholeVisible = true; + if (ent.blackholeId.isEmpty() && !ent.id.isEmpty()) { + ent.blackholeId = QStringLiteral("blackhole-%1").arg(ent.id); + } + ent.blackholeResolvedBy = QStringLiteral("pending"); if (!m_workspace.addEntity(ent, img)) { QMessageBox::warning(this, QStringLiteral("实体"), QStringLiteral("保存实体失败。")); return; @@ -2768,6 +3041,11 @@ void MainWindow::rebuildCentralPages() { if (ent.displayName.isEmpty()) { // 允许空:界面会用 id 展示 } + ent.blackholeVisible = true; + if (ent.blackholeId.isEmpty() && !ent.id.isEmpty()) { + ent.blackholeId = QStringLiteral("blackhole-%1").arg(ent.id); + } + ent.blackholeResolvedBy = QStringLiteral("pending"); QImage bg(m_workspace.backgroundAbsolutePath()); if (!bg.isNull() && bg.format() != QImage::Format_ARGB32_Premultiplied) { @@ -2812,6 +3090,26 @@ void MainWindow::rebuildCentralPages() { refreshProjectTree(); updateUiEnabledState(); }); + connect(m_editorCanvas, &EditorCanvas::requestResolveBlackholeCopy, this, + [this](const QString& entityId, const QPoint& sourceOffsetPx) { + if (!m_workspace.resolveBlackholeByCopyBackground(entityId, sourceOffsetPx, true)) { + QMessageBox::warning( + this, + QStringLiteral("黑洞修复"), + QStringLiteral("复制背景区域失败。请重新拖动取样框,确保采样区域在背景范围内。")); + return; + } + statusBar()->showMessage(QStringLiteral("黑洞已通过背景复制修复")); + refreshProjectTree(); + updateUiEnabledState(); + if (m_editorCanvas) { + m_editorCanvas->notifyBackgroundContentChanged(); + } + refreshEditorPage(); + if (m_previewRequested) { + refreshPreviewPage(); + } + }); connect(m_editorCanvas, &EditorCanvas::presentationEntityIntroRequested, this, [this](const QString& id, QPointF anchorView) { @@ -3194,7 +3492,8 @@ void MainWindow::refreshDopeSheet() { break; } } - addChannel(QStringLiteral("位置"), 0, hasLoc); + const QString locLabel = e.parentId.isEmpty() ? QStringLiteral("位置") : QStringLiteral("相对位置"); + addChannel(locLabel, 0, hasLoc); addChannel(QStringLiteral("缩放"), 1, hasSc); addChannel(QStringLiteral("图像"), 2, hasIm); } @@ -3221,6 +3520,70 @@ void MainWindow::showBackgroundContextMenu(const QPoint& globalPos) { refreshPreviewPage(); } +void MainWindow::showBlackholeContextMenu(const QPoint& globalPos, const QString& entityId) { + if (entityId.isEmpty() || !m_workspace.isOpen()) { + return; + } + + m_selectedBlackholeEntityId = entityId; + if (m_editorCanvas) { + m_editorCanvas->selectBlackholeByEntityId(entityId); + } + syncProjectTreeFromCanvasSelection(); + + QString holeLabel = entityId; + for (const auto& e : m_workspace.entities()) { + if (e.id == entityId) { + if (!e.blackholeId.isEmpty()) { + holeLabel = e.blackholeId; + } else { + holeLabel = QStringLiteral("blackhole-%1").arg(entityId); + } + break; + } + } + + QMenu menu(this); + QAction* actResolve = menu.addAction(QStringLiteral("修复")); + QAction* chosen = menu.exec(globalPos); + if (!chosen || chosen != actResolve) { + return; + } + + BlackholeResolveDialog dlg(holeLabel, this); + if (dlg.exec() != QDialog::Accepted) { + return; + } + + bool ok = false; + if (dlg.selectedAlgorithm() == BlackholeResolveDialog::Algorithm::CopyBackgroundRegion) { + if (!m_editorCanvas || !m_editorCanvas->startBlackholeCopyResolve(entityId)) { + QMessageBox::warning( + this, + QStringLiteral("黑洞修复"), + QStringLiteral("无法进入画布拖动模式,请确认黑洞与背景数据有效。")); + return; + } + statusBar()->showMessage(QStringLiteral("拖动画布中的青色取样框,松开鼠标即应用;Esc 取消")); + return; + } else { + ok = m_workspace.resolveBlackholeByUseOriginalBackground(entityId); + if (!ok) { + QMessageBox::warning(this, QStringLiteral("黑洞修复"), QStringLiteral("应用“使用原始背景”失败。")); + } + } + + if (ok) { + statusBar()->showMessage(QStringLiteral("黑洞修复已应用")); + refreshProjectTree(); + updateUiEnabledState(); + refreshEditorPage(); + if (m_previewRequested) { + refreshPreviewPage(); + } + } +} + void MainWindow::onNewProject() { if (m_workspace.isOpen()) { diff --git a/client/gui/main_window/MainWindow.h b/client/gui/main_window/MainWindow.h index 4be015f..26dc2d6 100644 --- a/client/gui/main_window/MainWindow.h +++ b/client/gui/main_window/MainWindow.h @@ -34,6 +34,7 @@ class EditorCanvas; class TimelineWidget; namespace gui { class BackgroundPropertySection; +class BlackholePropertySection; class EntityPropertySection; class ToolPropertySection; class EntityIntroPopup; @@ -100,6 +101,7 @@ private: void showProjectRootContextMenu(const QPoint& globalPos); void showBackgroundContextMenu(const QPoint& globalPos); + void showBlackholeContextMenu(const QPoint& globalPos, const QString& entityId); void rebuildCentralPages(); void showWelcomePage(); void showEditorPage(); @@ -125,6 +127,7 @@ private: QComboBox* m_modeSelector = nullptr; QStackedWidget* m_propertyStack = nullptr; gui::BackgroundPropertySection* m_bgPropertySection = nullptr; + gui::BlackholePropertySection* m_blackholePropertySection = nullptr; gui::EntityPropertySection* m_entityPropertySection = nullptr; gui::ToolPropertySection* m_toolPropertySection = nullptr; QToolButton* m_btnCreateEntity = nullptr; @@ -172,6 +175,7 @@ private: QPointF m_selectedEntityOrigin; QString m_selectedEntityId; QString m_selectedToolId; + QString m_selectedBlackholeEntityId; QString m_selectedEntityDisplayNameCache; QString m_bgAbsCache; QString m_bgSizeTextCache; diff --git a/client/gui/params/ParamControls.cpp b/client/gui/params/ParamControls.cpp index caf7ac6..0b66c21 100644 --- a/client/gui/params/ParamControls.cpp +++ b/client/gui/params/ParamControls.cpp @@ -24,7 +24,7 @@ Float01ParamControl::Float01ParamControl(QWidget* parent) m_spin->setRange(0.0, 1.0); m_spin->setDecimals(3); m_spin->setSingleStep(0.01); - m_spin->setMinimumWidth(84); + m_spin->setMinimumWidth(72); row->addWidget(m_spin); connect(m_slider, &QSlider::valueChanged, this, [this]() { syncFromSlider(); }); @@ -79,14 +79,14 @@ Vec2ParamControl::Vec2ParamControl(QWidget* parent) m_x->setRange(-1e9, 1e9); m_x->setDecimals(2); m_x->setSingleStep(1.0); - m_x->setMinimumWidth(88); + m_x->setMinimumWidth(72); row->addWidget(m_x, 1); m_y = new QDoubleSpinBox(this); m_y->setRange(-1e9, 1e9); m_y->setDecimals(2); m_y->setSingleStep(1.0); - m_y->setMinimumWidth(88); + m_y->setMinimumWidth(72); row->addWidget(m_y, 1); connect(m_x, qOverload(&QDoubleSpinBox::valueChanged), this, [this]() { emitIfChanged(); }); diff --git a/client/gui/props/BlackholePropertySection.cpp b/client/gui/props/BlackholePropertySection.cpp new file mode 100644 index 0000000..d8d501e --- /dev/null +++ b/client/gui/props/BlackholePropertySection.cpp @@ -0,0 +1,45 @@ +#include "props/BlackholePropertySection.h" + +#include +#include +#include + +namespace gui { + +BlackholePropertySection::BlackholePropertySection(QWidget* parent) + : PropertySectionWidget(parent) { + auto* lay = new QVBoxLayout(this); + lay->setContentsMargins(0, 0, 0, 0); + lay->setSpacing(6); + + auto* form = new QFormLayout(); + form->setContentsMargins(0, 0, 0, 0); + form->setSpacing(6); + + m_name = new QLabel(this); + m_status = new QLabel(this); + m_method = new QLabel(this); + m_method->setWordWrap(true); + + form->addRow(QStringLiteral("黑洞"), m_name); + form->addRow(QStringLiteral("是否解决"), m_status); + form->addRow(QStringLiteral("解决方案"), m_method); + + lay->addLayout(form); + lay->addStretch(1); +} + +void BlackholePropertySection::clearDisconnected() { + if (m_name) m_name->setText(QStringLiteral("-")); + if (m_status) m_status->setText(QStringLiteral("-")); + if (m_method) m_method->setText(QStringLiteral("-")); +} + +void BlackholePropertySection::applyState(const BlackholePropertyUiState& s) { + if (m_name) m_name->setText(s.blackholeName.isEmpty() ? QStringLiteral("-") : s.blackholeName); + if (m_status) m_status->setText(s.statusText.isEmpty() ? QStringLiteral("-") : s.statusText); + if (m_method) m_method->setText(s.methodText.isEmpty() ? QStringLiteral("-") : s.methodText); +} + +} // namespace gui + diff --git a/client/gui/props/BlackholePropertySection.h b/client/gui/props/BlackholePropertySection.h new file mode 100644 index 0000000..9314f3b --- /dev/null +++ b/client/gui/props/BlackholePropertySection.h @@ -0,0 +1,32 @@ +#pragma once + +#include "props/PropertySectionWidget.h" + +#include + +class QLabel; + +namespace gui { + +struct BlackholePropertyUiState { + QString blackholeName; + QString statusText; + QString methodText; +}; + +class BlackholePropertySection final : public PropertySectionWidget { + Q_OBJECT +public: + explicit BlackholePropertySection(QWidget* parent = nullptr); + + void clearDisconnected(); + void applyState(const BlackholePropertyUiState& s); + +private: + QLabel* m_name = nullptr; + QLabel* m_status = nullptr; + QLabel* m_method = nullptr; +}; + +} // namespace gui + diff --git a/client/gui/props/EntityPropertySection.cpp b/client/gui/props/EntityPropertySection.cpp index 67bbcaa..b18d2c5 100644 --- a/client/gui/props/EntityPropertySection.cpp +++ b/client/gui/props/EntityPropertySection.cpp @@ -40,13 +40,15 @@ EntityPropertySection::EntityPropertySection(QWidget* parent) form->addRow(QStringLiteral("深度"), m_depth); form->addRow(QStringLiteral("距离缩放"), m_distScale); + m_pivotLabel = new QLabel(QStringLiteral("中心坐标"), this); m_pivot = new Vec2ParamControl(this); m_pivot->setToolTip(QStringLiteral("枢轴在世界坐标中的位置(限制在轮廓包络内),用于重定位局部原点")); - form->addRow(QStringLiteral("中心坐标"), m_pivot); + form->addRow(m_pivotLabel, m_pivot); + m_centroidLabel = new QLabel(QStringLiteral("位置"), this); m_centroid = new Vec2ParamControl(this); m_centroid->setToolTip(QStringLiteral("实体几何质心的世界坐标;修改将整体平移实体")); - form->addRow(QStringLiteral("位置"), m_centroid); + form->addRow(m_centroidLabel, m_centroid); m_userScale = new QDoubleSpinBox(this); m_userScale->setRange(0.05, 20.0); @@ -190,6 +192,14 @@ void EntityPropertySection::clearDisconnected() { if (m_distScale) m_distScale->setText(QStringLiteral("-")); if (m_pivot) m_pivot->setValue(0.0, 0.0); if (m_centroid) m_centroid->setValue(0.0, 0.0); + if (m_pivotLabel) m_pivotLabel->setText(QStringLiteral("中心坐标")); + if (m_centroidLabel) m_centroidLabel->setText(QStringLiteral("位置")); + if (m_pivot) { + m_pivot->setToolTip(QStringLiteral("枢轴在世界坐标中的位置(限制在轮廓包络内),用于重定位局部原点")); + } + if (m_centroid) { + m_centroid->setToolTip(QStringLiteral("实体几何质心的世界坐标;修改将整体平移实体")); + } if (m_userScale) { m_userScale->blockSignals(true); m_userScale->setValue(1.0); @@ -239,6 +249,24 @@ void EntityPropertySection::applyState(const EntityPropertyUiState& s) { } if (m_depth) m_depth->setText(QString::number(s.depthZ)); if (m_distScale) m_distScale->setText(s.distanceScaleText); + if (m_pivotLabel) { + m_pivotLabel->setText(QStringLiteral("中心坐标")); + } + if (m_centroidLabel) { + m_centroidLabel->setText(QStringLiteral("位置")); + } + if (m_pivot) { + m_pivot->setToolTip( + s.parentRelativeMode + ? QStringLiteral("枢轴相对父对象的坐标;修改将写入相对父对象的位置关键帧") + : QStringLiteral("枢轴在世界坐标中的位置(限制在轮廓包络内),用于重定位局部原点")); + } + if (m_centroid) { + m_centroid->setToolTip( + s.parentRelativeMode + ? QStringLiteral("几何质心相对父对象的坐标;修改将写入相对父对象的位置关键帧") + : QStringLiteral("实体几何质心的世界坐标;修改将整体平移实体")); + } if (m_pivot) m_pivot->setValue(s.pivot.x(), s.pivot.y()); if (m_centroid) m_centroid->setValue(s.centroid.x(), s.centroid.y()); if (m_userScale) { diff --git a/client/gui/props/EntityPropertySection.h b/client/gui/props/EntityPropertySection.h index b409f81..8837abc 100644 --- a/client/gui/props/EntityPropertySection.h +++ b/client/gui/props/EntityPropertySection.h @@ -32,6 +32,7 @@ struct EntityPropertyUiState { double userScale = 1.0; bool ignoreDistanceScale = false; bool visible = true; + bool parentRelativeMode = false; core::EntityIntroContent intro; }; @@ -66,6 +67,8 @@ private: QLineEdit* m_name = nullptr; QLabel* m_depth = nullptr; QLabel* m_distScale = nullptr; + QLabel* m_pivotLabel = nullptr; + QLabel* m_centroidLabel = nullptr; Vec2ParamControl* m_pivot = nullptr; Vec2ParamControl* m_centroid = nullptr; QDoubleSpinBox* m_userScale = nullptr; diff --git a/client/gui/props/ToolPropertySection.cpp b/client/gui/props/ToolPropertySection.cpp index a54c221..2b27a5f 100644 --- a/client/gui/props/ToolPropertySection.cpp +++ b/client/gui/props/ToolPropertySection.cpp @@ -1,10 +1,13 @@ #include "props/ToolPropertySection.h" +#include "params/ParamControls.h" + #include #include #include #include +#include #include #include #include @@ -26,6 +29,11 @@ ToolPropertySection::ToolPropertySection(QWidget* parent) m_text->setPlaceholderText(QStringLiteral("对话内容…")); form->addRow(QStringLiteral("文字"), m_text); + m_positionLabel = new QLabel(QStringLiteral("位置"), this); + m_position = new Vec2ParamControl(this); + m_position->setToolTip(QStringLiteral("工具在世界坐标中的位置")); + form->addRow(m_positionLabel, m_position); + m_pointerT = new QSlider(Qt::Horizontal, this); m_pointerT->setRange(0, 1000); m_pointerT->setSingleStep(10); @@ -46,7 +54,7 @@ ToolPropertySection::ToolPropertySection(QWidget* parent) m_visible = new QCheckBox(QString(), this); m_visible->setChecked(true); - m_visible->setToolTip(QStringLiteral("随帧变化:在当前帧切换会写入可见性关键帧(10帧淡入淡出)")); + m_visible->setToolTip(QStringLiteral("随帧变化:在当前帧切换会写入可见性关键帧")); form->addRow(QStringLiteral("可见性"), m_visible); lay->addLayout(form); @@ -58,11 +66,13 @@ ToolPropertySection::ToolPropertySection(QWidget* parent) connect(m_pointerT, &QSlider::valueChanged, this, &ToolPropertySection::pointerTChanged); connect(m_fontPx, qOverload(&QSpinBox::valueChanged), this, &ToolPropertySection::fontPxChanged); connect(m_align, qOverload(&QComboBox::currentIndexChanged), this, &ToolPropertySection::alignChanged); + connect(m_position, &Vec2ParamControl::valueChanged, this, &ToolPropertySection::positionEdited); connect(m_visible, &QCheckBox::toggled, this, &ToolPropertySection::visibleToggled); } void ToolPropertySection::setEditingEnabled(bool on) { - for (auto* w : {static_cast(m_text), static_cast(m_pointerT), + for (auto* w : {static_cast(m_text), static_cast(m_position), + static_cast(m_pointerT), static_cast(m_fontPx), static_cast(m_align), static_cast(m_visible)}) { if (w) w->setEnabled(on); @@ -76,6 +86,13 @@ void ToolPropertySection::clearDisconnected() { m_text->clear(); m_text->blockSignals(false); } + if (m_positionLabel) m_positionLabel->setText(QStringLiteral("位置")); + if (m_position) { + m_position->blockSignals(true); + m_position->setToolTip(QStringLiteral("工具在世界坐标中的位置")); + m_position->setValue(0.0, 0.0); + m_position->blockSignals(false); + } if (m_pointerT) { m_pointerT->blockSignals(true); m_pointerT->setValue(500); @@ -105,6 +122,18 @@ void ToolPropertySection::applyState(const ToolPropertyUiState& s) { m_text->setText(s.text); m_text->blockSignals(false); } + if (m_positionLabel) { + m_positionLabel->setText(QStringLiteral("位置")); + } + if (m_position) { + m_position->blockSignals(true); + m_position->setToolTip( + s.parentRelativeMode + ? QStringLiteral("工具相对父对象的位置;修改将写入相对父对象的位置关键帧") + : QStringLiteral("工具在世界坐标中的位置")); + m_position->setValue(s.position.x(), s.position.y()); + m_position->blockSignals(false); + } if (m_pointerT) { m_pointerT->blockSignals(true); m_pointerT->setValue(std::clamp(s.pointerTThousandths, 0, 1000)); diff --git a/client/gui/props/ToolPropertySection.h b/client/gui/props/ToolPropertySection.h index d3c8d92..e5115f0 100644 --- a/client/gui/props/ToolPropertySection.h +++ b/client/gui/props/ToolPropertySection.h @@ -2,6 +2,7 @@ #include "props/PropertySectionWidget.h" +#include #include class QComboBox; @@ -9,12 +10,19 @@ class QLineEdit; class QSlider; class QSpinBox; class QCheckBox; +class QLabel; + +namespace gui { +class Vec2ParamControl; +} namespace gui { struct ToolPropertyUiState { QString displayName; QString text; + QPointF position; + bool parentRelativeMode = false; int pointerTThousandths = 500; // bubblePointerT01 * 1000,0=左 1000=右 int fontPx = 18; int alignIndex = 1; // 0=left,1=center,2=right @@ -35,10 +43,13 @@ signals: void pointerTChanged(int thousandths); void fontPxChanged(int px); void alignChanged(int alignIndex); + void positionEdited(double x, double y); // 可见性(动画通道):在当前帧写关键帧 void visibleToggled(bool on); private: + QLabel* m_positionLabel = nullptr; + Vec2ParamControl* m_position = nullptr; QLineEdit* m_text = nullptr; QSlider* m_pointerT = nullptr; QSpinBox* m_fontPx = nullptr;