#include "workspace/ProjectWorkspace.h" #include "animation/AnimationSampling.h" #include "persistence/EntityPayloadBinary.h" #include "depth/DepthService.h" #include "net/ModelServerClient.h" #include #include #include #include #include #include #include #include #include #include #include #include #include namespace core { namespace { QPointF polygonCentroidFromWorldPoints(const QVector& poly) { if (poly.size() < 3) { return poly.isEmpty() ? QPointF() : poly.front(); } double a2 = 0.0; double cx6a = 0.0; double cy6a = 0.0; for (int i = 0; i < poly.size(); ++i) { const QPointF p0 = poly[i]; const QPointF p1 = poly[(i + 1) % poly.size()]; const double cross = static_cast(p0.x()) * static_cast(p1.y()) - static_cast(p1.x()) * static_cast(p0.y()); a2 += cross; cx6a += (static_cast(p0.x()) + static_cast(p1.x())) * cross; cy6a += (static_cast(p0.y()) + static_cast(p1.y())) * cross; } if (std::abs(a2) < 1e-6) { double minX = poly[0].x(); double minY = poly[0].y(); double maxX = minX; double maxY = minY; for (const QPointF& p : poly) { minX = std::min(minX, static_cast(p.x())); minY = std::min(minY, static_cast(p.y())); maxX = std::max(maxX, static_cast(p.x())); maxY = std::max(maxY, static_cast(p.y())); } return QPointF(0.5 * (minX + maxX), 0.5 * (minY + maxY)); } const double inv6a = 1.0 / (3.0 * a2); return QPointF(cx6a * inv6a, cy6a * inv6a); } QPointF entityPolygonCentroidWorld(const Project::Entity& e, int frame, double sTotal) { const QPointF O = sampleLocation(e.locationKeys, frame, e.originWorld, KeyInterpolation::Linear); QVector w; w.reserve(e.polygonLocal.size()); for (const QPointF& lp : e.polygonLocal) { w.push_back(O + lp * sTotal); } return polygonCentroidFromWorldPoints(w); } QString ensureDir(const QString& path) { QDir dir(path); if (dir.exists()) { return dir.absolutePath(); } if (dir.mkpath(".")) { return dir.absolutePath(); } return {}; } QString normalizedProjectDir(const QString& projectDir) { QFileInfo fi(projectDir); return QDir(fi.absoluteFilePath()).absolutePath(); } QString sanitizeFolderName(QString name) { name = name.trimmed(); if (name.isEmpty()) { return QStringLiteral("project"); } // 简单做法:把明显不适合作为文件夹名的字符替换为下划线 static const QString badChars = QStringLiteral("\\/:*?\"<>|"); for (const QChar& ch : badChars) { name.replace(ch, QChar('_')); } return name; } QString pickUniqueSubdirPath(const QString& parentDir, const QString& baseName) { const auto cleanedBase = sanitizeFolderName(baseName); QDir parent(parentDir); if (!parent.exists()) { return {}; } const QString first = parent.filePath(cleanedBase); if (!QFileInfo::exists(first)) { return first; } for (int i = 1; i < 10000; ++i) { const QString cand = parent.filePath(QStringLiteral("%1_%2").arg(cleanedBase).arg(i)); if (!QFileInfo::exists(cand)) { return cand; } } return {}; } QRect clampRectToImage(const QRect& rect, const QSize& size) { QRect r = rect.normalized(); if (r.isNull() || r.width() <= 0 || r.height() <= 0) { return {}; } r.setLeft(std::max(0, r.left())); r.setTop(std::max(0, r.top())); r.setRight(std::min(size.width() - 1, r.right())); r.setBottom(std::min(size.height() - 1, r.bottom())); if (r.width() <= 0 || r.height() <= 0) { return {}; } return r; } } // namespace QString ProjectWorkspace::indexFilePath() const { if (m_projectDir.isEmpty()) { return {}; } return QDir(m_projectDir).filePath(QString::fromUtf8(kProjectIndexFileName)); } QString ProjectWorkspace::assetsDirPath() const { if (m_projectDir.isEmpty()) { return {}; } return QDir(m_projectDir).filePath(QString::fromUtf8(kAssetsDirName)); } QString ProjectWorkspace::backgroundAbsolutePath() const { if (m_projectDir.isEmpty() || m_project.backgroundImagePath().isEmpty()) { return {}; } return QDir(m_projectDir).filePath(m_project.backgroundImagePath()); } bool ProjectWorkspace::setBackgroundVisible(bool on) { if (m_projectDir.isEmpty()) { return false; } if (m_project.backgroundVisible() == on) { return true; } m_project.setBackgroundVisible(on); if (!writeIndexJson()) { m_project.setBackgroundVisible(!on); return false; } return true; } bool ProjectWorkspace::hasDepth() const { if (m_projectDir.isEmpty()) { return false; } if (!m_project.depthComputed() || m_project.depthMapPath().isEmpty()) { return false; } const auto abs = depthAbsolutePath(); return !abs.isEmpty() && QFileInfo::exists(abs); } QString ProjectWorkspace::depthAbsolutePath() const { if (m_projectDir.isEmpty() || m_project.depthMapPath().isEmpty()) { return {}; } return QDir(m_projectDir).filePath(m_project.depthMapPath()); } bool ProjectWorkspace::setProjectTitle(const QString& title) { if (m_projectDir.isEmpty()) { return false; } const QString t = title.trimmed(); if (t.isEmpty()) { return false; } if (t == m_project.name()) { return true; } const QString before = m_project.name(); m_project.setName(t); if (!writeIndexJson()) { m_project.setName(before); return false; } Operation op; op.type = Operation::Type::SetProjectTitle; op.label = QStringLiteral("重命名项目"); op.beforeProjectTitle = before; op.afterProjectTitle = t; pushOperation(op); m_redoStack.clear(); return true; } bool ProjectWorkspace::createNew(const QString& projectDir, const QString& name, const QString& backgroundImageSourcePath) { return createNew(projectDir, name, backgroundImageSourcePath, QRect()); } bool ProjectWorkspace::createNew(const QString& projectDir, const QString& name, const QString& backgroundImageSourcePath, const QRect& cropRectInSourceImage) { // 约束:新建项目必须选择背景;裁剪可选(为空时取整张图) if (backgroundImageSourcePath.isEmpty()) { return false; } // 这里的 projectDir 实际是“父目录”,我们在其下创建新的项目文件夹 const auto parentAbs = normalizedProjectDir(projectDir); if (parentAbs.isEmpty()) { return false; } const auto newProjectDir = pickUniqueSubdirPath(parentAbs, name); if (newProjectDir.isEmpty()) { return false; } m_projectDir = normalizedProjectDir(newProjectDir); if (m_projectDir.isEmpty()) { return false; } const auto rootOk = ensureDir(m_projectDir); if (rootOk.isEmpty()) { m_projectDir.clear(); return false; } const auto assetsOk = ensureDir(assetsDirPath()); if (assetsOk.isEmpty()) { m_projectDir.clear(); return false; } m_project.setName(name); m_project.setBackgroundImagePath(QString()); m_undoStack.clear(); m_redoStack.clear(); if (!importBackgroundImage(backgroundImageSourcePath, cropRectInSourceImage)) { m_projectDir.clear(); m_project = Project(); m_undoStack.clear(); m_redoStack.clear(); return false; } return writeIndexJson(); } bool ProjectWorkspace::openExisting(const QString& projectDir) { const auto dir = normalizedProjectDir(projectDir); const auto indexPath = QDir(dir).filePath(QString::fromUtf8(kProjectIndexFileName)); if (!QFileInfo::exists(indexPath)) { return false; } // 重要:readIndexJson(v2) 会尝试从磁盘 hydrate 实体 payload,需要 m_projectDir 已就绪 const QString prevDir = m_projectDir; const Project prevProject = m_project; m_projectDir = dir; if (!readIndexJson(indexPath)) { m_projectDir = prevDir; m_project = prevProject; return false; } // readIndexJson 会写入 m_project;这里补齐历史初始化 ensureDir(assetsDirPath()); m_undoStack.clear(); m_redoStack.clear(); return true; } void ProjectWorkspace::close() { m_projectDir.clear(); m_project = Project(); m_undoStack.clear(); m_redoStack.clear(); } bool ProjectWorkspace::canUndo() const { return !m_undoStack.isEmpty(); } bool ProjectWorkspace::canRedo() const { return !m_redoStack.isEmpty(); } bool ProjectWorkspace::undo() { if (!canUndo() || m_projectDir.isEmpty()) { return false; } const auto op = m_undoStack.takeLast(); Operation redoOp = op; redoOp.beforeBackgroundPath = op.beforeBackgroundPath; redoOp.afterBackgroundPath = op.afterBackgroundPath; if (op.type == Operation::Type::ImportBackground) { if (!applyBackgroundPath(op.beforeBackgroundPath, false, QString())) { m_undoStack.push_back(op); return false; } } else if (op.type == Operation::Type::SetEntities) { if (!applyEntities(op.beforeEntities, false, QString())) { m_undoStack.push_back(op); return false; } } else if (op.type == Operation::Type::SetProjectTitle) { m_project.setName(op.beforeProjectTitle); if (!writeIndexJson()) { m_project.setName(op.afterProjectTitle); m_undoStack.push_back(op); return false; } } m_redoStack.push_back(redoOp); return true; } bool ProjectWorkspace::redo() { if (!canRedo() || m_projectDir.isEmpty()) { return false; } const auto op = m_redoStack.takeLast(); Operation undoOp = op; if (op.type == Operation::Type::ImportBackground) { if (!applyBackgroundPath(op.afterBackgroundPath, false, QString())) { m_redoStack.push_back(op); return false; } } else if (op.type == Operation::Type::SetEntities) { if (!applyEntities(op.afterEntities, false, QString())) { m_redoStack.push_back(op); return false; } } else if (op.type == Operation::Type::SetProjectTitle) { m_project.setName(op.afterProjectTitle); if (!writeIndexJson()) { m_project.setName(op.beforeProjectTitle); m_redoStack.push_back(op); return false; } } m_undoStack.push_back(undoOp); return true; } QStringList ProjectWorkspace::historyLabelsNewestFirst() const { QStringList out; out.reserve(m_undoStack.size()); for (auto it = m_undoStack.crbegin(); it != m_undoStack.crend(); ++it) { out.push_back(it->label); } return out; } bool ProjectWorkspace::importBackgroundImage(const QString& backgroundImageSourcePath) { return importBackgroundImage(backgroundImageSourcePath, QRect()); } bool ProjectWorkspace::importBackgroundImage(const QString& backgroundImageSourcePath, const QRect& cropRectInSourceImage) { if (m_projectDir.isEmpty()) { return false; } // 约束:项目创建成功后不允许再更换/裁剪背景 if (!m_project.backgroundImagePath().isEmpty()) { return false; } // 背景变化会使深度失效:这里先直接清空深度状态(后续若允许更换背景,再完善历史记录) m_project.setDepthComputed(false); m_project.setDepthMapPath(QString()); const auto rel = copyIntoAssetsAsBackground(backgroundImageSourcePath, cropRectInSourceImage); if (rel.isEmpty()) { return false; } const auto label = cropRectInSourceImage.isNull() ? QStringLiteral("导入背景") : QStringLiteral("导入背景(裁剪)"); return applyBackgroundPath(rel, true, label); } bool ProjectWorkspace::writeIndexJson() { if (!m_projectDir.isEmpty() && !syncEntityPayloadsToDisk()) { return false; } const auto root = projectToJson(m_project); QJsonDocument doc(root); QFile f(indexFilePath()); if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate)) { return false; } const auto bytes = doc.toJson(QJsonDocument::Indented); return f.write(bytes) == bytes.size(); } bool ProjectWorkspace::readIndexJson(const QString& indexPath) { QFile f(indexPath); if (!f.open(QIODevice::ReadOnly)) { return false; } const auto data = f.readAll(); QJsonParseError err; const auto doc = QJsonDocument::fromJson(data, &err); if (err.error != QJsonParseError::NoError || !doc.isObject()) { return false; } Project p; int fileVer = 0; if (!projectFromJson(doc.object(), p, &fileVer)) { return false; } m_project = p; if (fileVer == 1) { loadV1LegacyAnimationSidecars(); } else { if (!hydrateEntityPayloadsFromDisk()) { return false; } } return true; } QJsonObject ProjectWorkspace::projectToJson(const Project& project) { QJsonObject root; root.insert("format", "hfut-bishe-project"); root.insert("version", kProjectIndexFormatVersion); root.insert("name", project.name()); root.insert("savedAt", QDateTime::currentDateTimeUtc().toString(Qt::ISODate)); root.insert("backgroundImagePath", project.backgroundImagePath()); root.insert("backgroundVisible", project.backgroundVisible()); root.insert("depthComputed", project.depthComputed()); root.insert("depthMapPath", project.depthMapPath()); root.insert("frameStart", project.frameStart()); root.insert("frameEnd", project.frameEnd()); root.insert("fps", project.fps()); QJsonArray ents; for (const auto& e : project.entities()) { ents.append(entityToJson(e)); } root.insert("entities", ents); return root; } bool ProjectWorkspace::projectFromJson(const QJsonObject& root, Project& outProject, int* outFileVersion) { if (root.value("format").toString() != QStringLiteral("hfut-bishe-project")) { return false; } const int version = root.value("version").toInt(); if (version != 1 && version != 2) { return false; } if (outFileVersion) { *outFileVersion = version; } outProject.setName(root.value("name").toString()); outProject.setBackgroundImagePath(asRelativeUnderProject(root.value("backgroundImagePath").toString())); outProject.setBackgroundVisible(root.value("backgroundVisible").toBool(true)); outProject.setDepthComputed(root.value("depthComputed").toBool(false)); outProject.setDepthMapPath(asOptionalRelativeUnderProject(root.value("depthMapPath").toString())); outProject.setFrameStart(root.value("frameStart").toInt(0)); outProject.setFrameEnd(root.value("frameEnd").toInt(600)); outProject.setFps(root.value("fps").toInt(60)); if (outProject.frameEnd() < outProject.frameStart()) { outProject.setFrameEnd(outProject.frameStart()); } QVector entities; const auto entsVal = root.value("entities"); if (entsVal.isArray()) { const QJsonArray arr = entsVal.toArray(); entities.reserve(arr.size()); for (const auto& v : arr) { if (!v.isObject()) { continue; } Project::Entity e; if (version == 1) { if (entityFromJsonV1(v.toObject(), e)) { entities.push_back(e); } } else { if (!entityStubFromJsonV2(v.toObject(), e)) { return false; } entities.push_back(e); } } } outProject.setEntities(entities); return true; } QString ProjectWorkspace::asRelativeUnderProject(const QString& relativePath) { if (relativePath.isEmpty()) { return {}; } QString p = relativePath; while (p.startsWith("./")) { p = p.mid(2); } if (QDir::isAbsolutePath(p)) { // 不允许绝对路径写入索引,避免项目不可迁移 return {}; } return QDir::cleanPath(p); } QString ProjectWorkspace::asOptionalRelativeUnderProject(const QString& relativePath) { if (relativePath.isEmpty()) { return {}; } return asRelativeUnderProject(relativePath); } QJsonObject ProjectWorkspace::entityToJson(const Project::Entity& e) { QJsonObject o; o.insert("id", e.id); o.insert("payload", e.entityPayloadPath); o.insert("visible", e.visible); return o; } bool ProjectWorkspace::entityStubFromJsonV2(const QJsonObject& o, Project::Entity& out) { out = Project::Entity{}; out.id = o.value("id").toString(); out.entityPayloadPath = asOptionalRelativeUnderProject(o.value("payload").toString()); out.visible = o.value("visible").toBool(true); if (out.id.isEmpty() || out.entityPayloadPath.isEmpty()) { return false; } return true; } bool ProjectWorkspace::entityFromJsonV1(const QJsonObject& o, Project::Entity& out) { out.id = o.value("id").toString(); if (out.id.isEmpty()) { return false; } out.depth = o.value("depth").toInt(0); out.imagePath = asOptionalRelativeUnderProject(o.value("imagePath").toString()); out.imageTopLeftWorld = QPointF(o.value("imageTopLeftX").toDouble(0.0), o.value("imageTopLeftY").toDouble(0.0)); out.originWorld = QPointF(o.value("originX").toDouble(0.0), o.value("originY").toDouble(0.0)); out.polygonLocal.clear(); out.cutoutPolygonWorld.clear(); out.entityPayloadPath.clear(); out.legacyAnimSidecarPath = asOptionalRelativeUnderProject(o.value("animationBundle").toString()); out.locationKeys.clear(); out.depthScaleKeys.clear(); out.imageFrames.clear(); const auto localVal = o.value("polygonLocal"); if (localVal.isArray()) { const QJsonArray arr = localVal.toArray(); out.polygonLocal.reserve(arr.size()); for (const auto& v : arr) { if (!v.isObject()) { continue; } const QJsonObject p = v.toObject(); out.polygonLocal.push_back(QPointF(p.value("x").toDouble(0.0), p.value("y").toDouble(0.0))); } } const auto cutoutVal = o.value("cutoutPolygon"); if (cutoutVal.isArray()) { const QJsonArray arr = cutoutVal.toArray(); out.cutoutPolygonWorld.reserve(arr.size()); for (const auto& v : arr) { if (!v.isObject()) { continue; } const QJsonObject p = v.toObject(); out.cutoutPolygonWorld.push_back(QPointF(p.value("x").toDouble(0.0), p.value("y").toDouble(0.0))); } } // 兼容旧字段:polygon(world) if (out.polygonLocal.isEmpty()) { const auto legacy = o.value("polygon"); if (legacy.isArray()) { const QJsonArray arr = legacy.toArray(); QVector polyWorld; polyWorld.reserve(arr.size()); for (const auto& v : arr) { if (!v.isObject()) continue; const QJsonObject p = v.toObject(); polyWorld.push_back(QPointF(p.value("x").toDouble(0.0), p.value("y").toDouble(0.0))); } if (!polyWorld.isEmpty()) { // 若没给 origin,则用包围盒中心近似 if (qFuzzyIsNull(out.originWorld.x()) && qFuzzyIsNull(out.originWorld.y())) { QRectF bb; for (const auto& pt : polyWorld) { bb = bb.isNull() ? QRectF(pt, QSizeF(1, 1)) : bb.united(QRectF(pt, QSizeF(1, 1))); } out.originWorld = bb.center(); } out.cutoutPolygonWorld = polyWorld; out.polygonLocal.reserve(polyWorld.size()); for (const auto& pt : polyWorld) { out.polygonLocal.push_back(pt - out.originWorld); } } } } if (out.cutoutPolygonWorld.isEmpty()) { // 没有 cutout 就默认用当前实体形状(origin+local) for (const auto& lp : out.polygonLocal) { out.cutoutPolygonWorld.push_back(out.originWorld + lp); } } // 旧版:关键帧内嵌在 project.json;若存在对应 .anim 文件,打开项目时会被二进制数据覆盖。 const auto lk = o.value("locationKeys"); if (lk.isArray()) { for (const auto& v : lk.toArray()) { if (!v.isObject()) continue; const auto ko = v.toObject(); Project::Entity::KeyframeVec2 kf; kf.frame = ko.value("frame").toInt(0); kf.value = QPointF(ko.value("x").toDouble(0.0), ko.value("y").toDouble(0.0)); out.locationKeys.push_back(kf); } } const auto dk = o.value("depthScaleKeys"); if (dk.isArray()) { for (const auto& v : dk.toArray()) { if (!v.isObject()) continue; const auto ko = v.toObject(); Project::Entity::KeyframeFloat01 kf; kf.frame = ko.value("frame").toInt(0); kf.value = ko.value("v").toDouble(0.5); out.depthScaleKeys.push_back(kf); } } const auto ik = o.value("imageFrames"); if (ik.isArray()) { for (const auto& v : ik.toArray()) { if (!v.isObject()) continue; const auto ko = v.toObject(); Project::Entity::ImageFrame kf; kf.frame = ko.value("frame").toInt(0); kf.imagePath = asOptionalRelativeUnderProject(ko.value("imagePath").toString()); if (!kf.imagePath.isEmpty()) { out.imageFrames.push_back(kf); } } } return !out.polygonLocal.isEmpty(); } QString ProjectWorkspace::fileSuffixWithDot(const QString& path) { QFileInfo fi(path); const auto suf = fi.suffix(); if (suf.isEmpty()) { return {}; } return "." + suf; } void ProjectWorkspace::pushOperation(const Operation& op) { m_undoStack.push_back(op); if (m_undoStack.size() > kMaxHistorySteps) { m_undoStack.remove(0, m_undoStack.size() - kMaxHistorySteps); } } bool ProjectWorkspace::applyBackgroundPath(const QString& relativePath, bool recordHistory, const QString& label) { const auto rel = asRelativeUnderProject(relativePath); if (relativePath.isEmpty()) { // 允许清空背景 if (recordHistory) { Operation op; op.type = Operation::Type::ImportBackground; op.label = label; op.beforeBackgroundPath = m_project.backgroundImagePath(); op.afterBackgroundPath = QString(); pushOperation(op); m_redoStack.clear(); } m_project.setBackgroundImagePath(QString()); return writeIndexJson(); } if (rel.isEmpty()) { return false; } const auto before = m_project.backgroundImagePath(); m_project.setBackgroundImagePath(rel); if (!writeIndexJson()) { m_project.setBackgroundImagePath(before); return false; } if (recordHistory) { Operation op; op.type = Operation::Type::ImportBackground; op.label = label; op.beforeBackgroundPath = before; op.afterBackgroundPath = rel; pushOperation(op); m_redoStack.clear(); } return true; } bool ProjectWorkspace::applyEntities(const QVector& entities, bool recordHistory, const QString& label) { const auto before = m_project.entities(); m_project.setEntities(entities); if (!writeIndexJson()) { m_project.setEntities(before); return false; } if (recordHistory) { Operation op; op.type = Operation::Type::SetEntities; op.label = label; op.beforeEntities = before; op.afterEntities = entities; pushOperation(op); m_redoStack.clear(); } return true; } QString ProjectWorkspace::ensureEntitiesDir() const { const auto assets = assetsDirPath(); if (assets.isEmpty()) { return {}; } const auto dir = QDir(assets).filePath(QStringLiteral("entities")); return ensureDir(dir); } bool ProjectWorkspace::syncEntityPayloadsToDisk() { if (ensureEntitiesDir().isEmpty()) { return false; } QVector ents = m_project.entities(); bool changed = false; for (auto& e : ents) { e.legacyAnimSidecarPath.clear(); if (e.entityPayloadPath.isEmpty()) { e.entityPayloadPath = QString::fromUtf8(kAssetsDirName) + QStringLiteral("/entities/") + e.id + QStringLiteral(".hfe"); changed = true; } const QString rel = asRelativeUnderProject(e.entityPayloadPath); if (rel.isEmpty()) { return false; } if (rel != e.entityPayloadPath) { e.entityPayloadPath = rel; changed = true; } const QString abs = QDir(m_projectDir).filePath(rel); if (!EntityPayloadBinary::save(abs, e)) { return false; } } if (changed) { m_project.setEntities(ents); } return true; } bool ProjectWorkspace::hydrateEntityPayloadsFromDisk() { if (m_projectDir.isEmpty()) { return true; } QVector ents = m_project.entities(); for (auto& e : ents) { const QString expectId = e.id; QString rel = e.entityPayloadPath; if (rel.isEmpty()) { return false; } rel = asRelativeUnderProject(rel); if (rel.isEmpty()) { return false; } const QString abs = QDir(m_projectDir).filePath(rel); if (!QFileInfo::exists(abs)) { return false; } if (!EntityPayloadBinary::load(abs, e)) { return false; } if (e.id != expectId) { return false; } e.entityPayloadPath = rel; } m_project.setEntities(ents); return true; } void ProjectWorkspace::loadV1LegacyAnimationSidecars() { if (m_projectDir.isEmpty()) { return; } QVector ents = m_project.entities(); for (auto& e : ents) { QString rel = e.legacyAnimSidecarPath; if (rel.isEmpty()) { rel = QString::fromUtf8(kAssetsDirName) + QStringLiteral("/anim/") + e.id + QStringLiteral(".anim"); } rel = asRelativeUnderProject(rel); if (!rel.isEmpty()) { const QString abs = QDir(m_projectDir).filePath(rel); if (QFileInfo::exists(abs)) { EntityPayloadBinary::loadLegacyAnimFile(abs, e); } } e.legacyAnimSidecarPath.clear(); } m_project.setEntities(ents); } bool ProjectWorkspace::writeEntityImage(const QString& entityId, const QImage& image, QString& outRelPath) { outRelPath.clear(); if (m_projectDir.isEmpty() || entityId.isEmpty() || image.isNull()) { return false; } const auto entsDir = ensureEntitiesDir(); if (entsDir.isEmpty()) { return false; } const auto fileName = QStringLiteral("%1.png").arg(entityId); const auto destAbs = QDir(entsDir).filePath(fileName); const auto destRel = QString::fromUtf8(kAssetsDirName) + "/entities/" + fileName; const auto tmpAbs = destAbs + ".tmp"; if (QFileInfo::exists(tmpAbs)) { QFile::remove(tmpAbs); } if (!image.save(tmpAbs, "PNG")) { QFile::remove(tmpAbs); return false; } QFile::remove(destAbs); if (!QFile::rename(tmpAbs, destAbs)) { QFile::remove(tmpAbs); return false; } outRelPath = destRel; return true; } bool ProjectWorkspace::writeEntityFrameImage(const QString& entityId, int frame, const QImage& image, QString& outRelPath) { outRelPath.clear(); if (m_projectDir.isEmpty() || entityId.isEmpty() || image.isNull() || frame < 0) { return false; } const auto entsDir = ensureEntitiesDir(); if (entsDir.isEmpty()) { return false; } const auto fileName = QStringLiteral("%1_f%2.png").arg(entityId).arg(frame); const auto destAbs = QDir(entsDir).filePath(fileName); const auto destRel = QString::fromUtf8(kAssetsDirName) + "/entities/" + fileName; const auto tmpAbs = destAbs + ".tmp"; if (QFileInfo::exists(tmpAbs)) { QFile::remove(tmpAbs); } if (!image.save(tmpAbs, "PNG")) { QFile::remove(tmpAbs); return false; } QFile::remove(destAbs); if (!QFile::rename(tmpAbs, destAbs)) { QFile::remove(tmpAbs); return false; } outRelPath = destRel; return true; } static void upsertKey(QVector& keys, int frame, const QPointF& v) { for (auto& k : keys) { if (k.frame == frame) { k.value = v; return; } } keys.push_back(Project::Entity::KeyframeVec2{frame, v}); } static void upsertKey(QVector& keys, int frame, double v) { for (auto& k : keys) { if (k.frame == frame) { k.value = v; return; } } Project::Entity::KeyframeFloat01 kf; kf.frame = frame; kf.value = v; keys.push_back(kf); } static void upsertKey(QVector& keys, int frame, double v) { for (auto& k : keys) { if (k.frame == frame) { k.value = v; return; } } Project::Entity::KeyframeDouble kf; kf.frame = frame; kf.value = v; keys.push_back(kf); } static void upsertFrame(QVector& frames, int frame, const QString& path) { for (auto& k : frames) { if (k.frame == frame) { k.imagePath = path; return; } } Project::Entity::ImageFrame kf; kf.frame = frame; kf.imagePath = path; frames.push_back(kf); } bool ProjectWorkspace::addEntity(const Project::Entity& entity, const QImage& image) { // 允许在“只有背景、尚未计算深度”的情况下创建实体:depth 会退化为 0。 if (m_projectDir.isEmpty() || !hasBackground()) { return false; } if (entity.id.isEmpty() || entity.polygonLocal.isEmpty()) { return false; } Project::Entity e = entity; if (!image.isNull()) { QString rel; if (!writeEntityImage(e.id, image, rel)) { return false; } e.imagePath = rel; } if (e.entityPayloadPath.isEmpty()) { e.entityPayloadPath = QString::fromUtf8(kAssetsDirName) + QStringLiteral("/entities/") + e.id + QStringLiteral(".hfe"); } auto ents = m_project.entities(); ents.push_back(e); return applyEntities(ents, true, QStringLiteral("添加实体")); } bool ProjectWorkspace::setEntityVisible(const QString& id, bool on) { if (m_projectDir.isEmpty() || id.isEmpty()) { return false; } auto ents = m_project.entities(); bool found = false; bool changed = false; for (auto& e : ents) { if (e.id != id) continue; found = true; if (e.visible != on) { e.visible = on; changed = true; } break; } if (!found) return false; if (!changed) return true; return applyEntities(ents, true, on ? QStringLiteral("显示实体") : QStringLiteral("隐藏实体")); } bool ProjectWorkspace::setEntityDisplayName(const QString& id, const QString& displayName) { if (m_projectDir.isEmpty() || id.isEmpty()) { return false; } const QString trimmed = displayName.trimmed(); const QString stored = (trimmed.isEmpty() || trimmed == id) ? QString() : trimmed; auto ents = m_project.entities(); bool found = false; for (auto& e : ents) { if (e.id != id) { continue; } found = true; e.displayName = stored; break; } if (!found) { return false; } return applyEntities(ents, true, QStringLiteral("重命名实体")); } bool ProjectWorkspace::setEntityUserScale(const QString& id, double userScale) { if (m_projectDir.isEmpty() || id.isEmpty()) { return false; } const double u = std::clamp(userScale, 0.05, 20.0); auto ents = m_project.entities(); bool found = false; for (auto& e : ents) { if (e.id != id) { continue; } found = true; if (qFuzzyCompare(e.userScale + 1.0, u + 1.0)) { return true; } e.userScale = u; break; } if (!found) { return false; } return applyEntities(ents, true, QStringLiteral("整体缩放")); } bool ProjectWorkspace::moveEntityCentroidTo(const QString& id, int frame, const QPointF& targetCentroidWorld, double sTotal, bool autoKeyLocation) { if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0 || sTotal <= 1e-9) { return false; } auto ents = m_project.entities(); for (const auto& e : ents) { if (e.id == id) { const QPointF c = entityPolygonCentroidWorld(e, frame, sTotal); const QPointF delta = targetCentroidWorld - c; return moveEntityBy(id, delta, frame, autoKeyLocation); } } return false; } bool ProjectWorkspace::reanchorEntityPivot(const QString& id, int frame, const QPointF& newPivotWorld, double sTotal) { if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0 || sTotal <= 1e-9) { return false; } auto ents = m_project.entities(); bool found = false; for (auto& e : ents) { if (e.id != id) { continue; } found = true; const QPointF O_anim = sampleLocation(e.locationKeys, frame, e.originWorld, KeyInterpolation::Linear); QVector polyWorld; polyWorld.reserve(e.polygonLocal.size()); for (const QPointF& lp : e.polygonLocal) { polyWorld.push_back(O_anim + lp * sTotal); } if (polyWorld.size() < 3) { return false; } double minX = polyWorld[0].x(); double minY = polyWorld[0].y(); double maxX = minX; double maxY = minY; for (const QPointF& p : polyWorld) { minX = std::min(minX, p.x()); minY = std::min(minY, p.y()); maxX = std::max(maxX, p.x()); maxY = std::max(maxY, p.y()); } QPointF O_new(newPivotWorld); O_new.setX(std::clamp(O_new.x(), minX, maxX)); O_new.setY(std::clamp(O_new.y(), minY, maxY)); const QPointF I_disp = O_anim + (e.imageTopLeftWorld - e.originWorld) * sTotal; const QPointF d = O_new - O_anim; QVector newLocal; newLocal.reserve(polyWorld.size()); for (const QPointF& p : polyWorld) { newLocal.push_back((p - O_new) / sTotal); } for (auto& k : e.locationKeys) { k.value += d; } e.originWorld += d; e.polygonLocal = std::move(newLocal); e.imageTopLeftWorld = e.originWorld + (I_disp - O_new) / sTotal; break; } if (!found) { return false; } return applyEntities(ents, true, QStringLiteral("属性:枢轴")); } bool ProjectWorkspace::reorderEntitiesById(const QStringList& idsInOrder) { if (m_projectDir.isEmpty()) { return false; } auto ents = m_project.entities(); if (ents.isEmpty()) { return true; } if (idsInOrder.isEmpty()) { return false; } // 构建 id->entity 映射,并确保 ids 覆盖全部实体且无重复 QHash map; map.reserve(ents.size()); for (const auto& e : ents) { map.insert(e.id, e); } if (map.size() != ents.size()) { return false; } if (idsInOrder.size() != ents.size()) { return false; } QVector reordered; reordered.reserve(ents.size()); QSet seen; for (const auto& id : idsInOrder) { if (id.isEmpty() || seen.contains(id) || !map.contains(id)) { return false; } seen.insert(id); reordered.push_back(map.value(id)); } // 若顺序没变,直接返回 bool same = true; for (int i = 0; i < ents.size(); ++i) { if (ents[i].id != reordered[i].id) { same = false; break; } } if (same) return true; return applyEntities(reordered, true, QStringLiteral("排序实体")); } bool ProjectWorkspace::moveEntityBy(const QString& id, const QPointF& delta, int currentFrame, bool autoKeyLocation) { if (m_projectDir.isEmpty() || id.isEmpty()) { return false; } if (qFuzzyIsNull(delta.x()) && qFuzzyIsNull(delta.y())) { return true; } auto ents = m_project.entities(); bool found = false; for (auto& e : ents) { if (e.id != id) { continue; } found = true; if (autoKeyLocation && currentFrame >= 0) { const QPointF sampled = sampleLocation(e.locationKeys, currentFrame, e.originWorld, KeyInterpolation::Linear); upsertKey(e.locationKeys, currentFrame, sampled + delta); } else { // 无自动关键帧时,直接修改“基准”位置 e.originWorld += delta; e.imageTopLeftWorld += delta; } break; } if (!found) { return false; } return applyEntities(ents, true, QStringLiteral("移动实体")); } bool ProjectWorkspace::setEntityLocationKey(const QString& id, int frame, const QPointF& originWorld) { if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) { return false; } auto ents = m_project.entities(); bool found = false; for (auto& e : ents) { if (e.id != id) continue; found = true; upsertKey(e.locationKeys, frame, originWorld); break; } if (!found) return false; return applyEntities(ents, true, QStringLiteral("插入关键帧(位置)")); } bool ProjectWorkspace::setEntityDepthScaleKey(const QString& id, int frame, double value01) { if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) { return false; } const double v = std::clamp(value01, 0.0, 1.0); auto ents = m_project.entities(); bool found = false; for (auto& e : ents) { if (e.id != id) continue; found = true; upsertKey(e.depthScaleKeys, frame, v); break; } if (!found) return false; return applyEntities(ents, true, QStringLiteral("插入关键帧(缩放)")); } bool ProjectWorkspace::setEntityUserScaleKey(const QString& id, int frame, double userScale) { if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) { return false; } const double v = std::clamp(userScale, 1e-6, 1e3); auto ents = m_project.entities(); bool found = false; for (auto& e : ents) { if (e.id != id) continue; found = true; upsertKey(e.userScaleKeys, frame, v); break; } if (!found) return false; return applyEntities(ents, true, QStringLiteral("插入关键帧(缩放)")); } bool ProjectWorkspace::setEntityImageFrame(const QString& id, int frame, const QImage& image, QString* outRelPath) { if (outRelPath) outRelPath->clear(); if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0 || image.isNull()) { return false; } QString rel; if (!writeEntityFrameImage(id, frame, image, rel)) { return false; } auto ents = m_project.entities(); bool found = false; for (auto& e : ents) { if (e.id != id) continue; found = true; upsertFrame(e.imageFrames, frame, rel); break; } if (!found) return false; if (!applyEntities(ents, true, QStringLiteral("插入关键帧(图像)"))) { return false; } if (outRelPath) *outRelPath = rel; return true; } namespace { bool removeLocationKeyAtFrame(QVector& keys, int frame) { for (int i = 0; i < keys.size(); ++i) { if (keys[i].frame == frame) { keys.remove(i); return true; } } return false; } bool removeDepthKeyAtFrame(QVector& keys, int frame) { for (int i = 0; i < keys.size(); ++i) { if (keys[i].frame == frame) { keys.remove(i); return true; } } return false; } bool removeUserScaleKeyAtFrame(QVector& keys, int frame) { for (int i = 0; i < keys.size(); ++i) { if (keys[i].frame == frame) { keys.remove(i); return true; } } return false; } bool removeImageKeyAtFrame(QVector& keys, int frame) { for (int i = 0; i < keys.size(); ++i) { if (keys[i].frame == frame) { keys.remove(i); return true; } } return false; } } // namespace bool ProjectWorkspace::removeEntityLocationKey(const QString& id, int frame) { if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) { return false; } auto ents = m_project.entities(); bool found = false; bool removed = false; for (auto& e : ents) { if (e.id != id) { continue; } found = true; removed = removeLocationKeyAtFrame(e.locationKeys, frame); break; } if (!found || !removed) { return false; } return applyEntities(ents, true, QStringLiteral("删除关键帧(位置)")); } bool ProjectWorkspace::removeEntityDepthScaleKey(const QString& id, int frame) { if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) { return false; } auto ents = m_project.entities(); bool found = false; bool removed = false; for (auto& e : ents) { if (e.id != id) { continue; } found = true; removed = removeDepthKeyAtFrame(e.depthScaleKeys, frame); break; } if (!found || !removed) { return false; } return applyEntities(ents, true, QStringLiteral("删除关键帧(缩放)")); } bool ProjectWorkspace::removeEntityUserScaleKey(const QString& id, int frame) { if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) { return false; } auto ents = m_project.entities(); bool found = false; bool removed = false; for (auto& e : ents) { if (e.id != id) { continue; } found = true; removed = removeUserScaleKeyAtFrame(e.userScaleKeys, frame); break; } if (!found || !removed) { return false; } return applyEntities(ents, true, QStringLiteral("删除关键帧(缩放)")); } bool ProjectWorkspace::removeEntityImageFrame(const QString& id, int frame) { if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) { return false; } auto ents = m_project.entities(); bool found = false; bool removed = false; for (auto& e : ents) { if (e.id != id) { continue; } found = true; removed = removeImageKeyAtFrame(e.imageFrames, frame); break; } if (!found || !removed) { return false; } return applyEntities(ents, true, QStringLiteral("删除关键帧(图像)")); } QString ProjectWorkspace::copyIntoAssetsAsBackground(const QString& sourceFilePath, const QRect& cropRectInSourceImage) { if (m_projectDir.isEmpty()) { return {}; } QFileInfo srcInfo(sourceFilePath); if (!srcInfo.exists() || !srcInfo.isFile()) { return {}; } const auto assetsDir = assetsDirPath(); if (assetsDir.isEmpty()) { return {}; } // 统一落盘为 png,避免后续读取处理复杂化 const auto fileName = QStringLiteral("background.png"); const auto destAbs = QDir(assetsDir).filePath(fileName); const auto destRel = QString::fromUtf8(kAssetsDirName) + "/" + fileName; QImage img(sourceFilePath); if (img.isNull()) { return {}; } const QRect crop = cropRectInSourceImage.isNull() ? QRect(0, 0, img.width(), img.height()) : clampRectToImage(cropRectInSourceImage, img.size()); if (crop.isNull()) { return {}; } const QImage cropped = img.copy(crop); if (cropped.isNull()) { return {}; } // 覆盖式更新背景:先写临时文件,再替换,避免中间态损坏 const auto tmpAbs = destAbs + ".tmp"; if (QFileInfo::exists(tmpAbs)) { QFile::remove(tmpAbs); } if (!cropped.save(tmpAbs, "PNG")) { QFile::remove(tmpAbs); return {}; } QFile::remove(destAbs); if (!QFile::rename(tmpAbs, destAbs)) { QFile::remove(tmpAbs); return {}; } return destRel; } bool ProjectWorkspace::writeDepthMap(const QImage& depth8) { if (m_projectDir.isEmpty() || depth8.isNull()) { return false; } const auto assetsDir = assetsDirPath(); if (assetsDir.isEmpty()) { return false; } const auto fileName = QStringLiteral("depth.png"); const auto destAbs = QDir(assetsDir).filePath(fileName); const auto destRel = QString::fromUtf8(kAssetsDirName) + "/" + fileName; const auto tmpAbs = destAbs + ".tmp"; if (QFileInfo::exists(tmpAbs)) { QFile::remove(tmpAbs); } if (!depth8.save(tmpAbs, "PNG")) { QFile::remove(tmpAbs); return false; } QFile::remove(destAbs); if (!QFile::rename(tmpAbs, destAbs)) { QFile::remove(tmpAbs); return false; } m_project.setDepthComputed(true); m_project.setDepthMapPath(destRel); return writeIndexJson(); } bool ProjectWorkspace::writeDepthMapBytes(const QByteArray& pngBytes) { if (m_projectDir.isEmpty() || pngBytes.isEmpty()) { return false; } const auto assetsDir = assetsDirPath(); if (assetsDir.isEmpty()) { return false; } const auto fileName = QStringLiteral("depth.png"); const auto destAbs = QDir(assetsDir).filePath(fileName); const auto destRel = QString::fromUtf8(kAssetsDirName) + "/" + fileName; const auto tmpAbs = destAbs + ".tmp"; if (QFileInfo::exists(tmpAbs)) { QFile::remove(tmpAbs); } QFile f(tmpAbs); if (!f.open(QIODevice::WriteOnly)) { QFile::remove(tmpAbs); return false; } const qint64 n = f.write(pngBytes); f.close(); if (n != pngBytes.size()) { QFile::remove(tmpAbs); return false; } QFile::remove(destAbs); if (!QFile::rename(tmpAbs, destAbs)) { QFile::remove(tmpAbs); return false; } m_project.setDepthComputed(true); m_project.setDepthMapPath(destRel); return writeIndexJson(); } bool ProjectWorkspace::computeFakeDepthForProject() { if (m_projectDir.isEmpty()) { return false; } const auto bgAbs = backgroundAbsolutePath(); if (bgAbs.isEmpty() || !QFileInfo::exists(bgAbs)) { return false; } QImage bg(bgAbs); if (bg.isNull()) { return false; } const QImage depth8 = DepthService::computeFakeDepthFromBackground(bg); if (depth8.isNull()) { return false; } return writeDepthMap(depth8); } bool ProjectWorkspace::computeDepthForProjectFromServer(const QString& serverBaseUrl, QString* outError, int timeoutMs) { if (outError) { outError->clear(); } if (m_projectDir.isEmpty()) { if (outError) *outError = QStringLiteral("项目未打开。"); return false; } const auto bgAbs = backgroundAbsolutePath(); if (bgAbs.isEmpty() || !QFileInfo::exists(bgAbs)) { if (outError) *outError = QStringLiteral("背景不存在。"); return false; } QFile bgFile(bgAbs); if (!bgFile.open(QIODevice::ReadOnly)) { if (outError) *outError = QStringLiteral("读取背景失败。"); return false; } const QByteArray bgBytes = bgFile.readAll(); bgFile.close(); if (bgBytes.isEmpty()) { if (outError) *outError = QStringLiteral("背景文件为空。"); return false; } QString base = serverBaseUrl.trimmed(); if (base.isEmpty()) { const QByteArray env = qgetenv("MODEL_SERVER_URL"); base = env.isEmpty() ? QStringLiteral("http://127.0.0.1:8000") : QString::fromUtf8(env); } ModelServerClient client; client.setBaseUrl(QUrl(base)); QByteArray depthPngBytes; QString err; if (!client.computeDepthPng8(bgBytes, depthPngBytes, err, timeoutMs)) { if (outError) *outError = err.isEmpty() ? QStringLiteral("后端计算深度失败。") : err; return false; } if (!writeDepthMapBytes(depthPngBytes)) { if (outError) *outError = QStringLiteral("写入深度图失败。"); return false; } return true; } bool ProjectWorkspace::saveDepthMapPngBytes(const QByteArray& pngBytes, QString* outError) { if (outError) { outError->clear(); } if (m_projectDir.isEmpty()) { if (outError) *outError = QStringLiteral("项目未打开。"); return false; } if (pngBytes.isEmpty()) { if (outError) *outError = QStringLiteral("深度数据为空。"); return false; } if (!writeDepthMapBytes(pngBytes)) { if (outError) *outError = QStringLiteral("写入深度图失败。"); return false; } return true; } } // namespace core