新增根据帧数控制可见性

This commit is contained in:
2026-04-09 17:30:55 +08:00
parent e116a9ec79
commit d67d7dc0c5
29 changed files with 4387 additions and 408 deletions

View File

@@ -9,6 +9,11 @@ set(CORE_SOURCES
${CORE_ROOT}/animation/AnimationSampling.cpp ${CORE_ROOT}/animation/AnimationSampling.cpp
${CORE_ROOT}/depth/DepthService.cpp ${CORE_ROOT}/depth/DepthService.cpp
${CORE_ROOT}/net/ModelServerClient.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 set(CORE_HEADERS
@@ -20,6 +25,11 @@ set(CORE_HEADERS
${CORE_ROOT}/animation/AnimationSampling.h ${CORE_ROOT}/animation/AnimationSampling.h
${CORE_ROOT}/depth/DepthService.h ${CORE_ROOT}/depth/DepthService.h
${CORE_ROOT}/net/ModelServerClient.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 add_library(core STATIC

View File

@@ -4,6 +4,7 @@
#include <QString> #include <QString>
#include <QPointF> #include <QPointF>
#include <QHash>
#include <QVector> #include <QVector>
#include <algorithm> #include <algorithm>
@@ -12,6 +13,8 @@ namespace core {
class Project { class Project {
public: public:
static constexpr int kClipFixedFrames = 600;
void setName(const QString& name) { m_name = name; } void setName(const QString& name) { m_name = name; }
const QString& name() const { return m_name; } const QString& name() const { return m_name; }
@@ -37,10 +40,15 @@ public:
void setFps(int fps) { m_fps = std::max(1, fps); } void setFps(int fps) { m_fps = std::max(1, fps); }
int fps() const { return m_fps; } int fps() const { return m_fps; }
struct ToolKeyframeBool {
int frame = 0;
bool value = true;
};
struct Entity { struct Entity {
QString id; QString id;
QString displayName; // 显示名(空则界面用 id QString displayName; // 显示名(空则界面用 id
bool visible = true; // Outliner 眼睛:默认显示 bool visible = true; // 默认显隐(无 visibilityKeys 时使用)
// 可移动实体形状:存为局部坐标(相对 originWorld // 可移动实体形状:存为局部坐标(相对 originWorld
QVector<QPointF> polygonLocal; QVector<QPointF> polygonLocal;
// 从背景中抠洞的位置:固定在创建时的 world 坐标,不随实体移动 // 从背景中抠洞的位置:固定在创建时的 world 坐标,不随实体移动
@@ -55,6 +63,15 @@ public:
// 抠图创建时该位置对应的原始距离乘子0.5+depth01用于校准该处 distanceScale==1.0。0 表示未校准(兼容旧工程) // 抠图创建时该位置对应的原始距离乘子0.5+depth01用于校准该处 distanceScale==1.0。0 表示未校准(兼容旧工程)
double distanceScaleCalibMult = 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 { struct KeyframeVec2 {
int frame = 0; int frame = 0;
QPointF value; QPointF value;
@@ -82,12 +99,137 @@ public:
QVector<KeyframeDouble> userScaleKeys; QVector<KeyframeDouble> userScaleKeys;
QVector<ImageFrame> imageFrames; QVector<ImageFrame> imageFrames;
// 可见性轨道:布尔关键帧(显示/隐藏渲染时会被解释为“10 帧淡入淡出”。
QVector<ToolKeyframeBool> visibilityKeys;
EntityIntroContent intro; EntityIntroContent intro;
}; };
void setEntities(const QVector<Entity>& entities) { m_entities = entities; } void setEntities(const QVector<Entity>& entities) { m_entities = entities; }
const QVector<Entity>& entities() const { return m_entities; } const QVector<Entity>& 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<Entity::KeyframeVec2> locationKeys;
// 可见性轨道:布尔关键帧(显示/隐藏渲染时会被解释为“10 帧淡入淡出”。
QVector<ToolKeyframeBool> 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<Tool>& tools) { m_tools = tools; }
const QVector<Tool>& tools() const { return m_tools; }
// —— 动画系统Blender/NLA 风格简化版,工程级)——
struct AnimationClip {
QString id;
QString name;
// Entity channels (keyed by entity id)
QHash<QString, QVector<Entity::KeyframeVec2>> entityLocationKeys;
QHash<QString, QVector<Entity::KeyframeDouble>> entityUserScaleKeys;
QHash<QString, QVector<Entity::ImageFrame>> entityImageFrames;
QHash<QString, QVector<ToolKeyframeBool>> entityVisibilityKeys;
// Tool channels (keyed by tool id)
QHash<QString, QVector<Entity::KeyframeVec2>> toolLocationKeys;
QHash<QString, QVector<ToolKeyframeBool>> 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<NlaStrip> strips;
};
struct AnimationScheme {
QString id;
QString name;
QVector<NlaTrack> tracks;
};
void setAnimationClips(const QVector<AnimationClip>& clips) { m_clips = clips; }
const QVector<AnimationClip>& animationClips() const { return m_clips; }
void setAnimationSchemes(const QVector<AnimationScheme>& schemes) { m_schemes = schemes; }
const QVector<AnimationScheme>& 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: private:
QString m_name; QString m_name;
QString m_backgroundImagePath; QString m_backgroundImagePath;
@@ -98,6 +240,12 @@ private:
int m_frameEnd = 600; int m_frameEnd = 600;
int m_fps = 60; int m_fps = 60;
QVector<Entity> m_entities; QVector<Entity> m_entities;
QVector<Tool> m_tools;
QVector<AnimationClip> m_clips;
QVector<AnimationScheme> m_schemes;
QString m_activeSchemeId;
QString m_selectedStripId;
}; };
} // namespace core } // namespace core

View File

@@ -0,0 +1,311 @@
#include "eval/ProjectEvaluator.h"
#include "animation/AnimationSampling.h"
#include <algorithm>
#include <unordered_set>
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<VisKey> normalizeVisibilityKeys(const QVector<core::Project::ToolKeyframeBool>& keys) {
QVector<VisKey> 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<VisKey> 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<core::Project::ToolKeyframeBool>& keysRaw, int frame, int fadeFrames) {
const int nFade = std::max(1, fadeFrames);
const QVector<VisKey> 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<QString, NodeRef> 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<QString, QPointF> resolvedOrigin;
QHash<QString, bool> resolving;
resolvedOrigin.reserve(index.size());
resolving.reserve(index.size());
std::function<QPointF(const QString&)> 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<core::Project::ToolKeyframeBool>& keys,
bool defaultVisible) -> double {
if (keys.isEmpty()) {
return defaultVisible ? 1.0 : 0.0;
}
return opacityFromBoolKeys(keys, localFrame, fadeFrames);
};
// Entitiesresolved 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<core::Project::ToolKeyframeBool> 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});
}
// Toolsresolved 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<core::Project::ToolKeyframeBool> 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

View File

@@ -0,0 +1,31 @@
#pragma once
#include "domain/Project.h"
#include <QHash>
#include <QPointF>
#include <QString>
#include <QVector>
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<ResolvedEntity> entities;
QVector<ResolvedTool> tools;
};
/// 逐帧求值:处理父子跟随与工具可见性淡入淡出。
ResolvedProjectFrame evaluateAtFrame(const core::Project& project, int frame, int fadeFrames = 10);
} // namespace core::eval

View File

@@ -0,0 +1,305 @@
#include "library/EntityJson.h"
#include <functional>
#include <QJsonArray>
#include <QJsonValue>
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<QPointF>& pts) {
QJsonArray a;
for (const auto& p : pts) {
a.append(pointToJson(p));
}
return a;
}
bool pointsFromJson(const QJsonValue& v, QVector<QPointF>& 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 <class T>
QJsonArray vecToJson(const QVector<T>& v, const std::function<QJsonObject(const T&)>& 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<core::Project::Entity::KeyframeVec2>(
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<core::Project::Entity::KeyframeFloat01>(
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<core::Project::Entity::KeyframeDouble>(
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<core::Project::Entity::ImageFrame>(
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<core::Project::ToolKeyframeBool>(
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<core::Project::Entity::KeyframeVec2>& 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<core::Project::Entity::KeyframeFloat01>& 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<core::Project::Entity::KeyframeDouble>& 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<core::Project::Entity::ImageFrame>& 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

View File

@@ -0,0 +1,13 @@
#pragma once
#include "domain/Project.h"
#include <QJsonObject>
namespace core::library {
QJsonObject entityToJson(const core::Project::Entity& e);
bool entityFromJson(const QJsonObject& o, core::Project::Entity& out);
} // namespace core::library

View File

@@ -0,0 +1,19 @@
#include "library/OnlineResourceLibraryProvider.h"
#include <QTimer>
namespace core::library {
OnlineResourceLibraryProvider::OnlineResourceLibraryProvider(QObject* parent)
: ResourceLibraryProvider(parent) {}
void OnlineResourceLibraryProvider::fetchResourcesAsync() {
// 在线资源预留:当前不返回假数据。
// 未来接入真实服务时保持“多次调用 fetchResourcesAsync()”用于分页即可。
QTimer::singleShot(0, this, [this]() {
emit resourcesReady({});
});
}
} // namespace core::library

View File

@@ -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

View File

@@ -0,0 +1,95 @@
#include "library/ResourceLibraryProvider.h"
#include <QTimer>
namespace core::library {
FakeResourceLibraryProvider::FakeResourceLibraryProvider(QObject* parent)
: ResourceLibraryProvider(parent) {}
void FakeResourceLibraryProvider::fetchResourcesAsync() {
// 预留:未来在这里用 QNetworkAccessManager 请求在线资源库。
// 当前返回内置假数据,并保持异步语义,避免 UI 假设同步返回。
QTimer::singleShot(0, this, [this]() {
QVector<LibraryResource> 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

View File

@@ -0,0 +1,59 @@
#pragma once
#include "domain/Project.h"
#include <QColor>
#include <QImage>
#include <QObject>
#include <QSize>
#include <QString>
#include <QVector>
namespace core::library {
/// 资源库中的一个“可拖拽实体资源”。未来可来自在线服务器。
struct LibraryResource {
enum class Kind { Entity, Tool };
QString resourceId;
QString displayName;
Kind kind = Kind::Entity;
/// 拖到画布后用于创建实体的模板id 可为空,主窗口会自动分配 entity-<n>)。
core::Project::Entity entityTemplate;
/// 拖到画布后用于创建工具的模板id 可为空,主窗口会自动分配 tool-<n>)。
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<core::library::LibraryResource>& 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

View File

@@ -0,0 +1,113 @@
#include "library/ToolJson.h"
#include <QJsonArray>
#include <algorithm>
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

View File

@@ -0,0 +1,13 @@
#pragma once
#include "domain/Project.h"
#include <QJsonObject>
namespace core::library {
QJsonObject toolToJson(const core::Project::Tool& t);
bool toolFromJson(const QJsonObject& o, core::Project::Tool& out);
} // namespace core::library

View File

@@ -251,6 +251,14 @@ public:
writeAnimationBlock(ds, entity, true); writeAnimationBlock(ds, entity, true);
ds << entity.displayName << double(entity.userScale) << double(entity.distanceScaleCalibMult); 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); writeIntroBlock(ds, entity);
} }
@@ -270,6 +278,40 @@ public:
tmp.displayName = dn; tmp.displayName = dn;
tmp.userScale = std::clamp(us, 1e-3, 1e3); tmp.userScale = std::clamp(us, 1e-3, 1e3);
tmp.distanceScaleCalibMult = (cal > 0.0) ? std::clamp(cal, 1e-6, 10.0) : 0.0; 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)) { if (!readIntroBlock(ds, tmp.intro)) {
return false; return false;
} }
@@ -329,7 +371,7 @@ bool EntityPayloadBinary::load(const QString& absolutePath, Project::Entity& ent
if (ds.status() != QDataStream::Ok || magic != kMagicPayload) { if (ds.status() != QDataStream::Ok || magic != kMagicPayload) {
return false; 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; return false;
} }
Project::Entity tmp; 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; 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 (ver >= 5) {
if (!readIntroBlock(ds, tmp.intro)) { if (!readIntroBlock(ds, tmp.intro)) {
return false; return false;
@@ -361,6 +445,10 @@ bool EntityPayloadBinary::load(const QString& absolutePath, Project::Entity& ent
} else { } else {
tmp.displayName.clear(); tmp.displayName.clear();
tmp.userScale = 1.0; tmp.userScale = 1.0;
tmp.ignoreDistanceScale = false;
tmp.parentId.clear();
tmp.parentOffsetWorld = QPointF();
tmp.visibilityKeys.clear();
} }
entity = std::move(tmp); entity = std::move(tmp);
return true; return true;

View File

@@ -12,7 +12,7 @@ namespace core {
class EntityPayloadBinary { class EntityPayloadBinary {
public: public:
static constexpr quint32 kMagicPayload = 0x48464550; // 'HFEP' static constexpr quint32 kMagicPayload = 0x48464550; // 'HFEP'
static constexpr quint32 kPayloadVersion = 5; // v5:追加 intro预览介绍标题/正文/配图/视频路径预留) static constexpr quint32 kPayloadVersion = 7; // v7:追加 visibilityKeys
// 旧版独立动画文件(仍用于打开 v1 项目时合并) // 旧版独立动画文件(仍用于打开 v1 项目时合并)
static constexpr quint32 kMagicLegacyAnim = 0x48465441; // 'HFTA' static constexpr quint32 kMagicLegacyAnim = 0x48465441; // 'HFTA'

File diff suppressed because it is too large Load Diff

View File

@@ -16,7 +16,7 @@ public:
static constexpr const char* kProjectIndexFileName = "project.json"; static constexpr const char* kProjectIndexFileName = "project.json";
static constexpr const char* kAssetsDirName = "assets"; static constexpr const char* kAssetsDirName = "assets";
// 写入 project.json 的 version 字段;仍可读 version 1内嵌实体 + 可选 .anim // 写入 project.json 的 version 字段;仍可读 version 1内嵌实体 + 可选 .anim
static constexpr int kProjectIndexFormatVersion = 2; static constexpr int kProjectIndexFormatVersion = 3;
ProjectWorkspace() = default; ProjectWorkspace() = default;
@@ -47,10 +47,17 @@ public:
// 写入 project.json 的 name 字段(可 undo // 写入 project.json 的 name 字段(可 undo
bool setProjectTitle(const QString& title); bool setProjectTitle(const QString& title);
// 时间轴范围(写入 project.json可 undo。start 必须 >=0end 会被归一化为 >= start
bool setProjectFrameRange(int start, int end);
// 自动扩展 end用于“无限延伸”仅在 end 变大时写盘recordHistory=false 时不占用 undo 栈
bool ensureProjectFrameEndAtLeast(int end, bool recordHistory = false);
Project& project() { return m_project; } Project& project() { return m_project; }
const Project& project() const { return m_project; } const Project& project() const { return m_project; }
// 仅写盘project.json + payload 同步)。动画 UI 直接编辑 Project 后可调用此函数持久化。
bool save();
// 历史操作(最多 30 步),类似 Blender维护 undo/redo 栈 // 历史操作(最多 30 步),类似 Blender维护 undo/redo 栈
bool canUndo() const; bool canUndo() const;
bool canRedo() const; bool canRedo() const;
@@ -74,11 +81,27 @@ public:
bool saveDepthMapPngBytes(const QByteArray& pngBytes, QString* outError = nullptr); bool saveDepthMapPngBytes(const QByteArray& pngBytes, QString* outError = nullptr);
const QVector<Project::Entity>& entities() const { return m_project.entities(); } const QVector<Project::Entity>& entities() const { return m_project.entities(); }
const QVector<Project::Tool>& 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 addEntity(const Project::Entity& entity, const QImage& image);
bool setEntityVisible(const QString& id, bool on); 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); bool setEntityDisplayName(const QString& id, const QString& displayName);
/// keyframeAtFrame >= 0 时同时写入该帧的 userScale 关键帧(与画布 sampleUserScale 一致) /// keyframeAtFrame >= 0 时同时写入该帧的 userScale 关键帧(与画布 sampleUserScale 一致)
bool setEntityUserScale(const QString& id, double userScale, int keyframeAtFrame = -1); 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 须与画布一致 // 将多边形质心平移到 targetCentroidWorld整体平移sTotal 须与画布一致
bool moveEntityCentroidTo(const QString& id, int frame, const QPointF& targetCentroidWorld, double sTotal, bool moveEntityCentroidTo(const QString& id, int frame, const QPointF& targetCentroidWorld, double sTotal,
bool autoKeyLocation); bool autoKeyLocation);
@@ -119,17 +142,25 @@ private:
static QJsonObject entityToJson(const Project::Entity& e); static QJsonObject entityToJson(const Project::Entity& e);
static bool entityFromJsonV1(const QJsonObject& o, Project::Entity& out); static bool entityFromJsonV1(const QJsonObject& o, Project::Entity& out);
static bool entityStubFromJsonV2(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 { struct Operation {
enum class Type { ImportBackground, SetEntities, SetProjectTitle }; enum class Type { ImportBackground, SetEntities, SetTools, SetProjectTitle, SetProjectFrameRange };
Type type {Type::ImportBackground}; Type type {Type::ImportBackground};
QString label; QString label;
QString beforeBackgroundPath; QString beforeBackgroundPath;
QString afterBackgroundPath; QString afterBackgroundPath;
QVector<Project::Entity> beforeEntities; QVector<Project::Entity> beforeEntities;
QVector<Project::Entity> afterEntities; QVector<Project::Entity> afterEntities;
QVector<Project::Tool> beforeTools;
QVector<Project::Tool> afterTools;
QString beforeProjectTitle; QString beforeProjectTitle;
QString afterProjectTitle; QString afterProjectTitle;
int beforeFrameStart = 0;
int afterFrameStart = 0;
int beforeFrameEnd = 600;
int afterFrameEnd = 600;
}; };
static constexpr int kMaxHistorySteps = 30; static constexpr int kMaxHistorySteps = 30;
@@ -137,6 +168,7 @@ private:
bool applyBackgroundPath(const QString& relativePath, bool recordHistory, const QString& label); bool applyBackgroundPath(const QString& relativePath, bool recordHistory, const QString& label);
bool applyEntities(const QVector<Project::Entity>& entities, bool recordHistory, const QString& label); bool applyEntities(const QVector<Project::Entity>& entities, bool recordHistory, const QString& label);
bool applyTools(const QVector<Project::Tool>& tools, bool recordHistory, const QString& label);
QString copyIntoAssetsAsBackground(const QString& sourceFilePath, const QRect& cropRectInSourceImage); QString copyIntoAssetsAsBackground(const QString& sourceFilePath, const QRect& cropRectInSourceImage);
bool writeDepthMap(const QImage& depth8); bool writeDepthMap(const QImage& depth8);
bool writeDepthMapBytes(const QByteArray& pngBytes); bool writeDepthMapBytes(const QByteArray& pngBytes);

View File

@@ -4,6 +4,7 @@ set(GUI_ROOT ${CMAKE_CURRENT_SOURCE_DIR})
set(GUI_SOURCES set(GUI_SOURCES
${GUI_ROOT}/app/main.cpp ${GUI_ROOT}/app/main.cpp
${GUI_ROOT}/main_window/MainWindow.cpp ${GUI_ROOT}/main_window/MainWindow.cpp
${GUI_ROOT}/library/ResourceLibraryDock.cpp
${GUI_ROOT}/widgets/LongPressSwitchToolButton.cpp ${GUI_ROOT}/widgets/LongPressSwitchToolButton.cpp
${GUI_ROOT}/widgets/ToolOptionPopup.cpp ${GUI_ROOT}/widgets/ToolOptionPopup.cpp
${GUI_ROOT}/main_window/RecentProjectHistory.cpp ${GUI_ROOT}/main_window/RecentProjectHistory.cpp
@@ -18,11 +19,13 @@ set(GUI_SOURCES
${GUI_ROOT}/params/ParamControls.cpp ${GUI_ROOT}/params/ParamControls.cpp
${GUI_ROOT}/props/BackgroundPropertySection.cpp ${GUI_ROOT}/props/BackgroundPropertySection.cpp
${GUI_ROOT}/props/EntityPropertySection.cpp ${GUI_ROOT}/props/EntityPropertySection.cpp
${GUI_ROOT}/props/ToolPropertySection.cpp
${GUI_ROOT}/timeline/TimelineWidget.cpp ${GUI_ROOT}/timeline/TimelineWidget.cpp
) )
set(GUI_HEADERS set(GUI_HEADERS
${GUI_ROOT}/main_window/MainWindow.h ${GUI_ROOT}/main_window/MainWindow.h
${GUI_ROOT}/library/ResourceLibraryDock.h
${GUI_ROOT}/widgets/LongPressSwitchToolButton.h ${GUI_ROOT}/widgets/LongPressSwitchToolButton.h
${GUI_ROOT}/widgets/ToolOptionPopup.h ${GUI_ROOT}/widgets/ToolOptionPopup.h
${GUI_ROOT}/main_window/RecentProjectHistory.h ${GUI_ROOT}/main_window/RecentProjectHistory.h
@@ -37,6 +40,7 @@ set(GUI_HEADERS
${GUI_ROOT}/params/ParamControls.h ${GUI_ROOT}/params/ParamControls.h
${GUI_ROOT}/props/BackgroundPropertySection.h ${GUI_ROOT}/props/BackgroundPropertySection.h
${GUI_ROOT}/props/EntityPropertySection.h ${GUI_ROOT}/props/EntityPropertySection.h
${GUI_ROOT}/props/ToolPropertySection.h
${GUI_ROOT}/props/PropertySectionWidget.h ${GUI_ROOT}/props/PropertySectionWidget.h
${GUI_ROOT}/timeline/TimelineWidget.h ${GUI_ROOT}/timeline/TimelineWidget.h
) )

View File

@@ -17,10 +17,19 @@
#include <QWheelEvent> #include <QWheelEvent>
#include <QKeyEvent> #include <QKeyEvent>
#include <QJsonArray> #include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QMimeData>
#include <QDragEnterEvent>
#include <QDropEvent>
#include <QTextOption>
#include <QIODevice> #include <QIODevice>
#include <QPen> #include <QPen>
#include <QPolygonF> #include <QPolygonF>
#include "core/library/EntityJson.h"
#include "core/library/ToolJson.h"
namespace { namespace {
constexpr int kSamCropMargin = 32; constexpr int kSamCropMargin = 32;
@@ -275,6 +284,37 @@ GizmoHit hitTestGizmo(const QPointF& mouseView, const QPointF& originView) {
return {}; 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<qreal>(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 } // namespace
EditorCanvas::EditorCanvas(QWidget* parent) EditorCanvas::EditorCanvas(QWidget* parent)
@@ -283,6 +323,7 @@ EditorCanvas::EditorCanvas(QWidget* parent)
setMinimumSize(480, 320); setMinimumSize(480, 320);
setFocusPolicy(Qt::StrongFocus); setFocusPolicy(Qt::StrongFocus);
setMouseTracking(true); setMouseTracking(true);
setAcceptDrops(true);
m_previewEmitTimer.start(); m_previewEmitTimer.start();
@@ -297,6 +338,129 @@ EditorCanvas::EditorCanvas(QWidget* parent)
updateCursor(); 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) { void EditorCanvas::setPresentationPreviewMode(bool on) {
if (m_presentationPreviewMode == on) { if (m_presentationPreviewMode == on) {
return; return;
@@ -328,7 +492,9 @@ void EditorCanvas::setPresentationPreviewMode(bool on) {
update(); update();
} }
void EditorCanvas::setEntities(const QVector<core::Project::Entity>& entities, const QString& projectDirAbs) { void EditorCanvas::setEntities(const QVector<core::Project::Entity>& entities,
const QVector<double>& opacities01,
const QString& projectDirAbs) {
const QString prevSelectedId = const QString prevSelectedId =
(m_selectedEntity >= 0 && m_selectedEntity < m_entities.size()) ? m_entities[m_selectedEntity].id : QString(); (m_selectedEntity >= 0 && m_selectedEntity < m_entities.size()) ? m_entities[m_selectedEntity].id : QString();
@@ -344,16 +510,19 @@ void EditorCanvas::setEntities(const QVector<core::Project::Entity>& 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; Entity v;
v.id = e.id; v.id = e.id;
v.hiddenInEditMode = !m_presentationPreviewMode && !e.visible; v.opacity = (iEnt < opacities01.size()) ? std::clamp(opacities01[iEnt], 0.0, 1.0) : 1.0;
using core::KeyInterpolation; // 注意MainWindow 传入的是“按当前帧求值后的实体”(包含父子跟随与曲线采样)。
const QPointF originWorld = // 这里必须直接使用 e.originWorld不能再对 locationKeys 做二次采样,否则父子实体会在刷新时复位/跳变。
core::sampleLocation(e.locationKeys, m_currentFrame, e.originWorld, KeyInterpolation::Linear); const QPointF originWorld = e.originWorld;
v.animatedOriginWorld = originWorld; v.animatedOriginWorld = originWorld;
v.cutoutPolygonWorld = e.cutoutPolygonWorld; v.cutoutPolygonWorld = e.cutoutPolygonWorld;
v.distanceScaleCalibMult = e.distanceScaleCalibMult; v.distanceScaleCalibMult = e.distanceScaleCalibMult;
v.ignoreDistanceScale = e.ignoreDistanceScale;
// 逐帧自动算 z使用实体多边形质心作为锚点采样深度O(1)),避免卡顿 // 逐帧自动算 z使用实体多边形质心作为锚点采样深度O(1)),避免卡顿
QVector<QPointF> polyTmp; QVector<QPointF> polyTmp;
@@ -370,7 +539,7 @@ void EditorCanvas::setEntities(const QVector<core::Project::Entity>& entities, c
const double userScaleAnimated = const double userScaleAnimated =
core::sampleUserScale(e.userScaleKeys, m_currentFrame, e.userScale, core::KeyInterpolation::Linear); core::sampleUserScale(e.userScaleKeys, m_currentFrame, e.userScale, core::KeyInterpolation::Linear);
v.userScale = std::max(1e-6, userScaleAnimated); 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; const double scale = distScale * v.userScale;
v.visualScale = scale; v.visualScale = scale;
@@ -432,6 +601,41 @@ void EditorCanvas::setEntities(const QVector<core::Project::Entity>& entities, c
update(); update();
} }
void EditorCanvas::setTools(const QVector<core::Project::Tool>& tools, const QVector<double>& 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<QString>& entityIds, const QSet<QString>& toolIds) {
m_tempHiddenEntityIds = entityIds;
m_tempHiddenToolIds = toolIds;
update();
}
void EditorCanvas::setCurrentFrame(int frame) { void EditorCanvas::setCurrentFrame(int frame) {
if (m_currentFrame == frame) { if (m_currentFrame == frame) {
return; return;
@@ -475,7 +679,7 @@ double EditorCanvas::selectedDistanceScaleMultiplier() const {
return 1.0; return 1.0;
} }
const auto& ent = m_entities[m_selectedEntity]; 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 { double EditorCanvas::selectedUserScale() const {
@@ -844,7 +1048,10 @@ int EditorCanvas::hitTestEntity(const QPointF& worldPos) const {
for (qsizetype i = m_entities.size(); i > 0; --i) { for (qsizetype i = m_entities.size(); i > 0; --i) {
const qsizetype idx = i - 1; const qsizetype idx = i - 1;
const auto& ent = m_entities[idx]; 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; continue;
} }
if (!ent.polygonWorld.isEmpty()) { if (!ent.polygonWorld.isEmpty()) {
@@ -908,7 +1115,7 @@ void EditorCanvas::paintEvent(QPaintEvent* e) {
m_bgCutoutDirty = false; m_bgCutoutDirty = false;
m_bgImageCutout = m_bgImage; m_bgImageCutout = m_bgImage;
for (const auto& ent : m_entities) { 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); 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) { for (int i = 0; i < m_entities.size(); ++i) {
const auto& ent = m_entities[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; continue;
} }
const bool isDragPreview = (!m_presentationPreviewMode && m_draggingEntity && m_dragPreviewActive && i == m_selectedEntity); 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)); 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 showHover = (i == m_presHoverEntityIndex);
const bool showFocus = (i == m_presFocusedEntityIndex); const bool showFocus = (i == m_presFocusedEntityIndex);
if (showHover || showFocus) { 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<qreal>(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<qreal>(m_scale, 0.001)));
p.drawPath(path);
}
}
// 创建实体手绘轨迹预览world 坐标) // 创建实体手绘轨迹预览world 坐标)
if (!m_presentationPreviewMode && m_tool == Tool::CreateEntity && m_drawingEntity && m_strokeWorld.size() >= 2) { if (!m_presentationPreviewMode && m_tool == Tool::CreateEntity && m_drawingEntity && m_strokeWorld.size() >= 2) {
QPen pen(QColor(255, 120, 0, 220), 2.0 / std::max<qreal>(m_scale, 0.001)); QPen pen(QColor(255, 120, 0, 220), 2.0 / std::max<qreal>(m_scale, 0.001));
@@ -1321,6 +1579,28 @@ void EditorCanvas::mousePressEvent(QMouseEvent* e) {
} }
if (m_tool == Tool::Move && e->button() == Qt::LeftButton) { 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<int>(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 手柄上,则开启轴约束拖动 // 优先:若已选中实体,且点在 gizmo 手柄上,则开启轴约束拖动
if (m_selectedEntity >= 0 && m_selectedEntity < m_entities.size()) { if (m_selectedEntity >= 0 && m_selectedEntity < m_entities.size()) {
const auto& ent = m_entities[m_selectedEntity]; 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); const int hit = hitTestEntity(worldPos);
if (hit >= 0) { if (hit >= 0) {
m_selectedEntity = hit; m_selectedEntity = hit;
m_selectedTool = -1;
m_draggingTool = false;
m_draggingEntity = true; m_draggingEntity = true;
m_dragMode = DragMode::Free; m_dragMode = DragMode::Free;
emit entityDragActiveChanged(true); 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) const QPointF origin = !m_entities[hit].polygonWorld.isEmpty() ? entity_cutout::polygonCentroid(m_entities[hit].polygonWorld)
: m_entities[hit].rect.center(); : m_entities[hit].rect.center();
emit selectedEntityChanged(true, m_entities[hit].id, m_entities[hit].depth, origin); emit selectedEntityChanged(true, m_entities[hit].id, m_entities[hit].depth, origin);
emit selectedToolChanged(false, QString(), QPointF());
update(); update();
return; return;
} }
m_selectedEntity = -1; m_selectedEntity = -1;
m_draggingEntity = false; m_draggingEntity = false;
m_selectedTool = -1;
m_draggingTool = false;
m_dragMode = DragMode::None; m_dragMode = DragMode::None;
emit selectedEntityChanged(false, QString(), 0, QPointF()); emit selectedEntityChanged(false, QString(), 0, QPointF());
emit selectedToolChanged(false, QString(), QPointF());
update(); update();
} }
} }
@@ -1536,7 +1861,7 @@ void EditorCanvas::mouseMoveEvent(QMouseEvent* e) {
const double ds01 = depthToScale01(depthZ); const double ds01 = depthToScale01(depthZ);
ent.animatedDepthScale01 = ds01; ent.animatedDepthScale01 = ds01;
const double newScale = const double newScale =
distanceScaleFromDepth01(ds01, ent.distanceScaleCalibMult) * ent.userScale; (ent.ignoreDistanceScale ? 1.0 : distanceScaleFromDepth01(ds01, ent.distanceScaleCalibMult)) * ent.userScale;
ent.visualScale = newScale; ent.visualScale = newScale;
if (m_dragPreviewActive) { if (m_dragPreviewActive) {
m_dragScaleRatio = std::clamp(newScale / std::max(1e-6, m_dragScaleBase), 0.02, 50.0); 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; 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) || if (m_tool == Tool::Move || (e->buttons() & Qt::MiddleButton) ||
(m_presentationPreviewMode && (e->buttons() & Qt::LeftButton))) { (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; m_dragging = false;
if (m_pendingDragging && e->button() == Qt::LeftButton) { if (m_pendingDragging && e->button() == Qt::LeftButton) {
m_pendingDragging = false; m_pendingDragging = false;
@@ -1636,6 +1981,7 @@ void EditorCanvas::mouseReleaseEvent(QMouseEvent* e) {
emit entityDragActiveChanged(false); emit entityDragActiveChanged(false);
} }
m_draggingEntity = false; m_draggingEntity = false;
m_draggingTool = false;
m_dragPreviewActive = false; m_dragPreviewActive = false;
m_dragMode = DragMode::None; m_dragMode = DragMode::None;
updateCursor(); updateCursor();

View File

@@ -14,6 +14,9 @@
#include <QTimer> #include <QTimer>
class QKeyEvent; class QKeyEvent;
class QDragEnterEvent;
class QDragMoveEvent;
class QDropEvent;
class EditorCanvas final : public QWidget { class EditorCanvas final : public QWidget {
Q_OBJECT Q_OBJECT
@@ -70,7 +73,11 @@ public:
/// 退出「点击实体放大」状态并平滑回到进入前的视图(预览模式) /// 退出「点击实体放大」状态并平滑回到进入前的视图(预览模式)
void clearPresentationEntityFocus(); void clearPresentationEntityFocus();
void setEntities(const QVector<core::Project::Entity>& entities, const QString& projectDirAbs); void setEntities(const QVector<core::Project::Entity>& entities,
const QVector<double>& opacities01,
const QString& projectDirAbs);
void setTools(const QVector<core::Project::Tool>& tools, const QVector<double>& opacities01);
void setTempHiddenIds(const QSet<QString>& entityIds, const QSet<QString>& toolIds);
void setCurrentFrame(int frame); void setCurrentFrame(int frame);
int currentFrame() const { return m_currentFrame; } int currentFrame() const { return m_currentFrame; }
@@ -93,7 +100,9 @@ signals:
void hoveredWorldPosChanged(const QPointF& worldPos); void hoveredWorldPosChanged(const QPointF& worldPos);
void hoveredWorldPosDepthChanged(const QPointF& worldPos, int depthZ); void hoveredWorldPosDepthChanged(const QPointF& worldPos, int depthZ);
void selectedEntityChanged(bool hasSelection, const QString& id, int depth, const QPointF& originWorld); 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 requestAddEntity(const core::Project::Entity& entity, const QImage& image);
void requestAddTool(const core::Project::Tool& tool);
/// 创建实体:将裁剪 RGB、标记叠加层与 SAM 提示发往模型服务,由主窗口收 JSON 轮廓后再 addEntity。 /// 创建实体:将裁剪 RGB、标记叠加层与 SAM 提示发往模型服务,由主窗口收 JSON 轮廓后再 addEntity。
void requestSamSegment( void requestSamSegment(
const QByteArray& cropRgbPng, const QByteArray& cropRgbPng,
@@ -105,6 +114,7 @@ signals:
/// 待确认实体:用户按回车/点击空白处确认后触发(由主窗口弹窗命名并落盘)。 /// 待确认实体:用户按回车/点击空白处确认后触发(由主窗口弹窗命名并落盘)。
void requestFinalizePendingEntity(const QVector<QPointF>& polyWorld); void requestFinalizePendingEntity(const QVector<QPointF>& polyWorld);
void requestMoveEntity(const QString& id, const QPointF& delta); void requestMoveEntity(const QString& id, const QPointF& delta);
void requestMoveTool(const QString& id, const QPointF& delta);
void entityDragActiveChanged(bool on); void entityDragActiveChanged(bool on);
void selectedEntityPreviewChanged(const QString& id, int depth, const QPointF& originWorld); void selectedEntityPreviewChanged(const QString& id, int depth, const QPointF& originWorld);
/// 预览模式下点击实体anchorView 为实体质心在视图中的位置,用于摆放介绍浮层 /// 预览模式下点击实体anchorView 为实体质心在视图中的位置,用于摆放介绍浮层
@@ -120,6 +130,9 @@ protected:
void mouseReleaseEvent(QMouseEvent* e) override; void mouseReleaseEvent(QMouseEvent* e) override;
void wheelEvent(QWheelEvent* e) override; void wheelEvent(QWheelEvent* e) override;
void keyPressEvent(QKeyEvent* e) override; void keyPressEvent(QKeyEvent* e) override;
void dragEnterEvent(QDragEnterEvent* e) override;
void dragMoveEvent(QDragMoveEvent* e) override;
void dropEvent(QDropEvent* e) override;
private: private:
void ensurePixmapLoaded() const; void ensurePixmapLoaded() const;
@@ -154,13 +167,19 @@ private:
double visualScale = 1.0; // 实体在 world 坐标下的缩放(用于贴图绘制) double visualScale = 1.0; // 实体在 world 坐标下的缩放(用于贴图绘制)
double userScale = 1.0; // 与深度距离缩放相乘 double userScale = 1.0; // 与深度距离缩放相乘
double distanceScaleCalibMult = 0.0; // 与 Project::Entity 一致0=未校准 double distanceScaleCalibMult = 0.0; // 与 Project::Entity 一致0=未校准
bool ignoreDistanceScale = false;
QPointF animatedOriginWorld; QPointF animatedOriginWorld;
double animatedDepthScale01 = 0.5; double animatedDepthScale01 = 0.5;
// 编辑模式下实体被设为隐藏时:不响应点选且不绘制,除非当前选中(便于树选隐藏实体 double opacity = 1.0; // 0..1(由可见性轨道求值
bool hiddenInEditMode = false;
}; };
int hitTestEntity(const QPointF& worldPos) const; int hitTestEntity(const QPointF& worldPos) const;
private:
struct ToolView {
core::Project::Tool tool;
double opacity = 1.0; // 0..1
};
private: private:
QString m_bgAbsPath; QString m_bgAbsPath;
bool m_backgroundVisible = true; bool m_backgroundVisible = true;
@@ -210,11 +229,18 @@ private:
qint64 m_lastPreviewEmitMs = 0; qint64 m_lastPreviewEmitMs = 0;
qint64 m_lastDepthScaleRecalcMs = 0; qint64 m_lastDepthScaleRecalcMs = 0;
int m_selectedEntity = -1; int m_selectedEntity = -1;
int m_selectedTool = -1;
bool m_draggingTool = false;
QPointF m_toolDragOffsetOriginWorld;
QPointF m_toolDragStartOriginWorld;
DragMode m_dragMode = DragMode::None; DragMode m_dragMode = DragMode::None;
QPointF m_dragStartMouseWorld; QPointF m_dragStartMouseWorld;
QVector<Entity> m_entities; QVector<Entity> m_entities;
QVector<ToolView> m_tools;
QSet<QString> m_tempHiddenEntityIds;
QSet<QString> m_tempHiddenToolIds;
QVector<QPointF> m_strokeWorld; QVector<QPointF> m_strokeWorld;
int m_currentFrame = 0; int m_currentFrame = 0;

View File

@@ -0,0 +1,264 @@
#include "library/ResourceLibraryDock.h"
#include "core/library/EntityJson.h"
#include "core/library/ToolJson.h"
#include <QApplication>
#include <QDrag>
#include <QJsonArray>
#include <QJsonDocument>
#include <QListWidget>
#include <QMimeData>
#include <QPainter>
#include <QVBoxLayout>
#include <QWidget>
#include <QScrollBar>
#include <algorithm>
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<core::library::LibraryResource>& 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<core::library::LibraryResource> 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<core::library::LibraryResource>& store,
bool isOnline) {
if (!p) {
store.clear();
return;
}
connect(p, &core::library::ResourceLibraryProvider::resourcesReady,
this, [this, &store, isOnline](const QVector<core::library::LibraryResource>& 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<ResourceListWidget*>(m_list);
list->clear();
QVector<core::library::LibraryResource> 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

View File

@@ -0,0 +1,38 @@
#pragma once
#include "core/library/ResourceLibraryProvider.h"
#include <QDockWidget>
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<core::library::LibraryResource> m_localResources;
QVector<core::library::LibraryResource> m_onlineResources;
QListWidget* m_list = nullptr;
bool m_onlineBound = false;
bool m_onlineLoading = false;
bool m_onlineExhausted = false;
};
} // namespace gui

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,7 @@
#include <QTimer> #include <QTimer>
#include <QToolButton> #include <QToolButton>
#include <QSet>
class ToolOptionPopup; class ToolOptionPopup;
@@ -34,7 +35,14 @@ class TimelineWidget;
namespace gui { namespace gui {
class BackgroundPropertySection; class BackgroundPropertySection;
class EntityPropertySection; class EntityPropertySection;
class ToolPropertySection;
class EntityIntroPopup; class EntityIntroPopup;
class ResourceLibraryDock;
}
namespace core::library {
class ResourceLibraryProvider;
class FakeResourceLibraryProvider;
class OnlineResourceLibraryProvider;
} }
class MainWindow : public QMainWindow { class MainWindow : public QMainWindow {
@@ -80,8 +88,10 @@ private:
void createEditMenu(); // 编辑菜单 void createEditMenu(); // 编辑菜单
void createHelpMenu(); // 帮助菜单 void createHelpMenu(); // 帮助菜单
void createViewMenu(); // 视图菜单 void createViewMenu(); // 视图菜单
void createWindowMenu(); // 窗口菜单(资源库等)
void createProjectTreeDock(); void createProjectTreeDock();
void createTimelineDock(); void createTimelineDock();
void createResourceLibraryDock();
void refreshProjectTree(); void refreshProjectTree();
void updateUiEnabledState(); // 更新“可用性/勾选/默认显隐”,不要做业务逻辑 void updateUiEnabledState(); // 更新“可用性/勾选/默认显隐”,不要做业务逻辑
void applyUiMode(UiMode mode); // 统一控制 welcome/editor 两态的显隐策略 void applyUiMode(UiMode mode); // 统一控制 welcome/editor 两态的显隐策略
@@ -99,6 +109,7 @@ private:
void refreshPreviewPage(); void refreshPreviewPage();
void refreshEditorPage(); void refreshEditorPage();
void applyTimelineFromProject(); void applyTimelineFromProject();
void updateTimelineTracks();
void refreshDopeSheet(); void refreshDopeSheet();
void setPreviewRequested(bool preview); void setPreviewRequested(bool preview);
void syncPreviewPlaybackBar(); void syncPreviewPlaybackBar();
@@ -115,6 +126,7 @@ private:
QStackedWidget* m_propertyStack = nullptr; QStackedWidget* m_propertyStack = nullptr;
gui::BackgroundPropertySection* m_bgPropertySection = nullptr; gui::BackgroundPropertySection* m_bgPropertySection = nullptr;
gui::EntityPropertySection* m_entityPropertySection = nullptr; gui::EntityPropertySection* m_entityPropertySection = nullptr;
gui::ToolPropertySection* m_toolPropertySection = nullptr;
QToolButton* m_btnCreateEntity = nullptr; QToolButton* m_btnCreateEntity = nullptr;
ToolOptionPopup* m_createEntityPopup = nullptr; ToolOptionPopup* m_createEntityPopup = nullptr;
QToolButton* m_btnToggleDepthOverlay = nullptr; QToolButton* m_btnToggleDepthOverlay = nullptr;
@@ -125,6 +137,7 @@ private:
QDockWidget* m_dockProjectTree = nullptr; QDockWidget* m_dockProjectTree = nullptr;
QDockWidget* m_dockProperties = nullptr; QDockWidget* m_dockProperties = nullptr;
QDockWidget* m_dockTimeline = nullptr; QDockWidget* m_dockTimeline = nullptr;
QDockWidget* m_dockResourceLibrary = nullptr;
QTreeWidgetItem* m_itemBackground = nullptr; QTreeWidgetItem* m_itemBackground = nullptr;
QAction* m_actionUndo = nullptr; QAction* m_actionUndo = nullptr;
@@ -134,6 +147,7 @@ private:
QAction* m_actionToggleProjectTree = nullptr; QAction* m_actionToggleProjectTree = nullptr;
QAction* m_actionToggleProperties = nullptr; QAction* m_actionToggleProperties = nullptr;
QAction* m_actionToggleTimeline = nullptr; QAction* m_actionToggleTimeline = nullptr;
QAction* m_actionToggleResourceLibrary = nullptr;
QAction* m_actionEnterPreview = nullptr; QAction* m_actionEnterPreview = nullptr;
QAction* m_actionBackToEditor = nullptr; QAction* m_actionBackToEditor = nullptr;
QAction* m_actionCanvasWorldAxes = nullptr; QAction* m_actionCanvasWorldAxes = nullptr;
@@ -152,13 +166,18 @@ private:
QPointF m_lastWorldPos; QPointF m_lastWorldPos;
int m_lastWorldZ = -1; int m_lastWorldZ = -1;
bool m_hasSelectedEntity = false; bool m_hasSelectedEntity = false;
bool m_hasSelectedTool = false;
bool m_syncingTreeSelection = false; bool m_syncingTreeSelection = false;
int m_selectedEntityDepth = 0; int m_selectedEntityDepth = 0;
QPointF m_selectedEntityOrigin; QPointF m_selectedEntityOrigin;
QString m_selectedEntityId; QString m_selectedEntityId;
QString m_selectedToolId;
QString m_selectedEntityDisplayNameCache; QString m_selectedEntityDisplayNameCache;
QString m_bgAbsCache; QString m_bgAbsCache;
QString m_bgSizeTextCache; QString m_bgSizeTextCache;
// 项目树“眼睛”:仅用于画布临时隐藏(不持久化、不进时间轴)
QSet<QString> m_tempHiddenEntityIds;
QSet<QString> m_tempHiddenToolIds;
void updateStatusBarText(); void updateStatusBarText();
void syncCreateEntityToolButtonTooltip(); void syncCreateEntityToolButtonTooltip();
void refreshPropertyPanel(); void refreshPropertyPanel();
@@ -174,7 +193,7 @@ private:
QTimer* m_playTimer = nullptr; QTimer* m_playTimer = nullptr;
TimelineWidget* m_timeline = nullptr; TimelineWidget* m_timeline = nullptr;
QToolButton* m_btnPlay = nullptr; QToolButton* m_btnPlay = nullptr;
QLabel* m_frameLabel = nullptr; QComboBox* m_schemeSelector = nullptr;
// 时间轴区间选择(用于逐帧贴图动画) // 时间轴区间选择(用于逐帧贴图动画)
int m_timelineRangeStart = -1; int m_timelineRangeStart = -1;
int m_timelineRangeEnd = -1; int m_timelineRangeEnd = -1;
@@ -187,4 +206,7 @@ private:
QFrame* m_previewPlaybackBar = nullptr; QFrame* m_previewPlaybackBar = nullptr;
QToolButton* m_previewBtnPlay = nullptr; QToolButton* m_previewBtnPlay = nullptr;
QToolButton* m_previewBtnPause = nullptr; QToolButton* m_previewBtnPause = nullptr;
gui::ResourceLibraryDock* m_resourceLibraryDockWidget = nullptr;
core::library::ResourceLibraryProvider* m_resourceLibraryProvider = nullptr;
}; };

View File

@@ -5,6 +5,7 @@
#include <QDoubleSpinBox> #include <QDoubleSpinBox>
#include <QFormLayout> #include <QFormLayout>
#include <QHBoxLayout> #include <QHBoxLayout>
#include <QCheckBox>
#include <QLabel> #include <QLabel>
#include <QLineEdit> #include <QLineEdit>
#include <QListWidget> #include <QListWidget>
@@ -55,6 +56,15 @@ EntityPropertySection::EntityPropertySection(QWidget* parent)
m_userScale->setToolTip(QStringLiteral("人为整体缩放,与深度距离缩放相乘")); m_userScale->setToolTip(QStringLiteral("人为整体缩放,与深度距离缩放相乘"));
form->addRow(QStringLiteral("整体缩放"), m_userScale); 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); lay->addLayout(form);
m_introHeader = new QWidget(this); m_introHeader = new QWidget(this);
@@ -133,6 +143,8 @@ EntityPropertySection::EntityPropertySection(QWidget* parent)
connect(m_pivot, &Vec2ParamControl::valueChanged, this, &EntityPropertySection::pivotEdited); connect(m_pivot, &Vec2ParamControl::valueChanged, this, &EntityPropertySection::pivotEdited);
connect(m_centroid, &Vec2ParamControl::valueChanged, this, &EntityPropertySection::centroidEdited); connect(m_centroid, &Vec2ParamControl::valueChanged, this, &EntityPropertySection::centroidEdited);
connect(m_userScale, qOverload<double>(&QDoubleSpinBox::valueChanged), this, &EntityPropertySection::userScaleEdited); connect(m_userScale, qOverload<double>(&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_introTitle, &QLineEdit::textChanged, this, [this](const QString&) { scheduleIntroPersist(); });
connect(m_introBody, &QTextEdit::textChanged, this, [this]() { scheduleIntroPersist(); }); connect(m_introBody, &QTextEdit::textChanged, this, [this]() { scheduleIntroPersist(); });
@@ -183,6 +195,16 @@ void EntityPropertySection::clearDisconnected() {
m_userScale->setValue(1.0); m_userScale->setValue(1.0);
m_userScale->blockSignals(false); 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) { if (m_introTitle) {
m_introTitle->blockSignals(true); m_introTitle->blockSignals(true);
m_introTitle->clear(); m_introTitle->clear();
@@ -224,6 +246,16 @@ void EntityPropertySection::applyState(const EntityPropertyUiState& s) {
m_userScale->setValue(s.userScale); m_userScale->setValue(s.userScale);
m_userScale->blockSignals(false); 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) { if (m_introTitle) {
m_introTitle->blockSignals(true); m_introTitle->blockSignals(true);
m_introTitle->setText(s.intro.title); m_introTitle->setText(s.intro.title);
@@ -288,6 +320,8 @@ void EntityPropertySection::setEditingEnabled(bool on) {
if (m_pivot) m_pivot->setEnabled(on); if (m_pivot) m_pivot->setEnabled(on);
if (m_centroid) m_centroid->setEnabled(on); if (m_centroid) m_centroid->setEnabled(on);
if (m_userScale) m_userScale->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_introHeader) m_introHeader->setEnabled(on);
if (m_introToggle) m_introToggle->setEnabled(on); if (m_introToggle) m_introToggle->setEnabled(on);
if (m_introTitle) m_introTitle->setEnabled(on); if (m_introTitle) m_introTitle->setEnabled(on);

View File

@@ -9,6 +9,7 @@
class QLabel; class QLabel;
class QLineEdit; class QLineEdit;
class QDoubleSpinBox; class QDoubleSpinBox;
class QCheckBox;
class QTextEdit; class QTextEdit;
class QListWidget; class QListWidget;
class QPushButton; class QPushButton;
@@ -29,6 +30,8 @@ struct EntityPropertyUiState {
QPointF pivot; QPointF pivot;
QPointF centroid; QPointF centroid;
double userScale = 1.0; double userScale = 1.0;
bool ignoreDistanceScale = false;
bool visible = true;
core::EntityIntroContent intro; core::EntityIntroContent intro;
}; };
@@ -49,6 +52,9 @@ signals:
void pivotEdited(double x, double y); void pivotEdited(double x, double y);
void centroidEdited(double x, double y); void centroidEdited(double x, double y);
void userScaleEdited(double value); void userScaleEdited(double value);
void ignoreDistanceScaleToggled(bool on);
// 可见性(动画通道):在当前帧写关键帧
void visibleToggled(bool on);
/// 介绍字段变更后防抖触发,由主窗口写入工程 /// 介绍字段变更后防抖触发,由主窗口写入工程
void introContentEdited(); void introContentEdited();
void introAddImageRequested(); void introAddImageRequested();
@@ -63,6 +69,8 @@ private:
Vec2ParamControl* m_pivot = nullptr; Vec2ParamControl* m_pivot = nullptr;
Vec2ParamControl* m_centroid = nullptr; Vec2ParamControl* m_centroid = nullptr;
QDoubleSpinBox* m_userScale = nullptr; QDoubleSpinBox* m_userScale = nullptr;
QCheckBox* m_ignoreDistanceScale = nullptr;
QCheckBox* m_visible = nullptr;
QLineEdit* m_introTitle = nullptr; QLineEdit* m_introTitle = nullptr;
QTextEdit* m_introBody = nullptr; QTextEdit* m_introBody = nullptr;

View File

@@ -0,0 +1,130 @@
#include "props/ToolPropertySection.h"
#include <algorithm>
#include <QComboBox>
#include <QFormLayout>
#include <QCheckBox>
#include <QLineEdit>
#include <QSlider>
#include <QSpinBox>
#include <QVBoxLayout>
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<int>(&QSpinBox::valueChanged), this, &ToolPropertySection::fontPxChanged);
connect(m_align, qOverload<int>(&QComboBox::currentIndexChanged), this, &ToolPropertySection::alignChanged);
connect(m_visible, &QCheckBox::toggled, this, &ToolPropertySection::visibleToggled);
}
void ToolPropertySection::setEditingEnabled(bool on) {
for (auto* w : {static_cast<QWidget*>(m_text), static_cast<QWidget*>(m_pointerT),
static_cast<QWidget*>(m_fontPx), static_cast<QWidget*>(m_align),
static_cast<QWidget*>(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

View File

@@ -0,0 +1,50 @@
#pragma once
#include "props/PropertySectionWidget.h"
#include <QString>
class QComboBox;
class QLineEdit;
class QSlider;
class QSpinBox;
class QCheckBox;
namespace gui {
struct ToolPropertyUiState {
QString displayName;
QString text;
int pointerTThousandths = 500; // bubblePointerT01 * 10000=左 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

View File

@@ -1,16 +1,33 @@
#include "timeline/TimelineWidget.h" #include "timeline/TimelineWidget.h"
#include <algorithm> #include <algorithm>
#include <cmath>
#include <QFontMetrics>
#include <QMouseEvent> #include <QMouseEvent>
#include <QPainter> #include <QPainter>
#include <QResizeEvent>
#include <QWheelEvent> #include <QWheelEvent>
namespace { namespace {
int clampFrame(int f, int a, int b) { static int pickMajorStep(double pixelsPerFrame) {
if (a > b) std::swap(a, b); // 主刻度间距(帧):保证屏幕上大约 ≥ 48px
return std::clamp(f, a, b); 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 } // namespace
@@ -18,17 +35,20 @@ int clampFrame(int f, int a, int b) {
TimelineWidget::TimelineWidget(QWidget* parent) TimelineWidget::TimelineWidget(QWidget* parent)
: QWidget(parent) { : QWidget(parent) {
setMouseTracking(true); setMouseTracking(true);
setMinimumHeight(28); // 单行紧凑:标尺 + 轨道(帧号画在播放头处,随坐标轴滚动)
setMinimumHeight(kRulerHeight + 18 + 6);
setFocusPolicy(Qt::StrongFocus); setFocusPolicy(Qt::StrongFocus);
setToolTip(QStringLiteral("片段时间轴(固定 0-600):左键拖动播放头;滚轮:逐帧"));
}
void TimelineWidget::resizeEvent(QResizeEvent* e) {
QWidget::resizeEvent(e);
update();
} }
void TimelineWidget::setFrameRange(int start, int end) { void TimelineWidget::setFrameRange(int start, int end) {
if (m_start == start && m_end == end) { (void)start;
return; (void)end;
}
m_start = start;
m_end = end;
m_currentFrame = clampFrame(m_currentFrame, m_start, m_end);
update(); update();
} }
@@ -36,6 +56,15 @@ void TimelineWidget::setCurrentFrame(int frame) {
setFrameInternal(frame, false); 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) { void TimelineWidget::setSelectionRange(int start, int end) {
if (start < 0 || end < 0) { if (start < 0 || end < 0) {
m_selStart = -1; m_selStart = -1;
@@ -43,41 +72,40 @@ void TimelineWidget::setSelectionRange(int start, int end) {
update(); update();
return; return;
} }
m_selStart = clampFrame(std::min(start, end), m_start, m_end); const int lo = std::min(start, end);
m_selEnd = clampFrame(std::max(start, end), m_start, m_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(); update();
} }
void TimelineWidget::setKeyframeTracks(const core::Project::Entity* e) { static void uniqSort(QVector<int>& v) {
m_locFrames.clear(); std::sort(v.begin(), v.end());
m_scaleFrames.clear(); v.erase(std::unique(v.begin(), v.end()), v.end());
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);
auto uniqSort = [](QVector<int>& v) { static bool containsFrame(const QVector<int>& v, int f) {
std::sort(v.begin(), v.end()); return std::binary_search(v.begin(), v.end(), f);
v.erase(std::unique(v.begin(), v.end()), v.end()); }
};
void TimelineWidget::setKeyframeTracks(const QVector<int>& locFrames,
const QVector<int>& scaleFrames,
const QVector<int>& imgFrames,
const QVector<int>& visFrames) {
m_locFrames = locFrames;
m_scaleFrames = scaleFrames;
m_imgFrames = imgFrames;
m_visFrames = visFrames;
uniqSort(m_locFrames); uniqSort(m_locFrames);
uniqSort(m_scaleFrames); uniqSort(m_scaleFrames);
uniqSort(m_imgFrames); uniqSort(m_imgFrames);
// 轨道变了:若当前选中的关键帧不再存在,则清除 uniqSort(m_visFrames);
auto contains = [](const QVector<int>& v, int f) {
return std::binary_search(v.begin(), v.end(), f);
};
bool ok = true; bool ok = true;
if (m_selKeyKind == KeyKind::Location) ok = contains(m_locFrames, m_selKeyFrame); if (m_selKeyKind == KeyKind::Location) ok = containsFrame(m_locFrames, m_selKeyFrame);
if (m_selKeyKind == KeyKind::UserScale) ok = contains(m_scaleFrames, m_selKeyFrame); if (m_selKeyKind == KeyKind::UserScale) ok = containsFrame(m_scaleFrames, m_selKeyFrame);
if (m_selKeyKind == KeyKind::Image) ok = contains(m_imgFrames, 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) { if (!ok) {
m_selKeyKind = KeyKind::None; m_selKeyKind = KeyKind::None;
m_selKeyFrame = -1; m_selKeyFrame = -1;
@@ -86,33 +114,67 @@ void TimelineWidget::setKeyframeTracks(const core::Project::Entity* e) {
update(); update();
} }
QRect TimelineWidget::trackRect() const { void TimelineWidget::setToolKeyframeTracks(const QVector<int>& locFrames,
const int pad = 8; const QVector<int>& visFrames) {
const int h = height(); m_locFrames = locFrames;
return QRect(pad, 0, std::max(1, width() - pad * 2), h); 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 { QRect TimelineWidget::contentRect() const {
const QRect r = trackRect(); return QRect(contentLeft(), 3, contentWidth(), std::max(24, height() - 6));
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); QRect TimelineWidget::rulerRect() const {
const int f = m_start + int(std::round(t * span)); const QRect c = contentRect();
return clampFrame(f, m_start, m_end); 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 { int TimelineWidget::frameToX(int frame) const {
const QRect r = trackRect(); return int(std::lround(frameToXf(double(frame))));
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); double TimelineWidget::xToFramef(int x) const {
const double t = double(f - m_start) / double(span); const double pxf = double(contentWidth()) / double(std::max(1, kEnd - kStart));
return r.left() + int(std::round(t * (r.width() - 1))); 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) { void TimelineWidget::setFrameInternal(int frame, bool commit) {
const int f = clampFrame(frame, m_start, m_end); const int f = std::clamp(frame, kStart, kEnd - 1);
if (m_currentFrame == f && !commit) { // 松手时若帧未变:只发 committed禁止再发 scrubbed否则主窗口会双次求值/刷新导致帧号与红线闪烁
if (m_currentFrame == f) {
if (commit) {
emit frameCommitted(f);
}
return; return;
} }
m_currentFrame = f; m_currentFrame = f;
@@ -127,64 +189,164 @@ void TimelineWidget::paintEvent(QPaintEvent*) {
QPainter p(this); QPainter p(this);
p.setRenderHint(QPainter::Antialiasing, true); p.setRenderHint(QPainter::Antialiasing, true);
const QRect r = rect(); p.fillRect(rect(), palette().base());
p.fillRect(r, palette().base());
const QRect tr = trackRect().adjusted(0, 8, 0, -8); const QRect cr = contentRect();
const QColor rail = palette().mid().color(); const QRect kr = keyAreaRect();
p.setPen(Qt::NoPen); const QRect rr = rulerRect();
p.setBrush(rail); const double fLeft = double(kStart);
p.drawRoundedRect(tr, 6, 6); 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) { if (m_selStart >= 0 && m_selEnd >= 0) {
const int x0 = frameToX(m_selStart); const int x0 = frameToX(m_selStart);
const int x1 = frameToX(m_selEnd); const int x1 = frameToX(m_selEnd);
QRect sel(QPoint(std::min(x0, x1), tr.top()), QPoint(std::max(x0, x1), tr.bottom())); const int xa = std::min(x0, x1);
sel = sel.adjusted(0, 2, 0, -2); const int xb = std::max(x0, x1);
QColor c = palette().highlight().color(); QRect sel(xa, cr.top(), xb - xa, cr.height());
c.setAlpha(50); sel = sel.intersected(cr);
p.setBrush(c); if (sel.width() > 0) {
p.drawRoundedRect(sel, 4, 4); 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<int>& frames, const QColor& c, int y) { // —— 关键帧切分背景(仅轨道):按可见范围切分,不因 m_end 隐藏尾部关键帧 ——
p.setBrush(c); QVector<int> allK;
p.setPen(Qt::NoPen); allK.reserve(m_locFrames.size() + m_scaleFrames.size() + m_imgFrames.size() + m_visFrames.size());
for (int f : frames) { allK += m_locFrames;
if (f < m_start || f > m_end) continue; allK += m_scaleFrames;
const int x = frameToX(f); allK += m_imgFrames;
const bool sel = allK += m_visFrames;
(m_selKeyFrame == f) std::sort(allK.begin(), allK.end());
&& ((m_selKeyKind == KeyKind::Image && &frames == &m_imgFrames) allK.erase(std::unique(allK.begin(), allK.end()), allK.end());
|| (m_selKeyKind == KeyKind::Location && &frames == &m_locFrames)
|| (m_selKeyKind == KeyKind::UserScale && &frames == &m_scaleFrames)); const int v0 = visMin;
if (sel) { const int v1 = visMax;
p.setPen(QPen(palette().highlight().color(), 2.0)); QVector<int> cuts;
p.setBrush(c); cuts.reserve(allK.size() + 4);
p.drawEllipse(QPointF(x, y), 4.4, 4.4); cuts.push_back(v0);
p.setPen(Qt::NoPen); for (int k : allK) {
} else { if (k > v0 && k < v1) {
p.drawEllipse(QPointF(x, y), 2.6, 2.6); 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(); for (int fr : m_imgFrames) drawKeyLine(fr, QColor(70, 130, 240), KeyKind::Image, -3);
drawDots(m_imgFrames, QColor(80, 160, 255, 230), yMid - 6); for (int fr : m_locFrames) drawKeyLine(fr, QColor(240, 110, 40), KeyKind::Location, -1);
drawDots(m_locFrames, QColor(255, 120, 0, 230), yMid); for (int fr : m_scaleFrames) drawKeyLine(fr, QColor(80, 190, 90), KeyKind::UserScale, 1);
drawDots(m_scaleFrames, QColor(140, 220, 140, 230), yMid + 6); 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<int>(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); const int cx = frameToX(m_currentFrame);
p.setPen(QPen(palette().highlight().color(), 2.0)); p.setPen(QPen(QColor(220, 55, 55, 250), 2.0));
p.drawLine(QPoint(cx, tr.top() - 6), QPoint(cx, tr.bottom() + 6)); p.drawLine(QPoint(cx, cr.top()), QPoint(cx, cr.bottom()));
}
static bool hitDot(const QPoint& pos, int dotX, int dotY, int radiusPx) { QFont bf = font();
const int dx = pos.x() - dotX; bf.setBold(true);
const int dy = pos.y() - dotY; p.setFont(bf);
return (dx * dx + dy * dy) <= (radiusPx * radiusPx); 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<int>& frames, int frame) { static int findNearestFrameInTrack(const QVector<int>& frames, int frame) {
@@ -241,44 +403,36 @@ void TimelineWidget::mouseReleaseEvent(QMouseEvent* e) {
const int f = xToFrame(e->pos().x()); const int f = xToFrame(e->pos().x());
setFrameInternal(f, true); setFrameInternal(f, true);
// 点击(非拖拽)时做选中:关键帧或区间
if (!m_moved) { if (!m_moved) {
const QRect tr = trackRect().adjusted(0, 8, 0, -8); const QRect cr = contentRect();
const int yMid = tr.center().y(); const int mx = e->pos().x();
const int yImg = yMid - 6; const int my = e->pos().y();
const int yLoc = yMid;
const int ySc = yMid + 6;
const int rad = 7;
auto trySelectKey = [&](KeyKind kind, const QVector<int>& frames, int laneY) -> bool { auto trySelectKey = [&](KeyKind kind, const QVector<int>& frames, int xOff) -> bool {
const int nearest = findNearestFrameInTrack(frames, f); const int nearest = findNearestFrameInTrack(frames, f);
if (nearest < 0) return false; if (nearest < 0) return false;
const int x = frameToX(nearest); const int xk = frameToX(nearest) + xOff;
if (hitDot(e->pos(), x, laneY, rad)) { if (std::abs(mx - xk) > 9) return false;
m_selKeyKind = kind; if (my < cr.top() || my > cr.bottom()) return false;
m_selKeyFrame = nearest; m_selKeyKind = kind;
emit keyframeSelectionChanged(m_selKeyKind, m_selKeyFrame); m_selKeyFrame = nearest;
update(); emit keyframeSelectionChanged(m_selKeyKind, m_selKeyFrame);
return true; update();
} return true;
return false;
}; };
// 先尝试命中关键帧(按 lane 优先) if (trySelectKey(KeyKind::Visibility, m_visFrames, 3) || trySelectKey(KeyKind::Image, m_imgFrames, -3)
if (trySelectKey(KeyKind::Image, m_imgFrames, yImg) || trySelectKey(KeyKind::Location, m_locFrames, -1) || trySelectKey(KeyKind::UserScale, m_scaleFrames, 1)) {
|| trySelectKey(KeyKind::Location, m_locFrames, yLoc)
|| trySelectKey(KeyKind::UserScale, m_scaleFrames, ySc)) {
// 选中关键帧时清掉区间
if (m_selStart >= 0 && m_selEnd >= 0) { if (m_selStart >= 0 && m_selEnd >= 0) {
m_selStart = -1; m_selStart = -1;
m_selEnd = -1; m_selEnd = -1;
emit intervalSelectionChanged(m_selStart, m_selEnd); emit intervalSelectionChanged(m_selStart, m_selEnd);
} }
} else { } else {
// 未命中关键帧:尝试选中由关键帧切分出的区间(使用三轨道的并集)
QVector<int> all = m_locFrames; QVector<int> all = m_locFrames;
all += m_scaleFrames; all += m_scaleFrames;
all += m_imgFrames; all += m_imgFrames;
all += m_visFrames;
std::sort(all.begin(), all.end()); std::sort(all.begin(), all.end());
all.erase(std::unique(all.begin(), all.end()), all.end()); all.erase(std::unique(all.begin(), all.end()), all.end());
int a = -1, b = -1; int a = -1, b = -1;
@@ -286,7 +440,6 @@ void TimelineWidget::mouseReleaseEvent(QMouseEvent* e) {
if (a >= 0 && b >= 0) { if (a >= 0 && b >= 0) {
setSelectionRange(a, b); setSelectionRange(a, b);
emit intervalSelectionChanged(m_selStart, m_selEnd); emit intervalSelectionChanged(m_selStart, m_selEnd);
// 选中区间时清掉关键帧选中
if (m_selKeyKind != KeyKind::None) { if (m_selKeyKind != KeyKind::None) {
m_selKeyKind = KeyKind::None; m_selKeyKind = KeyKind::None;
m_selKeyFrame = -1; m_selKeyFrame = -1;
@@ -303,8 +456,9 @@ void TimelineWidget::mouseReleaseEvent(QMouseEvent* e) {
} }
void TimelineWidget::wheelEvent(QWheelEvent* e) { void TimelineWidget::wheelEvent(QWheelEvent* e) {
const int delta = (e->angleDelta().y() > 0) ? 1 : -1; const double steps = e->angleDelta().y() / 120.0;
setFrameInternal(m_currentFrame + delta, true); const int delta = (steps > 0) ? 1 : -1;
// 滚轮只走 scrubbed避免每格同时 scrub+committed 造成双次 refresh 与帧号闪烁
setFrameInternal(m_currentFrame + delta, false);
e->accept(); e->accept();
} }

View File

@@ -1,33 +1,41 @@
#pragma once #pragma once
#include "core/domain/Project.h"
#include <QWidget> #include <QWidget>
class QResizeEvent;
class TimelineWidget final : public QWidget { class TimelineWidget final : public QWidget {
Q_OBJECT Q_OBJECT
public: public:
explicit TimelineWidget(QWidget* parent = nullptr); explicit TimelineWidget(QWidget* parent = nullptr);
// 兼容旧接口NLA/片段系统下时间轴始终固定为 0..600local frame
void setFrameRange(int start, int end); void setFrameRange(int start, int end);
void setCurrentFrame(int frame); void setCurrentFrame(int frame);
/// 由主窗口同步工程帧时调用:不发射 frameScrubbed避免与拖动/刷新打架造成数字闪烁
void setCurrentFrameProgrammatic(int frame);
int currentFrame() const { return m_currentFrame; } int currentFrame() const { return m_currentFrame; }
void setSelectionRange(int start, int end); // -1,-1 清除 void setSelectionRange(int start, int end); // -1,-1 清除
int selectionStart() const { return m_selStart; } int selectionStart() const { return m_selStart; }
int selectionEnd() const { return m_selEnd; } int selectionEnd() const { return m_selEnd; }
// 只显示“当前选中实体”的关键帧标记 // 轨道数据直接由上层提供(通常来自当前条带引用的 clip
void setKeyframeTracks(const core::Project::Entity* entityOrNull); void setKeyframeTracks(const QVector<int>& locFrames,
const QVector<int>& scaleFrames,
const QVector<int>& imgFrames,
const QVector<int>& visFrames);
void setToolKeyframeTracks(const QVector<int>& locFrames,
const QVector<int>& visFrames);
enum class KeyKind { None, Location, UserScale, Image }; enum class KeyKind { None, Location, UserScale, Image, Visibility };
KeyKind selectedKeyKind() const { return m_selKeyKind; } KeyKind selectedKeyKind() const { return m_selKeyKind; }
int selectedKeyFrame() const { return m_selKeyFrame; } int selectedKeyFrame() const { return m_selKeyFrame; }
bool hasSelectedKeyframe() const { return m_selKeyKind != KeyKind::None && m_selKeyFrame >= 0; } bool hasSelectedKeyframe() const { return m_selKeyKind != KeyKind::None && m_selKeyFrame >= 0; }
signals: signals:
void frameScrubbed(int frame); // 拖动中实时触发(用于实时预览) void frameScrubbed(int frame);
void frameCommitted(int frame); // 松手/点击确认(用于较重的刷新) void frameCommitted(int frame);
void contextMenuRequested(const QPoint& globalPos, int frame); void contextMenuRequested(const QPoint& globalPos, int frame);
void keyframeSelectionChanged(KeyKind kind, int frame); void keyframeSelectionChanged(KeyKind kind, int frame);
void intervalSelectionChanged(int start, int end); void intervalSelectionChanged(int start, int end);
@@ -38,18 +46,26 @@ protected:
void mouseMoveEvent(QMouseEvent*) override; void mouseMoveEvent(QMouseEvent*) override;
void mouseReleaseEvent(QMouseEvent*) override; void mouseReleaseEvent(QMouseEvent*) override;
void wheelEvent(QWheelEvent*) override; void wheelEvent(QWheelEvent*) override;
void resizeEvent(QResizeEvent* e) override;
private: 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; 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); void setFrameInternal(int frame, bool commit);
private: static constexpr int kStart = 0;
int m_start = 0; static constexpr int kEnd = 600; // exclusive for mapping, inclusive for UI labels
int m_end = 600; int m_currentFrame = 0; // local frame: 0..599
int m_currentFrame = 0;
int m_selStart = -1; int m_selStart = -1;
int m_selEnd = -1; int m_selEnd = -1;
@@ -58,12 +74,13 @@ private:
QPoint m_pressPos; QPoint m_pressPos;
bool m_moved = false; bool m_moved = false;
// snapshot避免频繁遍历 workspace static constexpr int kRulerHeight = 14;
QVector<int> m_locFrames; QVector<int> m_locFrames;
QVector<int> m_scaleFrames; QVector<int> m_scaleFrames;
QVector<int> m_imgFrames; QVector<int> m_imgFrames;
QVector<int> m_visFrames;
KeyKind m_selKeyKind = KeyKind::None; KeyKind m_selKeyKind = KeyKind::None;
int m_selKeyFrame = -1; int m_selKeyFrame = -1;
}; };