From d67d7dc0c5585dfef3fb8339b4be81fcf90aad57 Mon Sep 17 00:00:00 2001 From: DingVero Date: Thu, 9 Apr 2026 17:30:55 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=A0=B9=E6=8D=AE=E5=B8=A7?= =?UTF-8?q?=E6=95=B0=E6=8E=A7=E5=88=B6=E5=8F=AF=E8=A7=81=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/core/CMakeLists.txt | 10 + client/core/domain/Project.h | 150 ++- client/core/eval/ProjectEvaluator.cpp | 311 +++++ client/core/eval/ProjectEvaluator.h | 31 + client/core/library/EntityJson.cpp | 305 +++++ client/core/library/EntityJson.h | 13 + .../library/OnlineResourceLibraryProvider.cpp | 19 + .../library/OnlineResourceLibraryProvider.h | 16 + .../core/library/ResourceLibraryProvider.cpp | 95 ++ client/core/library/ResourceLibraryProvider.h | 59 + client/core/library/ToolJson.cpp | 113 ++ client/core/library/ToolJson.h | 13 + .../core/persistence/EntityPayloadBinary.cpp | 90 +- client/core/persistence/EntityPayloadBinary.h | 2 +- client/core/workspace/ProjectWorkspace.cpp | 1076 +++++++++++++++-- client/core/workspace/ProjectWorkspace.h | 36 +- client/gui/CMakeLists.txt | 4 + client/gui/editor/EditorCanvas.cpp | 372 +++++- client/gui/editor/EditorCanvas.h | 32 +- client/gui/library/ResourceLibraryDock.cpp | 264 ++++ client/gui/library/ResourceLibraryDock.h | 38 + client/gui/main_window/MainWindow.cpp | 1043 +++++++++++++--- client/gui/main_window/MainWindow.h | 24 +- client/gui/props/EntityPropertySection.cpp | 34 + client/gui/props/EntityPropertySection.h | 8 + client/gui/props/ToolPropertySection.cpp | 130 ++ client/gui/props/ToolPropertySection.h | 50 + client/gui/timeline/TimelineWidget.cpp | 410 +++++-- client/gui/timeline/TimelineWidget.h | 47 +- 29 files changed, 4387 insertions(+), 408 deletions(-) create mode 100644 client/core/eval/ProjectEvaluator.cpp create mode 100644 client/core/eval/ProjectEvaluator.h create mode 100644 client/core/library/EntityJson.cpp create mode 100644 client/core/library/EntityJson.h create mode 100644 client/core/library/OnlineResourceLibraryProvider.cpp create mode 100644 client/core/library/OnlineResourceLibraryProvider.h create mode 100644 client/core/library/ResourceLibraryProvider.cpp create mode 100644 client/core/library/ResourceLibraryProvider.h create mode 100644 client/core/library/ToolJson.cpp create mode 100644 client/core/library/ToolJson.h create mode 100644 client/gui/library/ResourceLibraryDock.cpp create mode 100644 client/gui/library/ResourceLibraryDock.h create mode 100644 client/gui/props/ToolPropertySection.cpp create mode 100644 client/gui/props/ToolPropertySection.h diff --git a/client/core/CMakeLists.txt b/client/core/CMakeLists.txt index 2f41dfd..ed80190 100644 --- a/client/core/CMakeLists.txt +++ b/client/core/CMakeLists.txt @@ -9,6 +9,11 @@ set(CORE_SOURCES ${CORE_ROOT}/animation/AnimationSampling.cpp ${CORE_ROOT}/depth/DepthService.cpp ${CORE_ROOT}/net/ModelServerClient.cpp + ${CORE_ROOT}/library/EntityJson.cpp + ${CORE_ROOT}/library/ToolJson.cpp + ${CORE_ROOT}/library/ResourceLibraryProvider.cpp + ${CORE_ROOT}/library/OnlineResourceLibraryProvider.cpp + ${CORE_ROOT}/eval/ProjectEvaluator.cpp ) set(CORE_HEADERS @@ -20,6 +25,11 @@ set(CORE_HEADERS ${CORE_ROOT}/animation/AnimationSampling.h ${CORE_ROOT}/depth/DepthService.h ${CORE_ROOT}/net/ModelServerClient.h + ${CORE_ROOT}/library/EntityJson.h + ${CORE_ROOT}/library/ToolJson.h + ${CORE_ROOT}/library/ResourceLibraryProvider.h + ${CORE_ROOT}/library/OnlineResourceLibraryProvider.h + ${CORE_ROOT}/eval/ProjectEvaluator.h ) add_library(core STATIC diff --git a/client/core/domain/Project.h b/client/core/domain/Project.h index 9a3ae1a..6e70d14 100644 --- a/client/core/domain/Project.h +++ b/client/core/domain/Project.h @@ -4,6 +4,7 @@ #include #include +#include #include #include @@ -12,6 +13,8 @@ namespace core { class Project { public: + static constexpr int kClipFixedFrames = 600; + void setName(const QString& name) { m_name = name; } const QString& name() const { return m_name; } @@ -37,10 +40,15 @@ public: void setFps(int fps) { m_fps = std::max(1, fps); } int fps() const { return m_fps; } + struct ToolKeyframeBool { + int frame = 0; + bool value = true; + }; + struct Entity { QString id; QString displayName; // 显示名(空则界面用 id) - bool visible = true; // Outliner 眼睛:默认显示 + bool visible = true; // 默认显隐(无 visibilityKeys 时使用) // 可移动实体形状:存为局部坐标(相对 originWorld) QVector polygonLocal; // 从背景中抠洞的位置:固定在创建时的 world 坐标,不随实体移动 @@ -55,6 +63,15 @@ public: // 抠图创建时该位置对应的原始距离乘子(0.5+depth01),用于校准:该处 distanceScale==1.0。0 表示未校准(兼容旧工程) double distanceScaleCalibMult = 0.0; + // 距离缩放开关:为 true 时实体不受 depth->distanceScale 影响,仅受 userScale 影响。 + // 约定:对话气泡等 UI 元素默认打开。 + bool ignoreDistanceScale = false; + + // 父子关系:当 parentId 非空时,实体会保持相对父实体的偏移(world 坐标)。 + // parentOffsetWorld 表示「childOrigin - parentOrigin」在 world 中的偏移。 + QString parentId; + QPointF parentOffsetWorld; + struct KeyframeVec2 { int frame = 0; QPointF value; @@ -82,12 +99,137 @@ public: QVector userScaleKeys; QVector imageFrames; + // 可见性轨道:布尔关键帧(显示/隐藏);渲染时会被解释为“10 帧淡入淡出”。 + QVector visibilityKeys; + EntityIntroContent intro; }; void setEntities(const QVector& entities) { m_entities = entities; } const QVector& entities() const { return m_entities; } + // —— 工具(精简版实体,不含 intro/图片/视频)—— + struct Tool { + enum class Type { Bubble }; + + QString id; + QString displayName; + bool visible = true; // 编辑模式显隐 + + // 父子关系:同实体规则。parentId 可指向实体或工具的 id。 + QString parentId; + QPointF parentOffsetWorld; + + // 基准位置(无关键帧时使用) + QPointF originWorld; + QVector locationKeys; + + // 可见性轨道:布尔关键帧(显示/隐藏);渲染时会被解释为“10 帧淡入淡出”。 + QVector visibilityKeys; + + // 类型与 payload + Type type = Type::Bubble; + + // Bubble payload + QString text; + int fontPx = 18; + enum class TextAlign { Left, Center, Right }; + TextAlign align = TextAlign::Center; + // 气泡底边「平直段」上三角形附着位置:0=靠左,0.5=居中,1=靠右;主体会水平平移,使该点始终位于 originWorld 尖端正上方 + double bubblePointerT01 = 0.5; + }; + + void setTools(const QVector& tools) { m_tools = tools; } + const QVector& tools() const { return m_tools; } + + // —— 动画系统(Blender/NLA 风格简化版,工程级)—— + struct AnimationClip { + QString id; + QString name; + + // Entity channels (keyed by entity id) + QHash> entityLocationKeys; + QHash> entityUserScaleKeys; + QHash> entityImageFrames; + QHash> entityVisibilityKeys; + + // Tool channels (keyed by tool id) + QHash> toolLocationKeys; + QHash> toolVisibilityKeys; + }; + + struct NlaStrip { + QString id; + QString clipId; + int startSlot = 0; // slot index; 1 slot = kClipFixedFrames frames + int slotLen = 1; // currently fixed to 1; reserved for future + bool enabled = true; + bool muted = false; + }; + + struct NlaTrack { + QString id; + QString name; + bool muted = false; + bool solo = false; + QVector strips; + }; + + struct AnimationScheme { + QString id; + QString name; + QVector tracks; + }; + + void setAnimationClips(const QVector& clips) { m_clips = clips; } + const QVector& animationClips() const { return m_clips; } + + void setAnimationSchemes(const QVector& schemes) { m_schemes = schemes; } + const QVector& animationSchemes() const { return m_schemes; } + + void setActiveSchemeId(const QString& id) { m_activeSchemeId = id; } + const QString& activeSchemeId() const { return m_activeSchemeId; } + + void setSelectedStripId(const QString& id) { m_selectedStripId = id; } + const QString& selectedStripId() const { return m_selectedStripId; } + + const AnimationScheme* findSchemeById(const QString& id) const { + for (const auto& s : m_schemes) { + if (s.id == id) return &s; + } + return nullptr; + } + AnimationScheme* findSchemeById(const QString& id) { + for (auto& s : m_schemes) { + if (s.id == id) return &s; + } + return nullptr; + } + + const AnimationClip* findClipById(const QString& id) const { + for (const auto& c : m_clips) { + if (c.id == id) return &c; + } + return nullptr; + } + AnimationClip* findClipById(const QString& id) { + for (auto& c : m_clips) { + if (c.id == id) return &c; + } + return nullptr; + } + + const AnimationScheme* activeSchemeOrNull() const { + const AnimationScheme* s = findSchemeById(m_activeSchemeId); + if (s) return s; + return m_schemes.isEmpty() ? nullptr : &m_schemes.front(); + } + AnimationScheme* activeSchemeOrNull() { + AnimationScheme* s = findSchemeById(m_activeSchemeId); + if (s) return s; + return m_schemes.isEmpty() ? nullptr : &m_schemes.front(); + } + private: QString m_name; QString m_backgroundImagePath; @@ -98,6 +240,12 @@ private: int m_frameEnd = 600; int m_fps = 60; QVector m_entities; + QVector m_tools; + + QVector m_clips; + QVector m_schemes; + QString m_activeSchemeId; + QString m_selectedStripId; }; } // namespace core diff --git a/client/core/eval/ProjectEvaluator.cpp b/client/core/eval/ProjectEvaluator.cpp new file mode 100644 index 0000000..7b0fca5 --- /dev/null +++ b/client/core/eval/ProjectEvaluator.cpp @@ -0,0 +1,311 @@ +#include "eval/ProjectEvaluator.h" + +#include "animation/AnimationSampling.h" + +#include +#include + +namespace core::eval { + +namespace { + +struct NodeRef { + enum class Kind { Entity, Tool }; + Kind kind = Kind::Entity; + int index = -1; +}; + +QPointF sampledOriginForEntity(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.originWorld, core::KeyInterpolation::Linear); + } + return core::sampleLocation(e.locationKeys, localFrame, e.originWorld, core::KeyInterpolation::Linear); +} + +QPointF sampledOriginForTool(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.originWorld, core::KeyInterpolation::Linear); + } + return core::sampleLocation(t.locationKeys, localFrame, t.originWorld, core::KeyInterpolation::Linear); +} + +struct VisKey { + int frame = 0; + bool value = true; +}; + +static QVector normalizeVisibilityKeys(const QVector& keys) { + QVector out; + out.reserve(keys.size()); + for (const auto& k : keys) { + out.push_back(VisKey{k.frame, k.value}); + } + std::sort(out.begin(), out.end(), [](const VisKey& a, const VisKey& b) { return a.frame < b.frame; }); + // 若同帧重复,保留最后一个 + QVector dedup; + dedup.reserve(out.size()); + for (const auto& k : out) { + if (!dedup.isEmpty() && dedup.last().frame == k.frame) { + dedup.last() = k; + } else { + dedup.push_back(k); + } + } + return dedup; +} + +double opacityFromBoolKeys(const QVector& keysRaw, int frame, int fadeFrames) { + const int nFade = std::max(1, fadeFrames); + const QVector keys = normalizeVisibilityKeys(keysRaw); + if (keys.isEmpty()) { + return 1.0; + } + // 规则:在发生状态变化的关键帧 t 附近做对称淡变 + // fadeFrames=10 -> 约 [t-5, t+5] 渐变,符合“60 帧切换则 55~65 过渡” + const double half = double(nFade) * 0.5; + + // first key 之前,直接采用 first value(不做凭空反向切换) + if (frame <= keys.front().frame) { + return keys.front().value ? 1.0 : 0.0; + } + + bool state = keys.front().value; + for (int i = 1; i < keys.size(); ++i) { + const bool prev = keys[i - 1].value; + const bool cur = keys[i].value; + const int t = keys[i].frame; + if (prev == cur) { + // 状态未变:忽略该 key + continue; + } + const double a = double(t) - half; + const double b = double(t) + half; + if (double(frame) < a) { + // 还没进入该次过渡 + return state ? 1.0 : 0.0; + } + if (double(frame) <= b) { + const double u = std::clamp((double(frame) - a) / std::max(1e-9, b - a), 0.0, 1.0); + const double x = prev ? 1.0 : 0.0; + const double y = cur ? 1.0 : 0.0; + return x + (y - x) * u; + } + // 该次过渡已结束,进入新状态 + state = cur; + } + return state ? 1.0 : 0.0; +} + +struct StripEvalCtx { + const core::Project::AnimationScheme* scheme = nullptr; + const core::Project::NlaStrip* strip = nullptr; + const core::Project::AnimationClip* clip = nullptr; + int slot = 0; + int localFrame = 0; // 0..kClipFixedFrames-1 +}; + +static const core::Project::NlaStrip* findStripById(const core::Project::AnimationScheme& scheme, const QString& id) { + if (id.isEmpty()) return nullptr; + for (const auto& tr : scheme.tracks) { + for (const auto& st : tr.strips) { + if (st.id == id) return &st; + } + } + return nullptr; +} + +static bool trackIsEffectivelyMuted(const core::Project::AnimationScheme& scheme, const core::Project::NlaTrack& t) { + // 若有任意 solo=true,则只有 solo 的 track 生效(且仍受自身 muted 控制) + bool anySolo = false; + for (const auto& tr : scheme.tracks) { + if (tr.solo) { + anySolo = true; + break; + } + } + if (anySolo && !t.solo) { + return true; + } + return t.muted; +} + +static const core::Project::NlaStrip* pickStripAtSlot(const core::Project::AnimationScheme& scheme, int slot) { + const core::Project::NlaStrip* chosen = nullptr; + for (const auto& tr : scheme.tracks) { + if (trackIsEffectivelyMuted(scheme, tr)) continue; + for (const auto& st : tr.strips) { + if (!st.enabled || st.muted) continue; + const int a = st.startSlot; + const int b = st.startSlot + std::max(1, st.slotLen); + if (slot >= a && slot < b) { + chosen = &st; // 轨道顺序靠后的覆盖靠前的(更接近“上层”) + } + } + } + return chosen; +} + +static StripEvalCtx resolveStripCtx(const core::Project& project, int globalFrame) { + StripEvalCtx ctx; + const auto* scheme = project.activeSchemeOrNull(); + if (!scheme) { + ctx.localFrame = std::max(0, globalFrame); + return ctx; + } + ctx.scheme = scheme; + const int g = std::max(0, globalFrame); + ctx.slot = g / core::Project::kClipFixedFrames; + ctx.localFrame = g % core::Project::kClipFixedFrames; + + const core::Project::NlaStrip* st = findStripById(*scheme, project.selectedStripId()); + // 若选中条带不覆盖当前 slot,则退回自动挑选 + if (!st || ctx.slot < st->startSlot || ctx.slot >= (st->startSlot + std::max(1, st->slotLen)) || !st->enabled || st->muted) { + st = pickStripAtSlot(*scheme, ctx.slot); + } + ctx.strip = st; + if (st) { + ctx.clip = project.findClipById(st->clipId); + } + return ctx; +} + +} // namespace + +ResolvedProjectFrame evaluateAtFrame(const core::Project& project, int frame, int fadeFrames) { + ResolvedProjectFrame out; + const auto& ents = project.entities(); + const auto& tools = project.tools(); + out.entities.reserve(ents.size()); + out.tools.reserve(tools.size()); + + const StripEvalCtx ctx = resolveStripCtx(project, frame); + const int localFrame = ctx.localFrame; + const core::Project::AnimationClip* clip = ctx.clip; + + QHash index; + index.reserve(ents.size() + tools.size()); + for (int i = 0; i < ents.size(); ++i) { + if (!ents[i].id.isEmpty()) { + index.insert(ents[i].id, NodeRef{NodeRef::Kind::Entity, i}); + } + } + for (int i = 0; i < tools.size(); ++i) { + if (!tools[i].id.isEmpty() && !index.contains(tools[i].id)) { + index.insert(tools[i].id, NodeRef{NodeRef::Kind::Tool, i}); + } + } + + QHash resolvedOrigin; + QHash resolving; + resolvedOrigin.reserve(index.size()); + resolving.reserve(index.size()); + + std::function resolve = [&](const QString& id) -> QPointF { + if (resolvedOrigin.contains(id)) { + return resolvedOrigin.value(id); + } + if (!index.contains(id)) { + resolvedOrigin.insert(id, QPointF()); + return QPointF(); + } + if (resolving.value(id, false)) { + // cycle:降级为自身采样 origin + const NodeRef r = index.value(id); + QPointF o; + if (r.kind == NodeRef::Kind::Entity) o = sampledOriginForEntity(ents[r.index], clip, localFrame); + else o = sampledOriginForTool(tools[r.index], clip, localFrame); + resolvedOrigin.insert(id, o); + return o; + } + resolving.insert(id, true); + + 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; + } + + resolving.insert(id, false); + resolvedOrigin.insert(id, outO); + return outO; + }; + + auto opacityWithDefault = [&](const QVector& keys, + bool defaultVisible) -> double { + if (keys.isEmpty()) { + return defaultVisible ? 1.0 : 0.0; + } + return opacityFromBoolKeys(keys, localFrame, fadeFrames); + }; + + // Entities:resolved origin + opacity(可见性轨道) + for (int i = 0; i < ents.size(); ++i) { + core::Project::Entity e = ents[i]; + const QPointF base = e.originWorld; + const QPointF ro = (!e.id.isEmpty()) ? resolve(e.id) : sampledOriginForEntity(e, clip, localFrame); + const QPointF delta = ro - base; + e.originWorld = ro; + e.imageTopLeftWorld += delta; + + // Clip channels: userScale / imagePath(迁移后仍能逐帧显示) + if (clip && clip->entityUserScaleKeys.contains(e.id)) { + const auto& keys = clip->entityUserScaleKeys.value(e.id); + e.userScale = core::sampleUserScale(keys, localFrame, e.userScale, core::KeyInterpolation::Linear); + } + if (clip && clip->entityImageFrames.contains(e.id)) { + const auto& frames = clip->entityImageFrames.value(e.id); + e.imagePath = core::sampleImagePath(frames, localFrame, e.imagePath); + } + + QVector visKeys = e.visibilityKeys; + if (clip && clip->entityVisibilityKeys.contains(e.id)) { + visKeys = clip->entityVisibilityKeys.value(e.id); + } + const double op = opacityWithDefault(visKeys, e.visible); + out.entities.push_back(ResolvedEntity{e, op}); + } + + // Tools:resolved origin + opacity(可见性轨道) + for (int i = 0; i < tools.size(); ++i) { + core::Project::Tool t = tools[i]; + const QPointF base = t.originWorld; + const QPointF ro = (!t.id.isEmpty()) ? resolve(t.id) : sampledOriginForTool(t, clip, localFrame); + const QPointF delta = ro - base; + t.originWorld = ro; + // parentOffsetWorld 已包含相对关系,不在这里改 + QVector visKeys = t.visibilityKeys; + if (clip && clip->toolVisibilityKeys.contains(t.id)) { + visKeys = clip->toolVisibilityKeys.value(t.id); + } + const double op = opacityWithDefault(visKeys, t.visible); + (void)delta; + out.tools.push_back(ResolvedTool{t, op}); + } + + return out; +} + +} // namespace core::eval + diff --git a/client/core/eval/ProjectEvaluator.h b/client/core/eval/ProjectEvaluator.h new file mode 100644 index 0000000..14bb8b7 --- /dev/null +++ b/client/core/eval/ProjectEvaluator.h @@ -0,0 +1,31 @@ +#pragma once + +#include "domain/Project.h" + +#include +#include +#include +#include + +namespace core::eval { + +struct ResolvedEntity { + core::Project::Entity entity; // 拷贝:originWorld/imageTopLeftWorld 已替换为求值后的值 + double opacity = 1.0; // 0..1 +}; + +struct ResolvedTool { + core::Project::Tool tool; // 拷贝:originWorld 已替换为求值后的值 + double opacity = 1.0; // 0..1 +}; + +struct ResolvedProjectFrame { + QVector entities; + QVector tools; +}; + +/// 逐帧求值:处理父子跟随与工具可见性淡入淡出。 +ResolvedProjectFrame evaluateAtFrame(const core::Project& project, int frame, int fadeFrames = 10); + +} // namespace core::eval + diff --git a/client/core/library/EntityJson.cpp b/client/core/library/EntityJson.cpp new file mode 100644 index 0000000..da3c8c7 --- /dev/null +++ b/client/core/library/EntityJson.cpp @@ -0,0 +1,305 @@ +#include "library/EntityJson.h" + +#include +#include +#include + +namespace core::library { + +namespace { + +QJsonArray pointToJson(const QPointF& p) { + return QJsonArray{p.x(), p.y()}; +} + +bool pointFromJson(const QJsonValue& v, QPointF& out) { + if (!v.isArray()) { + return false; + } + const QJsonArray a = v.toArray(); + if (a.size() < 2) { + return false; + } + out = QPointF(a.at(0).toDouble(), a.at(1).toDouble()); + return true; +} + +QJsonArray pointsToJson(const QVector& pts) { + QJsonArray a; + for (const auto& p : pts) { + a.append(pointToJson(p)); + } + return a; +} + +bool pointsFromJson(const QJsonValue& v, QVector& out) { + out.clear(); + if (!v.isArray()) { + return false; + } + const QJsonArray a = v.toArray(); + out.reserve(a.size()); + for (const auto& it : a) { + QPointF p; + if (!pointFromJson(it, p)) { + return false; + } + out.push_back(p); + } + return true; +} + +template +QJsonArray vecToJson(const QVector& v, const std::function& fn) { + QJsonArray a; + for (const auto& x : v) { + a.append(fn(x)); + } + return a; +} + +} // namespace + +QJsonObject entityToJson(const core::Project::Entity& e) { + QJsonObject o; + o.insert(QStringLiteral("id"), e.id); + o.insert(QStringLiteral("displayName"), e.displayName); + o.insert(QStringLiteral("visible"), e.visible); + o.insert(QStringLiteral("polygonLocal"), pointsToJson(e.polygonLocal)); + o.insert(QStringLiteral("cutoutPolygonWorld"), pointsToJson(e.cutoutPolygonWorld)); + o.insert(QStringLiteral("originWorld"), pointToJson(e.originWorld)); + o.insert(QStringLiteral("depth"), e.depth); + o.insert(QStringLiteral("imagePath"), e.imagePath); + o.insert(QStringLiteral("imageTopLeftWorld"), pointToJson(e.imageTopLeftWorld)); + o.insert(QStringLiteral("userScale"), e.userScale); + o.insert(QStringLiteral("distanceScaleCalibMult"), e.distanceScaleCalibMult); + o.insert(QStringLiteral("ignoreDistanceScale"), e.ignoreDistanceScale); + o.insert(QStringLiteral("parentId"), e.parentId); + o.insert(QStringLiteral("parentOffsetWorld"), pointToJson(e.parentOffsetWorld)); + + o.insert(QStringLiteral("entityPayloadPath"), e.entityPayloadPath); + o.insert(QStringLiteral("legacyAnimSidecarPath"), e.legacyAnimSidecarPath); + + o.insert(QStringLiteral("locationKeys"), + vecToJson( + e.locationKeys, [](const core::Project::Entity::KeyframeVec2& k) { + QJsonObject ko; + ko.insert(QStringLiteral("frame"), k.frame); + ko.insert(QStringLiteral("value"), pointToJson(k.value)); + return ko; + })); + + o.insert(QStringLiteral("depthScaleKeys"), + vecToJson( + e.depthScaleKeys, [](const core::Project::Entity::KeyframeFloat01& k) { + QJsonObject ko; + ko.insert(QStringLiteral("frame"), k.frame); + ko.insert(QStringLiteral("value"), k.value); + return ko; + })); + + o.insert(QStringLiteral("userScaleKeys"), + vecToJson( + e.userScaleKeys, [](const core::Project::Entity::KeyframeDouble& k) { + QJsonObject ko; + ko.insert(QStringLiteral("frame"), k.frame); + ko.insert(QStringLiteral("value"), k.value); + return ko; + })); + + o.insert(QStringLiteral("imageFrames"), + vecToJson( + e.imageFrames, [](const core::Project::Entity::ImageFrame& k) { + QJsonObject ko; + ko.insert(QStringLiteral("frame"), k.frame); + ko.insert(QStringLiteral("imagePath"), k.imagePath); + return ko; + })); + + o.insert(QStringLiteral("visibilityKeys"), + vecToJson( + e.visibilityKeys, [](const core::Project::ToolKeyframeBool& k) { + QJsonObject ko; + ko.insert(QStringLiteral("frame"), k.frame); + ko.insert(QStringLiteral("value"), k.value); + return ko; + })); + + { + QJsonObject intro; + intro.insert(QStringLiteral("title"), e.intro.title); + intro.insert(QStringLiteral("bodyText"), e.intro.bodyText); + QJsonArray imgs; + for (const auto& p : e.intro.imagePathsRelative) { + imgs.append(p); + } + intro.insert(QStringLiteral("imagePathsRelative"), imgs); + intro.insert(QStringLiteral("videoPathRelative"), e.intro.videoPathRelative); + o.insert(QStringLiteral("intro"), intro); + } + return o; +} + +bool entityFromJson(const QJsonObject& o, core::Project::Entity& out) { + core::Project::Entity e; + e.id = o.value(QStringLiteral("id")).toString(); + e.displayName = o.value(QStringLiteral("displayName")).toString(); + e.visible = o.value(QStringLiteral("visible")).toBool(true); + if (!pointsFromJson(o.value(QStringLiteral("polygonLocal")), e.polygonLocal)) { + return false; + } + if (!pointsFromJson(o.value(QStringLiteral("cutoutPolygonWorld")), e.cutoutPolygonWorld)) { + // cutout 允许不存在:按空处理 + e.cutoutPolygonWorld.clear(); + } + { + QPointF p; + if (!pointFromJson(o.value(QStringLiteral("originWorld")), p)) { + p = QPointF(); + } + e.originWorld = p; + } + e.depth = o.value(QStringLiteral("depth")).toInt(0); + e.imagePath = o.value(QStringLiteral("imagePath")).toString(); + { + QPointF p; + if (!pointFromJson(o.value(QStringLiteral("imageTopLeftWorld")), p)) { + p = QPointF(); + } + e.imageTopLeftWorld = p; + } + e.userScale = o.value(QStringLiteral("userScale")).toDouble(1.0); + e.distanceScaleCalibMult = o.value(QStringLiteral("distanceScaleCalibMult")).toDouble(0.0); + e.ignoreDistanceScale = o.value(QStringLiteral("ignoreDistanceScale")).toBool(false); + e.parentId = o.value(QStringLiteral("parentId")).toString(); + { + QPointF p; + if (!pointFromJson(o.value(QStringLiteral("parentOffsetWorld")), p)) { + p = QPointF(); + } + e.parentOffsetWorld = p; + } + e.entityPayloadPath = o.value(QStringLiteral("entityPayloadPath")).toString(); + e.legacyAnimSidecarPath = o.value(QStringLiteral("legacyAnimSidecarPath")).toString(); + + auto parseKeyframesVec2 = [&](const QString& key, QVector& dst) -> bool { + dst.clear(); + const QJsonValue v = o.value(key); + if (!v.isArray()) { + return true; + } + const QJsonArray a = v.toArray(); + dst.reserve(a.size()); + for (const auto& it : a) { + if (!it.isObject()) return false; + const QJsonObject ko = it.toObject(); + core::Project::Entity::KeyframeVec2 k; + k.frame = ko.value(QStringLiteral("frame")).toInt(0); + QPointF pv; + if (!pointFromJson(ko.value(QStringLiteral("value")), pv)) { + return false; + } + k.value = pv; + dst.push_back(k); + } + return true; + }; + + auto parseKeyframesFloat01 = [&](const QString& key, QVector& dst) -> bool { + dst.clear(); + const QJsonValue v = o.value(key); + if (!v.isArray()) { + return true; + } + const QJsonArray a = v.toArray(); + dst.reserve(a.size()); + for (const auto& it : a) { + if (!it.isObject()) return false; + const QJsonObject ko = it.toObject(); + core::Project::Entity::KeyframeFloat01 k; + k.frame = ko.value(QStringLiteral("frame")).toInt(0); + k.value = ko.value(QStringLiteral("value")).toDouble(0.5); + dst.push_back(k); + } + return true; + }; + + auto parseKeyframesDouble = [&](const QString& key, QVector& dst) -> bool { + dst.clear(); + const QJsonValue v = o.value(key); + if (!v.isArray()) { + return true; + } + const QJsonArray a = v.toArray(); + dst.reserve(a.size()); + for (const auto& it : a) { + if (!it.isObject()) return false; + const QJsonObject ko = it.toObject(); + core::Project::Entity::KeyframeDouble k; + k.frame = ko.value(QStringLiteral("frame")).toInt(0); + k.value = ko.value(QStringLiteral("value")).toDouble(1.0); + dst.push_back(k); + } + return true; + }; + + auto parseImageFrames = [&](const QString& key, QVector& dst) -> bool { + dst.clear(); + const QJsonValue v = o.value(key); + if (!v.isArray()) { + return true; + } + const QJsonArray a = v.toArray(); + dst.reserve(a.size()); + for (const auto& it : a) { + if (!it.isObject()) return false; + const QJsonObject ko = it.toObject(); + core::Project::Entity::ImageFrame k; + k.frame = ko.value(QStringLiteral("frame")).toInt(0); + k.imagePath = ko.value(QStringLiteral("imagePath")).toString(); + dst.push_back(k); + } + return true; + }; + + if (!parseKeyframesVec2(QStringLiteral("locationKeys"), e.locationKeys)) return false; + if (!parseKeyframesFloat01(QStringLiteral("depthScaleKeys"), e.depthScaleKeys)) return false; + if (!parseKeyframesDouble(QStringLiteral("userScaleKeys"), e.userScaleKeys)) return false; + if (!parseImageFrames(QStringLiteral("imageFrames"), e.imageFrames)) return false; + + // visibilityKeys:可缺省(默认永远可见) + e.visibilityKeys.clear(); + if (o.value(QStringLiteral("visibilityKeys")).isArray()) { + const QJsonArray a = o.value(QStringLiteral("visibilityKeys")).toArray(); + e.visibilityKeys.reserve(a.size()); + for (const auto& it : a) { + if (!it.isObject()) return false; + const QJsonObject ko = it.toObject(); + core::Project::ToolKeyframeBool k; + k.frame = ko.value(QStringLiteral("frame")).toInt(0); + k.value = ko.value(QStringLiteral("value")).toBool(true); + e.visibilityKeys.push_back(k); + } + } + + if (o.contains(QStringLiteral("intro")) && o.value(QStringLiteral("intro")).isObject()) { + const QJsonObject intro = o.value(QStringLiteral("intro")).toObject(); + e.intro.title = intro.value(QStringLiteral("title")).toString(); + e.intro.bodyText = intro.value(QStringLiteral("bodyText")).toString(); + e.intro.videoPathRelative = intro.value(QStringLiteral("videoPathRelative")).toString(); + e.intro.imagePathsRelative.clear(); + if (intro.value(QStringLiteral("imagePathsRelative")).isArray()) { + const QJsonArray imgs = intro.value(QStringLiteral("imagePathsRelative")).toArray(); + e.intro.imagePathsRelative.reserve(imgs.size()); + for (const auto& iv : imgs) { + e.intro.imagePathsRelative.push_back(iv.toString()); + } + } + } + + out = e; + return true; +} + +} // namespace core::library + diff --git a/client/core/library/EntityJson.h b/client/core/library/EntityJson.h new file mode 100644 index 0000000..3517800 --- /dev/null +++ b/client/core/library/EntityJson.h @@ -0,0 +1,13 @@ +#pragma once + +#include "domain/Project.h" + +#include + +namespace core::library { + +QJsonObject entityToJson(const core::Project::Entity& e); +bool entityFromJson(const QJsonObject& o, core::Project::Entity& out); + +} // namespace core::library + diff --git a/client/core/library/OnlineResourceLibraryProvider.cpp b/client/core/library/OnlineResourceLibraryProvider.cpp new file mode 100644 index 0000000..3a8a284 --- /dev/null +++ b/client/core/library/OnlineResourceLibraryProvider.cpp @@ -0,0 +1,19 @@ +#include "library/OnlineResourceLibraryProvider.h" + +#include + +namespace core::library { + +OnlineResourceLibraryProvider::OnlineResourceLibraryProvider(QObject* parent) + : ResourceLibraryProvider(parent) {} + +void OnlineResourceLibraryProvider::fetchResourcesAsync() { + // 在线资源预留:当前不返回假数据。 + // 未来接入真实服务时保持“多次调用 fetchResourcesAsync()”用于分页即可。 + QTimer::singleShot(0, this, [this]() { + emit resourcesReady({}); + }); +} + +} // namespace core::library + diff --git a/client/core/library/OnlineResourceLibraryProvider.h b/client/core/library/OnlineResourceLibraryProvider.h new file mode 100644 index 0000000..30c2d1a --- /dev/null +++ b/client/core/library/OnlineResourceLibraryProvider.h @@ -0,0 +1,16 @@ +#pragma once + +#include "library/ResourceLibraryProvider.h" + +namespace core::library { + +/// 在线资源库(预留):当前不做真实网络请求,仅提供接口占位。 +class OnlineResourceLibraryProvider final : public ResourceLibraryProvider { + Q_OBJECT +public: + explicit OnlineResourceLibraryProvider(QObject* parent = nullptr); + void fetchResourcesAsync() override; +}; + +} // namespace core::library + diff --git a/client/core/library/ResourceLibraryProvider.cpp b/client/core/library/ResourceLibraryProvider.cpp new file mode 100644 index 0000000..faf271e --- /dev/null +++ b/client/core/library/ResourceLibraryProvider.cpp @@ -0,0 +1,95 @@ +#include "library/ResourceLibraryProvider.h" + +#include + +namespace core::library { + +FakeResourceLibraryProvider::FakeResourceLibraryProvider(QObject* parent) + : ResourceLibraryProvider(parent) {} + +void FakeResourceLibraryProvider::fetchResourcesAsync() { + // 预留:未来在这里用 QNetworkAccessManager 请求在线资源库。 + // 当前返回内置假数据,并保持异步语义,避免 UI 假设同步返回。 + QTimer::singleShot(0, this, [this]() { + QVector out; + + { + LibraryResource r; + r.resourceId = QStringLiteral("demo/tree"); + r.displayName = QStringLiteral("树(示例资源)"); + r.kind = LibraryResource::Kind::Entity; + r.imageSize = QSize(220, 260); + r.accent = QColor(46, 204, 113); + r.imageOffsetFromOrigin = QPointF(-110, -130); + r.entityTemplate.displayName = QStringLiteral("树"); + r.entityTemplate.visible = true; + r.entityTemplate.depth = 160; + r.entityTemplate.userScale = 1.0; + r.entityTemplate.distanceScaleCalibMult = 0.0; + // 以 origin 为中心的简单多边形(局部坐标) + r.entityTemplate.polygonLocal = { + QPointF(-60, 80), QPointF(-90, 20), QPointF(-60, -80), QPointF(0, -110), + QPointF(60, -80), QPointF(90, 20), QPointF(60, 80)}; + out.push_back(r); + } + + { + LibraryResource r; + r.resourceId = QStringLiteral("demo/rock"); + r.displayName = QStringLiteral("岩石(示例资源)"); + r.kind = LibraryResource::Kind::Entity; + r.imageSize = QSize(240, 180); + r.accent = QColor(120, 120, 120); + r.imageOffsetFromOrigin = QPointF(-120, -90); + r.entityTemplate.displayName = QStringLiteral("岩石"); + r.entityTemplate.visible = true; + r.entityTemplate.depth = 90; + r.entityTemplate.userScale = 1.0; + r.entityTemplate.distanceScaleCalibMult = 0.0; + r.entityTemplate.polygonLocal = { + QPointF(-100, 10), QPointF(-70, -60), QPointF(0, -80), QPointF(90, -40), + QPointF(110, 20), QPointF(40, 70), QPointF(-40, 60)}; + out.push_back(r); + } + + { + LibraryResource r; + r.resourceId = QStringLiteral("demo/house"); + r.displayName = QStringLiteral("小屋(示例资源)"); + r.kind = LibraryResource::Kind::Entity; + r.imageSize = QSize(280, 220); + r.accent = QColor(231, 76, 60); + r.imageOffsetFromOrigin = QPointF(-140, -110); + r.entityTemplate.displayName = QStringLiteral("小屋"); + r.entityTemplate.visible = true; + r.entityTemplate.depth = 200; + r.entityTemplate.userScale = 1.0; + r.entityTemplate.distanceScaleCalibMult = 0.0; + r.entityTemplate.polygonLocal = { + QPointF(-120, 90), QPointF(120, 90), QPointF(120, -10), QPointF(0, -120), QPointF(-120, -10)}; + out.push_back(r); + } + + // —— 对话气泡:工具资源;底边三角形位置在属性里用滑块自调 —— + { + LibraryResource r; + r.resourceId = QStringLiteral("local/bubble"); + r.displayName = QStringLiteral("对话气泡"); + r.kind = LibraryResource::Kind::Tool; + r.imageSize = QSize(260, 160); + r.accent = QColor(120, 150, 255); + r.imageOffsetFromOrigin = QPointF(-130, -140); + r.toolTemplate.displayName = QStringLiteral("对话气泡"); + r.toolTemplate.visible = true; + r.toolTemplate.type = core::Project::Tool::Type::Bubble; + r.toolTemplate.text = QStringLiteral("……"); + r.toolTemplate.bubblePointerT01 = 0.5; + out.push_back(r); + } + + emit resourcesReady(out); + }); +} + +} // namespace core::library + diff --git a/client/core/library/ResourceLibraryProvider.h b/client/core/library/ResourceLibraryProvider.h new file mode 100644 index 0000000..088e761 --- /dev/null +++ b/client/core/library/ResourceLibraryProvider.h @@ -0,0 +1,59 @@ +#pragma once + +#include "domain/Project.h" + +#include +#include +#include +#include +#include +#include + +namespace core::library { + +/// 资源库中的一个“可拖拽实体资源”。未来可来自在线服务器。 +struct LibraryResource { + enum class Kind { Entity, Tool }; + QString resourceId; + QString displayName; + + Kind kind = Kind::Entity; + + /// 拖到画布后用于创建实体的模板(id 可为空,主窗口会自动分配 entity-)。 + core::Project::Entity entityTemplate; + + /// 拖到画布后用于创建工具的模板(id 可为空,主窗口会自动分配 tool-)。 + core::Project::Tool toolTemplate; + + /// 资源预览/占位贴图生成参数(当前无真实来源时用)。 + QSize imageSize = QSize(256, 256); + QColor accent = QColor(80, 160, 255); + + /// 贴图相对 originWorld 的偏移(world 坐标;落到画布后会做 originWorld + offset)。 + QPointF imageOffsetFromOrigin = QPointF(-128, -128); +}; + +/// 资源库提供者接口:可用假实现占位,后续接入在线服务时替换。 +class ResourceLibraryProvider : public QObject { + Q_OBJECT +public: + explicit ResourceLibraryProvider(QObject* parent = nullptr) : QObject(parent) {} + ~ResourceLibraryProvider() override = default; + + virtual void fetchResourcesAsync() = 0; + +signals: + void resourcesReady(const QVector& resources); + void resourcesFailed(const QString& error); +}; + +/// 假资源提供者:返回内置的若干资源,预留接口用于未来在线获取。 +class FakeResourceLibraryProvider final : public ResourceLibraryProvider { + Q_OBJECT +public: + explicit FakeResourceLibraryProvider(QObject* parent = nullptr); + void fetchResourcesAsync() override; +}; + +} // namespace core::library + diff --git a/client/core/library/ToolJson.cpp b/client/core/library/ToolJson.cpp new file mode 100644 index 0000000..e5b25d2 --- /dev/null +++ b/client/core/library/ToolJson.cpp @@ -0,0 +1,113 @@ +#include "library/ToolJson.h" + +#include + +#include + +namespace core::library { + +static QJsonArray pointToJson(const QPointF& p) { + QJsonArray a; + a.append(p.x()); + a.append(p.y()); + return a; +} + +static QPointF pointFromJson(const QJsonValue& v) { + const QJsonArray a = v.toArray(); + if (a.size() >= 2) { + return QPointF(a.at(0).toDouble(0.0), a.at(1).toDouble(0.0)); + } + return QPointF(); +} + +QJsonObject toolToJson(const core::Project::Tool& t) { + QJsonObject o; + o.insert(QStringLiteral("id"), t.id); + o.insert(QStringLiteral("displayName"), t.displayName); + o.insert(QStringLiteral("visible"), t.visible); + o.insert(QStringLiteral("parentId"), t.parentId); + o.insert(QStringLiteral("parentOffsetWorld"), pointToJson(t.parentOffsetWorld)); + o.insert(QStringLiteral("originWorld"), pointToJson(t.originWorld)); + + o.insert(QStringLiteral("type"), QStringLiteral("bubble")); + o.insert(QStringLiteral("text"), t.text); + o.insert(QStringLiteral("fontPx"), t.fontPx); + QString align = QStringLiteral("center"); + if (t.align == core::Project::Tool::TextAlign::Left) align = QStringLiteral("left"); + if (t.align == core::Project::Tool::TextAlign::Right) align = QStringLiteral("right"); + o.insert(QStringLiteral("align"), align); + o.insert(QStringLiteral("pointerT"), t.bubblePointerT01); + + QJsonArray vis; + for (const auto& k : t.visibilityKeys) { + QJsonObject ko; + ko.insert(QStringLiteral("frame"), k.frame); + ko.insert(QStringLiteral("value"), k.value); + vis.append(ko); + } + o.insert(QStringLiteral("visibilityKeys"), vis); + + QJsonArray loc; + for (const auto& k : t.locationKeys) { + QJsonObject ko; + ko.insert(QStringLiteral("frame"), k.frame); + ko.insert(QStringLiteral("x"), k.value.x()); + ko.insert(QStringLiteral("y"), k.value.y()); + loc.append(ko); + } + o.insert(QStringLiteral("locationKeys"), loc); + return o; +} + +bool toolFromJson(const QJsonObject& o, core::Project::Tool& out) { + core::Project::Tool t; + t.id = o.value(QStringLiteral("id")).toString(); + t.displayName = o.value(QStringLiteral("displayName")).toString(); + t.visible = o.value(QStringLiteral("visible")).toBool(true); + t.parentId = o.value(QStringLiteral("parentId")).toString(); + t.parentOffsetWorld = pointFromJson(o.value(QStringLiteral("parentOffsetWorld"))); + t.originWorld = pointFromJson(o.value(QStringLiteral("originWorld"))); + + const QString type = o.value(QStringLiteral("type")).toString(QStringLiteral("bubble")); + if (type != QStringLiteral("bubble")) { + return false; + } + t.type = core::Project::Tool::Type::Bubble; + t.text = o.value(QStringLiteral("text")).toString(); + t.fontPx = std::clamp(o.value(QStringLiteral("fontPx")).toInt(18), 8, 120); + const QString align = o.value(QStringLiteral("align")).toString(QStringLiteral("center")); + if (align == QStringLiteral("left")) t.align = core::Project::Tool::TextAlign::Left; + else if (align == QStringLiteral("right")) t.align = core::Project::Tool::TextAlign::Right; + else t.align = core::Project::Tool::TextAlign::Center; + if (o.contains(QStringLiteral("pointerT"))) { + t.bubblePointerT01 = std::clamp(o.value(QStringLiteral("pointerT")).toDouble(0.5), 0.0, 1.0); + } else { + const QString ptr = o.value(QStringLiteral("pointer")).toString(QStringLiteral("mid")); + if (ptr == QStringLiteral("left")) t.bubblePointerT01 = 0.12; + else if (ptr == QStringLiteral("right")) t.bubblePointerT01 = 0.88; + else t.bubblePointerT01 = 0.5; + } + + const QJsonArray vis = o.value(QStringLiteral("visibilityKeys")).toArray(); + for (const auto& kv : vis) { + const QJsonObject ko = kv.toObject(); + core::Project::ToolKeyframeBool k; + k.frame = ko.value(QStringLiteral("frame")).toInt(0); + k.value = ko.value(QStringLiteral("value")).toBool(true); + t.visibilityKeys.push_back(k); + } + const QJsonArray loc = o.value(QStringLiteral("locationKeys")).toArray(); + for (const auto& kv : loc) { + const QJsonObject ko = kv.toObject(); + core::Project::Entity::KeyframeVec2 k; + k.frame = ko.value(QStringLiteral("frame")).toInt(0); + k.value = QPointF(ko.value(QStringLiteral("x")).toDouble(0.0), ko.value(QStringLiteral("y")).toDouble(0.0)); + t.locationKeys.push_back(k); + } + out = t; + return true; +} + +} // namespace core::library + diff --git a/client/core/library/ToolJson.h b/client/core/library/ToolJson.h new file mode 100644 index 0000000..62e2a82 --- /dev/null +++ b/client/core/library/ToolJson.h @@ -0,0 +1,13 @@ +#pragma once + +#include "domain/Project.h" + +#include + +namespace core::library { + +QJsonObject toolToJson(const core::Project::Tool& t); +bool toolFromJson(const QJsonObject& o, core::Project::Tool& out); + +} // namespace core::library + diff --git a/client/core/persistence/EntityPayloadBinary.cpp b/client/core/persistence/EntityPayloadBinary.cpp index a756f52..d152fa7 100644 --- a/client/core/persistence/EntityPayloadBinary.cpp +++ b/client/core/persistence/EntityPayloadBinary.cpp @@ -251,6 +251,14 @@ public: writeAnimationBlock(ds, entity, true); ds << entity.displayName << double(entity.userScale) << double(entity.distanceScaleCalibMult); + ds << bool(entity.ignoreDistanceScale); + ds << entity.parentId; + ds << double(entity.parentOffsetWorld.x()) << double(entity.parentOffsetWorld.y()); + // v7:实体可见性关键帧 + ds << qint32(entity.visibilityKeys.size()); + for (const auto& k : entity.visibilityKeys) { + ds << qint32(k.frame) << bool(k.value); + } writeIntroBlock(ds, entity); } @@ -270,6 +278,40 @@ public: tmp.displayName = dn; tmp.userScale = std::clamp(us, 1e-3, 1e3); tmp.distanceScaleCalibMult = (cal > 0.0) ? std::clamp(cal, 1e-6, 10.0) : 0.0; + bool ign = false; + QString pid; + double pox = 0.0; + double poy = 0.0; + ds >> ign >> pid >> pox >> poy; + if (ds.status() != QDataStream::Ok) { + return false; + } + tmp.ignoreDistanceScale = ign; + tmp.parentId = pid; + tmp.parentOffsetWorld = QPointF(pox, poy); + + // v7:实体可见性关键帧 + qint32 nVis = 0; + ds >> nVis; + if (ds.status() != QDataStream::Ok) { + return false; + } + tmp.visibilityKeys.clear(); + if (nVis > 0) { + tmp.visibilityKeys.reserve(nVis); + for (qint32 i = 0; i < nVis; ++i) { + qint32 fr = 0; + bool val = true; + ds >> fr >> val; + if (ds.status() != QDataStream::Ok) { + return false; + } + core::Project::ToolKeyframeBool k; + k.frame = int(fr); + k.value = val; + tmp.visibilityKeys.push_back(k); + } + } if (!readIntroBlock(ds, tmp.intro)) { return false; } @@ -329,7 +371,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) { + if (ver != 1 && ver != 2 && ver != 3 && ver != 4 && ver != 5 && ver != 6 && ver != 7) { return false; } Project::Entity tmp; @@ -353,6 +395,48 @@ bool EntityPayloadBinary::load(const QString& absolutePath, Project::Entity& ent } tmp.distanceScaleCalibMult = (cal > 0.0) ? std::clamp(cal, 1e-6, 10.0) : 0.0; } + if (ver >= 6) { + bool ign = false; + QString pid; + double pox = 0.0; + double poy = 0.0; + ds >> ign >> pid >> pox >> poy; + if (ds.status() != QDataStream::Ok) { + return false; + } + tmp.ignoreDistanceScale = ign; + tmp.parentId = pid; + tmp.parentOffsetWorld = QPointF(pox, poy); + } else { + tmp.ignoreDistanceScale = false; + tmp.parentId.clear(); + tmp.parentOffsetWorld = QPointF(); + } + if (ver >= 7) { + qint32 nVis = 0; + ds >> nVis; + if (ds.status() != QDataStream::Ok) { + return false; + } + tmp.visibilityKeys.clear(); + if (nVis > 0) { + tmp.visibilityKeys.reserve(nVis); + for (qint32 i = 0; i < nVis; ++i) { + qint32 fr = 0; + bool val = true; + ds >> fr >> val; + if (ds.status() != QDataStream::Ok) { + return false; + } + core::Project::ToolKeyframeBool k; + k.frame = int(fr); + k.value = val; + tmp.visibilityKeys.push_back(k); + } + } + } else { + tmp.visibilityKeys.clear(); + } if (ver >= 5) { if (!readIntroBlock(ds, tmp.intro)) { return false; @@ -361,6 +445,10 @@ bool EntityPayloadBinary::load(const QString& absolutePath, Project::Entity& ent } else { tmp.displayName.clear(); tmp.userScale = 1.0; + tmp.ignoreDistanceScale = false; + tmp.parentId.clear(); + tmp.parentOffsetWorld = QPointF(); + tmp.visibilityKeys.clear(); } entity = std::move(tmp); return true; diff --git a/client/core/persistence/EntityPayloadBinary.h b/client/core/persistence/EntityPayloadBinary.h index 598dbc6..19f898c 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 = 5; // v5:追加 intro(预览介绍:标题/正文/配图/视频路径预留) + static constexpr quint32 kPayloadVersion = 7; // v7:追加 visibilityKeys // 旧版独立动画文件(仍用于打开 v1 项目时合并) static constexpr quint32 kMagicLegacyAnim = 0x48465441; // 'HFTA' diff --git a/client/core/workspace/ProjectWorkspace.cpp b/client/core/workspace/ProjectWorkspace.cpp index 8941e79..6689e04 100644 --- a/client/core/workspace/ProjectWorkspace.cpp +++ b/client/core/workspace/ProjectWorkspace.cpp @@ -132,6 +132,48 @@ QRect clampRectToImage(const QRect& rect, const QSize& size) { return r; } +static const Project::NlaStrip* findStripById(const Project::AnimationScheme& scheme, const QString& stripId) { + if (stripId.isEmpty()) return nullptr; + for (const auto& tr : scheme.tracks) { + for (const auto& st : tr.strips) { + if (st.id == stripId) return &st; + } + } + return nullptr; +} + +static void upsertBoolKey(QVector& keys, int frame, bool value) { + for (auto& k : keys) { + if (k.frame == frame) { + k.value = value; + return; + } + } + Project::ToolKeyframeBool k; + k.frame = frame; + k.value = value; + keys.push_back(k); +} + +static Project::AnimationClip* activeClipOrNull(Project& project) { + auto* scheme = project.activeSchemeOrNull(); + if (!scheme) return nullptr; + const Project::NlaStrip* st = findStripById(*scheme, project.selectedStripId()); + if (!st) { + for (const auto& tr : scheme->tracks) { + for (const auto& s : tr.strips) { + if (s.enabled && !s.muted) { + st = &s; + break; + } + } + if (st) break; + } + } + if (!st) return nullptr; + return project.findClipById(st->clipId); +} + } // namespace QString ProjectWorkspace::indexFilePath() const { @@ -266,6 +308,34 @@ bool ProjectWorkspace::createNew(const QString& projectDir, const QString& name, return false; } + // 初始化默认动画方案/片段(600 帧固定片段) + if (m_project.animationSchemes().isEmpty()) { + Project::AnimationClip clip; + clip.id = QStringLiteral("clip-1"); + clip.name = QStringLiteral("Clip_001"); + m_project.setAnimationClips({clip}); + + Project::NlaStrip strip; + strip.id = QStringLiteral("strip-1"); + strip.clipId = clip.id; + strip.startSlot = 0; + strip.slotLen = 1; + + Project::NlaTrack track; + track.id = QStringLiteral("track-1"); + track.name = QStringLiteral("Track"); + track.strips = {strip}; + + Project::AnimationScheme scheme; + scheme.id = QStringLiteral("scheme-1"); + scheme.name = QStringLiteral("方案_001"); + scheme.tracks = {track}; + + m_project.setAnimationSchemes({scheme}); + m_project.setActiveSchemeId(scheme.id); + m_project.setSelectedStripId(strip.id); + } + return writeIndexJson(); } @@ -290,6 +360,62 @@ bool ProjectWorkspace::openExisting(const QString& projectDir) { ensureDir(assetsDirPath()); m_undoStack.clear(); m_redoStack.clear(); + + // 旧项目迁移:若无动画方案/片段,则将实体/工具的关键帧迁移到默认 clip,并创建默认 scheme/track/strip + if (m_project.animationSchemes().isEmpty()) { + Project::AnimationClip clip; + clip.id = QStringLiteral("clip-1"); + clip.name = QStringLiteral("Clip_001"); + + auto ents = m_project.entities(); + for (auto& e : ents) { + if (e.id.isEmpty()) continue; + if (!e.locationKeys.isEmpty()) clip.entityLocationKeys.insert(e.id, e.locationKeys); + if (!e.userScaleKeys.isEmpty()) clip.entityUserScaleKeys.insert(e.id, e.userScaleKeys); + if (!e.imageFrames.isEmpty()) clip.entityImageFrames.insert(e.id, e.imageFrames); + if (!e.visibilityKeys.isEmpty()) clip.entityVisibilityKeys.insert(e.id, e.visibilityKeys); + e.locationKeys.clear(); + e.userScaleKeys.clear(); + e.imageFrames.clear(); + e.visibilityKeys.clear(); + } + m_project.setEntities(ents); + + auto tools = m_project.tools(); + for (auto& t : tools) { + if (t.id.isEmpty()) continue; + if (!t.locationKeys.isEmpty()) clip.toolLocationKeys.insert(t.id, t.locationKeys); + if (!t.visibilityKeys.isEmpty()) clip.toolVisibilityKeys.insert(t.id, t.visibilityKeys); + t.locationKeys.clear(); + t.visibilityKeys.clear(); + } + m_project.setTools(tools); + + m_project.setAnimationClips({clip}); + + Project::NlaStrip strip; + strip.id = QStringLiteral("strip-1"); + strip.clipId = clip.id; + strip.startSlot = 0; + strip.slotLen = 1; + + Project::NlaTrack track; + track.id = QStringLiteral("track-1"); + track.name = QStringLiteral("Track"); + track.strips = {strip}; + + Project::AnimationScheme scheme; + scheme.id = QStringLiteral("scheme-1"); + scheme.name = QStringLiteral("方案_001"); + scheme.tracks = {track}; + + m_project.setAnimationSchemes({scheme}); + m_project.setActiveSchemeId(scheme.id); + m_project.setSelectedStripId(strip.id); + + // 迁移后立即落盘,避免后续求值出现双来源 + writeIndexJson(); + } return true; } @@ -300,6 +426,13 @@ void ProjectWorkspace::close() { m_redoStack.clear(); } +bool ProjectWorkspace::save() { + if (m_projectDir.isEmpty()) { + return false; + } + return writeIndexJson(); +} + bool ProjectWorkspace::canUndo() const { return !m_undoStack.isEmpty(); } @@ -327,6 +460,11 @@ bool ProjectWorkspace::undo() { m_undoStack.push_back(op); return false; } + } else if (op.type == Operation::Type::SetTools) { + if (!applyTools(op.beforeTools, false, QString())) { + m_undoStack.push_back(op); + return false; + } } else if (op.type == Operation::Type::SetProjectTitle) { m_project.setName(op.beforeProjectTitle); if (!writeIndexJson()) { @@ -334,6 +472,15 @@ bool ProjectWorkspace::undo() { m_undoStack.push_back(op); return false; } + } else if (op.type == Operation::Type::SetProjectFrameRange) { + m_project.setFrameStart(std::max(0, op.beforeFrameStart)); + m_project.setFrameEnd(std::max(m_project.frameStart(), op.beforeFrameEnd)); + if (!writeIndexJson()) { + m_project.setFrameStart(std::max(0, op.afterFrameStart)); + m_project.setFrameEnd(std::max(m_project.frameStart(), op.afterFrameEnd)); + m_undoStack.push_back(op); + return false; + } } m_redoStack.push_back(redoOp); return true; @@ -356,6 +503,11 @@ bool ProjectWorkspace::redo() { m_redoStack.push_back(op); return false; } + } else if (op.type == Operation::Type::SetTools) { + if (!applyTools(op.afterTools, false, QString())) { + m_redoStack.push_back(op); + return false; + } } else if (op.type == Operation::Type::SetProjectTitle) { m_project.setName(op.afterProjectTitle); if (!writeIndexJson()) { @@ -363,11 +515,85 @@ bool ProjectWorkspace::redo() { m_redoStack.push_back(op); return false; } + } else if (op.type == Operation::Type::SetProjectFrameRange) { + m_project.setFrameStart(std::max(0, op.afterFrameStart)); + m_project.setFrameEnd(std::max(m_project.frameStart(), op.afterFrameEnd)); + if (!writeIndexJson()) { + m_project.setFrameStart(std::max(0, op.beforeFrameStart)); + m_project.setFrameEnd(std::max(m_project.frameStart(), op.beforeFrameEnd)); + m_redoStack.push_back(op); + return false; + } } m_undoStack.push_back(undoOp); return true; } +bool ProjectWorkspace::setProjectFrameRange(int start, int end) { + if (m_projectDir.isEmpty()) { + return false; + } + const int s = std::max(0, start); + const int e = std::max(end, s); + if (m_project.frameStart() == s && m_project.frameEnd() == e) { + return true; + } + const int beforeS = m_project.frameStart(); + const int beforeE = m_project.frameEnd(); + m_project.setFrameStart(s); + m_project.setFrameEnd(e); + if (!writeIndexJson()) { + m_project.setFrameStart(beforeS); + m_project.setFrameEnd(beforeE); + return false; + } + Operation op; + op.type = Operation::Type::SetProjectFrameRange; + op.label = QStringLiteral("时间轴范围"); + op.beforeFrameStart = beforeS; + op.beforeFrameEnd = beforeE; + op.afterFrameStart = s; + op.afterFrameEnd = e; + pushOperation(op); + m_redoStack.clear(); + return true; +} + +bool ProjectWorkspace::ensureProjectFrameEndAtLeast(int end, bool recordHistory) { + if (m_projectDir.isEmpty()) { + return false; + } + const int s = std::max(0, m_project.frameStart()); + const int want = std::max(end, s); + if (want <= m_project.frameEnd()) { + return true; + } + // 按“页/片段长度”扩展:避免 scrub 时每帧都写盘,并使时间轴增长呈分段语义 + // 约定:默认一页 600 帧(历史工程默认 frameEnd=600),后续可做成可配置。 + constexpr int kPageLen = 600; + const int beforeE = m_project.frameEnd(); + const int spanFromStart = std::max(0, want - s); + const int pages = (spanFromStart + kPageLen - 1) / kPageLen; // ceil + const int newEnd = s + pages * kPageLen; + m_project.setFrameEnd(std::max(newEnd, want)); + if (!writeIndexJson()) { + m_project.setFrameEnd(beforeE); + return false; + } + if (recordHistory) { + Operation op; + op.type = Operation::Type::SetProjectFrameRange; + op.label = QStringLiteral("扩展时间轴"); + op.beforeFrameStart = m_project.frameStart(); + op.afterFrameStart = m_project.frameStart(); + op.beforeFrameEnd = beforeE; + op.afterFrameEnd = m_project.frameEnd(); + pushOperation(op); + m_redoStack.clear(); + } + return true; +} + QStringList ProjectWorkspace::historyLabelsNewestFirst() const { QStringList out; out.reserve(m_undoStack.size()); @@ -466,6 +692,114 @@ QJsonObject ProjectWorkspace::projectToJson(const Project& project) { } root.insert("entities", ents); + QJsonArray tools; + for (const auto& t : project.tools()) { + tools.append(toolToJson(t)); + } + root.insert("tools", tools); + + // —— 动画(v3)—— + root.insert("activeSchemeId", project.activeSchemeId()); + root.insert("selectedStripId", project.selectedStripId()); + + QJsonArray clips; + for (const auto& c : project.animationClips()) { + QJsonObject co; + co.insert("id", c.id); + co.insert("name", c.name); + + auto encodeVec2 = [](const QVector& keys) { + QJsonArray a; + for (const auto& k : keys) { + QJsonObject o; + o.insert("frame", k.frame); + o.insert("x", k.value.x()); + o.insert("y", k.value.y()); + a.append(o); + } + return a; + }; + auto encodeDouble = [](const QVector& keys) { + QJsonArray a; + for (const auto& k : keys) { + QJsonObject o; + o.insert("frame", k.frame); + o.insert("value", k.value); + a.append(o); + } + return a; + }; + auto encodeImage = [](const QVector& keys) { + QJsonArray a; + for (const auto& k : keys) { + QJsonObject o; + o.insert("frame", k.frame); + o.insert("path", k.imagePath); + a.append(o); + } + return a; + }; + auto encodeBool = [](const QVector& keys) { + QJsonArray a; + for (const auto& k : keys) { + QJsonObject o; + o.insert("frame", k.frame); + o.insert("value", k.value); + a.append(o); + } + return a; + }; + + auto encodeHash = [&](const auto& h, auto encoder) { + QJsonObject out; + for (auto it = h.constBegin(); it != h.constEnd(); ++it) { + out.insert(it.key(), encoder(it.value())); + } + return out; + }; + + co.insert("entityLocationKeys", encodeHash(c.entityLocationKeys, encodeVec2)); + co.insert("entityUserScaleKeys", encodeHash(c.entityUserScaleKeys, encodeDouble)); + co.insert("entityImageFrames", encodeHash(c.entityImageFrames, encodeImage)); + co.insert("entityVisibilityKeys", encodeHash(c.entityVisibilityKeys, encodeBool)); + co.insert("toolLocationKeys", encodeHash(c.toolLocationKeys, encodeVec2)); + co.insert("toolVisibilityKeys", encodeHash(c.toolVisibilityKeys, encodeBool)); + + clips.append(co); + } + root.insert("animationClips", clips); + + QJsonArray schemes; + for (const auto& s : project.animationSchemes()) { + QJsonObject so; + so.insert("id", s.id); + so.insert("name", s.name); + QJsonArray tracks; + for (const auto& t : s.tracks) { + QJsonObject to; + to.insert("id", t.id); + to.insert("name", t.name); + to.insert("muted", t.muted); + to.insert("solo", t.solo); + QJsonArray strips; + for (const auto& st : t.strips) { + QJsonObject sto; + sto.insert("id", st.id); + sto.insert("clipId", st.clipId); + sto.insert("startSlot", st.startSlot); + sto.insert("slotLen", st.slotLen); + sto.insert("enabled", st.enabled); + sto.insert("muted", st.muted); + strips.append(sto); + } + to.insert("strips", strips); + tracks.append(to); + } + so.insert("tracks", tracks); + schemes.append(so); + } + root.insert("animationSchemes", schemes); + return root; } @@ -474,7 +808,7 @@ bool ProjectWorkspace::projectFromJson(const QJsonObject& root, Project& outProj return false; } const int version = root.value("version").toInt(); - if (version != 1 && version != 2) { + if (version != 1 && version != 2 && version != 3) { return false; } if (outFileVersion) { @@ -515,6 +849,172 @@ bool ProjectWorkspace::projectFromJson(const QJsonObject& root, Project& outProj } } outProject.setEntities(entities); + + QVector tools; + if (version >= 2) { + const auto toolsVal = root.value("tools"); + if (toolsVal.isArray()) { + const QJsonArray arr = toolsVal.toArray(); + tools.reserve(arr.size()); + for (const auto& v : arr) { + if (!v.isObject()) continue; + Project::Tool t; + if (toolFromJsonV2(v.toObject(), t)) { + tools.push_back(t); + } + } + } + } + outProject.setTools(tools); + + // —— 动画(v3,可选)—— + if (version >= 3) { + outProject.setActiveSchemeId(root.value("activeSchemeId").toString()); + outProject.setSelectedStripId(root.value("selectedStripId").toString()); + + QVector clips; + const auto clipsVal = root.value("animationClips"); + if (clipsVal.isArray()) { + const QJsonArray arr = clipsVal.toArray(); + clips.reserve(arr.size()); + for (const auto& v : arr) { + if (!v.isObject()) continue; + const QJsonObject co = v.toObject(); + Project::AnimationClip c; + c.id = co.value("id").toString(); + c.name = co.value("name").toString(); + + auto decodeVec2 = [](const QJsonValue& val) { + QVector out; + if (!val.isArray()) return out; + const QJsonArray karr = val.toArray(); + out.reserve(karr.size()); + for (const auto& kv : karr) { + if (!kv.isObject()) continue; + const QJsonObject o = kv.toObject(); + Project::Entity::KeyframeVec2 k; + k.frame = o.value("frame").toInt(0); + k.value = QPointF(o.value("x").toDouble(0.0), o.value("y").toDouble(0.0)); + out.push_back(k); + } + return out; + }; + auto decodeDouble = [](const QJsonValue& val) { + QVector out; + if (!val.isArray()) return out; + const QJsonArray karr = val.toArray(); + out.reserve(karr.size()); + for (const auto& kv : karr) { + if (!kv.isObject()) continue; + const QJsonObject o = kv.toObject(); + Project::Entity::KeyframeDouble k; + k.frame = o.value("frame").toInt(0); + k.value = o.value("value").toDouble(1.0); + out.push_back(k); + } + return out; + }; + auto decodeImage = [](const QJsonValue& val) { + QVector out; + if (!val.isArray()) return out; + const QJsonArray karr = val.toArray(); + out.reserve(karr.size()); + for (const auto& kv : karr) { + if (!kv.isObject()) continue; + const QJsonObject o = kv.toObject(); + Project::Entity::ImageFrame k; + k.frame = o.value("frame").toInt(0); + k.imagePath = asOptionalRelativeUnderProject(o.value("path").toString()); + out.push_back(k); + } + return out; + }; + auto decodeBool = [](const QJsonValue& val) { + QVector out; + if (!val.isArray()) return out; + const QJsonArray karr = val.toArray(); + out.reserve(karr.size()); + for (const auto& kv : karr) { + if (!kv.isObject()) continue; + const QJsonObject o = kv.toObject(); + Project::ToolKeyframeBool k; + k.frame = o.value("frame").toInt(0); + k.value = o.value("value").toBool(true); + out.push_back(k); + } + return out; + }; + + auto decodeHash = [&](const QJsonValue& val, auto decoder, auto& dst) { + dst.clear(); + if (!val.isObject()) return; + const QJsonObject ho = val.toObject(); + for (auto it = ho.begin(); it != ho.end(); ++it) { + dst.insert(it.key(), decoder(it.value())); + } + }; + + decodeHash(co.value("entityLocationKeys"), decodeVec2, c.entityLocationKeys); + decodeHash(co.value("entityUserScaleKeys"), decodeDouble, c.entityUserScaleKeys); + decodeHash(co.value("entityImageFrames"), decodeImage, c.entityImageFrames); + decodeHash(co.value("entityVisibilityKeys"), decodeBool, c.entityVisibilityKeys); + decodeHash(co.value("toolLocationKeys"), decodeVec2, c.toolLocationKeys); + decodeHash(co.value("toolVisibilityKeys"), decodeBool, c.toolVisibilityKeys); + + clips.push_back(std::move(c)); + } + } + outProject.setAnimationClips(clips); + + QVector schemes; + const auto schemesVal = root.value("animationSchemes"); + if (schemesVal.isArray()) { + const QJsonArray arr = schemesVal.toArray(); + schemes.reserve(arr.size()); + for (const auto& v : arr) { + if (!v.isObject()) continue; + const QJsonObject so = v.toObject(); + Project::AnimationScheme s; + s.id = so.value("id").toString(); + s.name = so.value("name").toString(); + const auto tracksVal = so.value("tracks"); + if (tracksVal.isArray()) { + const QJsonArray tarr = tracksVal.toArray(); + s.tracks.reserve(tarr.size()); + for (const auto& tv : tarr) { + if (!tv.isObject()) continue; + const QJsonObject to = tv.toObject(); + Project::NlaTrack t; + t.id = to.value("id").toString(); + t.name = to.value("name").toString(); + t.muted = to.value("muted").toBool(false); + t.solo = to.value("solo").toBool(false); + const auto stripsVal = to.value("strips"); + if (stripsVal.isArray()) { + const QJsonArray sarr = stripsVal.toArray(); + t.strips.reserve(sarr.size()); + for (const auto& sv : sarr) { + if (!sv.isObject()) continue; + const QJsonObject sto = sv.toObject(); + Project::NlaStrip st; + st.id = sto.value("id").toString(); + st.clipId = sto.value("clipId").toString(); + st.startSlot = sto.value("startSlot").toInt(0); + st.slotLen = std::max(1, sto.value("slotLen").toInt(1)); + st.enabled = sto.value("enabled").toBool(true); + st.muted = sto.value("muted").toBool(false); + t.strips.push_back(std::move(st)); + } + } + s.tracks.push_back(std::move(t)); + } + } + schemes.push_back(std::move(s)); + } + } + outProject.setAnimationSchemes(schemes); + } + return true; } @@ -548,6 +1048,106 @@ QJsonObject ProjectWorkspace::entityToJson(const Project::Entity& e) { return o; } +QJsonObject ProjectWorkspace::toolToJson(const Project::Tool& t) { + QJsonObject o; + o.insert("id", t.id); + o.insert("displayName", t.displayName); + o.insert("visible", t.visible); + o.insert("parentId", t.parentId); + o.insert("parentOffsetX", t.parentOffsetWorld.x()); + o.insert("parentOffsetY", t.parentOffsetWorld.y()); + o.insert("originX", t.originWorld.x()); + o.insert("originY", t.originWorld.y()); + o.insert("type", (t.type == Project::Tool::Type::Bubble) ? QStringLiteral("bubble") : QStringLiteral("bubble")); + o.insert("text", t.text); + o.insert("fontPx", t.fontPx); + const QString align = + (t.align == Project::Tool::TextAlign::Left) ? QStringLiteral("left") + : (t.align == Project::Tool::TextAlign::Right) ? QStringLiteral("right") + : QStringLiteral("center"); + o.insert("align", align); + o.insert("pointerT", t.bubblePointerT01); + QJsonArray vis; + for (const auto& k : t.visibilityKeys) { + QJsonObject ko; + ko.insert("frame", k.frame); + ko.insert("value", k.value); + vis.append(ko); + } + o.insert("visibilityKeys", vis); + QJsonArray loc; + for (const auto& k : t.locationKeys) { + QJsonObject ko; + ko.insert("frame", k.frame); + ko.insert("x", k.value.x()); + ko.insert("y", k.value.y()); + loc.append(ko); + } + o.insert("locationKeys", loc); + return o; +} + +bool ProjectWorkspace::toolFromJsonV2(const QJsonObject& o, Project::Tool& out) { + Project::Tool t; + t.id = o.value("id").toString(); + if (t.id.isEmpty()) { + return false; + } + t.displayName = o.value("displayName").toString(); + t.visible = o.value("visible").toBool(true); + t.parentId = o.value("parentId").toString(); + t.parentOffsetWorld = QPointF(o.value("parentOffsetX").toDouble(0.0), o.value("parentOffsetY").toDouble(0.0)); + t.originWorld = QPointF(o.value("originX").toDouble(0.0), o.value("originY").toDouble(0.0)); + const QString type = o.value("type").toString(); + t.type = (type == QStringLiteral("bubble")) ? Project::Tool::Type::Bubble : Project::Tool::Type::Bubble; + t.text = o.value("text").toString(); + t.fontPx = std::clamp(o.value("fontPx").toInt(18), 8, 120); + const QString align = o.value("align").toString(QStringLiteral("center")); + if (align == QStringLiteral("left")) t.align = Project::Tool::TextAlign::Left; + else if (align == QStringLiteral("right")) t.align = Project::Tool::TextAlign::Right; + else t.align = Project::Tool::TextAlign::Center; + if (o.contains(QStringLiteral("pointerT"))) { + t.bubblePointerT01 = std::clamp(o.value(QStringLiteral("pointerT")).toDouble(0.5), 0.0, 1.0); + } else { + const QString ptr = o.value(QStringLiteral("pointer")).toString(); + if (ptr == QStringLiteral("left")) t.bubblePointerT01 = 0.12; + else if (ptr == QStringLiteral("right")) t.bubblePointerT01 = 0.88; + else t.bubblePointerT01 = 0.5; + } + + t.visibilityKeys.clear(); + const auto visVal = o.value("visibilityKeys"); + if (visVal.isArray()) { + const QJsonArray arr = visVal.toArray(); + t.visibilityKeys.reserve(arr.size()); + for (const auto& v : arr) { + if (!v.isObject()) continue; + const auto ko = v.toObject(); + Project::ToolKeyframeBool k; + k.frame = ko.value("frame").toInt(0); + k.value = ko.value("value").toBool(true); + t.visibilityKeys.push_back(k); + } + } + t.locationKeys.clear(); + const auto locVal = o.value("locationKeys"); + if (locVal.isArray()) { + const QJsonArray arr = locVal.toArray(); + t.locationKeys.reserve(arr.size()); + for (const auto& v : arr) { + if (!v.isObject()) continue; + const auto ko = v.toObject(); + Project::Entity::KeyframeVec2 k; + k.frame = ko.value("frame").toInt(0); + k.value = QPointF(ko.value("x").toDouble(0.0), ko.value("y").toDouble(0.0)); + t.locationKeys.push_back(k); + } + } + + out = std::move(t); + return true; +} + bool ProjectWorkspace::entityStubFromJsonV2(const QJsonObject& o, Project::Entity& out) { out = Project::Entity{}; out.id = o.value("id").toString(); @@ -757,6 +1357,27 @@ bool ProjectWorkspace::applyEntities(const QVector& entities, return true; } +bool ProjectWorkspace::applyTools(const QVector& tools, + bool recordHistory, + const QString& label) { + const auto before = m_project.tools(); + m_project.setTools(tools); + if (!writeIndexJson()) { + m_project.setTools(before); + return false; + } + if (recordHistory) { + Operation op; + op.type = Operation::Type::SetTools; + op.label = label; + op.beforeTools = before; + op.afterTools = tools; + pushOperation(op); + m_redoStack.clear(); + } + return true; +} + QString ProjectWorkspace::ensureEntitiesDir() const { const auto assets = assetsDirPath(); if (assets.isEmpty()) { @@ -1008,6 +1629,38 @@ bool ProjectWorkspace::setEntityVisible(const QString& id, bool on) { return applyEntities(ents, true, on ? QStringLiteral("显示实体") : QStringLiteral("隐藏实体")); } +bool ProjectWorkspace::setEntityVisibilityKey(const QString& id, int frame, bool visible) { + if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) { + return false; + } + Project::AnimationClip* clip = activeClipOrNull(m_project); + if (!clip) return false; + auto& keys = clip->entityVisibilityKeys[id]; + upsertBoolKey(keys, frame, visible); + return writeIndexJson(); +} + +bool ProjectWorkspace::removeEntityVisibilityKey(const QString& id, int frame) { + if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) { + return false; + } + Project::AnimationClip* clip = activeClipOrNull(m_project); + if (!clip) return false; + if (!clip->entityVisibilityKeys.contains(id)) return true; + auto keys = clip->entityVisibilityKeys.value(id); + bool changed = false; + for (int i = 0; i < keys.size(); ++i) { + if (keys[i].frame == frame) { + keys.removeAt(i); + changed = true; + break; + } + } + if (!changed) return true; + clip->entityVisibilityKeys.insert(id, keys); + return writeIndexJson(); +} + bool ProjectWorkspace::setEntityDisplayName(const QString& id, const QString& displayName) { if (m_projectDir.isEmpty() || id.isEmpty()) { return false; @@ -1098,7 +1751,10 @@ bool ProjectWorkspace::setEntityUserScale(const QString& id, double userScale, i const bool baseSame = qFuzzyCompare(e.userScale + 1.0, u + 1.0); e.userScale = u; if (keyframeAtFrame >= 0) { - upsertKey(e.userScaleKeys, keyframeAtFrame, std::clamp(u, 1e-6, 1e3)); + Project::AnimationClip* clip = activeClipOrNull(m_project); + if (!clip) return false; + auto& keys = clip->entityUserScaleKeys[id]; + upsertKey(keys, keyframeAtFrame, std::clamp(u, 1e-6, 1e3)); } else if (baseSame) { return true; } @@ -1107,7 +1763,57 @@ bool ProjectWorkspace::setEntityUserScale(const QString& id, double userScale, i if (!found) { return false; } - return applyEntities(ents, true, QStringLiteral("整体缩放")); + if (!applyEntities(ents, true, QStringLiteral("整体缩放"))) { + return false; + } + // 缩放关键帧已写入 clip,此处只需保证索引落盘 + return writeIndexJson(); +} + +bool ProjectWorkspace::setEntityIgnoreDistanceScale(const QString& id, bool on) { + if (m_projectDir.isEmpty() || id.isEmpty()) { + return false; + } + auto ents = m_project.entities(); + bool found = false; + bool changed = false; + for (auto& e : ents) { + if (e.id != id) continue; + found = true; + if (e.ignoreDistanceScale != on) { + e.ignoreDistanceScale = on; + changed = true; + } + break; + } + if (!found) return false; + if (!changed) return true; + return applyEntities(ents, true, QStringLiteral("距离缩放开关")); +} + +bool ProjectWorkspace::setEntityParent(const QString& id, const QString& parentId, const QPointF& parentOffsetWorld) { + if (m_projectDir.isEmpty() || id.isEmpty()) { + return false; + } + if (id == parentId) { + return false; + } + auto ents = m_project.entities(); + QSet ids; + for (const auto& e : ents) ids.insert(e.id); + if (!parentId.isEmpty() && !ids.contains(parentId)) { + return false; + } + bool found = false; + for (auto& e : ents) { + if (e.id != id) continue; + found = true; + e.parentId = parentId; + e.parentOffsetWorld = parentOffsetWorld; + break; + } + if (!found) return false; + return applyEntities(ents, true, QStringLiteral("设置父实体")); } bool ProjectWorkspace::moveEntityCentroidTo(const QString& id, int frame, const QPointF& targetCentroidWorld, @@ -1245,22 +1951,34 @@ 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]; + } + const bool hasCurve = (keys && !keys->isEmpty()) || (!e.locationKeys.isEmpty()); if (autoKeyLocation && currentFrame >= 0) { - const QPointF sampled = sampleLocation(e.locationKeys, - currentFrame, - e.originWorld, - KeyInterpolation::Linear); - upsertKey(e.locationKeys, currentFrame, sampled + delta); - } else if (e.locationKeys.isEmpty()) { + const QPointF sampled = + (keys) + ? sampleLocation(*keys, currentFrame, e.originWorld, KeyInterpolation::Linear) + : sampleLocation(e.locationKeys, currentFrame, e.originWorld, KeyInterpolation::Linear); + if (keys) upsertKey(*keys, currentFrame, sampled + delta); + else upsertKey(e.locationKeys, currentFrame, sampled + delta); + } else if (!hasCurve) { e.originWorld += delta; e.imageTopLeftWorld += delta; } else if (currentFrame >= 0) { - // 已有位置曲线但未勾选「自动关键帧」时,仍应移动当前帧上的位置,否则画布仍按关键帧插值,看起来不生效 - const QPointF sampled = sampleLocation(e.locationKeys, - currentFrame, - e.originWorld, - KeyInterpolation::Linear); - upsertKey(e.locationKeys, currentFrame, sampled + delta); + const QPointF sampled = + (keys) + ? sampleLocation(*keys, currentFrame, e.originWorld, KeyInterpolation::Linear) + : sampleLocation(e.locationKeys, currentFrame, e.originWorld, KeyInterpolation::Linear); + if (keys) upsertKey(*keys, currentFrame, sampled + delta); + else upsertKey(e.locationKeys, currentFrame, sampled + delta); } else { e.originWorld += delta; e.imageTopLeftWorld += delta; @@ -1270,23 +1988,228 @@ bool ProjectWorkspace::moveEntityBy(const QString& id, const QPointF& delta, int if (!found) { return false; } - return applyEntities(ents, true, QStringLiteral("移动实体")); + if (!applyEntities(ents, true, QStringLiteral("移动实体"))) { + return false; + } + // clip 曲线与选中条带存于 project.json,需要保证落盘 + return writeIndexJson(); +} + +bool ProjectWorkspace::addTool(const Project::Tool& tool) { + if (m_projectDir.isEmpty()) { + return false; + } + if (tool.id.isEmpty()) { + return false; + } + auto tools = m_project.tools(); + for (const auto& t : tools) { + if (t.id == tool.id) { + return false; + } + } + tools.push_back(tool); + return applyTools(tools, true, QStringLiteral("添加工具")); +} + +bool ProjectWorkspace::setToolVisible(const QString& id, bool on) { + if (m_projectDir.isEmpty() || id.isEmpty()) { + return false; + } + auto tools = m_project.tools(); + bool found = false; + bool changed = false; + for (auto& t : tools) { + if (t.id != id) continue; + found = true; + if (t.visible != on) { + t.visible = on; + changed = true; + } + break; + } + if (!found) return false; + if (!changed) return true; + return applyTools(tools, true, on ? QStringLiteral("显示工具") : QStringLiteral("隐藏工具")); +} + +bool ProjectWorkspace::setToolText(const QString& id, const QString& text) { + if (m_projectDir.isEmpty() || id.isEmpty()) { + return false; + } + auto tools = m_project.tools(); + bool found = false; + for (auto& t : tools) { + if (t.id != id) continue; + found = true; + t.text = text; + break; + } + if (!found) return false; + return applyTools(tools, true, QStringLiteral("工具文本")); +} + +bool ProjectWorkspace::setToolBubblePointerT01(const QString& id, double t01) { + if (m_projectDir.isEmpty() || id.isEmpty()) { + return false; + } + const double tClamped = std::clamp(t01, 0.0, 1.0); + auto tools = m_project.tools(); + bool found = false; + for (auto& tool : tools) { + if (tool.id != id) continue; + found = true; + tool.bubblePointerT01 = tClamped; + break; + } + if (!found) return false; + return applyTools(tools, true, QStringLiteral("气泡指向位置")); +} + +bool ProjectWorkspace::setToolFontPx(const QString& id, int fontPx) { + if (m_projectDir.isEmpty() || id.isEmpty()) { + return false; + } + const int px = std::clamp(fontPx, 8, 120); + auto tools = m_project.tools(); + bool found = false; + for (auto& t : tools) { + if (t.id != id) continue; + found = true; + t.fontPx = px; + break; + } + if (!found) return false; + return applyTools(tools, true, QStringLiteral("工具字号")); +} + +bool ProjectWorkspace::setToolAlign(const QString& id, core::Project::Tool::TextAlign align) { + if (m_projectDir.isEmpty() || id.isEmpty()) { + return false; + } + auto tools = m_project.tools(); + bool found = false; + for (auto& t : tools) { + if (t.id != id) continue; + found = true; + t.align = align; + break; + } + if (!found) return false; + return applyTools(tools, true, QStringLiteral("工具对齐")); +} + +bool ProjectWorkspace::setToolVisibilityKey(const QString& id, int frame, bool visible) { + if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) { + return false; + } + Project::AnimationClip* clip = activeClipOrNull(m_project); + if (!clip) return false; + auto& keys = clip->toolVisibilityKeys[id]; + upsertBoolKey(keys, frame, visible); + return writeIndexJson(); +} + +bool ProjectWorkspace::removeToolVisibilityKey(const QString& id, int frame) { + if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) { + return false; + } + Project::AnimationClip* clip = activeClipOrNull(m_project); + if (!clip) return false; + if (!clip->toolVisibilityKeys.contains(id)) return true; + auto keys = clip->toolVisibilityKeys.value(id); + bool changed = false; + for (int i = 0; i < keys.size(); ++i) { + if (keys[i].frame == frame) { + keys.removeAt(i); + changed = true; + break; + } + } + if (!changed) return true; + clip->toolVisibilityKeys.insert(id, keys); + return writeIndexJson(); +} + +bool ProjectWorkspace::setToolParent(const QString& id, const QString& parentId, const QPointF& parentOffsetWorld) { + if (m_projectDir.isEmpty() || id.isEmpty()) { + return false; + } + if (id == parentId) { + return false; + } + auto tools = m_project.tools(); + bool found = false; + for (auto& t : tools) { + if (t.id != id) continue; + found = true; + t.parentId = parentId; + t.parentOffsetWorld = parentOffsetWorld; + break; + } + if (!found) return false; + return applyTools(tools, true, QStringLiteral("设置工具父对象")); +} + +bool ProjectWorkspace::moveToolBy(const QString& id, const QPointF& delta, int currentFrame, bool autoKeyLocation) { + if (m_projectDir.isEmpty() || id.isEmpty()) { + return false; + } + if (qFuzzyIsNull(delta.x()) && qFuzzyIsNull(delta.y())) { + return true; + } + auto tools = m_project.tools(); + bool found = false; + for (auto& t : tools) { + if (t.id != id) continue; + found = true; + if (!t.parentId.isEmpty()) { + t.parentOffsetWorld += delta; + break; + } + Project::AnimationClip* clip = activeClipOrNull(m_project); + QVector* keys = nullptr; + if (clip) { + keys = &clip->toolLocationKeys[t.id]; + } + const bool hasCurve = (keys && !keys->isEmpty()) || (!t.locationKeys.isEmpty()); + if (autoKeyLocation && currentFrame >= 0) { + const QPointF sampled = + (keys) + ? sampleLocation(*keys, currentFrame, t.originWorld, KeyInterpolation::Linear) + : sampleLocation(t.locationKeys, currentFrame, t.originWorld, KeyInterpolation::Linear); + if (keys) upsertKey(*keys, currentFrame, sampled + delta); + else upsertKey(t.locationKeys, currentFrame, sampled + delta); + } else if (!hasCurve) { + t.originWorld += delta; + } else if (currentFrame >= 0) { + const QPointF sampled = + (keys) + ? sampleLocation(*keys, currentFrame, t.originWorld, KeyInterpolation::Linear) + : sampleLocation(t.locationKeys, currentFrame, t.originWorld, KeyInterpolation::Linear); + if (keys) upsertKey(*keys, currentFrame, sampled + delta); + else upsertKey(t.locationKeys, currentFrame, sampled + delta); + } else { + t.originWorld += delta; + } + break; + } + if (!found) return false; + if (!applyTools(tools, true, QStringLiteral("移动工具"))) { + return false; + } + return writeIndexJson(); } bool ProjectWorkspace::setEntityLocationKey(const QString& id, int frame, const QPointF& originWorld) { if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) { return false; } - 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("插入关键帧(位置)")); + Project::AnimationClip* clip = activeClipOrNull(m_project); + if (!clip) return false; + auto& keys = clip->entityLocationKeys[id]; + upsertKey(keys, frame, originWorld); + return writeIndexJson(); } bool ProjectWorkspace::setEntityDepthScaleKey(const QString& id, int frame, double value01) { @@ -1311,16 +2234,11 @@ bool ProjectWorkspace::setEntityUserScaleKey(const QString& id, int frame, doubl 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("插入关键帧(缩放)")); + Project::AnimationClip* clip = activeClipOrNull(m_project); + if (!clip) return false; + auto& keys = clip->entityUserScaleKeys[id]; + upsertKey(keys, frame, v); + return writeIndexJson(); } bool ProjectWorkspace::setEntityImageFrame(const QString& id, int frame, const QImage& image, QString* outRelPath) { @@ -1332,18 +2250,11 @@ bool ProjectWorkspace::setEntityImageFrame(const QString& id, int frame, const Q 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; - } + Project::AnimationClip* clip = activeClipOrNull(m_project); + if (!clip) return false; + auto& frames = clip->entityImageFrames[id]; + upsertFrame(frames, frame, rel); + if (!writeIndexJson()) return false; if (outRelPath) *outRelPath = rel; return true; } @@ -1416,21 +2327,14 @@ 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("删除关键帧(位置)")); + Project::AnimationClip* clip = activeClipOrNull(m_project); + if (!clip) return false; + if (!clip->entityLocationKeys.contains(id)) return false; + auto keys = clip->entityLocationKeys.value(id); + const bool removed = removeLocationKeyAtFrame(keys, frame); + if (!removed) return false; + clip->entityLocationKeys.insert(id, keys); + return writeIndexJson(); } bool ProjectWorkspace::removeEntityDepthScaleKey(const QString& id, int frame) { @@ -1458,42 +2362,28 @@ 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("删除关键帧(缩放)")); + Project::AnimationClip* clip = activeClipOrNull(m_project); + if (!clip) return false; + if (!clip->entityUserScaleKeys.contains(id)) return false; + auto keys = clip->entityUserScaleKeys.value(id); + const bool removed = removeUserScaleKeyAtFrame(keys, frame); + if (!removed) return false; + clip->entityUserScaleKeys.insert(id, keys); + return writeIndexJson(); } bool ProjectWorkspace::removeEntityImageFrame(const QString& id, int frame) { if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) { return false; } - 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("删除关键帧(图像)")); + Project::AnimationClip* clip = activeClipOrNull(m_project); + if (!clip) return false; + if (!clip->entityImageFrames.contains(id)) return false; + auto keys = clip->entityImageFrames.value(id); + const bool removed = removeImageKeyAtFrame(keys, frame); + if (!removed) return false; + clip->entityImageFrames.insert(id, keys); + return writeIndexJson(); } QString ProjectWorkspace::copyIntoAssetsAsBackground(const QString& sourceFilePath, diff --git a/client/core/workspace/ProjectWorkspace.h b/client/core/workspace/ProjectWorkspace.h index c497a5e..df83b9c 100644 --- a/client/core/workspace/ProjectWorkspace.h +++ b/client/core/workspace/ProjectWorkspace.h @@ -16,7 +16,7 @@ public: static constexpr const char* kProjectIndexFileName = "project.json"; static constexpr const char* kAssetsDirName = "assets"; // 写入 project.json 的 version 字段;仍可读 version 1(内嵌实体 + 可选 .anim)。 - static constexpr int kProjectIndexFormatVersion = 2; + static constexpr int kProjectIndexFormatVersion = 3; ProjectWorkspace() = default; @@ -47,10 +47,17 @@ public: // 写入 project.json 的 name 字段(可 undo) bool setProjectTitle(const QString& title); + // 时间轴范围(写入 project.json,可 undo)。start 必须 >=0;end 会被归一化为 >= start + bool setProjectFrameRange(int start, int end); + // 自动扩展 end(用于“无限延伸”):仅在 end 变大时写盘;recordHistory=false 时不占用 undo 栈 + bool ensureProjectFrameEndAtLeast(int end, bool recordHistory = false); Project& project() { return m_project; } const Project& project() const { return m_project; } + // 仅写盘(project.json + payload 同步)。动画 UI 直接编辑 Project 后可调用此函数持久化。 + bool save(); + // 历史操作(最多 30 步),类似 Blender:维护 undo/redo 栈 bool canUndo() const; bool canRedo() const; @@ -74,11 +81,27 @@ public: bool saveDepthMapPngBytes(const QByteArray& pngBytes, QString* outError = nullptr); const QVector& entities() const { return m_project.entities(); } + const QVector& tools() const { return m_project.tools(); } + + bool addTool(const Project::Tool& tool); + bool setToolVisible(const QString& id, bool on); + bool setToolText(const QString& id, const QString& text); + bool setToolBubblePointerT01(const QString& id, double t01); + bool setToolFontPx(const QString& id, int fontPx); + bool setToolAlign(const QString& id, core::Project::Tool::TextAlign align); + bool setToolVisibilityKey(const QString& id, int frame, bool visible); + bool removeToolVisibilityKey(const QString& id, int frame); + bool setToolParent(const QString& id, const QString& parentId, const QPointF& parentOffsetWorld); + 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 setEntityVisibilityKey(const QString& id, int frame, bool visible); + bool removeEntityVisibilityKey(const QString& id, int frame); bool setEntityDisplayName(const QString& id, const QString& displayName); /// keyframeAtFrame >= 0 时同时写入该帧的 userScale 关键帧(与画布 sampleUserScale 一致) bool setEntityUserScale(const QString& id, double userScale, int keyframeAtFrame = -1); + bool setEntityIgnoreDistanceScale(const QString& id, bool on); + bool setEntityParent(const QString& id, const QString& parentId, const QPointF& parentOffsetWorld); // 将多边形质心平移到 targetCentroidWorld(整体平移);sTotal 须与画布一致 bool moveEntityCentroidTo(const QString& id, int frame, const QPointF& targetCentroidWorld, double sTotal, bool autoKeyLocation); @@ -119,17 +142,25 @@ private: static QJsonObject entityToJson(const Project::Entity& e); static bool entityFromJsonV1(const QJsonObject& o, Project::Entity& out); static bool entityStubFromJsonV2(const QJsonObject& o, Project::Entity& out); + static QJsonObject toolToJson(const Project::Tool& t); + static bool toolFromJsonV2(const QJsonObject& o, Project::Tool& out); struct Operation { - enum class Type { ImportBackground, SetEntities, SetProjectTitle }; + enum class Type { ImportBackground, SetEntities, SetTools, SetProjectTitle, SetProjectFrameRange }; Type type {Type::ImportBackground}; QString label; QString beforeBackgroundPath; QString afterBackgroundPath; QVector beforeEntities; QVector afterEntities; + QVector beforeTools; + QVector afterTools; QString beforeProjectTitle; QString afterProjectTitle; + int beforeFrameStart = 0; + int afterFrameStart = 0; + int beforeFrameEnd = 600; + int afterFrameEnd = 600; }; static constexpr int kMaxHistorySteps = 30; @@ -137,6 +168,7 @@ private: bool applyBackgroundPath(const QString& relativePath, bool recordHistory, const QString& label); bool applyEntities(const QVector& entities, bool recordHistory, const QString& label); + bool applyTools(const QVector& tools, bool recordHistory, const QString& label); QString copyIntoAssetsAsBackground(const QString& sourceFilePath, const QRect& cropRectInSourceImage); bool writeDepthMap(const QImage& depth8); bool writeDepthMapBytes(const QByteArray& pngBytes); diff --git a/client/gui/CMakeLists.txt b/client/gui/CMakeLists.txt index a9d4c44..7c84fc9 100644 --- a/client/gui/CMakeLists.txt +++ b/client/gui/CMakeLists.txt @@ -4,6 +4,7 @@ set(GUI_ROOT ${CMAKE_CURRENT_SOURCE_DIR}) set(GUI_SOURCES ${GUI_ROOT}/app/main.cpp ${GUI_ROOT}/main_window/MainWindow.cpp + ${GUI_ROOT}/library/ResourceLibraryDock.cpp ${GUI_ROOT}/widgets/LongPressSwitchToolButton.cpp ${GUI_ROOT}/widgets/ToolOptionPopup.cpp ${GUI_ROOT}/main_window/RecentProjectHistory.cpp @@ -18,11 +19,13 @@ set(GUI_SOURCES ${GUI_ROOT}/params/ParamControls.cpp ${GUI_ROOT}/props/BackgroundPropertySection.cpp ${GUI_ROOT}/props/EntityPropertySection.cpp + ${GUI_ROOT}/props/ToolPropertySection.cpp ${GUI_ROOT}/timeline/TimelineWidget.cpp ) set(GUI_HEADERS ${GUI_ROOT}/main_window/MainWindow.h + ${GUI_ROOT}/library/ResourceLibraryDock.h ${GUI_ROOT}/widgets/LongPressSwitchToolButton.h ${GUI_ROOT}/widgets/ToolOptionPopup.h ${GUI_ROOT}/main_window/RecentProjectHistory.h @@ -37,6 +40,7 @@ set(GUI_HEADERS ${GUI_ROOT}/params/ParamControls.h ${GUI_ROOT}/props/BackgroundPropertySection.h ${GUI_ROOT}/props/EntityPropertySection.h + ${GUI_ROOT}/props/ToolPropertySection.h ${GUI_ROOT}/props/PropertySectionWidget.h ${GUI_ROOT}/timeline/TimelineWidget.h ) diff --git a/client/gui/editor/EditorCanvas.cpp b/client/gui/editor/EditorCanvas.cpp index 5e9ddad..4846f10 100644 --- a/client/gui/editor/EditorCanvas.cpp +++ b/client/gui/editor/EditorCanvas.cpp @@ -17,10 +17,19 @@ #include #include #include +#include +#include +#include +#include +#include +#include #include #include #include +#include "core/library/EntityJson.h" +#include "core/library/ToolJson.h" + namespace { constexpr int kSamCropMargin = 32; @@ -275,6 +284,37 @@ GizmoHit hitTestGizmo(const QPointF& mouseView, const QPointF& originView) { return {}; } +struct BubbleLayoutWorld { + QPainterPath path; + QRectF bodyRect; +}; + +// originWorld = 朝下三角形尖端;滑块改变主体水平位置,使「平直底边」上 t01 对应点始终在尖端正上方(三角竖直、与主体一体平移) +static BubbleLayoutWorld bubbleLayoutWorld(const core::Project::Tool& tool) { + const QPointF tip = tool.originWorld; + const qreal w = 220.0; + const qreal h = 110.0; + const qreal rx = 16.0; + const qreal arrowH = 22.0; + const double t01 = std::clamp(tool.bubblePointerT01, 0.0, 1.0); + const qreal spanFlat = std::max(w - 2.0 * rx, 1.0); + const qreal bodyLeft = tip.x() - rx - static_cast(t01) * spanFlat; + const QRectF body(bodyLeft, tip.y() - (h + arrowH), w, h); + const qreal halfTri = 14.0; + const qreal baseCx = tip.x(); + + QPainterPath path; + path.addRoundedRect(body, rx, rx); + QPolygonF tri; + tri << QPointF(baseCx - halfTri, body.bottom()) << QPointF(baseCx + halfTri, body.bottom()) << QPointF(tip.x(), tip.y()); + path.addPolygon(tri); + return BubbleLayoutWorld{path, body}; +} + +static QPainterPath bubblePathWorld(const core::Project::Tool& tool) { + return bubbleLayoutWorld(tool).path; +} + } // namespace EditorCanvas::EditorCanvas(QWidget* parent) @@ -283,6 +323,7 @@ EditorCanvas::EditorCanvas(QWidget* parent) setMinimumSize(480, 320); setFocusPolicy(Qt::StrongFocus); setMouseTracking(true); + setAcceptDrops(true); m_previewEmitTimer.start(); @@ -297,6 +338,129 @@ EditorCanvas::EditorCanvas(QWidget* parent) updateCursor(); } +void EditorCanvas::dragEnterEvent(QDragEnterEvent* e) { + if (!e || !e->mimeData()) { + return; + } + if (e->mimeData()->hasFormat(QStringLiteral("application/x-hfut-resource+json"))) { + e->acceptProposedAction(); + return; + } + QWidget::dragEnterEvent(e); +} + +void EditorCanvas::dragMoveEvent(QDragMoveEvent* e) { + if (!e || !e->mimeData()) { + return; + } + if (e->mimeData()->hasFormat(QStringLiteral("application/x-hfut-resource+json"))) { + e->acceptProposedAction(); + return; + } + QWidget::dragMoveEvent(e); +} + +void EditorCanvas::dropEvent(QDropEvent* e) { + if (!e || !e->mimeData()) { + QWidget::dropEvent(e); + return; + } + if (!e->mimeData()->hasFormat(QStringLiteral("application/x-hfut-resource+json"))) { + QWidget::dropEvent(e); + return; + } + const QByteArray bytes = e->mimeData()->data(QStringLiteral("application/x-hfut-resource+json")); + const auto doc = QJsonDocument::fromJson(bytes); + if (!doc.isObject()) { + e->ignore(); + return; + } + const QJsonObject root = doc.object(); + const QString kind = root.value(QStringLiteral("kind")).toString(QStringLiteral("entity")); + + const QPointF dropWorld = viewToWorld(e->position()); + if (kind == QStringLiteral("tool")) { + if (!root.value(QStringLiteral("tool")).isObject()) { + e->ignore(); + return; + } + core::Project::Tool t; + if (!core::library::toolFromJson(root.value(QStringLiteral("tool")).toObject(), t)) { + e->ignore(); + return; + } + // 让主窗口分配 id,避免冲突 + t.id.clear(); + t.parentId.clear(); + t.parentOffsetWorld = QPointF(); + t.originWorld = dropWorld; + emit requestAddTool(t); + e->acceptProposedAction(); + return; + } + + if (!root.value(QStringLiteral("entity")).isObject()) { + e->ignore(); + return; + } + core::Project::Entity ent; + if (!core::library::entityFromJson(root.value(QStringLiteral("entity")).toObject(), ent)) { + e->ignore(); + return; + } + + // 让主窗口分配 id,避免资源 id 与工程内冲突 + ent.id.clear(); + ent.imagePath.clear(); + ent.entityPayloadPath.clear(); + ent.legacyAnimSidecarPath.clear(); + + ent.originWorld = dropWorld; + + // 默认把贴图左上角放到 originWorld + offset + QPointF imageOffset(-128, -128); + if (root.value(QStringLiteral("imageOffsetFromOrigin")).isArray()) { + const QJsonArray a = root.value(QStringLiteral("imageOffsetFromOrigin")).toArray(); + if (a.size() >= 2) { + imageOffset = QPointF(a.at(0).toDouble(), a.at(1).toDouble()); + } + } + ent.imageTopLeftWorld = ent.originWorld + imageOffset; + + // 生成占位贴图(未来可替换为真实资源图片) + QSize imgSize(256, 256); + if (root.value(QStringLiteral("imageSize")).isArray()) { + const QJsonArray a = root.value(QStringLiteral("imageSize")).toArray(); + if (a.size() >= 2) { + imgSize = QSize(a.at(0).toInt(256), a.at(1).toInt(256)); + } + } + QColor accent(80, 160, 255); + if (root.value(QStringLiteral("accent")).isArray()) { + const QJsonArray a = root.value(QStringLiteral("accent")).toArray(); + if (a.size() >= 4) { + accent = QColor(a.at(0).toInt(80), a.at(1).toInt(160), a.at(2).toInt(255), a.at(3).toInt(255)); + } + } + if (!imgSize.isValid()) { + imgSize = QSize(256, 256); + } + QImage img(imgSize, QImage::Format_ARGB32_Premultiplied); + img.fill(Qt::transparent); + { + QPainter p(&img); + p.setRenderHint(QPainter::Antialiasing, true); + QRectF rr(QPointF(0, 0), QSizeF(imgSize)); + rr = rr.adjusted(6, 6, -6, -6); + p.setPen(QPen(QColor(0, 0, 0, 60), 2)); + p.setBrush(QBrush(accent)); + p.drawRoundedRect(rr, 18, 18); + } + + emit requestAddEntity(ent, img); + e->acceptProposedAction(); +} + void EditorCanvas::setPresentationPreviewMode(bool on) { if (m_presentationPreviewMode == on) { return; @@ -328,7 +492,9 @@ void EditorCanvas::setPresentationPreviewMode(bool on) { update(); } -void EditorCanvas::setEntities(const QVector& entities, const QString& projectDirAbs) { +void EditorCanvas::setEntities(const QVector& entities, + const QVector& opacities01, + const QString& projectDirAbs) { const QString prevSelectedId = (m_selectedEntity >= 0 && m_selectedEntity < m_entities.size()) ? m_entities[m_selectedEntity].id : QString(); @@ -344,16 +510,19 @@ void EditorCanvas::setEntities(const QVector& entities, c } } - for (const auto& e : entities) { + const qsizetype nEnt = entities.size(); + for (qsizetype iEnt = 0; iEnt < nEnt; ++iEnt) { + const auto& e = entities[iEnt]; Entity v; v.id = e.id; - v.hiddenInEditMode = !m_presentationPreviewMode && !e.visible; - using core::KeyInterpolation; - const QPointF originWorld = - core::sampleLocation(e.locationKeys, m_currentFrame, e.originWorld, KeyInterpolation::Linear); + v.opacity = (iEnt < opacities01.size()) ? std::clamp(opacities01[iEnt], 0.0, 1.0) : 1.0; + // 注意:MainWindow 传入的是“按当前帧求值后的实体”(包含父子跟随与曲线采样)。 + // 这里必须直接使用 e.originWorld,不能再对 locationKeys 做二次采样,否则父子实体会在刷新时复位/跳变。 + const QPointF originWorld = e.originWorld; v.animatedOriginWorld = originWorld; v.cutoutPolygonWorld = e.cutoutPolygonWorld; v.distanceScaleCalibMult = e.distanceScaleCalibMult; + v.ignoreDistanceScale = e.ignoreDistanceScale; // 逐帧自动算 z:使用实体多边形质心作为锚点采样深度(O(1)),避免卡顿 QVector polyTmp; @@ -370,7 +539,7 @@ void EditorCanvas::setEntities(const QVector& entities, c const double userScaleAnimated = core::sampleUserScale(e.userScaleKeys, m_currentFrame, e.userScale, core::KeyInterpolation::Linear); v.userScale = std::max(1e-6, userScaleAnimated); - const double distScale = distanceScaleFromDepth01(ds01, e.distanceScaleCalibMult); + const double distScale = e.ignoreDistanceScale ? 1.0 : distanceScaleFromDepth01(ds01, e.distanceScaleCalibMult); const double scale = distScale * v.userScale; v.visualScale = scale; @@ -432,6 +601,41 @@ void EditorCanvas::setEntities(const QVector& entities, c update(); } +void EditorCanvas::setTools(const QVector& tools, const QVector& opacities01) { + m_tools.clear(); + const qsizetype n = tools.size(); + m_tools.reserve(n); + for (qsizetype i = 0; i < n; ++i) { + ToolView tv; + tv.tool = tools[i]; + tv.opacity = (i < opacities01.size()) ? std::clamp(opacities01[i], 0.0, 1.0) : 1.0; + m_tools.push_back(tv); + } + // 轨道变更:若当前选中的工具已不存在,则清除 + if (m_selectedTool >= 0) { + const QString selId = (m_selectedTool >= 0 && m_selectedTool < m_tools.size()) ? m_tools[m_selectedTool].tool.id : QString(); + if (!selId.isEmpty()) { + int hit = -1; + for (int i = 0; i < m_tools.size(); ++i) { + if (m_tools[i].tool.id == selId) { + hit = i; + break; + } + } + m_selectedTool = hit; + } else { + m_selectedTool = -1; + } + } + update(); +} + +void EditorCanvas::setTempHiddenIds(const QSet& entityIds, const QSet& toolIds) { + m_tempHiddenEntityIds = entityIds; + m_tempHiddenToolIds = toolIds; + update(); +} + void EditorCanvas::setCurrentFrame(int frame) { if (m_currentFrame == frame) { return; @@ -475,7 +679,7 @@ double EditorCanvas::selectedDistanceScaleMultiplier() const { return 1.0; } const auto& ent = m_entities[m_selectedEntity]; - return distanceScaleFromDepth01(ent.animatedDepthScale01, ent.distanceScaleCalibMult); + return ent.ignoreDistanceScale ? 1.0 : distanceScaleFromDepth01(ent.animatedDepthScale01, ent.distanceScaleCalibMult); } double EditorCanvas::selectedUserScale() const { @@ -844,7 +1048,10 @@ int EditorCanvas::hitTestEntity(const QPointF& worldPos) const { for (qsizetype i = m_entities.size(); i > 0; --i) { const qsizetype idx = i - 1; const auto& ent = m_entities[idx]; - if (ent.hiddenInEditMode) { + if (ent.opacity <= 0.001) { + continue; + } + if (!ent.id.isEmpty() && m_tempHiddenEntityIds.contains(ent.id)) { continue; } if (!ent.polygonWorld.isEmpty()) { @@ -908,7 +1115,7 @@ void EditorCanvas::paintEvent(QPaintEvent* e) { m_bgCutoutDirty = false; m_bgImageCutout = m_bgImage; for (const auto& ent : m_entities) { - if (!ent.cutoutPolygonWorld.isEmpty() && !m_bgImageCutout.isNull()) { + if (ent.opacity > 0.001 && !ent.cutoutPolygonWorld.isEmpty() && !m_bgImageCutout.isNull()) { entity_cutout::applyBlackFillToBackground(m_bgImageCutout, ent.cutoutPolygonWorld); } } @@ -969,7 +1176,10 @@ void EditorCanvas::paintEvent(QPaintEvent* e) { // 实体元素(占位):后续可替换为真实数据 for (int i = 0; i < m_entities.size(); ++i) { const auto& ent = m_entities[i]; - if (ent.hiddenInEditMode && i != m_selectedEntity) { + if (ent.opacity <= 0.001) { + continue; + } + if (!ent.id.isEmpty() && m_tempHiddenEntityIds.contains(ent.id)) { continue; } const bool isDragPreview = (!m_presentationPreviewMode && m_draggingEntity && m_dragPreviewActive && i == m_selectedEntity); @@ -1070,7 +1280,7 @@ void EditorCanvas::paintEvent(QPaintEvent* e) { p.drawRect(ent.rect.adjusted(-2, -2, 2, 2)); } } - if (m_presentationPreviewMode && !ent.hiddenInEditMode) { + if (m_presentationPreviewMode && ent.opacity > 0.001) { const bool showHover = (i == m_presHoverEntityIndex); const bool showFocus = (i == m_presFocusedEntityIndex); if (showHover || showFocus) { @@ -1099,6 +1309,54 @@ void EditorCanvas::paintEvent(QPaintEvent* e) { } } + // 工具:对话气泡(world 坐标),按 opacity 淡入淡出 + for (int i = 0; i < m_tools.size(); ++i) { + const auto& tv = m_tools[i]; + const auto& tool = tv.tool; + const double opacity = std::clamp(tv.opacity, 0.0, 1.0); + // tool.visible 仅表示“基础可见性”,动画可见性由 opacity(关键帧+淡入淡出)驱动 + if (opacity <= 0.001) { + continue; + } + if (!tool.id.isEmpty() && m_tempHiddenToolIds.contains(tool.id)) { + continue; + } + if (tool.type != core::Project::Tool::Type::Bubble) { + continue; + } + const BubbleLayoutWorld lay = bubbleLayoutWorld(tool); + const QPainterPath& path = lay.path; + const QRectF& body = lay.bodyRect; + + QColor fill(255, 255, 255, int(220 * opacity)); + QColor border(0, 0, 0, int(120 * opacity)); + p.setBrush(fill); + p.setPen(QPen(border, 1.2 / std::max(m_scale, 0.001))); + p.drawPath(path); + + // 文本 + if (!tool.text.trimmed().isEmpty()) { + p.setPen(QColor(10, 10, 10, int(230 * opacity))); + QFont f = p.font(); + f.setPixelSize(std::clamp(tool.fontPx, 8, 120)); + p.setFont(f); + QTextOption opt; + opt.setWrapMode(QTextOption::WrapAtWordBoundaryOrAnywhere); + if (tool.align == core::Project::Tool::TextAlign::Left) opt.setAlignment(Qt::AlignLeft | Qt::AlignVCenter); + else if (tool.align == core::Project::Tool::TextAlign::Right) opt.setAlignment(Qt::AlignRight | Qt::AlignVCenter); + else opt.setAlignment(Qt::AlignHCenter | Qt::AlignVCenter); + const QRectF textRect = body.adjusted(10, 8, -10, -8); + p.drawText(textRect, tool.text, opt); + } + + // 选中描边 + if (!m_presentationPreviewMode && i == m_selectedTool) { + p.setBrush(Qt::NoBrush); + p.setPen(QPen(QColor(80, 160, 255, 220), 2.0 / std::max(m_scale, 0.001))); + p.drawPath(path); + } + } + // 创建实体:手绘轨迹预览(world 坐标) if (!m_presentationPreviewMode && m_tool == Tool::CreateEntity && m_drawingEntity && m_strokeWorld.size() >= 2) { QPen pen(QColor(255, 120, 0, 220), 2.0 / std::max(m_scale, 0.001)); @@ -1321,6 +1579,28 @@ void EditorCanvas::mousePressEvent(QMouseEvent* e) { } if (m_tool == Tool::Move && e->button() == Qt::LeftButton) { + // 工具(气泡)优先命中:绘制在实体之后,交互也应优先 + for (qsizetype i = m_tools.size(); i > 0; --i) { + const qsizetype idx = i - 1; + const auto& tv = m_tools[idx]; + if (tv.opacity <= 0.001) continue; + if (!tv.tool.id.isEmpty() && m_tempHiddenToolIds.contains(tv.tool.id)) continue; + if (tv.tool.type != core::Project::Tool::Type::Bubble) continue; + const QPainterPath path = bubblePathWorld(tv.tool); + if (path.contains(worldPos)) { + m_selectedTool = static_cast(idx); + m_selectedEntity = -1; + m_draggingTool = true; + m_dragMode = DragMode::Free; + m_toolDragOffsetOriginWorld = worldPos - m_tools[m_selectedTool].tool.originWorld; + m_toolDragStartOriginWorld = m_tools[m_selectedTool].tool.originWorld; + emit selectedEntityChanged(false, QString(), 0, QPointF()); + emit selectedToolChanged(true, m_tools[m_selectedTool].tool.id, m_tools[m_selectedTool].tool.originWorld); + update(); + return; + } + } + // 优先:若已选中实体,且点在 gizmo 手柄上,则开启轴约束拖动 if (m_selectedEntity >= 0 && m_selectedEntity < m_entities.size()) { const auto& ent = m_entities[m_selectedEntity]; @@ -1364,9 +1644,50 @@ void EditorCanvas::mousePressEvent(QMouseEvent* e) { } } + // 若已选中实体:点击命中该实体本体时,优先拖动“已选中实体”。 + // 这对父子层级很重要:子实体可能被父实体遮挡,但用户在项目树中选中子实体后仍应可拖动它。 + if (m_selectedEntity >= 0 && m_selectedEntity < m_entities.size()) { + const auto& ent = m_entities[m_selectedEntity]; + bool hitSelected = false; + if (!ent.pathWorld.isEmpty()) { + hitSelected = ent.pathWorld.contains(worldPos); + } else if (!ent.polygonWorld.isEmpty()) { + hitSelected = entity_cutout::pathFromWorldPolygon(ent.polygonWorld).contains(worldPos); + } else { + hitSelected = ent.rect.contains(worldPos); + } + if (hitSelected) { + m_draggingEntity = true; + m_dragMode = DragMode::Free; + emit entityDragActiveChanged(true); + const QRectF r = ent.rect.isNull() && !ent.polygonWorld.isEmpty() + ? entity_cutout::pathFromWorldPolygon(ent.polygonWorld).boundingRect() + : ent.rect; + m_entities[m_selectedEntity].rect = r; + m_entityDragOffsetOriginWorld = worldPos - m_entities[m_selectedEntity].animatedOriginWorld; + m_entityDragStartAnimatedOrigin = m_entities[m_selectedEntity].animatedOriginWorld; + // drag preview baseline + m_dragPreviewActive = true; + m_dragDelta = QPointF(0, 0); + m_dragOriginBase = m_entities[m_selectedEntity].animatedOriginWorld; + m_dragRectBase = m_entities[m_selectedEntity].rect; + m_dragImageTopLeftBase = m_entities[m_selectedEntity].imageTopLeft; + m_dragScaleBase = std::max(1e-6, m_entities[m_selectedEntity].visualScale); + m_dragScaleRatio = 1.0; + m_dragPolyBase = m_entities[m_selectedEntity].polygonWorld; + m_dragPathBase = m_entities[m_selectedEntity].pathWorld; + m_dragCentroidBase = + m_dragPolyBase.isEmpty() ? m_dragRectBase.center() : entity_cutout::polygonCentroid(m_dragPolyBase); + update(); + return; + } + } + const int hit = hitTestEntity(worldPos); if (hit >= 0) { m_selectedEntity = hit; + m_selectedTool = -1; + m_draggingTool = false; m_draggingEntity = true; m_dragMode = DragMode::Free; emit entityDragActiveChanged(true); @@ -1391,13 +1712,17 @@ void EditorCanvas::mousePressEvent(QMouseEvent* e) { const QPointF origin = !m_entities[hit].polygonWorld.isEmpty() ? entity_cutout::polygonCentroid(m_entities[hit].polygonWorld) : m_entities[hit].rect.center(); emit selectedEntityChanged(true, m_entities[hit].id, m_entities[hit].depth, origin); + emit selectedToolChanged(false, QString(), QPointF()); update(); return; } m_selectedEntity = -1; m_draggingEntity = false; + m_selectedTool = -1; + m_draggingTool = false; m_dragMode = DragMode::None; emit selectedEntityChanged(false, QString(), 0, QPointF()); + emit selectedToolChanged(false, QString(), QPointF()); update(); } } @@ -1536,7 +1861,7 @@ void EditorCanvas::mouseMoveEvent(QMouseEvent* e) { const double ds01 = depthToScale01(depthZ); ent.animatedDepthScale01 = ds01; const double newScale = - distanceScaleFromDepth01(ds01, ent.distanceScaleCalibMult) * ent.userScale; + (ent.ignoreDistanceScale ? 1.0 : distanceScaleFromDepth01(ds01, ent.distanceScaleCalibMult)) * ent.userScale; ent.visualScale = newScale; if (m_dragPreviewActive) { m_dragScaleRatio = std::clamp(newScale / std::max(1e-6, m_dragScaleBase), 0.02, 50.0); @@ -1553,6 +1878,16 @@ void EditorCanvas::mouseMoveEvent(QMouseEvent* e) { return; } + if (m_draggingTool && m_selectedTool >= 0 && m_selectedTool < m_tools.size()) { + const QPointF worldPos = viewToWorld(cur); + const QPointF newOrigin = worldPos - m_toolDragOffsetOriginWorld; + QPointF delta = newOrigin - m_tools[m_selectedTool].tool.originWorld; + m_tools[m_selectedTool].tool.originWorld += delta; + emit selectedToolChanged(true, m_tools[m_selectedTool].tool.id, m_tools[m_selectedTool].tool.originWorld); + update(); + return; + } + // 平移画布 if (m_tool == Tool::Move || (e->buttons() & Qt::MiddleButton) || (m_presentationPreviewMode && (e->buttons() & Qt::LeftButton))) { @@ -1626,6 +1961,16 @@ void EditorCanvas::mouseReleaseEvent(QMouseEvent* e) { } } + if (m_draggingTool && m_selectedTool >= 0 && m_selectedTool < m_tools.size() && e->button() == Qt::LeftButton) { + const auto& tv = m_tools[m_selectedTool]; + const QPointF delta = tv.tool.originWorld - m_toolDragStartOriginWorld; + if (!tv.tool.id.isEmpty() && (!qFuzzyIsNull(delta.x()) || !qFuzzyIsNull(delta.y()))) { + emit requestMoveTool(tv.tool.id, delta); + } else if (!tv.tool.id.isEmpty()) { + emit selectedToolChanged(true, tv.tool.id, tv.tool.originWorld); + } + } + m_dragging = false; if (m_pendingDragging && e->button() == Qt::LeftButton) { m_pendingDragging = false; @@ -1636,6 +1981,7 @@ void EditorCanvas::mouseReleaseEvent(QMouseEvent* e) { emit entityDragActiveChanged(false); } m_draggingEntity = false; + m_draggingTool = false; m_dragPreviewActive = false; m_dragMode = DragMode::None; updateCursor(); diff --git a/client/gui/editor/EditorCanvas.h b/client/gui/editor/EditorCanvas.h index f5bb4df..53a8b40 100644 --- a/client/gui/editor/EditorCanvas.h +++ b/client/gui/editor/EditorCanvas.h @@ -14,6 +14,9 @@ #include class QKeyEvent; +class QDragEnterEvent; +class QDragMoveEvent; +class QDropEvent; class EditorCanvas final : public QWidget { Q_OBJECT @@ -70,7 +73,11 @@ public: /// 退出「点击实体放大」状态并平滑回到进入前的视图(预览模式) void clearPresentationEntityFocus(); - void setEntities(const QVector& entities, const QString& projectDirAbs); + void setEntities(const QVector& entities, + const QVector& opacities01, + const QString& projectDirAbs); + void setTools(const QVector& tools, const QVector& opacities01); + void setTempHiddenIds(const QSet& entityIds, const QSet& toolIds); void setCurrentFrame(int frame); int currentFrame() const { return m_currentFrame; } @@ -93,7 +100,9 @@ signals: void hoveredWorldPosChanged(const QPointF& worldPos); void hoveredWorldPosDepthChanged(const QPointF& worldPos, int depthZ); void selectedEntityChanged(bool hasSelection, const QString& id, int depth, const QPointF& originWorld); + void selectedToolChanged(bool hasSelection, const QString& id, const QPointF& originWorld); void requestAddEntity(const core::Project::Entity& entity, const QImage& image); + void requestAddTool(const core::Project::Tool& tool); /// 创建实体:将裁剪 RGB、标记叠加层与 SAM 提示发往模型服务,由主窗口收 JSON 轮廓后再 addEntity。 void requestSamSegment( const QByteArray& cropRgbPng, @@ -105,6 +114,7 @@ signals: /// 待确认实体:用户按回车/点击空白处确认后触发(由主窗口弹窗命名并落盘)。 void requestFinalizePendingEntity(const QVector& polyWorld); void requestMoveEntity(const QString& id, const QPointF& delta); + void requestMoveTool(const QString& id, const QPointF& delta); void entityDragActiveChanged(bool on); void selectedEntityPreviewChanged(const QString& id, int depth, const QPointF& originWorld); /// 预览模式下点击实体:anchorView 为实体质心在视图中的位置,用于摆放介绍浮层 @@ -120,6 +130,9 @@ protected: void mouseReleaseEvent(QMouseEvent* e) override; void wheelEvent(QWheelEvent* e) override; void keyPressEvent(QKeyEvent* e) override; + void dragEnterEvent(QDragEnterEvent* e) override; + void dragMoveEvent(QDragMoveEvent* e) override; + void dropEvent(QDropEvent* e) override; private: void ensurePixmapLoaded() const; @@ -154,13 +167,19 @@ private: double visualScale = 1.0; // 实体在 world 坐标下的缩放(用于贴图绘制) double userScale = 1.0; // 与深度距离缩放相乘 double distanceScaleCalibMult = 0.0; // 与 Project::Entity 一致;0=未校准 + bool ignoreDistanceScale = false; QPointF animatedOriginWorld; double animatedDepthScale01 = 0.5; - // 编辑模式下实体被设为隐藏时:不响应点选且不绘制,除非当前选中(便于树选隐藏实体) - bool hiddenInEditMode = false; + double opacity = 1.0; // 0..1(由可见性轨道求值) }; int hitTestEntity(const QPointF& worldPos) const; +private: + struct ToolView { + core::Project::Tool tool; + double opacity = 1.0; // 0..1 + }; + private: QString m_bgAbsPath; bool m_backgroundVisible = true; @@ -210,11 +229,18 @@ private: qint64 m_lastPreviewEmitMs = 0; qint64 m_lastDepthScaleRecalcMs = 0; int m_selectedEntity = -1; + int m_selectedTool = -1; + bool m_draggingTool = false; + QPointF m_toolDragOffsetOriginWorld; + QPointF m_toolDragStartOriginWorld; DragMode m_dragMode = DragMode::None; QPointF m_dragStartMouseWorld; QVector m_entities; + QVector m_tools; + QSet m_tempHiddenEntityIds; + QSet m_tempHiddenToolIds; QVector m_strokeWorld; int m_currentFrame = 0; diff --git a/client/gui/library/ResourceLibraryDock.cpp b/client/gui/library/ResourceLibraryDock.cpp new file mode 100644 index 0000000..92b4efd --- /dev/null +++ b/client/gui/library/ResourceLibraryDock.cpp @@ -0,0 +1,264 @@ +#include "library/ResourceLibraryDock.h" + +#include "core/library/EntityJson.h" +#include "core/library/ToolJson.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include + +namespace gui { + +namespace { + +constexpr const char* kMimeType = "application/x-hfut-resource+json"; + +QPixmap makePreviewPixmap(const core::library::LibraryResource& r) { + const QSize sz = r.imageSize.isValid() ? r.imageSize : QSize(200, 200); + QImage img(sz, QImage::Format_ARGB32_Premultiplied); + img.fill(Qt::transparent); + + QPainter p(&img); + p.setRenderHint(QPainter::Antialiasing, true); + + QRectF rr(QPointF(0, 0), QSizeF(sz)); + rr = rr.adjusted(10, 10, -10, -10); + p.setPen(QPen(QColor(0, 0, 0, 50), 2)); + p.setBrush(QBrush(r.accent)); + p.drawRoundedRect(rr, 18, 18); + + p.setPen(QColor(255, 255, 255, 240)); + QFont f = p.font(); + f.setPointSize(std::max(9, f.pointSize())); + f.setBold(true); + p.setFont(f); + p.drawText(rr.adjusted(12, 12, -12, -12), Qt::AlignLeft | Qt::AlignTop, r.displayName); + + return QPixmap::fromImage(img); +} + +class ResourceListWidget final : public QListWidget { +public: + explicit ResourceListWidget(QWidget* parent = nullptr) : QListWidget(parent) { + setSelectionMode(QAbstractItemView::SingleSelection); + setDragEnabled(true); + setDragDropMode(QAbstractItemView::DragOnly); + setDefaultDropAction(Qt::CopyAction); + setUniformItemSizes(false); + setSpacing(6); + setViewMode(QListView::IconMode); + setResizeMode(QListView::Adjust); + // 注意:Static 会让拖拽“完全没反应”(不会启动 drag)。 + // 这里用 Free,仍然不会在列表内部重排(DragOnly),但允许拖出到画布。 + setMovement(QListView::Free); + setIconSize(QSize(96, 96)); + setWordWrap(true); + // “一行两个资源”:用网格尺寸 + 固定列宽近似实现 + setGridSize(QSize(180, 140)); + } + + void setResources(const QVector& r) { m_resources = r; } + +protected: + void startDrag(Qt::DropActions supportedActions) override { + Q_UNUSED(supportedActions); + auto* item = currentItem(); + if (!item) { + return; + } + bool ok = false; + const int idx = item->data(Qt::UserRole).toInt(&ok); + if (!ok || idx < 0 || idx >= m_resources.size()) { + return; + } + const auto& res = m_resources[idx]; + + QJsonObject payload; + payload.insert(QStringLiteral("resourceId"), res.resourceId); + payload.insert(QStringLiteral("displayName"), res.displayName); + payload.insert(QStringLiteral("kind"), res.kind == core::library::LibraryResource::Kind::Tool ? QStringLiteral("tool") + : QStringLiteral("entity")); + if (res.kind == core::library::LibraryResource::Kind::Tool) { + payload.insert(QStringLiteral("tool"), core::library::toolToJson(res.toolTemplate)); + } else { + payload.insert(QStringLiteral("entity"), core::library::entityToJson(res.entityTemplate)); + } + { + QJsonArray a; + a.append(res.imageSize.width()); + a.append(res.imageSize.height()); + payload.insert(QStringLiteral("imageSize"), a); + } + { + QJsonArray a; + a.append(res.accent.red()); + a.append(res.accent.green()); + a.append(res.accent.blue()); + a.append(res.accent.alpha()); + payload.insert(QStringLiteral("accent"), a); + } + { + QJsonArray a; + a.append(res.imageOffsetFromOrigin.x()); + a.append(res.imageOffsetFromOrigin.y()); + payload.insert(QStringLiteral("imageOffsetFromOrigin"), a); + } + + const QByteArray bytes = QJsonDocument(payload).toJson(QJsonDocument::Compact); + auto* mime = new QMimeData(); + mime->setData(QString::fromUtf8(kMimeType), bytes); + + auto* drag = new QDrag(this); + drag->setMimeData(mime); + drag->setPixmap(item->icon().pixmap(iconSize())); + drag->setHotSpot(QPoint(iconSize().width() / 2, iconSize().height() / 2)); + drag->exec(Qt::CopyAction); + } + +private: + QVector m_resources; +}; + +} // namespace + +ResourceLibraryDock::ResourceLibraryDock(QWidget* parent) + : QDockWidget(QStringLiteral("资源库"), parent) { + setAllowedAreas(Qt::LeftDockWidgetArea | Qt::RightDockWidgetArea); + setFeatures(QDockWidget::DockWidgetMovable | QDockWidget::DockWidgetFloatable | QDockWidget::DockWidgetClosable); + + auto* shell = new QWidget(this); + auto* layout = new QVBoxLayout(shell); + layout->setContentsMargins(8, 8, 8, 8); + layout->setSpacing(6); + + m_list = new ResourceListWidget(shell); + layout->addWidget(m_list, 1); + if (m_list && m_list->verticalScrollBar()) { + connect(m_list->verticalScrollBar(), &QScrollBar::valueChanged, this, [this](int value) { + if (!m_list || !m_list->verticalScrollBar()) return; + if (!m_onlineProvider || !m_onlineBound || m_onlineLoading || m_onlineExhausted) return; + const int maxV = m_list->verticalScrollBar()->maximum(); + // 接近底部(阈值 80px):自动加载下一页 + if (value >= maxV - 80) { + fetchOnlineNextPage(); + } + }); + } + + setWidget(shell); +} + +void ResourceLibraryDock::setProviders(core::library::ResourceLibraryProvider* localProvider, + core::library::ResourceLibraryProvider* onlineProvider) { + if (m_localProvider == localProvider && m_onlineProvider == onlineProvider) { + return; + } + if (m_localProvider) disconnect(m_localProvider, nullptr, this, nullptr); + if (m_onlineProvider) disconnect(m_onlineProvider, nullptr, this, nullptr); + + m_localProvider = localProvider; + m_onlineProvider = onlineProvider; + m_onlineExhausted = false; + + auto bind = [&](core::library::ResourceLibraryProvider* p, + QVector& store, + bool isOnline) { + if (!p) { + store.clear(); + return; + } + connect(p, &core::library::ResourceLibraryProvider::resourcesReady, + this, [this, &store, isOnline](const QVector& res) { + if (!isOnline) { + store = res; + } else { + m_onlineLoading = false; + if (res.isEmpty()) { + m_onlineExhausted = true; + return; + } + store += res; + } + rebuildCombinedList(); + }); + connect(p, &core::library::ResourceLibraryProvider::resourcesFailed, + this, [this, isOnline](const QString& err) { + Q_UNUSED(err); + if (isOnline) { + m_onlineLoading = false; + // 在线错误默认不打扰 UI;下次滚动到底仍可重试(除非 provider 选择返回空 ready)。 + } + }); + }; + + bind(m_localProvider, m_localResources, false); + bind(m_onlineProvider, m_onlineResources, true); + + if (m_localProvider) { + m_localProvider->fetchResourcesAsync(); + } else { + m_localResources.clear(); + } + rebuildCombinedList(); + ensureOnlineLoaded(); +} + +void ResourceLibraryDock::ensureOnlineLoaded() { + if (m_onlineBound) { + return; + } + m_onlineBound = true; + if (!m_onlineProvider) { + return; + } + fetchOnlineNextPage(); +} + +void ResourceLibraryDock::fetchOnlineNextPage() { + if (!m_onlineProvider) { + return; + } + if (m_onlineLoading) { + return; + } + if (m_onlineExhausted) { + return; + } + m_onlineLoading = true; + // provider 当前仍是预留实现;这里保持“分页”调用语义(多次调用 fetchResourcesAsync) + m_onlineProvider->fetchResourcesAsync(); +} + +void ResourceLibraryDock::rebuildCombinedList() { + if (!m_list) return; + auto* list = static_cast(m_list); + list->clear(); + QVector combined; + combined.reserve(m_localResources.size() + m_onlineResources.size()); + combined += m_localResources; + combined += m_onlineResources; + list->setResources(combined); + for (int i = 0; i < combined.size(); ++i) { + const auto& r = combined[i]; + auto* it = new QListWidgetItem(); + it->setFlags(it->flags() | Qt::ItemIsDragEnabled); + it->setText(r.displayName); + it->setIcon(QIcon(makePreviewPixmap(r))); + it->setData(Qt::UserRole, i); + it->setToolTip(r.resourceId); + list->addItem(it); + } +} + +} // namespace gui + diff --git a/client/gui/library/ResourceLibraryDock.h b/client/gui/library/ResourceLibraryDock.h new file mode 100644 index 0000000..9f47400 --- /dev/null +++ b/client/gui/library/ResourceLibraryDock.h @@ -0,0 +1,38 @@ +#pragma once + +#include "core/library/ResourceLibraryProvider.h" + +#include + +class QListWidget; + +namespace gui { + +class ResourceLibraryDock final : public QDockWidget { + Q_OBJECT +public: + explicit ResourceLibraryDock(QWidget* parent = nullptr); + ~ResourceLibraryDock() override = default; + + void setProviders(core::library::ResourceLibraryProvider* localProvider, + core::library::ResourceLibraryProvider* onlineProvider); + +private: + void rebuildCombinedList(); + void ensureOnlineLoaded(); + void fetchOnlineNextPage(); + +private: + core::library::ResourceLibraryProvider* m_localProvider = nullptr; + core::library::ResourceLibraryProvider* m_onlineProvider = nullptr; + QVector m_localResources; + QVector m_onlineResources; + + QListWidget* m_list = nullptr; + bool m_onlineBound = false; + bool m_onlineLoading = false; + bool m_onlineExhausted = false; +}; + +} // namespace gui + diff --git a/client/gui/main_window/MainWindow.cpp b/client/gui/main_window/MainWindow.cpp index fd87318..8897fba 100644 --- a/client/gui/main_window/MainWindow.cpp +++ b/client/gui/main_window/MainWindow.cpp @@ -11,9 +11,14 @@ #include "params/ParamControls.h" #include "props/BackgroundPropertySection.h" #include "props/EntityPropertySection.h" +#include "props/ToolPropertySection.h" #include "timeline/TimelineWidget.h" #include "dialogs/FrameAnimationDialog.h" #include "dialogs/EntityIntroPopup.h" +#include "library/ResourceLibraryDock.h" +#include "core/library/ResourceLibraryProvider.h" +#include "core/library/OnlineResourceLibraryProvider.h" +#include "core/eval/ProjectEvaluator.h" #include #include @@ -24,6 +29,7 @@ #include #include #include +#include #include #include #include @@ -38,6 +44,10 @@ #include #include #include +#include +#include +#include +#include #include #include #include @@ -278,6 +288,106 @@ protected: } // namespace +namespace { + +constexpr const char* kMimeProjectNodeJson = "application/x-hfut-project-node+json"; + +class ProjectTreeWidget final : public QTreeWidget { +public: + explicit ProjectTreeWidget(QWidget* parent = nullptr) : QTreeWidget(parent) { + setDragEnabled(true); + setAcceptDrops(true); + setDropIndicatorShown(true); + setDragDropMode(QAbstractItemView::DragDrop); + setDefaultDropAction(Qt::MoveAction); + } + + // parentKind / parentId 为空表示“解除父子关系” + std::function + onNodeParentDropRequested; + +protected: + void startDrag(Qt::DropActions supportedActions) override { + Q_UNUSED(supportedActions); + auto* item = currentItem(); + if (!item) { + return; + } + const QString kind = item->data(0, Qt::UserRole).toString(); + if (kind != QStringLiteral("entity") && kind != QStringLiteral("tool")) { + return; + } + const QString id = item->data(0, Qt::UserRole + 1).toString(); + if (id.isEmpty()) { + return; + } + auto* mime = new QMimeData(); + QJsonObject o; + o.insert(QStringLiteral("kind"), kind); + o.insert(QStringLiteral("id"), id); + mime->setData(QString::fromUtf8(kMimeProjectNodeJson), QJsonDocument(o).toJson(QJsonDocument::Compact)); + auto* drag = new QDrag(this); + drag->setMimeData(mime); + drag->exec(Qt::MoveAction); + } + + void dragEnterEvent(QDragEnterEvent* e) override { + if (e && e->mimeData() && e->mimeData()->hasFormat(QString::fromUtf8(kMimeProjectNodeJson))) { + e->acceptProposedAction(); + return; + } + QTreeWidget::dragEnterEvent(e); + } + + void dragMoveEvent(QDragMoveEvent* e) override { + if (e && e->mimeData() && e->mimeData()->hasFormat(QString::fromUtf8(kMimeProjectNodeJson))) { + e->acceptProposedAction(); + return; + } + QTreeWidget::dragMoveEvent(e); + } + + void dropEvent(QDropEvent* e) override { + if (!e || !e->mimeData() || !e->mimeData()->hasFormat(QString::fromUtf8(kMimeProjectNodeJson))) { + QTreeWidget::dropEvent(e); + return; + } + const auto payload = e->mimeData()->data(QString::fromUtf8(kMimeProjectNodeJson)); + const QJsonDocument doc = QJsonDocument::fromJson(payload); + if (!doc.isObject()) { + e->ignore(); + return; + } + const QJsonObject o = doc.object(); + const QString childKind = o.value(QStringLiteral("kind")).toString(); + const QString childId = o.value(QStringLiteral("id")).toString(); + if (childId.isEmpty() || (childKind != QStringLiteral("entity") && childKind != QStringLiteral("tool"))) { + e->ignore(); + return; + } + QString parentKind; + QString parentId; + if (auto* it = itemAt(e->position().toPoint())) { + const QString kind = it->data(0, Qt::UserRole).toString(); + if (kind == QStringLiteral("entity") || kind == QStringLiteral("tool")) { + parentKind = kind; + parentId = it->data(0, Qt::UserRole + 1).toString(); + } + } + // 拖到空白处:解除父子关系 + if (!parentId.isEmpty() && childId == parentId && childKind == parentKind) { + e->ignore(); + return; + } + if (onNodeParentDropRequested) { + onNodeParentDropRequested(childKind, childId, parentKind, parentId); + } + e->acceptProposedAction(); + } +}; + +} // namespace + /// @brief MainWindow 类实现,负责构建和管理主界面,包括菜单、停靠窗口和属性面板。 MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) { @@ -289,6 +399,7 @@ MainWindow::MainWindow(QWidget* parent) createMenus(); createProjectTreeDock(); createTimelineDock(); + createResourceLibraryDock(); if (m_previewBtnPlay && m_previewBtnPause && m_btnPlay) { connect(m_previewBtnPlay, &QToolButton::clicked, this, [this]() { @@ -335,17 +446,12 @@ void MainWindow::createTimelineDock() { m_dockTimeline->setAllowedAreas(Qt::BottomDockWidgetArea | Qt::TopDockWidgetArea); m_dockTimeline->setFeatures(QDockWidget::DockWidgetMovable | QDockWidget::DockWidgetFloatable); - auto* dockContent = new QWidget(m_dockTimeline); - auto* outer = new QVBoxLayout(dockContent); - outer->setContentsMargins(10, 6, 10, 6); - outer->setSpacing(6); - - auto* bar = new QWidget(dockContent); + auto* bar = new QWidget(m_dockTimeline); bar->setObjectName(QStringLiteral("TimelineDockBar")); bar->setStyleSheet(QString::fromUtf8(kTimelineBarQss)); auto* layout = new QHBoxLayout(bar); - layout->setContentsMargins(0, 0, 0, 0); - layout->setSpacing(8); + layout->setContentsMargins(6, 4, 6, 4); + layout->setSpacing(6); m_btnPlay = new QToolButton(bar); m_btnPlay->setText(QStringLiteral("▶")); @@ -354,21 +460,14 @@ void MainWindow::createTimelineDock() { polishCompactToolButton(m_btnPlay, 34); layout->addWidget(m_btnPlay); - m_timeline = new TimelineWidget(bar); - m_timeline->setFrameRange(0, 600); - m_timeline->setCurrentFrame(0); - layout->addWidget(m_timeline, 1); + m_schemeSelector = new QComboBox(bar); + m_schemeSelector->setMinimumWidth(140); + m_schemeSelector->setToolTip(QStringLiteral("动画方案")); + layout->addWidget(m_schemeSelector); - m_frameLabel = new QLabel(QStringLiteral("0"), bar); - m_frameLabel->setMinimumWidth(40); - m_frameLabel->setAlignment(Qt::AlignCenter); - { - QFont f = m_frameLabel->font(); - f.setBold(true); - f.setPointSizeF(f.pointSizeF() + 0.5); - m_frameLabel->setFont(f); - } - layout->addWidget(m_frameLabel); + m_timeline = new TimelineWidget(bar); + m_timeline->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::MinimumExpanding); + layout->addWidget(m_timeline, 1); // 合并后的关键帧按钮:一次写入位置 + userScale auto* btnKeyCombined = new QToolButton(bar); @@ -377,12 +476,7 @@ void MainWindow::createTimelineDock() { polishCompactToolButton(btnKeyCombined, 34); layout->addWidget(btnKeyCombined); - outer->addWidget(bar); - - // 旧版 DopeSheet 去除,预留一个占位条,后续可在此添加区间选择或更紧凑的可视化 - outer->addStretch(1); - - m_dockTimeline->setWidget(dockContent); + m_dockTimeline->setWidget(bar); addDockWidget(Qt::BottomDockWidgetArea, m_dockTimeline); connect(m_dockTimeline, &QDockWidget::visibilityChanged, this, [this](bool visible) { @@ -398,39 +492,125 @@ void MainWindow::createTimelineDock() { if (!m_timeline || !m_workspace.isOpen()) { return; } - int v = m_timeline->currentFrame(); - if (v >= 600) { - v = 0; - } else { - ++v; - } - m_timeline->setCurrentFrame(v); + // 简化:无编排,globalFrame==localFrame,固定 600 帧循环 + m_currentFrame = (m_currentFrame + 1) % core::Project::kClipFixedFrames; + m_timeline->setCurrentFrame(m_currentFrame); }); connect(m_btnPlay, &QToolButton::toggled, this, &MainWindow::onTogglePlay); connect(m_timeline, &TimelineWidget::frameScrubbed, this, [this](int v) { // 轻量实时预览:只更新画布帧,不做 refreshEditorPage 的全量重建 - m_currentFrame = v; - if (m_frameLabel) { - m_frameLabel->setText(QString::number(v)); - } + m_currentFrame = std::clamp(v, 0, core::Project::kClipFixedFrames - 1); if (m_editorCanvas && m_workspace.isOpen()) { // 需要重新求值实体几何/贴图轨道,否则拖动实体与属性变更在非 0 帧会失效 m_timelineScrubbing = true; - m_editorCanvas->setCurrentFrame(v); - m_editorCanvas->setEntities(m_workspace.entities(), m_workspace.projectDir()); + m_editorCanvas->setCurrentFrame(m_currentFrame); + const core::eval::ResolvedProjectFrame rf = core::eval::evaluateAtFrame(m_workspace.project(), m_currentFrame, 10); + QVector ents; + QVector entOps; + ents.reserve(rf.entities.size()); + entOps.reserve(rf.entities.size()); + for (const auto& re : rf.entities) { + ents.push_back(re.entity); + entOps.push_back(re.opacity); + } + m_editorCanvas->setEntities(ents, entOps, m_workspace.projectDir()); + QVector tools; + QVector toolOps; + tools.reserve(rf.tools.size()); + toolOps.reserve(rf.tools.size()); + for (const auto& rt : rf.tools) { + tools.push_back(rt.tool); + toolOps.push_back(rt.opacity); + } + m_editorCanvas->setTools(tools, toolOps); + m_editorCanvas->setTempHiddenIds(m_tempHiddenEntityIds, m_tempHiddenToolIds); m_timelineScrubbing = false; } else if (m_editorCanvas) { - m_editorCanvas->setCurrentFrame(v); + m_editorCanvas->setCurrentFrame(m_currentFrame); } }); connect(m_timeline, &TimelineWidget::frameCommitted, this, [this](int v) { // 松手再做一次较重刷新(如果后续还有需要同步的 UI) - m_currentFrame = v; + m_currentFrame = std::clamp(v, 0, core::Project::kClipFixedFrames - 1); refreshEditorPage(); }); connect(btnKeyCombined, &QToolButton::clicked, this, &MainWindow::onInsertCombinedKey); + // 方案切换(下拉里包含“新建方案…”) + connect(m_schemeSelector, QOverload::of(&QComboBox::currentIndexChanged), this, [this](int idx) { + if (!m_workspace.isOpen() || !m_schemeSelector) return; + const QString schemeId = m_schemeSelector->itemData(idx).toString(); + // 特殊项:新建方案 + if (schemeId == QStringLiteral("__create__")) { + auto& proj = m_workspace.project(); + + auto nextId = [](const QString& prefix, const QStringList& existing) { + int n = 1; + while (existing.contains(prefix + QString::number(n))) ++n; + return prefix + QString::number(n); + }; + + QStringList clipIds; + for (const auto& c : proj.animationClips()) clipIds << c.id; + QStringList schemeIds; + for (const auto& s : proj.animationSchemes()) schemeIds << s.id; + + const QString clipId = nextId(QStringLiteral("clip-"), clipIds); + core::Project::AnimationClip clip; + clip.id = clipId; + clip.name = QStringLiteral("Clip_%1").arg(proj.animationClips().size() + 1, 3, 10, QChar('0')); + auto clips = proj.animationClips(); + clips.push_back(clip); + proj.setAnimationClips(clips); + + const QString schemeNewId = nextId(QStringLiteral("scheme-"), schemeIds); + core::Project::NlaStrip st; + st.id = QStringLiteral("strip-1"); + st.clipId = clipId; + st.startSlot = 0; + st.slotLen = 1; + st.enabled = true; + + core::Project::NlaTrack tr; + tr.id = QStringLiteral("track-1"); + tr.name = QStringLiteral("Track"); + tr.strips = {st}; + + core::Project::AnimationScheme scheme; + scheme.id = schemeNewId; + scheme.name = QStringLiteral("方案_%1").arg(proj.animationSchemes().size() + 1, 3, 10, QChar('0')); + scheme.tracks = {tr}; + + auto schemes = proj.animationSchemes(); + schemes.push_back(scheme); + proj.setAnimationSchemes(schemes); + proj.setActiveSchemeId(schemeNewId); + proj.setSelectedStripId(st.id); + + m_workspace.save(); + refreshEditorPage(); + return; + } + if (schemeId.isEmpty()) return; + if (schemeId == m_workspace.project().activeSchemeId()) return; + m_workspace.project().setActiveSchemeId(schemeId); + // 切换时默认选中该方案第一条带 + const auto* scheme = m_workspace.project().findSchemeById(schemeId); + QString firstStrip; + if (scheme) { + for (const auto& tr : scheme->tracks) { + if (!tr.strips.isEmpty()) { + firstStrip = tr.strips.front().id; + break; + } + } + } + m_workspace.project().setSelectedStripId(firstStrip); + m_workspace.save(); + refreshEditorPage(); + }); + connect(m_timeline, &TimelineWidget::contextMenuRequested, this, [this](const QPoint& globalPos, int frame) { if (!m_timeline) return; QMenu menu(this); @@ -441,17 +621,18 @@ void MainWindow::createTimelineDock() { menu.addSeparator(); QAction* actAnim = menu.addAction(QStringLiteral("动画…")); - // 简单提示当前区间(不占面板空间) + const bool entityKeyUi = (m_workspace.isOpen() && !m_selectedEntityId.isEmpty()); + const bool toolKeyUi = (m_workspace.isOpen() && m_hasSelectedTool && !m_selectedToolId.isEmpty()); + actDeleteKey->setEnabled(m_workspace.isOpen() && m_timeline->hasSelectedKeyframe() && (entityKeyUi || toolKeyUi)); const int selA = m_timeline->selectionStart(); const int selB = m_timeline->selectionEnd(); const bool hasRange = (selA >= 0 && selB >= 0); actClear->setEnabled(hasRange); actAnim->setEnabled(hasRange && !m_selectedEntityId.isEmpty() && m_workspace.isOpen()); - actDeleteKey->setEnabled(m_workspace.isOpen() && !m_selectedEntityId.isEmpty() && m_timeline->hasSelectedKeyframe()); // 右键命中帧:用鼠标位置对应的 frame - m_currentFrame = std::clamp(frame, 0, 600); - if (m_frameLabel) m_frameLabel->setText(QString::number(m_currentFrame)); + // 右键命中 localFrame:globalFrame==localFrame + m_currentFrame = std::clamp(frame, 0, core::Project::kClipFixedFrames - 1); if (m_editorCanvas) m_editorCanvas->setCurrentFrame(m_currentFrame); QAction* chosen = menu.exec(globalPos); @@ -459,20 +640,24 @@ void MainWindow::createTimelineDock() { return; } if (chosen == actDeleteKey) { - if (!m_workspace.isOpen() || m_selectedEntityId.isEmpty() || !m_timeline->hasSelectedKeyframe()) { + if (!m_workspace.isOpen() || !m_timeline->hasSelectedKeyframe()) { return; } const int f = m_timeline->selectedKeyFrame(); bool ok = false; switch (m_timeline->selectedKeyKind()) { case TimelineWidget::KeyKind::Location: - ok = m_workspace.removeEntityLocationKey(m_selectedEntityId, f); + if (!m_selectedEntityId.isEmpty()) ok = m_workspace.removeEntityLocationKey(m_selectedEntityId, f); break; case TimelineWidget::KeyKind::UserScale: - ok = m_workspace.removeEntityUserScaleKey(m_selectedEntityId, f); + if (!m_selectedEntityId.isEmpty()) ok = m_workspace.removeEntityUserScaleKey(m_selectedEntityId, f); break; case TimelineWidget::KeyKind::Image: - ok = m_workspace.removeEntityImageFrame(m_selectedEntityId, f); + if (!m_selectedEntityId.isEmpty()) ok = m_workspace.removeEntityImageFrame(m_selectedEntityId, f); + break; + case TimelineWidget::KeyKind::Visibility: + if (!m_selectedEntityId.isEmpty()) ok = m_workspace.removeEntityVisibilityKey(m_selectedEntityId, f); + else if (m_hasSelectedTool && !m_selectedToolId.isEmpty()) ok = m_workspace.removeToolVisibilityKey(m_selectedToolId, f); break; default: break; @@ -483,9 +668,9 @@ void MainWindow::createTimelineDock() { return; } if (chosen == actSetStart) { - m_timelineRangeStart = m_currentFrame; + m_timelineRangeStart = m_currentFrame % core::Project::kClipFixedFrames; if (m_timelineRangeEnd < 0) { - m_timelineRangeEnd = m_currentFrame; + m_timelineRangeEnd = m_currentFrame % core::Project::kClipFixedFrames; } if (m_timelineRangeEnd < m_timelineRangeStart) { std::swap(m_timelineRangeStart, m_timelineRangeEnd); @@ -496,9 +681,9 @@ void MainWindow::createTimelineDock() { return; } if (chosen == actSetEnd) { - m_timelineRangeEnd = m_currentFrame; + m_timelineRangeEnd = m_currentFrame % core::Project::kClipFixedFrames; if (m_timelineRangeStart < 0) { - m_timelineRangeStart = m_currentFrame; + m_timelineRangeStart = m_currentFrame % core::Project::kClipFixedFrames; } if (m_timelineRangeEnd < m_timelineRangeStart) { std::swap(m_timelineRangeStart, m_timelineRangeEnd); @@ -520,18 +705,44 @@ void MainWindow::createTimelineDock() { if (m_selectedEntityId.isEmpty() || !m_workspace.isOpen()) { return; } - const int a = std::clamp(std::min(selA, selB), 0, 600); - const int b = std::clamp(std::max(selA, selB), 0, 600); + const int fs = 0; + const int fe = core::Project::kClipFixedFrames - 1; + const int a = std::clamp(std::min(selA, selB), fs, fe); + const int b = std::clamp(std::max(selA, selB), fs, fe); if (a > b) { return; } FrameAnimationDialog dlg(m_workspace, m_selectedEntityId, a, b, this); dlg.exec(); refreshEditorPage(); + return; } }); } +void MainWindow::createResourceLibraryDock() { + m_resourceLibraryDockWidget = new gui::ResourceLibraryDock(this); + m_dockResourceLibrary = m_resourceLibraryDockWidget; + addDockWidget(Qt::LeftDockWidgetArea, m_dockResourceLibrary); + + // 默认不自动弹出,用户通过“窗口-资源库”打开 + m_dockResourceLibrary->setVisible(false); + + connect(m_dockResourceLibrary, &QDockWidget::visibilityChanged, this, [this](bool visible) { + if (!m_actionToggleResourceLibrary) { + return; + } + m_actionToggleResourceLibrary->blockSignals(true); + m_actionToggleResourceLibrary->setChecked(visible); + m_actionToggleResourceLibrary->blockSignals(false); + }); + + auto* local = new core::library::FakeResourceLibraryProvider(this); + auto* online = new core::library::OnlineResourceLibraryProvider(this); + m_resourceLibraryProvider = local; + m_resourceLibraryDockWidget->setProviders(local, online); +} + void MainWindow::syncCreateEntityToolButtonTooltip() { if (!m_btnCreateEntity || !m_editorCanvas) { return; @@ -714,11 +925,11 @@ void MainWindow::onInsertCombinedKey() { } // 位置关键帧:使用当前帧下的动画原点 const QPointF o = m_editorCanvas->selectedAnimatedOriginWorld(); - m_workspace.setEntityLocationKey(m_selectedEntityId, m_currentFrame, o); + m_workspace.setEntityLocationKey(m_selectedEntityId, m_currentFrame % core::Project::kClipFixedFrames, o); // 缩放关键帧:使用当前帧下的 userScale(而非 depthScale01) const double s = m_editorCanvas->selectedUserScale(); - m_workspace.setEntityUserScaleKey(m_selectedEntityId, m_currentFrame, s); + m_workspace.setEntityUserScaleKey(m_selectedEntityId, m_currentFrame % core::Project::kClipFixedFrames, s); refreshEditorPage(); } @@ -727,6 +938,7 @@ void MainWindow::createMenus() { createFileMenu(); createEditMenu(); createViewMenu(); + createWindowMenu(); createHelpMenu(); } @@ -807,31 +1019,7 @@ void MainWindow::createViewMenu() { auto* viewMenu = menuBar()->addMenu(QString()); viewMenu->setTitle("视图"); - m_actionToggleProjectTree = viewMenu->addAction(QStringLiteral("项目树")); - m_actionToggleProjectTree->setCheckable(true); - connect(m_actionToggleProjectTree, &QAction::toggled, this, [this](bool on) { - m_rightDocksNarrowHidden = false; - if (m_dockProjectTree) { - m_dockProjectTree->setVisible(on); - } - }); - - m_actionToggleProperties = viewMenu->addAction(QStringLiteral("属性")); - m_actionToggleProperties->setCheckable(true); - connect(m_actionToggleProperties, &QAction::toggled, this, [this](bool on) { - m_rightDocksNarrowHidden = false; - if (m_dockProperties) { - m_dockProperties->setVisible(on); - } - }); - - m_actionToggleTimeline = viewMenu->addAction(QStringLiteral("动画面板")); - m_actionToggleTimeline->setCheckable(true); - connect(m_actionToggleTimeline, &QAction::toggled, this, [this](bool on) { - if (m_dockTimeline) { - m_dockTimeline->setVisible(on); - } - }); + auto* canvasMenu = viewMenu->addMenu(QStringLiteral("画布")); @@ -916,6 +1104,45 @@ void MainWindow::createViewMenu() { }); } +void MainWindow::createWindowMenu() { + auto* winMenu = menuBar()->addMenu(QString()); + winMenu->setTitle(QStringLiteral("窗口")); + + m_actionToggleProjectTree = winMenu->addAction(QStringLiteral("项目树")); + m_actionToggleProjectTree->setCheckable(true); + connect(m_actionToggleProjectTree, &QAction::toggled, this, [this](bool on) { + m_rightDocksNarrowHidden = false; + if (m_dockProjectTree) { + m_dockProjectTree->setVisible(on); + } + }); + + m_actionToggleProperties = winMenu->addAction(QStringLiteral("属性")); + m_actionToggleProperties->setCheckable(true); + connect(m_actionToggleProperties, &QAction::toggled, this, [this](bool on) { + m_rightDocksNarrowHidden = false; + if (m_dockProperties) { + m_dockProperties->setVisible(on); + } + }); + + m_actionToggleTimeline = winMenu->addAction(QStringLiteral("动画面板")); + m_actionToggleTimeline->setCheckable(true); + connect(m_actionToggleTimeline, &QAction::toggled, this, [this](bool on) { + if (m_dockTimeline) { + m_dockTimeline->setVisible(on); + } + }); + + m_actionToggleResourceLibrary = winMenu->addAction(QStringLiteral("资源库")); + m_actionToggleResourceLibrary->setCheckable(true); + connect(m_actionToggleResourceLibrary, &QAction::toggled, this, [this](bool on) { + if (m_dockResourceLibrary) { + m_dockResourceLibrary->setVisible(on); + } + }); +} + void MainWindow::createProjectTreeDock() { m_dockProjectTree = new QDockWidget(QStringLiteral("项目树"), this); m_dockProjectTree->setToolTip(QStringLiteral("右键条目可切换编辑与预览。")); @@ -930,16 +1157,13 @@ void MainWindow::createProjectTreeDock() { dockLayout->setContentsMargins(4, 4, 4, 4); dockLayout->setSpacing(4); - m_projectTree = new QTreeWidget(dockContent); + m_projectTree = new ProjectTreeWidget(dockContent); m_projectTree->setColumnCount(2); m_projectTree->setHeaderHidden(true); - m_projectTree->setRootIsDecorated(false); - m_projectTree->setIndentation(0); + m_projectTree->setRootIsDecorated(true); + m_projectTree->setIndentation(14); m_projectTree->setUniformRowHeights(true); - m_projectTree->setDragEnabled(false); - m_projectTree->setAcceptDrops(false); - m_projectTree->setDropIndicatorShown(false); - m_projectTree->setDragDropMode(QAbstractItemView::NoDragDrop); + // 允许拖拽来设置父子关系;真正的父子逻辑由 dropEvent 发信号驱动,不使用默认内部移动 if (m_projectTree->header()) { m_projectTree->header()->setSectionResizeMode(0, QHeaderView::ResizeToContents); m_projectTree->header()->setSectionResizeMode(1, QHeaderView::Stretch); @@ -959,9 +1183,85 @@ void MainWindow::createProjectTreeDock() { } if (item == m_itemBackground) { showBackgroundContextMenu(m_projectTree->viewport()->mapToGlobal(pos)); + return; } }); connect(m_projectTree, &QTreeWidget::itemClicked, this, &MainWindow::onProjectTreeItemClicked); + static_cast(m_projectTree)->onNodeParentDropRequested = + [this](const QString& childKind, const QString& childId, const QString& parentKind, const QString& parentIdOrEmpty) { + if (!m_workspace.isOpen() || childId.isEmpty()) { + return; + } + + QString pid = parentIdOrEmpty; + QString pk = parentKind; + if (pid.isEmpty()) { + pk.clear(); + } + if (!pid.isEmpty() && pid == childId && pk == childKind) { + pid.clear(); + pk.clear(); + } + + const auto ents = m_workspace.entities(); + const auto tools = m_workspace.tools(); + QSet entIds; + QSet toolIds; + entIds.reserve(ents.size()); + toolIds.reserve(tools.size()); + for (const auto& e : ents) entIds.insert(e.id); + for (const auto& t : tools) toolIds.insert(t.id); + + auto getParentOf = [&](const QString& id) -> QString { + for (const auto& e : ents) { + if (e.id == id) return e.parentId; + } + for (const auto& t : tools) { + if (t.id == id) return t.parentId; + } + return QString(); + }; + auto wouldCreateCycle = [&](const QString& child, const QString& parent) -> bool { + if (child.isEmpty() || parent.isEmpty()) return false; + // 从 parent 往上走,若遇到 child 则成环 + QSet seen; + QString cur = parent; + for (int guard = 0; guard < 10000 && !cur.isEmpty(); ++guard) { + if (cur == child) return true; + if (seen.contains(cur)) return true; + seen.insert(cur); + cur = getParentOf(cur); + } + return false; + }; + if (!pid.isEmpty() && wouldCreateCycle(childId, pid)) { + return; + } + + auto originOf = [&](const QString& kind, const QString& id) -> QPointF { + if (kind == QStringLiteral("entity")) { + for (const auto& e : ents) if (e.id == id) return e.originWorld; + } + if (kind == QStringLiteral("tool")) { + for (const auto& t : tools) if (t.id == id) return t.originWorld; + } + return QPointF(); + }; + + const QPointF childOrigin = originOf(childKind, childId); + const QPointF parentOrigin = (!pid.isEmpty()) ? originOf(pk, pid) : QPointF(); + const QPointF off = (!pid.isEmpty()) ? (childOrigin - parentOrigin) : QPointF(); + + bool ok = false; + if (childKind == QStringLiteral("entity")) { + ok = m_workspace.setEntityParent(childId, pid, off); + } else if (childKind == QStringLiteral("tool")) { + ok = m_workspace.setToolParent(childId, pid, off); + } + if (!ok) return; + refreshEditorPage(); + refreshProjectTree(); + }; auto* treeScroll = new SpinFriendlyScrollArea(dockContent); treeScroll->setWidgetResizable(true); @@ -984,11 +1284,13 @@ void MainWindow::createProjectTreeDock() { m_bgPropertySection = new gui::BackgroundPropertySection(); 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_entityPropertySection); + m_propertyStack->addWidget(m_toolPropertySection); connect(m_bgPropertySection, &gui::BackgroundPropertySection::backgroundVisibleToggled, this, [this](bool on) { if (!m_workspace.isOpen()) return; @@ -1018,6 +1320,18 @@ void MainWindow::createProjectTreeDock() { refreshProjectTree(); refreshDopeSheet(); }); + connect(m_entityPropertySection, &gui::EntityPropertySection::ignoreDistanceScaleToggled, this, [this](bool on) { + if (m_selectedEntityId.isEmpty()) return; + if (!m_workspace.setEntityIgnoreDistanceScale(m_selectedEntityId, on)) return; + refreshEditorPage(); + refreshPreviewPage(); + }); + connect(m_entityPropertySection, &gui::EntityPropertySection::visibleToggled, this, [this](bool on) { + if (m_selectedEntityId.isEmpty()) return; + const int f = std::clamp(m_currentFrame, 0, core::Project::kClipFixedFrames - 1); + if (!m_workspace.setEntityVisibilityKey(m_selectedEntityId, f, on)) return; + refreshEditorPage(); + }); connect(m_entityPropertySection, &gui::EntityPropertySection::pivotEdited, this, [this](double x, double y) { if (m_selectedEntityId.isEmpty() || !m_editorCanvas) return; const double s = m_editorCanvas->selectedCombinedScale(); @@ -1072,6 +1386,37 @@ void MainWindow::createProjectTreeDock() { m_entityPropertySection->appendIntroImagePath(rel); }); + connect(m_toolPropertySection, &gui::ToolPropertySection::textCommitted, this, [this](const QString& text) { + if (m_selectedToolId.isEmpty() || !m_workspace.isOpen()) return; + m_workspace.setToolText(m_selectedToolId, text); + refreshEditorPage(); + refreshProjectTree(); + }); + connect(m_toolPropertySection, &gui::ToolPropertySection::pointerTChanged, this, [this](int thousandths) { + if (m_selectedToolId.isEmpty() || !m_workspace.isOpen()) return; + m_workspace.setToolBubblePointerT01(m_selectedToolId, static_cast(thousandths) / 1000.0); + refreshEditorPage(); + }); + connect(m_toolPropertySection, &gui::ToolPropertySection::fontPxChanged, this, [this](int px) { + if (m_selectedToolId.isEmpty() || !m_workspace.isOpen()) return; + m_workspace.setToolFontPx(m_selectedToolId, px); + refreshEditorPage(); + }); + connect(m_toolPropertySection, &gui::ToolPropertySection::alignChanged, this, [this](int idx) { + if (m_selectedToolId.isEmpty() || !m_workspace.isOpen()) return; + core::Project::Tool::TextAlign a = core::Project::Tool::TextAlign::Center; + if (idx == 0) a = core::Project::Tool::TextAlign::Left; + else if (idx == 2) a = core::Project::Tool::TextAlign::Right; + m_workspace.setToolAlign(m_selectedToolId, a); + refreshEditorPage(); + }); + 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); + if (!m_workspace.setToolVisibilityKey(m_selectedToolId, f, on)) return; + refreshEditorPage(); + }); + auto* propScroll = new SpinFriendlyScrollArea(m_dockProperties); propScroll->setWidgetResizable(true); propScroll->setFrameShape(QFrame::NoFrame); @@ -1123,7 +1468,7 @@ void MainWindow::createProjectTreeDock() { } void MainWindow::refreshPropertyPanel() { - if (!m_bgPropertySection || !m_entityPropertySection || !m_propertyStack) { + if (!m_bgPropertySection || !m_entityPropertySection || !m_toolPropertySection || !m_propertyStack) { return; } @@ -1155,9 +1500,92 @@ void MainWindow::refreshPropertyPanel() { m_bgPropertySection->syncDepthOverlayChecked(m_editorCanvas->depthOverlayEnabled()); } + auto activeClipForUi = [this]() -> const core::Project::AnimationClip* { + if (!m_workspace.isOpen()) return nullptr; + const auto& allClips = m_workspace.project().animationClips(); + const auto* scheme = m_workspace.project().activeSchemeOrNull(); + const core::Project::AnimationClip* clip = nullptr; + if (scheme) { + const QString stripId = m_workspace.project().selectedStripId(); + const core::Project::NlaStrip* chosenStrip = nullptr; + if (!stripId.isEmpty()) { + for (const auto& tr : scheme->tracks) { + for (const auto& st : tr.strips) { + if (st.id == stripId) { + chosenStrip = &st; + break; + } + } + if (chosenStrip) break; + } + } + if (!chosenStrip) { + for (const auto& tr : scheme->tracks) { + for (const auto& st : tr.strips) { + if (st.enabled && !st.muted) { + chosenStrip = &st; + break; + } + } + if (chosenStrip) break; + } + } + if (chosenStrip) { + clip = m_workspace.project().findClipById(chosenStrip->clipId); + } + } + if (!clip && !allClips.isEmpty()) clip = &allClips.front(); + return clip; + }; + + auto visAtFrame = [](const QVector& keys, int frame, bool defaultVisible) { + if (keys.isEmpty()) return defaultVisible; + bool cur = defaultVisible; + int best = -1; + for (const auto& k : keys) { + if (k.frame <= frame && k.frame >= best) { + best = k.frame; + cur = k.value; + } + } + return cur; + }; + + const bool toolUi = m_hasSelectedTool && m_workspace.isOpen() && !m_selectedToolId.isEmpty(); + if (toolUi) { + gui::ToolPropertyUiState st; + const int f = std::clamp(m_currentFrame, 0, core::Project::kClipFixedFrames - 1); + const auto* clip = activeClipForUi(); + for (const auto& t : m_workspace.tools()) { + if (t.id == m_selectedToolId) { + st.displayName = t.displayName.isEmpty() ? t.id : t.displayName; + st.text = t.text; + { + const double x = std::clamp(t.bubblePointerT01, 0.0, 1.0) * 1000.0; + st.pointerTThousandths = static_cast(x + 0.5); + } + st.fontPx = t.fontPx; + st.alignIndex = + (t.align == core::Project::Tool::TextAlign::Left) ? 0 : + (t.align == core::Project::Tool::TextAlign::Right) ? 2 : 1; + const QVector keys = + (clip && clip->toolVisibilityKeys.contains(t.id)) + ? clip->toolVisibilityKeys.value(t.id) + : QVector{}; + st.visible = visAtFrame(keys, f, t.visible); + break; + } + } + m_toolPropertySection->applyState(st); + m_propertyStack->setCurrentWidget(m_toolPropertySection); + m_dockProperties->setWindowTitle(QStringLiteral("属性 — 工具")); + return; + } + const bool entUi = m_hasSelectedEntity && m_workspace.isOpen() && !m_selectedEntityId.isEmpty() && m_editorCanvas; if (!entUi) { m_entityPropertySection->clearDisconnected(); + m_toolPropertySection->clearDisconnected(); m_propertyStack->setCurrentWidget(m_bgPropertySection); m_dockProperties->setWindowTitle(QStringLiteral("属性 — 背景")); return; @@ -1165,12 +1593,22 @@ void MainWindow::refreshPropertyPanel() { QString displayName; double userScale = 1.0; + bool ignoreDist = false; + bool entVisible = true; core::EntityIntroContent intro; + const int f = std::clamp(m_currentFrame, 0, core::Project::kClipFixedFrames - 1); + const auto* clip = activeClipForUi(); for (const auto& e : m_workspace.entities()) { if (e.id == m_selectedEntityId) { displayName = e.displayName; userScale = e.userScale; intro = e.intro; + ignoreDist = e.ignoreDistanceScale; + const QVector keys = + (clip && clip->entityVisibilityKeys.contains(e.id)) + ? clip->entityVisibilityKeys.value(e.id) + : QVector{}; + entVisible = visAtFrame(keys, f, e.visible); break; } } @@ -1184,6 +1622,8 @@ void MainWindow::refreshPropertyPanel() { st.pivot = m_editorCanvas->selectedAnimatedOriginWorld(); st.centroid = m_editorCanvas->selectedEntityCentroidWorld(); st.userScale = userScale; + st.ignoreDistanceScale = ignoreDist; + st.visible = entVisible; st.intro = intro; m_entityPropertySection->applyState(st); m_propertyStack->setCurrentWidget(m_entityPropertySection); @@ -1207,6 +1647,7 @@ void MainWindow::refreshEntityPropertyPanelFast() { st.pivot = m_editorCanvas->selectedAnimatedOriginWorld(); st.centroid = m_editorCanvas->selectedEntityCentroidWorld(); st.userScale = m_editorCanvas->selectedUserScale(); + // ignoreDistanceScale 在拖动中不变更,fast path 不必更新(避免再遍历 entities) m_entityPropertySection->applyState(st); // 拖动中不切换 stack、不改 dock 标题,避免多余布局开销 } @@ -1226,8 +1667,8 @@ void MainWindow::refreshProjectTree() { m_itemBackground->setTextAlignment(1, Qt::AlignRight | Qt::AlignVCenter); m_itemBackground->setData(0, Qt::UserRole, QStringLiteral("background")); - // “眼睛”按钮(固定尺寸,各行一致) - auto makeEye = [this, eyeSide, iconPm](bool visible) -> QToolButton* { + // “眼睛”按钮(固定尺寸,各行一致);canvasTempOnly 表示仅画布临时显隐,与属性里工程可见性无关 + auto makeEye = [this, eyeSide, iconPm](bool visible, bool canvasTempOnly = false) -> QToolButton* { auto* btn = new QToolButton(m_projectTree); btn->setFixedSize(eyeSide, eyeSide); btn->setIconSize(QSize(iconPm, iconPm)); @@ -1235,7 +1676,12 @@ void MainWindow::refreshProjectTree() { btn->setAutoRaise(true); btn->setCheckable(true); btn->setChecked(visible); - btn->setToolTip(visible ? QStringLiteral("隐藏") : QStringLiteral("显示")); + if (canvasTempOnly) { + btn->setToolTip(visible ? QStringLiteral("画布显示中。点击可仅在此视图中暂时隐藏(工程可见性在「属性」)") + : QStringLiteral("画布已暂时隐藏。点击恢复显示(工程可见性在「属性」)")); + } else { + btn->setToolTip(visible ? QStringLiteral("隐藏") : QStringLiteral("显示")); + } // 尽量用主题图标,失败则退化为文本 const QIcon onIcon = QIcon::fromTheme(QStringLiteral("view-visible")); const QIcon offIcon = QIcon::fromTheme(QStringLiteral("view-hidden")); @@ -1244,8 +1690,13 @@ void MainWindow::refreshProjectTree() { } else { btn->setText(visible ? QStringLiteral("👁") : QStringLiteral("×")); } - connect(btn, &QToolButton::toggled, this, [btn](bool on) { - btn->setToolTip(on ? QStringLiteral("隐藏") : QStringLiteral("显示")); + connect(btn, &QToolButton::toggled, this, [btn, canvasTempOnly](bool on) { + if (canvasTempOnly) { + btn->setToolTip(on ? QStringLiteral("画布显示中。点击可仅在此视图中暂时隐藏(工程可见性在「属性」)") + : QStringLiteral("画布已暂时隐藏。点击恢复显示(工程可见性在「属性」)")); + } else { + btn->setToolTip(on ? QStringLiteral("隐藏") : QStringLiteral("显示")); + } const QIcon visIcon = QIcon::fromTheme(QStringLiteral("view-visible")); const QIcon hidIcon = QIcon::fromTheme(QStringLiteral("view-hidden")); if (!visIcon.isNull() && !hidIcon.isNull()) { @@ -1268,10 +1719,16 @@ void MainWindow::refreshProjectTree() { refreshPreviewPage(); }); - // 实体:与画布一致,按深度由远到近排列(远的在上,近的在下,绘制时近盖远) + // 实体 + 工具:支持父子层级显示,同时保持“远到近”顺序(同层级内) QVector sortedEnts; + QVector sortedTools; + QHash entById; + QHash toolById; if (m_workspace.isOpen()) { sortedEnts = m_workspace.entities(); + sortedTools = m_workspace.tools(); + for (const auto& e : sortedEnts) entById.insert(e.id, e); + for (const auto& t : sortedTools) toolById.insert(t.id, t); std::stable_sort(sortedEnts.begin(), sortedEnts.end(), [](const core::Project::Entity& a, const core::Project::Entity& b) { if (a.depth != b.depth) { @@ -1279,23 +1736,100 @@ void MainWindow::refreshProjectTree() { } return a.id < b.id; }); + std::stable_sort(sortedTools.begin(), sortedTools.end(), + [](const core::Project::Tool& a, const core::Project::Tool& b) { + return a.id < b.id; + }); } + + struct NodeRef { + QString kind; // "entity" / "tool" + QString id; + }; + auto keyOf = [](const QString& kind, const QString& id) -> QString { return kind + QStringLiteral(":") + id; }; + + QHash> children; + children.reserve(sortedEnts.size() + sortedTools.size()); + auto parentKeyOrRoot = [&](const QString& pid) -> QString { + if (pid.isEmpty()) return QString(); + if (entById.contains(pid)) return keyOf(QStringLiteral("entity"), pid); + if (toolById.contains(pid)) return keyOf(QStringLiteral("tool"), pid); + return QString(); + }; for (const auto& e : sortedEnts) { - auto* it = new QTreeWidgetItem(m_projectTree); + children[parentKeyOrRoot(e.parentId)].push_back(NodeRef{QStringLiteral("entity"), e.id}); + } + for (const auto& t : sortedTools) { + children[parentKeyOrRoot(t.parentId)].push_back(NodeRef{QStringLiteral("tool"), t.id}); + } + + auto makeEntityItem = [&](QTreeWidgetItem* parentItem, const core::Project::Entity& e) -> QTreeWidgetItem* { + auto* it = parentItem ? new QTreeWidgetItem(parentItem) : new QTreeWidgetItem(m_projectTree); it->setText(1, e.displayName.isEmpty() ? e.id : e.displayName); it->setTextAlignment(1, Qt::AlignRight | Qt::AlignVCenter); it->setData(0, Qt::UserRole, QStringLiteral("entity")); it->setData(0, Qt::UserRole + 1, e.id); - it->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled); + it->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled | Qt::ItemIsDragEnabled | Qt::ItemIsDropEnabled); - auto* eye = makeEye(e.visible); + auto* eye = makeEye(!m_tempHiddenEntityIds.contains(e.id), true); m_projectTree->setItemWidget(it, 0, eye); connect(eye, &QToolButton::toggled, this, [this, id = e.id](bool on) { - if (!m_workspace.setEntityVisible(id, on)) return; - refreshEditorPage(); - updateUiEnabledState(); + if (!m_workspace.isOpen()) return; + if (on) m_tempHiddenEntityIds.remove(id); + else m_tempHiddenEntityIds.insert(id); + if (m_editorCanvas) { + m_editorCanvas->setTempHiddenIds(m_tempHiddenEntityIds, m_tempHiddenToolIds); + } }); - } + return it; + }; + + auto makeToolItem = [&](QTreeWidgetItem* parentItem, const core::Project::Tool& t) -> QTreeWidgetItem* { + auto* it = parentItem ? new QTreeWidgetItem(parentItem) : new QTreeWidgetItem(m_projectTree); + it->setText(1, t.displayName.isEmpty() ? t.id : t.displayName); + it->setTextAlignment(1, Qt::AlignRight | Qt::AlignVCenter); + it->setData(0, Qt::UserRole, QStringLiteral("tool")); + it->setData(0, Qt::UserRole + 1, t.id); + it->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled | Qt::ItemIsDragEnabled | Qt::ItemIsDropEnabled); + + auto* eye = makeEye(!m_tempHiddenToolIds.contains(t.id), true); + m_projectTree->setItemWidget(it, 0, eye); + connect(eye, &QToolButton::toggled, this, [this, id = t.id](bool on) { + if (!m_workspace.isOpen()) return; + if (on) m_tempHiddenToolIds.remove(id); + else m_tempHiddenToolIds.insert(id); + if (m_editorCanvas) { + m_editorCanvas->setTempHiddenIds(m_tempHiddenEntityIds, m_tempHiddenToolIds); + } + }); + return it; + }; + + QSet visiting; + std::function addSubtree; + addSubtree = [&](const QString& parentKey, QTreeWidgetItem* parentItem) { + const auto list = children.value(parentKey); + for (const auto& n : list) { + const QString nk = keyOf(n.kind, n.id); + if (visiting.contains(nk)) { + continue; + } + visiting.insert(nk); + QTreeWidgetItem* it = nullptr; + if (n.kind == QStringLiteral("entity")) { + it = makeEntityItem(parentItem, entById.value(n.id)); + } else if (n.kind == QStringLiteral("tool")) { + it = makeToolItem(parentItem, toolById.value(n.id)); + } + if (it) { + addSubtree(nk, it); + } + visiting.remove(nk); + } + }; + + addSubtree(QString(), nullptr); + m_projectTree->expandAll(); if (m_projectTree->header()) { m_projectTree->header()->setSectionResizeMode(0, QHeaderView::Fixed); @@ -1311,14 +1845,19 @@ void MainWindow::syncProjectTreeFromCanvasSelection() { } m_syncingTreeSelection = true; m_projectTree->blockSignals(true); - if (!m_hasSelectedEntity || m_selectedEntityId.isEmpty()) { + if ((!m_hasSelectedEntity || m_selectedEntityId.isEmpty()) && (!m_hasSelectedTool || m_selectedToolId.isEmpty())) { m_projectTree->clearSelection(); } else { QTreeWidgetItem* found = nullptr; for (QTreeWidgetItemIterator it(m_projectTree); *it; ++it) { QTreeWidgetItem* node = *it; - if (node->data(0, Qt::UserRole).toString() == QStringLiteral("entity") - && node->data(0, Qt::UserRole + 1).toString() == m_selectedEntityId) { + const QString kind = node->data(0, Qt::UserRole).toString(); + const QString id = node->data(0, Qt::UserRole + 1).toString(); + if (m_hasSelectedEntity && !m_selectedEntityId.isEmpty() && kind == QStringLiteral("entity") && id == m_selectedEntityId) { + found = node; + break; + } + if (m_hasSelectedTool && !m_selectedToolId.isEmpty() && kind == QStringLiteral("tool") && id == m_selectedToolId) { found = node; break; } @@ -1343,10 +1882,33 @@ 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_hasSelectedTool = false; + m_selectedToolId.clear(); + if (m_timeline) { + m_timeline->setToolKeyframeTracks({}, {}); + } m_editorCanvas->selectEntityById(id); } + } else if (kind == QStringLiteral("tool")) { + const QString id = item->data(0, Qt::UserRole + 1).toString(); + if (!id.isEmpty()) { + m_hasSelectedTool = true; + m_selectedToolId = id; + m_hasSelectedEntity = false; + m_selectedEntityId.clear(); + if (m_editorCanvas) { + m_editorCanvas->clearEntitySelection(); + } + if (m_timeline) { + updateTimelineTracks(); + } + refreshPropertyPanel(); + } } else if (kind == QStringLiteral("background")) { + m_hasSelectedTool = false; + m_selectedToolId.clear(); m_editorCanvas->clearEntitySelection(); + updateTimelineTracks(); } } @@ -1488,6 +2050,12 @@ void MainWindow::applyUiMode(UiMode mode) { if (m_dockTimeline) { m_dockTimeline->setVisible(projectOpen && !preview); } + if (m_dockResourceLibrary) { + // Preview 维持“纯展示”,Welcome 也隐藏;Editor 允许用户手动打开 + if (!projectOpen || preview) { + m_dockResourceLibrary->setVisible(false); + } + } if (m_floatingModeDock) { m_floatingModeDock->setVisible(projectOpen); @@ -1526,6 +2094,12 @@ void MainWindow::applyUiMode(UiMode mode) { m_actionToggleTimeline->setChecked(projectOpen && !preview && m_dockTimeline && m_dockTimeline->isVisible()); m_actionToggleTimeline->blockSignals(false); } + if (m_actionToggleResourceLibrary) { + m_actionToggleResourceLibrary->setEnabled(projectOpen && !preview); + m_actionToggleResourceLibrary->blockSignals(true); + m_actionToggleResourceLibrary->setChecked(projectOpen && !preview && m_dockResourceLibrary && m_dockResourceLibrary->isVisible()); + m_actionToggleResourceLibrary->blockSignals(false); + } } void MainWindow::showProjectRootContextMenu(const QPoint& globalPos) { @@ -1818,28 +2392,42 @@ void MainWindow::rebuildCentralPages() { updateStatusBarText(); }); connect(m_editorCanvas, &EditorCanvas::selectedEntityChanged, this, [this](bool hasSel, const QString& id, int depth, const QPointF& origin) { - if (m_timelineScrubbing) { - return; - } m_hasSelectedEntity = hasSel; m_selectedEntityId = id; m_selectedEntityDepth = depth; m_selectedEntityOrigin = origin; - updateStatusBarText(); - refreshPropertyPanel(); - syncProjectTreeFromCanvasSelection(); - if (m_timeline && m_workspace.isOpen() && hasSel && !id.isEmpty()) { - const core::Project::Entity* hit = nullptr; + m_hasSelectedTool = false; + m_selectedToolId.clear(); + if (hasSel && !id.isEmpty()) { for (const auto& e : m_workspace.entities()) { if (e.id == id) { - hit = &e; m_selectedEntityDisplayNameCache = e.displayName; break; } } - m_timeline->setKeyframeTracks(hit); - } else if (m_timeline) { - m_timeline->setKeyframeTracks(nullptr); + } + updateTimelineTracks(); + if (!m_timelineScrubbing) { + updateStatusBarText(); + refreshPropertyPanel(); + syncProjectTreeFromCanvasSelection(); + } + }); + + connect(m_editorCanvas, &EditorCanvas::selectedToolChanged, this, [this](bool hasSel, const QString& id, const QPointF& origin) { + Q_UNUSED(origin); + m_hasSelectedTool = hasSel; + m_selectedToolId = id; + if (hasSel) { + m_hasSelectedEntity = false; + m_selectedEntityId.clear(); + m_selectedEntityDisplayNameCache.clear(); + } + updateTimelineTracks(); + if (!m_timelineScrubbing) { + updateStatusBarText(); + refreshPropertyPanel(); + syncProjectTreeFromCanvasSelection(); } }); @@ -1895,6 +2483,33 @@ void MainWindow::rebuildCentralPages() { refreshProjectTree(); updateUiEnabledState(); }); + connect(m_editorCanvas, &EditorCanvas::requestAddTool, this, [this](const core::Project::Tool& tool) { + if (!m_workspace.isOpen()) { + return; + } + core::Project::Tool t = tool; + if (t.id.isEmpty()) { + QSet used; + for (const auto& ex : m_workspace.tools()) { + used.insert(ex.id); + } + int n = static_cast(m_workspace.tools().size()) + 1; + for (int guard = 0; guard < 100000; ++guard, ++n) { + const QString cand = QStringLiteral("tool-%1").arg(n); + if (!used.contains(cand)) { + t.id = cand; + break; + } + } + } + if (!m_workspace.addTool(t)) { + QMessageBox::warning(this, QStringLiteral("工具"), QStringLiteral("保存工具失败。")); + return; + } + refreshEditorPage(); + refreshProjectTree(); + updateUiEnabledState(); + }); connect(m_editorCanvas, &EditorCanvas::requestSamSegment, this, [this](const QByteArray& cropRgbPng, const QByteArray& overlayPng, @@ -2180,7 +2795,7 @@ void MainWindow::rebuildCentralPages() { connect(m_editorCanvas, &EditorCanvas::requestMoveEntity, this, [this](const QString& id, const QPointF& delta) { // 动画编辑:拖动即写入当前位置关键帧,避免被既有关键帧插值“拉回去” const bool autoKey = true; - if (!m_workspace.moveEntityBy(id, delta, m_currentFrame, autoKey)) { + if (!m_workspace.moveEntityBy(id, delta, m_currentFrame % core::Project::kClipFixedFrames, autoKey)) { return; } refreshEditorPage(); @@ -2188,22 +2803,31 @@ void MainWindow::rebuildCentralPages() { updateUiEnabledState(); }); + connect(m_editorCanvas, &EditorCanvas::requestMoveTool, this, [this](const QString& id, const QPointF& delta) { + const bool autoKey = true; + if (!m_workspace.moveToolBy(id, delta, m_currentFrame % core::Project::kClipFixedFrames, autoKey)) { + return; + } + refreshEditorPage(); + refreshProjectTree(); + updateUiEnabledState(); + }); + connect(m_editorCanvas, &EditorCanvas::presentationEntityIntroRequested, this, [this](const QString& id, QPointF anchorView) { if (!m_entityIntroPopup || !m_workspace.isOpen()) { return; } - const core::Project::Entity* hit = nullptr; + m_entityIntroPopup->setProjectDir(m_workspace.projectDir()); + bool found = false; for (const auto& e : m_workspace.entities()) { if (e.id == id) { - hit = &e; + m_entityIntroPopup->setContent(e.intro); + found = true; break; } } - m_entityIntroPopup->setProjectDir(m_workspace.projectDir()); - if (hit) { - m_entityIntroPopup->setContent(hit->intro); - } else { + if (!found) { core::EntityIntroContent empty; m_entityIntroPopup->setContent(empty); } @@ -2354,25 +2978,59 @@ void MainWindow::refreshEditorPage() { m_editorCanvas->setBackgroundVisible(open ? m_workspace.project().backgroundVisible() : true); m_editorCanvas->setDepthMapPath(open ? m_workspace.depthAbsolutePath() : QString()); if (open) { - applyTimelineFromProject(); - m_editorCanvas->setEntities(m_workspace.entities(), m_workspace.projectDir()); - m_editorCanvas->setCurrentFrame(m_currentFrame); - if (m_timeline && !m_selectedEntityId.isEmpty()) { - const core::Project::Entity* hit = nullptr; - for (const auto& e : m_workspace.entities()) { - if (e.id == m_selectedEntityId) { - hit = &e; + // —— 方案选择器(时间轴最前)—— + if (m_schemeSelector) { + m_schemeSelector->blockSignals(true); + m_schemeSelector->clear(); + const auto& schemes = m_workspace.project().animationSchemes(); + for (const auto& s : schemes) { + const QString label = s.name.isEmpty() ? s.id : s.name; + m_schemeSelector->addItem(label, s.id); + } + m_schemeSelector->addItem(QStringLiteral("+ 新建方案…"), QStringLiteral("__create__")); + const QString activeId = m_workspace.project().activeSchemeId(); + int idx = -1; + for (int i = 0; i < m_schemeSelector->count(); ++i) { + if (m_schemeSelector->itemData(i).toString() == activeId) { + idx = i; break; } } - m_timeline->setKeyframeTracks(hit); - } else if (m_timeline) { - m_timeline->setKeyframeTracks(nullptr); + if (idx < 0 && m_schemeSelector->count() > 0) idx = 0; + if (idx >= 0) m_schemeSelector->setCurrentIndex(idx); + m_schemeSelector->blockSignals(false); } + + applyTimelineFromProject(); + const core::eval::ResolvedProjectFrame rf = + core::eval::evaluateAtFrame(m_workspace.project(), m_currentFrame, 10); + QVector ents; + ents.reserve(rf.entities.size()); + QVector entOps; + entOps.reserve(rf.entities.size()); + for (const auto& re : rf.entities) { + ents.push_back(re.entity); + entOps.push_back(re.opacity); + } + m_editorCanvas->setEntities(ents, entOps, m_workspace.projectDir()); + QVector tools; + QVector opacities; + tools.reserve(rf.tools.size()); + opacities.reserve(rf.tools.size()); + for (const auto& rt : rf.tools) { + tools.push_back(rt.tool); + opacities.push_back(rt.opacity); + } + m_editorCanvas->setTools(tools, opacities); + m_editorCanvas->setCurrentFrame(m_currentFrame); + m_editorCanvas->setTempHiddenIds(m_tempHiddenEntityIds, m_tempHiddenToolIds); + updateTimelineTracks(); + } else { - m_editorCanvas->setEntities({}, QString()); + m_editorCanvas->setEntities({}, {}, QString()); if (m_timeline) { - m_timeline->setKeyframeTracks(nullptr); + m_timeline->setKeyframeTracks({}, {}, {}, {}); + m_timeline->setToolKeyframeTracks({}, {}); } } } @@ -2389,20 +3047,105 @@ void MainWindow::refreshEditorPage() { refreshDopeSheet(); } +void MainWindow::updateTimelineTracks() { + if (!m_timeline || !m_workspace.isOpen()) { + return; + } + + const bool wantEntity = !m_selectedEntityId.isEmpty(); + const bool wantTool = (m_hasSelectedTool && !m_selectedToolId.isEmpty()); + + // 未选中时不显示关键帧(按需求) + if (!wantEntity) m_timeline->setKeyframeTracks({}, {}, {}, {}); + if (!wantTool) m_timeline->setToolKeyframeTracks({}, {}); + if (!wantEntity && !wantTool) return; + + // 选择当前 clip(与 workspace 写入规则一致) + const core::Project::AnimationClip* clip = nullptr; + const auto& allClips = m_workspace.project().animationClips(); + const auto* scheme = m_workspace.project().activeSchemeOrNull(); + if (scheme) { + const QString stripId = m_workspace.project().selectedStripId(); + const core::Project::NlaStrip* chosenStrip = nullptr; + if (!stripId.isEmpty()) { + for (const auto& tr : scheme->tracks) { + for (const auto& st : tr.strips) { + if (st.id == stripId) { + chosenStrip = &st; + break; + } + } + if (chosenStrip) break; + } + } + if (!chosenStrip) { + for (const auto& tr : scheme->tracks) { + for (const auto& st : tr.strips) { + if (st.enabled && !st.muted) { + chosenStrip = &st; + break; + } + } + if (chosenStrip) break; + } + } + if (chosenStrip) { + clip = m_workspace.project().findClipById(chosenStrip->clipId); + } + } + if (!clip && !allClips.isEmpty()) { + clip = &allClips.front(); + } + if (!clip) return; + + auto framesOfVec2 = [](const QVector& keys) { + QVector out; + out.reserve(keys.size()); + for (const auto& k : keys) out.push_back(k.frame); + return out; + }; + auto framesOfDouble = [](const QVector& keys) { + QVector out; + out.reserve(keys.size()); + for (const auto& k : keys) out.push_back(k.frame); + return out; + }; + auto framesOfImage = [](const QVector& keys) { + QVector out; + out.reserve(keys.size()); + for (const auto& k : keys) out.push_back(k.frame); + return out; + }; + auto framesOfBool = [](const QVector& keys) { + QVector out; + out.reserve(keys.size()); + for (const auto& k : keys) out.push_back(k.frame); + return out; + }; + + if (wantEntity) { + const auto loc = clip->entityLocationKeys.value(m_selectedEntityId); + const auto sc = clip->entityUserScaleKeys.value(m_selectedEntityId); + const auto im = clip->entityImageFrames.value(m_selectedEntityId); + const auto vis = clip->entityVisibilityKeys.value(m_selectedEntityId); + m_timeline->setKeyframeTracks(framesOfVec2(loc), framesOfDouble(sc), framesOfImage(im), framesOfBool(vis)); + } + if (wantTool) { + const auto loc = clip->toolLocationKeys.value(m_selectedToolId); + const auto vis = clip->toolVisibilityKeys.value(m_selectedToolId); + m_timeline->setToolKeyframeTracks(framesOfVec2(loc), framesOfBool(vis)); + } +} + void MainWindow::applyTimelineFromProject() { if (!m_timeline || !m_workspace.isOpen()) { return; } - const int v = std::clamp(m_currentFrame, 0, 600); - m_currentFrame = v; - m_timeline->setFrameRange(0, 600); - m_timeline->setCurrentFrame(v); - if (m_frameLabel) { - m_frameLabel->setText(QString::number(v)); - } - if (m_editorCanvas) { - m_editorCanvas->setCurrentFrame(v); - } + const int g = std::max(0, m_currentFrame); + const int local = std::clamp(g, 0, core::Project::kClipFixedFrames - 1); + m_timeline->setFrameRange(0, core::Project::kClipFixedFrames); + m_timeline->setCurrentFrameProgrammatic(local); + if (m_editorCanvas) m_editorCanvas->setCurrentFrame(g); } void MainWindow::refreshDopeSheet() { diff --git a/client/gui/main_window/MainWindow.h b/client/gui/main_window/MainWindow.h index 58856a9..4be015f 100644 --- a/client/gui/main_window/MainWindow.h +++ b/client/gui/main_window/MainWindow.h @@ -10,6 +10,7 @@ #include #include +#include class ToolOptionPopup; @@ -34,7 +35,14 @@ class TimelineWidget; namespace gui { class BackgroundPropertySection; class EntityPropertySection; +class ToolPropertySection; class EntityIntroPopup; +class ResourceLibraryDock; +} +namespace core::library { +class ResourceLibraryProvider; +class FakeResourceLibraryProvider; +class OnlineResourceLibraryProvider; } class MainWindow : public QMainWindow { @@ -80,8 +88,10 @@ private: void createEditMenu(); // 编辑菜单 void createHelpMenu(); // 帮助菜单 void createViewMenu(); // 视图菜单 + void createWindowMenu(); // 窗口菜单(资源库等) void createProjectTreeDock(); void createTimelineDock(); + void createResourceLibraryDock(); void refreshProjectTree(); void updateUiEnabledState(); // 更新“可用性/勾选/默认显隐”,不要做业务逻辑 void applyUiMode(UiMode mode); // 统一控制 welcome/editor 两态的显隐策略 @@ -99,6 +109,7 @@ private: void refreshPreviewPage(); void refreshEditorPage(); void applyTimelineFromProject(); + void updateTimelineTracks(); void refreshDopeSheet(); void setPreviewRequested(bool preview); void syncPreviewPlaybackBar(); @@ -115,6 +126,7 @@ private: QStackedWidget* m_propertyStack = nullptr; gui::BackgroundPropertySection* m_bgPropertySection = nullptr; gui::EntityPropertySection* m_entityPropertySection = nullptr; + gui::ToolPropertySection* m_toolPropertySection = nullptr; QToolButton* m_btnCreateEntity = nullptr; ToolOptionPopup* m_createEntityPopup = nullptr; QToolButton* m_btnToggleDepthOverlay = nullptr; @@ -125,6 +137,7 @@ private: QDockWidget* m_dockProjectTree = nullptr; QDockWidget* m_dockProperties = nullptr; QDockWidget* m_dockTimeline = nullptr; + QDockWidget* m_dockResourceLibrary = nullptr; QTreeWidgetItem* m_itemBackground = nullptr; QAction* m_actionUndo = nullptr; @@ -134,6 +147,7 @@ private: QAction* m_actionToggleProjectTree = nullptr; QAction* m_actionToggleProperties = nullptr; QAction* m_actionToggleTimeline = nullptr; + QAction* m_actionToggleResourceLibrary = nullptr; QAction* m_actionEnterPreview = nullptr; QAction* m_actionBackToEditor = nullptr; QAction* m_actionCanvasWorldAxes = nullptr; @@ -152,13 +166,18 @@ private: QPointF m_lastWorldPos; int m_lastWorldZ = -1; bool m_hasSelectedEntity = false; + bool m_hasSelectedTool = false; bool m_syncingTreeSelection = false; int m_selectedEntityDepth = 0; QPointF m_selectedEntityOrigin; QString m_selectedEntityId; + QString m_selectedToolId; QString m_selectedEntityDisplayNameCache; QString m_bgAbsCache; QString m_bgSizeTextCache; + // 项目树“眼睛”:仅用于画布临时隐藏(不持久化、不进时间轴) + QSet m_tempHiddenEntityIds; + QSet m_tempHiddenToolIds; void updateStatusBarText(); void syncCreateEntityToolButtonTooltip(); void refreshPropertyPanel(); @@ -174,7 +193,7 @@ private: QTimer* m_playTimer = nullptr; TimelineWidget* m_timeline = nullptr; QToolButton* m_btnPlay = nullptr; - QLabel* m_frameLabel = nullptr; + QComboBox* m_schemeSelector = nullptr; // 时间轴区间选择(用于逐帧贴图动画) int m_timelineRangeStart = -1; int m_timelineRangeEnd = -1; @@ -187,4 +206,7 @@ private: QFrame* m_previewPlaybackBar = nullptr; QToolButton* m_previewBtnPlay = nullptr; QToolButton* m_previewBtnPause = nullptr; + + gui::ResourceLibraryDock* m_resourceLibraryDockWidget = nullptr; + core::library::ResourceLibraryProvider* m_resourceLibraryProvider = nullptr; }; diff --git a/client/gui/props/EntityPropertySection.cpp b/client/gui/props/EntityPropertySection.cpp index 4caaafa..67bbcaa 100644 --- a/client/gui/props/EntityPropertySection.cpp +++ b/client/gui/props/EntityPropertySection.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -55,6 +56,15 @@ EntityPropertySection::EntityPropertySection(QWidget* parent) m_userScale->setToolTip(QStringLiteral("人为整体缩放,与深度距离缩放相乘")); form->addRow(QStringLiteral("整体缩放"), m_userScale); + m_ignoreDistanceScale = new QCheckBox(QStringLiteral("不受距离缩放影响"), this); + m_ignoreDistanceScale->setToolTip(QStringLiteral("开启后实体不受深度驱动的距离缩放影响,仅受整体缩放影响(对话气泡默认开启)")); + form->addRow(QStringLiteral("距离缩放"), m_ignoreDistanceScale); + + m_visible = new QCheckBox(QString(), this); + m_visible->setChecked(true); + m_visible->setToolTip(QStringLiteral("随帧变化:在当前帧切换会写入可见性关键帧(10帧淡入淡出)")); + form->addRow(QStringLiteral("可见性"), m_visible); + lay->addLayout(form); m_introHeader = new QWidget(this); @@ -133,6 +143,8 @@ EntityPropertySection::EntityPropertySection(QWidget* parent) connect(m_pivot, &Vec2ParamControl::valueChanged, this, &EntityPropertySection::pivotEdited); connect(m_centroid, &Vec2ParamControl::valueChanged, this, &EntityPropertySection::centroidEdited); connect(m_userScale, qOverload(&QDoubleSpinBox::valueChanged), this, &EntityPropertySection::userScaleEdited); + connect(m_ignoreDistanceScale, &QCheckBox::toggled, this, &EntityPropertySection::ignoreDistanceScaleToggled); + connect(m_visible, &QCheckBox::toggled, this, &EntityPropertySection::visibleToggled); connect(m_introTitle, &QLineEdit::textChanged, this, [this](const QString&) { scheduleIntroPersist(); }); connect(m_introBody, &QTextEdit::textChanged, this, [this]() { scheduleIntroPersist(); }); @@ -183,6 +195,16 @@ void EntityPropertySection::clearDisconnected() { m_userScale->setValue(1.0); m_userScale->blockSignals(false); } + if (m_ignoreDistanceScale) { + m_ignoreDistanceScale->blockSignals(true); + m_ignoreDistanceScale->setChecked(false); + m_ignoreDistanceScale->blockSignals(false); + } + if (m_visible) { + m_visible->blockSignals(true); + m_visible->setChecked(true); + m_visible->blockSignals(false); + } if (m_introTitle) { m_introTitle->blockSignals(true); m_introTitle->clear(); @@ -224,6 +246,16 @@ void EntityPropertySection::applyState(const EntityPropertyUiState& s) { m_userScale->setValue(s.userScale); m_userScale->blockSignals(false); } + if (m_ignoreDistanceScale) { + m_ignoreDistanceScale->blockSignals(true); + m_ignoreDistanceScale->setChecked(s.ignoreDistanceScale); + m_ignoreDistanceScale->blockSignals(false); + } + if (m_visible) { + m_visible->blockSignals(true); + m_visible->setChecked(s.visible); + m_visible->blockSignals(false); + } if (m_introTitle) { m_introTitle->blockSignals(true); m_introTitle->setText(s.intro.title); @@ -288,6 +320,8 @@ void EntityPropertySection::setEditingEnabled(bool on) { if (m_pivot) m_pivot->setEnabled(on); if (m_centroid) m_centroid->setEnabled(on); if (m_userScale) m_userScale->setEnabled(on); + if (m_ignoreDistanceScale) m_ignoreDistanceScale->setEnabled(on); + if (m_visible) m_visible->setEnabled(on); if (m_introHeader) m_introHeader->setEnabled(on); if (m_introToggle) m_introToggle->setEnabled(on); if (m_introTitle) m_introTitle->setEnabled(on); diff --git a/client/gui/props/EntityPropertySection.h b/client/gui/props/EntityPropertySection.h index cefdb1f..b409f81 100644 --- a/client/gui/props/EntityPropertySection.h +++ b/client/gui/props/EntityPropertySection.h @@ -9,6 +9,7 @@ class QLabel; class QLineEdit; class QDoubleSpinBox; +class QCheckBox; class QTextEdit; class QListWidget; class QPushButton; @@ -29,6 +30,8 @@ struct EntityPropertyUiState { QPointF pivot; QPointF centroid; double userScale = 1.0; + bool ignoreDistanceScale = false; + bool visible = true; core::EntityIntroContent intro; }; @@ -49,6 +52,9 @@ signals: void pivotEdited(double x, double y); void centroidEdited(double x, double y); void userScaleEdited(double value); + void ignoreDistanceScaleToggled(bool on); + // 可见性(动画通道):在当前帧写关键帧 + void visibleToggled(bool on); /// 介绍字段变更后防抖触发,由主窗口写入工程 void introContentEdited(); void introAddImageRequested(); @@ -63,6 +69,8 @@ private: Vec2ParamControl* m_pivot = nullptr; Vec2ParamControl* m_centroid = nullptr; QDoubleSpinBox* m_userScale = nullptr; + QCheckBox* m_ignoreDistanceScale = nullptr; + QCheckBox* m_visible = nullptr; QLineEdit* m_introTitle = nullptr; QTextEdit* m_introBody = nullptr; diff --git a/client/gui/props/ToolPropertySection.cpp b/client/gui/props/ToolPropertySection.cpp new file mode 100644 index 0000000..a54c221 --- /dev/null +++ b/client/gui/props/ToolPropertySection.cpp @@ -0,0 +1,130 @@ +#include "props/ToolPropertySection.h" + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace gui { + +ToolPropertySection::ToolPropertySection(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_text = new QLineEdit(this); + m_text->setPlaceholderText(QStringLiteral("对话内容…")); + form->addRow(QStringLiteral("文字"), m_text); + + m_pointerT = new QSlider(Qt::Horizontal, this); + m_pointerT->setRange(0, 1000); + m_pointerT->setSingleStep(10); + m_pointerT->setPageStep(50); + m_pointerT->setValue(500); + m_pointerT->setToolTip(QStringLiteral("发言实体位置")); + form->addRow(QStringLiteral("指向"), m_pointerT); + + m_fontPx = new QSpinBox(this); + m_fontPx->setRange(8, 120); + m_fontPx->setSingleStep(1); + m_fontPx->setValue(18); + form->addRow(QStringLiteral("字号"), m_fontPx); + + m_align = new QComboBox(this); + m_align->addItems({QStringLiteral("左对齐"), QStringLiteral("居中"), QStringLiteral("右对齐")}); + form->addRow(QStringLiteral("对齐"), m_align); + + m_visible = new QCheckBox(QString(), this); + m_visible->setChecked(true); + m_visible->setToolTip(QStringLiteral("随帧变化:在当前帧切换会写入可见性关键帧(10帧淡入淡出)")); + form->addRow(QStringLiteral("可见性"), m_visible); + + lay->addLayout(form); + lay->addStretch(1); + + connect(m_text, &QLineEdit::editingFinished, this, [this]() { + if (m_text) emit textCommitted(m_text->text()); + }); + 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_visible, &QCheckBox::toggled, this, &ToolPropertySection::visibleToggled); +} + +void ToolPropertySection::setEditingEnabled(bool on) { + for (auto* w : {static_cast(m_text), static_cast(m_pointerT), + static_cast(m_fontPx), static_cast(m_align), + static_cast(m_visible)}) { + if (w) w->setEnabled(on); + } +} + +void ToolPropertySection::clearDisconnected() { + setEditingEnabled(false); + if (m_text) { + m_text->blockSignals(true); + m_text->clear(); + m_text->blockSignals(false); + } + if (m_pointerT) { + m_pointerT->blockSignals(true); + m_pointerT->setValue(500); + m_pointerT->blockSignals(false); + } + if (m_fontPx) { + m_fontPx->blockSignals(true); + m_fontPx->setValue(18); + m_fontPx->blockSignals(false); + } + if (m_align) { + m_align->blockSignals(true); + m_align->setCurrentIndex(1); + m_align->blockSignals(false); + } + if (m_visible) { + m_visible->blockSignals(true); + m_visible->setChecked(true); + m_visible->blockSignals(false); + } +} + +void ToolPropertySection::applyState(const ToolPropertyUiState& s) { + setEditingEnabled(true); + if (m_text) { + m_text->blockSignals(true); + m_text->setText(s.text); + m_text->blockSignals(false); + } + if (m_pointerT) { + m_pointerT->blockSignals(true); + m_pointerT->setValue(std::clamp(s.pointerTThousandths, 0, 1000)); + m_pointerT->blockSignals(false); + } + if (m_fontPx) { + m_fontPx->blockSignals(true); + m_fontPx->setValue(std::clamp(s.fontPx, 8, 120)); + m_fontPx->blockSignals(false); + } + if (m_align) { + m_align->blockSignals(true); + m_align->setCurrentIndex(std::clamp(s.alignIndex, 0, 2)); + m_align->blockSignals(false); + } + if (m_visible) { + m_visible->blockSignals(true); + m_visible->setChecked(s.visible); + m_visible->blockSignals(false); + } +} + +} // namespace gui diff --git a/client/gui/props/ToolPropertySection.h b/client/gui/props/ToolPropertySection.h new file mode 100644 index 0000000..d3c8d92 --- /dev/null +++ b/client/gui/props/ToolPropertySection.h @@ -0,0 +1,50 @@ +#pragma once + +#include "props/PropertySectionWidget.h" + +#include + +class QComboBox; +class QLineEdit; +class QSlider; +class QSpinBox; +class QCheckBox; + +namespace gui { + +struct ToolPropertyUiState { + QString displayName; + QString text; + int pointerTThousandths = 500; // bubblePointerT01 * 1000,0=左 1000=右 + int fontPx = 18; + int alignIndex = 1; // 0=left,1=center,2=right + bool visible = true; +}; + +class ToolPropertySection final : public PropertySectionWidget { + Q_OBJECT +public: + explicit ToolPropertySection(QWidget* parent = nullptr); + + void clearDisconnected(); + void applyState(const ToolPropertyUiState& s); + void setEditingEnabled(bool on); + +signals: + void textCommitted(const QString& text); + void pointerTChanged(int thousandths); + void fontPxChanged(int px); + void alignChanged(int alignIndex); + // 可见性(动画通道):在当前帧写关键帧 + void visibleToggled(bool on); + +private: + QLineEdit* m_text = nullptr; + QSlider* m_pointerT = nullptr; + QSpinBox* m_fontPx = nullptr; + QComboBox* m_align = nullptr; + QCheckBox* m_visible = nullptr; +}; + +} // namespace gui + diff --git a/client/gui/timeline/TimelineWidget.cpp b/client/gui/timeline/TimelineWidget.cpp index 2112409..13ff7d1 100644 --- a/client/gui/timeline/TimelineWidget.cpp +++ b/client/gui/timeline/TimelineWidget.cpp @@ -1,16 +1,33 @@ #include "timeline/TimelineWidget.h" #include +#include +#include #include #include +#include #include namespace { -int clampFrame(int f, int a, int b) { - if (a > b) std::swap(a, b); - return std::clamp(f, a, b); +static int pickMajorStep(double pixelsPerFrame) { + // 主刻度间距(帧):保证屏幕上大约 ≥ 48px + const double targetPx = 48.0; + const double raw = targetPx / std::max(pixelsPerFrame, 1e-6); + static const int cand[] = {1, 2, 5, 10, 15, 20, 25, 50, 75, 100, 150, 200, 250, 500, 1000, 2000, 5000}; + for (int c : cand) { + if (c >= raw) return c; + } + return cand[sizeof(cand) / sizeof(cand[0]) - 1]; +} + +static int pickMinorStep(int major) { + if (major >= 100) return major / 5; + if (major >= 20) return major / 4; + if (major >= 10) return major / 5; + if (major >= 5) return 1; + return 1; } } // namespace @@ -18,17 +35,20 @@ int clampFrame(int f, int a, int b) { TimelineWidget::TimelineWidget(QWidget* parent) : QWidget(parent) { setMouseTracking(true); - setMinimumHeight(28); + // 单行紧凑:标尺 + 轨道(帧号画在播放头处,随坐标轴滚动) + setMinimumHeight(kRulerHeight + 18 + 6); setFocusPolicy(Qt::StrongFocus); + setToolTip(QStringLiteral("片段时间轴(固定 0-600):左键拖动播放头;滚轮:逐帧")); +} + +void TimelineWidget::resizeEvent(QResizeEvent* e) { + QWidget::resizeEvent(e); + update(); } void TimelineWidget::setFrameRange(int start, int end) { - if (m_start == start && m_end == end) { - return; - } - m_start = start; - m_end = end; - m_currentFrame = clampFrame(m_currentFrame, m_start, m_end); + (void)start; + (void)end; update(); } @@ -36,6 +56,15 @@ void TimelineWidget::setCurrentFrame(int frame) { setFrameInternal(frame, false); } +void TimelineWidget::setCurrentFrameProgrammatic(int frame) { + const int f = std::clamp(frame, kStart, kEnd - 1); + if (m_currentFrame == f) { + return; + } + m_currentFrame = f; + update(); +} + void TimelineWidget::setSelectionRange(int start, int end) { if (start < 0 || end < 0) { m_selStart = -1; @@ -43,41 +72,40 @@ void TimelineWidget::setSelectionRange(int start, int end) { update(); return; } - m_selStart = clampFrame(std::min(start, end), m_start, m_end); - m_selEnd = clampFrame(std::max(start, end), m_start, m_end); + const int lo = std::min(start, end); + const int hi = std::max(start, end); + m_selStart = std::clamp(lo, kStart, kEnd - 1); + m_selEnd = std::clamp(hi, m_selStart, kEnd - 1); update(); } -void TimelineWidget::setKeyframeTracks(const core::Project::Entity* e) { - m_locFrames.clear(); - m_scaleFrames.clear(); - m_imgFrames.clear(); - if (!e) { - update(); - return; - } - m_locFrames.reserve(e->locationKeys.size()); - for (const auto& k : e->locationKeys) m_locFrames.push_back(k.frame); - m_scaleFrames.reserve(e->userScaleKeys.size()); - for (const auto& k : e->userScaleKeys) m_scaleFrames.push_back(k.frame); - m_imgFrames.reserve(e->imageFrames.size()); - for (const auto& k : e->imageFrames) m_imgFrames.push_back(k.frame); +static void uniqSort(QVector& v) { + std::sort(v.begin(), v.end()); + v.erase(std::unique(v.begin(), v.end()), v.end()); +} - auto uniqSort = [](QVector& v) { - std::sort(v.begin(), v.end()); - v.erase(std::unique(v.begin(), v.end()), v.end()); - }; +static bool containsFrame(const QVector& v, int f) { + return std::binary_search(v.begin(), v.end(), f); +} + +void TimelineWidget::setKeyframeTracks(const QVector& locFrames, + const QVector& scaleFrames, + const QVector& imgFrames, + const QVector& visFrames) { + m_locFrames = locFrames; + m_scaleFrames = scaleFrames; + m_imgFrames = imgFrames; + m_visFrames = visFrames; uniqSort(m_locFrames); uniqSort(m_scaleFrames); uniqSort(m_imgFrames); - // 轨道变了:若当前选中的关键帧不再存在,则清除 - auto contains = [](const QVector& v, int f) { - return std::binary_search(v.begin(), v.end(), f); - }; + uniqSort(m_visFrames); + bool ok = true; - if (m_selKeyKind == KeyKind::Location) ok = contains(m_locFrames, m_selKeyFrame); - if (m_selKeyKind == KeyKind::UserScale) ok = contains(m_scaleFrames, m_selKeyFrame); - if (m_selKeyKind == KeyKind::Image) ok = contains(m_imgFrames, m_selKeyFrame); + if (m_selKeyKind == KeyKind::Location) ok = containsFrame(m_locFrames, m_selKeyFrame); + if (m_selKeyKind == KeyKind::UserScale) ok = containsFrame(m_scaleFrames, m_selKeyFrame); + if (m_selKeyKind == KeyKind::Image) ok = containsFrame(m_imgFrames, m_selKeyFrame); + if (m_selKeyKind == KeyKind::Visibility) ok = containsFrame(m_visFrames, m_selKeyFrame); if (!ok) { m_selKeyKind = KeyKind::None; m_selKeyFrame = -1; @@ -86,33 +114,67 @@ void TimelineWidget::setKeyframeTracks(const core::Project::Entity* e) { update(); } -QRect TimelineWidget::trackRect() const { - const int pad = 8; - const int h = height(); - return QRect(pad, 0, std::max(1, width() - pad * 2), h); +void TimelineWidget::setToolKeyframeTracks(const QVector& locFrames, + const QVector& visFrames) { + m_locFrames = locFrames; + m_scaleFrames.clear(); + m_imgFrames.clear(); + m_visFrames = visFrames; + uniqSort(m_locFrames); + uniqSort(m_visFrames); + + bool ok = true; + if (m_selKeyKind == KeyKind::Location) ok = containsFrame(m_locFrames, m_selKeyFrame); + if (m_selKeyKind == KeyKind::Visibility) ok = containsFrame(m_visFrames, m_selKeyFrame); + if (!ok) { + m_selKeyKind = KeyKind::None; + m_selKeyFrame = -1; + emit keyframeSelectionChanged(m_selKeyKind, m_selKeyFrame); + } + update(); } -int TimelineWidget::xToFrame(int x) const { - const QRect r = trackRect(); - if (r.width() <= 1) return m_start; - const double t = std::clamp((x - r.left()) / double(r.width() - 1), 0.0, 1.0); - const int span = std::max(1, m_end - m_start); - const int f = m_start + int(std::round(t * span)); - return clampFrame(f, m_start, m_end); +QRect TimelineWidget::contentRect() const { + return QRect(contentLeft(), 3, contentWidth(), std::max(24, height() - 6)); +} + +QRect TimelineWidget::rulerRect() const { + const QRect c = contentRect(); + return QRect(c.left(), c.top(), c.width(), kRulerHeight); +} + +QRect TimelineWidget::keyAreaRect() const { + const QRect c = contentRect(); + const QRect r = rulerRect(); + const int top = r.bottom(); + return QRect(c.left(), top, c.width(), std::max(1, c.bottom() - top)); +} + +double TimelineWidget::frameToXf(double frame) const { + const double pxf = double(contentWidth()) / double(std::max(1, kEnd - kStart)); + return double(contentLeft()) + (frame - double(kStart)) * pxf; } int TimelineWidget::frameToX(int frame) const { - const QRect r = trackRect(); - if (r.width() <= 1) return r.left(); - const int f = clampFrame(frame, m_start, m_end); - const int span = std::max(1, m_end - m_start); - const double t = double(f - m_start) / double(span); - return r.left() + int(std::round(t * (r.width() - 1))); + return int(std::lround(frameToXf(double(frame)))); +} + +double TimelineWidget::xToFramef(int x) const { + const double pxf = double(contentWidth()) / double(std::max(1, kEnd - kStart)); + return double(kStart) + double(x - contentLeft()) / std::max(pxf, 1e-9); +} + +int TimelineWidget::xToFrame(int x) const { + return int(std::lround(xToFramef(x))); } void TimelineWidget::setFrameInternal(int frame, bool commit) { - const int f = clampFrame(frame, m_start, m_end); - if (m_currentFrame == f && !commit) { + const int f = std::clamp(frame, kStart, kEnd - 1); + // 松手时若帧未变:只发 committed,禁止再发 scrubbed,否则主窗口会双次求值/刷新导致帧号与红线闪烁 + if (m_currentFrame == f) { + if (commit) { + emit frameCommitted(f); + } return; } m_currentFrame = f; @@ -127,64 +189,164 @@ void TimelineWidget::paintEvent(QPaintEvent*) { QPainter p(this); p.setRenderHint(QPainter::Antialiasing, true); - const QRect r = rect(); - p.fillRect(r, palette().base()); + p.fillRect(rect(), palette().base()); - const QRect tr = trackRect().adjusted(0, 8, 0, -8); - const QColor rail = palette().mid().color(); - p.setPen(Qt::NoPen); - p.setBrush(rail); - p.drawRoundedRect(tr, 6, 6); + const QRect cr = contentRect(); + const QRect kr = keyAreaRect(); + const QRect rr = rulerRect(); + const double fLeft = double(kStart); + const int visMin = kStart; + const int visMax = kEnd; - // selection range + auto frameVisible = [&](int fr) { return fr >= visMin && fr <= visMax; }; + + // —— 区间:贯穿标尺+轨道,贴在时间坐标上 —— if (m_selStart >= 0 && m_selEnd >= 0) { const int x0 = frameToX(m_selStart); const int x1 = frameToX(m_selEnd); - QRect sel(QPoint(std::min(x0, x1), tr.top()), QPoint(std::max(x0, x1), tr.bottom())); - sel = sel.adjusted(0, 2, 0, -2); - QColor c = palette().highlight().color(); - c.setAlpha(50); - p.setBrush(c); - p.drawRoundedRect(sel, 4, 4); + const int xa = std::min(x0, x1); + const int xb = std::max(x0, x1); + QRect sel(xa, cr.top(), xb - xa, cr.height()); + sel = sel.intersected(cr); + if (sel.width() > 0) { + QColor c = palette().highlight().color(); + c.setAlpha(72); + p.setPen(Qt::NoPen); + p.setBrush(c); + p.drawRoundedRect(sel, 3, 3); + p.setPen(QPen(palette().highlight().color(), 1)); + p.setBrush(Qt::NoBrush); + p.drawRoundedRect(sel, 3, 3); + } } - auto drawDots = [&](const QVector& frames, const QColor& c, int y) { - p.setBrush(c); - p.setPen(Qt::NoPen); - for (int f : frames) { - if (f < m_start || f > m_end) continue; - const int x = frameToX(f); - const bool sel = - (m_selKeyFrame == f) - && ((m_selKeyKind == KeyKind::Image && &frames == &m_imgFrames) - || (m_selKeyKind == KeyKind::Location && &frames == &m_locFrames) - || (m_selKeyKind == KeyKind::UserScale && &frames == &m_scaleFrames)); - if (sel) { - p.setPen(QPen(palette().highlight().color(), 2.0)); - p.setBrush(c); - p.drawEllipse(QPointF(x, y), 4.4, 4.4); - p.setPen(Qt::NoPen); - } else { - p.drawEllipse(QPointF(x, y), 2.6, 2.6); - } + // —— 关键帧切分背景(仅轨道):按可见范围切分,不因 m_end 隐藏尾部关键帧 —— + QVector allK; + allK.reserve(m_locFrames.size() + m_scaleFrames.size() + m_imgFrames.size() + m_visFrames.size()); + allK += m_locFrames; + allK += m_scaleFrames; + allK += m_imgFrames; + allK += m_visFrames; + std::sort(allK.begin(), allK.end()); + allK.erase(std::unique(allK.begin(), allK.end()), allK.end()); + + const int v0 = visMin; + const int v1 = visMax; + QVector cuts; + cuts.reserve(allK.size() + 4); + cuts.push_back(v0); + for (int k : allK) { + if (k > v0 && k < v1) { + cuts.push_back(k); } + } + cuts.push_back(v1); + std::sort(cuts.begin(), cuts.end()); + cuts.erase(std::unique(cuts.begin(), cuts.end()), cuts.end()); + + for (int i = 0; i + 1 < cuts.size(); ++i) { + const int a = cuts[i]; + const int b = cuts[i + 1]; + if (a >= b) continue; + const int x0 = frameToX(a); + const int x1 = frameToX(b); + if (x1 < kr.left() || x0 > kr.right()) continue; + QRect seg(std::max(x0, kr.left()), kr.top(), std::min(x1, kr.right()) - std::max(x0, kr.left()), kr.height()); + if (seg.width() <= 0) continue; + QColor c = (i % 2) ? QColor(255, 255, 255, 28) : QColor(0, 0, 0, 12); + p.setPen(Qt::NoPen); + p.setBrush(c); + p.drawRect(seg); + } + + const QColor rail = palette().mid().color(); + p.setPen(Qt::NoPen); + p.setBrush(rail); + p.drawRoundedRect(kr, 4, 4); + + // —— 关键帧:竖线贯穿标尺+轨道(位置钉在帧坐标上,随平移/缩放移动)—— + auto drawKeyLine = [&](int fr, const QColor& col, KeyKind kind, int xOffPx) { + if (!frameVisible(fr)) return; + const int x = frameToX(fr) + xOffPx; + if (x < cr.left() - 4 || x > cr.right() + 4) return; + const bool sel = (m_selKeyFrame == fr && m_selKeyKind == kind); + QPen pen(col, sel ? 3.2 : 2.0); + pen.setCapStyle(Qt::FlatCap); + p.setPen(pen); + p.drawLine(x, cr.top() + 1, x, cr.bottom() - 1); + const int yb = kr.bottom() - 4; + QPolygonF dia; + dia << QPointF(x, yb - 5) << QPointF(x + 4, yb) << QPointF(x, yb + 4) << QPointF(x - 4, yb); + p.setPen(Qt::NoPen); + p.setBrush(col); + p.drawPolygon(dia); }; - const int yMid = tr.center().y(); - drawDots(m_imgFrames, QColor(80, 160, 255, 230), yMid - 6); - drawDots(m_locFrames, QColor(255, 120, 0, 230), yMid); - drawDots(m_scaleFrames, QColor(140, 220, 140, 230), yMid + 6); + for (int fr : m_imgFrames) drawKeyLine(fr, QColor(70, 130, 240), KeyKind::Image, -3); + for (int fr : m_locFrames) drawKeyLine(fr, QColor(240, 110, 40), KeyKind::Location, -1); + for (int fr : m_scaleFrames) drawKeyLine(fr, QColor(80, 190, 90), KeyKind::UserScale, 1); + for (int fr : m_visFrames) drawKeyLine(fr, QColor(160, 100, 230), KeyKind::Visibility, 3); - // current frame caret + // —— 标尺底与刻度文字(与帧一一对应,随 view 滚动)—— + p.setPen(Qt::NoPen); + p.setBrush(palette().alternateBase()); + p.drawRoundedRect(rr, 3, 3); + + const double pxf = double(contentWidth()) / double(std::max(1, kEnd - kStart)); + const int major = pickMajorStep(pxf); + const int minor = pickMinorStep(major); + QPen minorPen(QColor(60, 60, 60, 100)); + minorPen.setWidth(1); + QPen majorPen(QColor(35, 35, 35, 170)); + majorPen.setWidth(1); + QFont rulerFont = font(); + rulerFont.setPointSize(std::max(7, rulerFont.pointSize() - 1)); + p.setFont(rulerFont); + + for (int f = static_cast(std::floor(fLeft / minor) * minor); f <= visMax + minor; f += minor) { + if (f < visMin - minor) continue; + const double xf = frameToXf(f); + if (xf < rr.left() - 2 || xf > rr.right() + 2) continue; + const int xi = int(std::lround(xf)); + const bool isMajor = (major > 0) && (f % major == 0); + p.setPen(isMajor ? majorPen : minorPen); + const int tickH = isMajor ? 8 : 4; + p.drawLine(xi, rr.bottom() - tickH, xi, rr.bottom()); + if (isMajor && pxf > 0.28) { + p.setPen(QColor(25, 25, 25, 235)); + const QString txt = QString::number(f); + const int tw = QFontMetrics(rulerFont).horizontalAdvance(txt); + int tx = xi - tw / 2; + tx = std::clamp(tx, rr.left() + 1, rr.right() - tw - 1); + p.drawText(tx, rr.top() + QFontMetrics(rulerFont).ascent() + 1, txt); + } + } + + // 播放头 + 帧号(钉在轴上,与红线同一 x) const int cx = frameToX(m_currentFrame); - p.setPen(QPen(palette().highlight().color(), 2.0)); - p.drawLine(QPoint(cx, tr.top() - 6), QPoint(cx, tr.bottom() + 6)); -} + p.setPen(QPen(QColor(220, 55, 55, 250), 2.0)); + p.drawLine(QPoint(cx, cr.top()), QPoint(cx, cr.bottom())); -static bool hitDot(const QPoint& pos, int dotX, int dotY, int radiusPx) { - const int dx = pos.x() - dotX; - const int dy = pos.y() - dotY; - return (dx * dx + dy * dy) <= (radiusPx * radiusPx); + QFont bf = font(); + bf.setBold(true); + p.setFont(bf); + QFontMetrics fm(bf); + const QString ft = QString::number(m_currentFrame); + const int tw = fm.horizontalAdvance(ft); + const int ph = fm.height() + 3; + const int pw = tw + 10; + int px = cx - pw / 2; + if (pw <= cr.width() - 4) { + px = std::clamp(px, cr.left() + 2, cr.right() - pw - 2); + } else { + px = cr.left() + 2; + } + const int py = cr.top() + 1; + p.setPen(QPen(QColor(180, 40, 40), 1)); + p.setBrush(QColor(255, 245, 245, 245)); + p.drawRoundedRect(px, py, pw, ph, 3, 3); + p.setPen(QColor(20, 20, 20)); + p.drawText(px + 5, py + fm.ascent() + 1, ft); } static int findNearestFrameInTrack(const QVector& frames, int frame) { @@ -241,44 +403,36 @@ void TimelineWidget::mouseReleaseEvent(QMouseEvent* e) { const int f = xToFrame(e->pos().x()); setFrameInternal(f, true); - // 点击(非拖拽)时做选中:关键帧或区间 if (!m_moved) { - const QRect tr = trackRect().adjusted(0, 8, 0, -8); - const int yMid = tr.center().y(); - const int yImg = yMid - 6; - const int yLoc = yMid; - const int ySc = yMid + 6; - const int rad = 7; + const QRect cr = contentRect(); + const int mx = e->pos().x(); + const int my = e->pos().y(); - auto trySelectKey = [&](KeyKind kind, const QVector& frames, int laneY) -> bool { + auto trySelectKey = [&](KeyKind kind, const QVector& frames, int xOff) -> bool { const int nearest = findNearestFrameInTrack(frames, f); if (nearest < 0) return false; - const int x = frameToX(nearest); - if (hitDot(e->pos(), x, laneY, rad)) { - m_selKeyKind = kind; - m_selKeyFrame = nearest; - emit keyframeSelectionChanged(m_selKeyKind, m_selKeyFrame); - update(); - return true; - } - return false; + const int xk = frameToX(nearest) + xOff; + if (std::abs(mx - xk) > 9) return false; + if (my < cr.top() || my > cr.bottom()) return false; + m_selKeyKind = kind; + m_selKeyFrame = nearest; + emit keyframeSelectionChanged(m_selKeyKind, m_selKeyFrame); + update(); + return true; }; - // 先尝试命中关键帧(按 lane 优先) - if (trySelectKey(KeyKind::Image, m_imgFrames, yImg) - || trySelectKey(KeyKind::Location, m_locFrames, yLoc) - || trySelectKey(KeyKind::UserScale, m_scaleFrames, ySc)) { - // 选中关键帧时清掉区间 + if (trySelectKey(KeyKind::Visibility, m_visFrames, 3) || trySelectKey(KeyKind::Image, m_imgFrames, -3) + || trySelectKey(KeyKind::Location, m_locFrames, -1) || trySelectKey(KeyKind::UserScale, m_scaleFrames, 1)) { if (m_selStart >= 0 && m_selEnd >= 0) { m_selStart = -1; m_selEnd = -1; emit intervalSelectionChanged(m_selStart, m_selEnd); } } else { - // 未命中关键帧:尝试选中由关键帧切分出的区间(使用三轨道的并集) QVector all = m_locFrames; all += m_scaleFrames; all += m_imgFrames; + all += m_visFrames; std::sort(all.begin(), all.end()); all.erase(std::unique(all.begin(), all.end()), all.end()); int a = -1, b = -1; @@ -286,7 +440,6 @@ void TimelineWidget::mouseReleaseEvent(QMouseEvent* e) { if (a >= 0 && b >= 0) { setSelectionRange(a, b); emit intervalSelectionChanged(m_selStart, m_selEnd); - // 选中区间时清掉关键帧选中 if (m_selKeyKind != KeyKind::None) { m_selKeyKind = KeyKind::None; m_selKeyFrame = -1; @@ -303,8 +456,9 @@ void TimelineWidget::mouseReleaseEvent(QMouseEvent* e) { } void TimelineWidget::wheelEvent(QWheelEvent* e) { - const int delta = (e->angleDelta().y() > 0) ? 1 : -1; - setFrameInternal(m_currentFrame + delta, true); + const double steps = e->angleDelta().y() / 120.0; + const int delta = (steps > 0) ? 1 : -1; + // 滚轮只走 scrubbed:避免每格同时 scrub+committed 造成双次 refresh 与帧号闪烁 + setFrameInternal(m_currentFrame + delta, false); e->accept(); } - diff --git a/client/gui/timeline/TimelineWidget.h b/client/gui/timeline/TimelineWidget.h index 074091d..5eb4eee 100644 --- a/client/gui/timeline/TimelineWidget.h +++ b/client/gui/timeline/TimelineWidget.h @@ -1,33 +1,41 @@ #pragma once -#include "core/domain/Project.h" - #include +class QResizeEvent; + class TimelineWidget final : public QWidget { Q_OBJECT public: explicit TimelineWidget(QWidget* parent = nullptr); + // 兼容旧接口:NLA/片段系统下时间轴始终固定为 0..600(local frame)。 void setFrameRange(int start, int end); void setCurrentFrame(int frame); + /// 由主窗口同步工程帧时调用:不发射 frameScrubbed,避免与拖动/刷新打架造成数字闪烁 + void setCurrentFrameProgrammatic(int frame); int currentFrame() const { return m_currentFrame; } void setSelectionRange(int start, int end); // -1,-1 清除 int selectionStart() const { return m_selStart; } int selectionEnd() const { return m_selEnd; } - // 只显示“当前选中实体”的关键帧标记 - void setKeyframeTracks(const core::Project::Entity* entityOrNull); + // 轨道数据直接由上层提供(通常来自当前条带引用的 clip)。 + void setKeyframeTracks(const QVector& locFrames, + const QVector& scaleFrames, + const QVector& imgFrames, + const QVector& visFrames); + void setToolKeyframeTracks(const QVector& locFrames, + const QVector& visFrames); - enum class KeyKind { None, Location, UserScale, Image }; + enum class KeyKind { None, Location, UserScale, Image, Visibility }; KeyKind selectedKeyKind() const { return m_selKeyKind; } int selectedKeyFrame() const { return m_selKeyFrame; } bool hasSelectedKeyframe() const { return m_selKeyKind != KeyKind::None && m_selKeyFrame >= 0; } signals: - void frameScrubbed(int frame); // 拖动中实时触发(用于实时预览) - void frameCommitted(int frame); // 松手/点击确认(用于较重的刷新) + void frameScrubbed(int frame); + void frameCommitted(int frame); void contextMenuRequested(const QPoint& globalPos, int frame); void keyframeSelectionChanged(KeyKind kind, int frame); void intervalSelectionChanged(int start, int end); @@ -38,18 +46,26 @@ protected: void mouseMoveEvent(QMouseEvent*) override; void mouseReleaseEvent(QMouseEvent*) override; void wheelEvent(QWheelEvent*) override; + void resizeEvent(QResizeEvent* e) override; private: - int xToFrame(int x) const; + int contentLeft() const { return 6; } + int contentWidth() const { return std::max(1, width() - 12); } + QRect contentRect() const; + + double frameToXf(double frame) const; int frameToX(int frame) const; - QRect trackRect() const; + double xToFramef(int x) const; + int xToFrame(int x) const; + + QRect rulerRect() const; + QRect keyAreaRect() const; void setFrameInternal(int frame, bool commit); -private: - int m_start = 0; - int m_end = 600; - int m_currentFrame = 0; + static constexpr int kStart = 0; + static constexpr int kEnd = 600; // exclusive for mapping, inclusive for UI labels + int m_currentFrame = 0; // local frame: 0..599 int m_selStart = -1; int m_selEnd = -1; @@ -58,12 +74,13 @@ private: QPoint m_pressPos; bool m_moved = false; - // snapshot(避免频繁遍历 workspace) + static constexpr int kRulerHeight = 14; + QVector m_locFrames; QVector m_scaleFrames; QVector m_imgFrames; + QVector m_visFrames; KeyKind m_selKeyKind = KeyKind::None; int m_selKeyFrame = -1; }; -