Compare commits

..

5 Commits

Author SHA1 Message Date
0710090b4d 新增模型补全空洞 2026-04-09 23:38:14 +08:00
6cb82cec57 update 2026-04-09 23:13:33 +08:00
d67d7dc0c5 新增根据帧数控制可见性 2026-04-09 17:30:55 +08:00
e116a9ec79 修复动画问题 2026-04-08 15:01:17 +08:00
a79c31a056 添加模型分割 2026-04-08 14:37:01 +08:00
51 changed files with 8030 additions and 613 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,14 +40,25 @@ 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 坐标,不随实体移动
QVector<QPointF> cutoutPolygonWorld; QVector<QPointF> cutoutPolygonWorld;
// 背景空缺标识:用于项目树节点和交互,空则按 id 生成默认值
QString blackholeId;
// 背景空缺可见性:与实体可见性轨道解耦,控制背景空缺是否显示
bool blackholeVisible = true;
// 背景空缺修复方案copy_background / use_original_background / model_inpaint预留
QString blackholeResolvedBy;
QPointF originWorld; QPointF originWorld;
int depth = 0; // 0..255 int depth = 0; // 0..255
QString imagePath; // 相对路径,例如 "assets/entities/entity-1.png" QString imagePath; // 相对路径,例如 "assets/entities/entity-1.png"
@@ -55,6 +69,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 +105,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 +246,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,331 @@
#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 sampledRelativeForEntity(const core::Project::Entity& e,
const core::Project::AnimationClip* clipOrNull,
int localFrame) {
if (clipOrNull && clipOrNull->entityLocationKeys.contains(e.id)) {
const auto& keys = clipOrNull->entityLocationKeys.value(e.id);
return core::sampleLocation(keys, localFrame, e.parentOffsetWorld, core::KeyInterpolation::Linear);
}
return core::sampleLocation(e.locationKeys, localFrame, e.parentOffsetWorld, core::KeyInterpolation::Linear);
}
QPointF sampledOriginForTool(const core::Project::Tool& t,
const core::Project::AnimationClip* clipOrNull,
int localFrame) {
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);
}
QPointF sampledRelativeForTool(const core::Project::Tool& t,
const core::Project::AnimationClip* clipOrNull,
int localFrame) {
if (clipOrNull && clipOrNull->toolLocationKeys.contains(t.id)) {
const auto& keys = clipOrNull->toolLocationKeys.value(t.id);
return core::sampleLocation(keys, localFrame, t.parentOffsetWorld, core::KeyInterpolation::Linear);
}
return core::sampleLocation(t.locationKeys, localFrame, t.parentOffsetWorld, core::KeyInterpolation::Linear);
}
struct VisKey {
int frame = 0;
bool value = true;
};
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 selfSampled;
if (r.kind == NodeRef::Kind::Entity) {
const auto& e = ents[r.index];
parentId = e.parentId;
selfSampled = sampledOriginForEntity(e, clip, localFrame);
} else {
const auto& t = tools[r.index];
parentId = t.parentId;
selfSampled = sampledOriginForTool(t, clip, localFrame);
}
QPointF outO = selfSampled;
if (!parentId.isEmpty() && index.contains(parentId)) {
const QPointF po = resolve(parentId);
const QPointF rel = (r.kind == NodeRef::Kind::Entity)
? sampledRelativeForEntity(ents[r.index], clip, localFrame)
: sampledRelativeForTool(tools[r.index], clip, localFrame);
outO = po + rel;
}
resolving.insert(id, false);
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,314 @@
#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("blackholeId"), e.blackholeId);
o.insert(QStringLiteral("blackholeVisible"), e.blackholeVisible);
o.insert(QStringLiteral("blackholeResolvedBy"), e.blackholeResolvedBy);
o.insert(QStringLiteral("originWorld"), pointToJson(e.originWorld));
o.insert(QStringLiteral("depth"), e.depth);
o.insert(QStringLiteral("imagePath"), e.imagePath);
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();
}
e.blackholeId = o.value(QStringLiteral("blackholeId")).toString();
if (e.blackholeId.isEmpty() && !e.id.isEmpty()) {
e.blackholeId = QStringLiteral("blackhole-%1").arg(e.id);
}
e.blackholeVisible = o.value(QStringLiteral("blackholeVisible")).toBool(true);
e.blackholeResolvedBy = o.value(QStringLiteral("blackholeResolvedBy")).toString();
{
QPointF p;
if (!pointFromJson(o.value(QStringLiteral("originWorld")), p)) {
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

@@ -1,6 +1,7 @@
#include "net/ModelServerClient.h" #include "net/ModelServerClient.h"
#include <QEventLoop> #include <QEventLoop>
#include <QJsonArray>
#include <QJsonDocument> #include <QJsonDocument>
#include <QJsonObject> #include <QJsonObject>
#include <QNetworkAccessManager> #include <QNetworkAccessManager>
@@ -49,6 +50,84 @@ QNetworkReply* ModelServerClient::computeDepthPng8Async(const QByteArray& imageB
return m_nam->post(req, body); return m_nam->post(req, body);
} }
QNetworkReply* ModelServerClient::segmentSamPromptAsync(
const QByteArray& cropRgbPngBytes,
const QByteArray& overlayPngBytes,
const QJsonArray& pointCoords,
const QJsonArray& pointLabels,
const QJsonArray& boxXyxy,
QString* outImmediateError
) {
if (outImmediateError) {
outImmediateError->clear();
}
if (!m_baseUrl.isValid() || m_baseUrl.isEmpty()) {
if (outImmediateError) *outImmediateError = QStringLiteral("后端地址无效。");
return nullptr;
}
if (cropRgbPngBytes.isEmpty()) {
if (outImmediateError) *outImmediateError = QStringLiteral("裁剪图像为空。");
return nullptr;
}
const QUrl url = m_baseUrl.resolved(QUrl(QStringLiteral("/segment/sam_prompt")));
QNetworkRequest req(url);
req.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json"));
QJsonObject payload;
payload.insert(QStringLiteral("image_b64"), QString::fromLatin1(cropRgbPngBytes.toBase64()));
if (!overlayPngBytes.isEmpty()) {
payload.insert(QStringLiteral("overlay_b64"), QString::fromLatin1(overlayPngBytes.toBase64()));
}
payload.insert(QStringLiteral("point_coords"), pointCoords);
payload.insert(QStringLiteral("point_labels"), pointLabels);
payload.insert(QStringLiteral("box_xyxy"), boxXyxy);
const QByteArray body = QJsonDocument(payload).toJson(QJsonDocument::Compact);
return m_nam->post(req, body);
}
QNetworkReply* ModelServerClient::inpaintAsync(
const QByteArray& cropRgbPngBytes,
const QByteArray& maskPngBytes,
const QString& prompt,
const QString& negativePrompt,
double strength,
int maxSide,
QString* outImmediateError
) {
if (outImmediateError) {
outImmediateError->clear();
}
if (!m_baseUrl.isValid() || m_baseUrl.isEmpty()) {
if (outImmediateError) *outImmediateError = QStringLiteral("后端地址无效。");
return nullptr;
}
if (cropRgbPngBytes.isEmpty()) {
if (outImmediateError) *outImmediateError = QStringLiteral("裁剪图像为空。");
return nullptr;
}
if (maskPngBytes.isEmpty()) {
if (outImmediateError) *outImmediateError = QStringLiteral("Mask 为空。");
return nullptr;
}
const QUrl url = m_baseUrl.resolved(QUrl(QStringLiteral("/inpaint")));
QNetworkRequest req(url);
req.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json"));
QJsonObject payload;
payload.insert(QStringLiteral("image_b64"), QString::fromLatin1(cropRgbPngBytes.toBase64()));
payload.insert(QStringLiteral("mask_b64"), QString::fromLatin1(maskPngBytes.toBase64()));
payload.insert(QStringLiteral("prompt"), prompt);
payload.insert(QStringLiteral("negative_prompt"), negativePrompt);
payload.insert(QStringLiteral("strength"), strength);
payload.insert(QStringLiteral("max_side"), maxSide);
const QByteArray body = QJsonDocument(payload).toJson(QJsonDocument::Compact);
return m_nam->post(req, body);
}
bool ModelServerClient::computeDepthPng8( bool ModelServerClient::computeDepthPng8(
const QByteArray& imageBytes, const QByteArray& imageBytes,
QByteArray& outPngBytes, QByteArray& outPngBytes,

View File

@@ -1,6 +1,7 @@
#pragma once #pragma once
#include <QByteArray> #include <QByteArray>
#include <QJsonArray>
#include <QObject> #include <QObject>
#include <QString> #include <QString>
#include <QUrl> #include <QUrl>
@@ -27,6 +28,25 @@ public:
// 返回 nullptr 表示参数/URL 非法导致无法发起。 // 返回 nullptr 表示参数/URL 非法导致无法发起。
QNetworkReply* computeDepthPng8Async(const QByteArray& imageBytes, QString* outImmediateError = nullptr); QNetworkReply* computeDepthPng8Async(const QByteArray& imageBytes, QString* outImmediateError = nullptr);
// POST /segment/sam_promptJSON 响应由调用方解析success / contour / error
QNetworkReply* segmentSamPromptAsync(
const QByteArray& cropRgbPngBytes,
const QByteArray& overlayPngBytes,
const QJsonArray& pointCoords,
const QJsonArray& pointLabels,
const QJsonArray& boxXyxy,
QString* outImmediateError = nullptr);
// POST /inpaintJSON 响应由调用方解析success / output_image_b64 / error / output_path
QNetworkReply* inpaintAsync(
const QByteArray& cropRgbPngBytes,
const QByteArray& maskPngBytes,
const QString& prompt,
const QString& negativePrompt,
double strength,
int maxSide,
QString* outImmediateError = nullptr);
private: private:
QNetworkAccessManager* m_nam = nullptr; QNetworkAccessManager* m_nam = nullptr;
QUrl m_baseUrl; QUrl m_baseUrl;

View File

@@ -251,7 +251,22 @@ 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);
// v8/v9黑洞元数据与实体可见性解耦
ds << bool(entity.blackholeVisible);
const QString holeId = entity.blackholeId.isEmpty()
? QStringLiteral("blackhole-%1").arg(entity.id)
: entity.blackholeId;
ds << holeId;
ds << entity.blackholeResolvedBy;
} }
bool readBody(QDataStream& ds) override { bool readBody(QDataStream& ds) override {
@@ -270,9 +285,53 @@ 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;
} }
bool holeVisible = true;
QString holeId;
QString resolvedBy;
ds >> holeVisible >> holeId >> resolvedBy;
if (ds.status() != QDataStream::Ok) {
return false;
}
tmp.blackholeVisible = holeVisible;
tmp.blackholeId = holeId.isEmpty() ? QStringLiteral("blackhole-%1").arg(tmp.id) : holeId;
tmp.blackholeResolvedBy = resolvedBy;
*m_dst = std::move(tmp); *m_dst = std::move(tmp);
return true; return true;
} }
@@ -329,7 +388,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 && ver != 8 && ver != 9) {
return false; return false;
} }
Project::Entity tmp; Project::Entity tmp;
@@ -353,14 +412,87 @@ 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;
} }
} }
if (ver >= 8) {
bool holeVisible = true;
QString holeId;
ds >> holeVisible >> holeId;
if (ds.status() != QDataStream::Ok) {
return false;
}
tmp.blackholeVisible = holeVisible;
tmp.blackholeId = holeId.isEmpty() ? QStringLiteral("blackhole-%1").arg(tmp.id) : holeId;
if (ver >= 9) {
QString resolvedBy;
ds >> resolvedBy;
if (ds.status() != QDataStream::Ok) {
return false;
}
tmp.blackholeResolvedBy = resolvedBy;
} else {
tmp.blackholeResolvedBy = QStringLiteral("pending");
}
} else {
tmp.blackholeVisible = true;
tmp.blackholeId = QStringLiteral("blackhole-%1").arg(tmp.id);
tmp.blackholeResolvedBy = QStringLiteral("pending");
}
} else { } 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();
tmp.blackholeVisible = true;
tmp.blackholeId = QStringLiteral("blackhole-%1").arg(tmp.id);
tmp.blackholeResolvedBy = QStringLiteral("pending");
} }
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 = 9; // v9:追加 blackholeResolvedBy
// 旧版独立动画文件(仍用于打开 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

@@ -8,6 +8,7 @@
#include <QString> #include <QString>
#include <QStringList> #include <QStringList>
#include <QVector> #include <QVector>
#include <QPoint>
namespace core { namespace core {
@@ -16,7 +17,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 +48,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 +82,35 @@ 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 setEntityBlackholeVisible(const QString& id, bool on);
bool resolveBlackholeByUseOriginalBackground(const QString& id);
// 复制背景其他区域填充黑洞sourceOffsetPx 以黑洞包围盒左上角为基准偏移)
bool resolveBlackholeByCopyBackground(const QString& id, const QPoint& sourceOffsetPx,
bool hideBlackholeAfterFill);
// 使用模型补全后的结果写回背景patchedBackground 已包含补全贴合后的完整背景图像)
bool resolveBlackholeByModelInpaint(const QString& id, const QImage& patchedBackground,
bool hideBlackholeAfterFill);
bool setEntityVisibilityKey(const QString& id, int frame, bool visible);
bool removeEntityVisibilityKey(const QString& id, int frame);
bool setEntityDisplayName(const QString& id, const QString& displayName); 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);
@@ -91,6 +123,8 @@ public:
bool setEntityDepthScaleKey(const QString& id, int frame, double value01); bool setEntityDepthScaleKey(const QString& id, int frame, double value01);
bool setEntityUserScaleKey(const QString& id, int frame, double userScale); bool setEntityUserScaleKey(const QString& id, int frame, double userScale);
bool setEntityImageFrame(const QString& id, int frame, const QImage& image, QString* outRelPath = nullptr); bool setEntityImageFrame(const QString& id, int frame, const QImage& image, QString* outRelPath = nullptr);
// 仅更新 imageFrames 中某帧的图像路径不读图、不写盘用于高性能地“切断”Hold 区间
bool setEntityImageFramePath(const QString& id, int frame, const QString& relativePath);
bool removeEntityLocationKey(const QString& id, int frame); bool removeEntityLocationKey(const QString& id, int frame);
bool removeEntityDepthScaleKey(const QString& id, int frame); bool removeEntityDepthScaleKey(const QString& id, int frame);
bool removeEntityUserScaleKey(const QString& id, int frame); bool removeEntityUserScaleKey(const QString& id, int frame);
@@ -108,6 +142,8 @@ private:
bool syncEntityPayloadsToDisk(); bool syncEntityPayloadsToDisk();
bool hydrateEntityPayloadsFromDisk(); bool hydrateEntityPayloadsFromDisk();
void loadV1LegacyAnimationSidecars(); void loadV1LegacyAnimationSidecars();
bool writeIndexJsonWithoutPayloadSync();
bool saveSingleEntityPayload(Project::Entity& entity);
static QJsonObject projectToJson(const Project& project); static QJsonObject projectToJson(const Project& project);
static bool projectFromJson(const QJsonObject& root, Project& outProject, int* outFileVersion); static bool projectFromJson(const QJsonObject& root, Project& outProject, int* outFileVersion);
@@ -117,17 +153,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;
@@ -135,6 +179,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,31 +4,49 @@ 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/ToolOptionPopup.cpp
${GUI_ROOT}/main_window/RecentProjectHistory.cpp ${GUI_ROOT}/main_window/RecentProjectHistory.cpp
${GUI_ROOT}/dialogs/AboutWindow.cpp ${GUI_ROOT}/dialogs/AboutWindow.cpp
${GUI_ROOT}/dialogs/ImageCropDialog.cpp ${GUI_ROOT}/dialogs/ImageCropDialog.cpp
${GUI_ROOT}/dialogs/FrameAnimationDialog.cpp ${GUI_ROOT}/dialogs/FrameAnimationDialog.cpp
${GUI_ROOT}/dialogs/CancelableTaskDialog.cpp ${GUI_ROOT}/dialogs/CancelableTaskDialog.cpp
${GUI_ROOT}/dialogs/EntityFinalizeDialog.cpp
${GUI_ROOT}/dialogs/BlackholeResolveDialog.cpp
${GUI_ROOT}/dialogs/InpaintPreviewDialog.cpp
${GUI_ROOT}/dialogs/EntityIntroPopup.cpp ${GUI_ROOT}/dialogs/EntityIntroPopup.cpp
${GUI_ROOT}/editor/EditorCanvas.cpp ${GUI_ROOT}/editor/EditorCanvas.cpp
${GUI_ROOT}/editor/EntityCutoutUtils.cpp
${GUI_ROOT}/params/ParamControls.cpp ${GUI_ROOT}/params/ParamControls.cpp
${GUI_ROOT}/props/BackgroundPropertySection.cpp ${GUI_ROOT}/props/BackgroundPropertySection.cpp
${GUI_ROOT}/props/BlackholePropertySection.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/ToolOptionPopup.h
${GUI_ROOT}/main_window/RecentProjectHistory.h ${GUI_ROOT}/main_window/RecentProjectHistory.h
${GUI_ROOT}/dialogs/AboutWindow.h ${GUI_ROOT}/dialogs/AboutWindow.h
${GUI_ROOT}/dialogs/ImageCropDialog.h ${GUI_ROOT}/dialogs/ImageCropDialog.h
${GUI_ROOT}/dialogs/FrameAnimationDialog.h ${GUI_ROOT}/dialogs/FrameAnimationDialog.h
${GUI_ROOT}/dialogs/CancelableTaskDialog.h ${GUI_ROOT}/dialogs/CancelableTaskDialog.h
${GUI_ROOT}/dialogs/EntityFinalizeDialog.h
${GUI_ROOT}/dialogs/BlackholeResolveDialog.h
${GUI_ROOT}/dialogs/InpaintPreviewDialog.h
${GUI_ROOT}/dialogs/EntityIntroPopup.h ${GUI_ROOT}/dialogs/EntityIntroPopup.h
${GUI_ROOT}/editor/EditorCanvas.h ${GUI_ROOT}/editor/EditorCanvas.h
${GUI_ROOT}/editor/EntityCutoutUtils.h
${GUI_ROOT}/params/ParamControls.h ${GUI_ROOT}/params/ParamControls.h
${GUI_ROOT}/props/BackgroundPropertySection.h ${GUI_ROOT}/props/BackgroundPropertySection.h
${GUI_ROOT}/props/BlackholePropertySection.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

@@ -1,11 +1,17 @@
#include "main_window/MainWindow.h" #include "main_window/MainWindow.h"
#include <QApplication> #include <QApplication>
#include <QImageReader>
int main(int argc, char* argv[]) { int main(int argc, char* argv[]) {
QApplication app(argc, argv); QApplication app(argc, argv);
app.setApplicationName(QStringLiteral("landscape tool")); app.setApplicationName(QStringLiteral("landscape tool"));
// 全局放宽 Qt 图片分配限制(默认常见为 256MB否则超大分辨率背景/深度可能在任意加载路径被拒绝。
#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)
QImageReader::setAllocationLimit(1024); // MB
#endif
MainWindow window; MainWindow window;
window.show(); window.show();

View File

@@ -0,0 +1,208 @@
#include "dialogs/BlackholeResolveDialog.h"
#include <QDialogButtonBox>
#include <QFrame>
#include <QLabel>
#include <QPlainTextEdit>
#include <QPushButton>
#include <QStackedWidget>
#include <QVBoxLayout>
namespace {
QPushButton* makeAlgoButton(const QString& title, const QString& subtitle, QWidget* parent) {
auto* btn = new QPushButton(parent);
btn->setCheckable(false);
btn->setCursor(Qt::PointingHandCursor);
btn->setMinimumHeight(86);
btn->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
btn->setText(title + QStringLiteral("\n") + subtitle);
btn->setStyleSheet(
"QPushButton { text-align: left; padding: 10px 12px; border: 1px solid palette(mid); border-radius: 8px; }"
"QPushButton:hover { border-color: palette(highlight); }");
return btn;
}
} // namespace
BlackholeResolveDialog::BlackholeResolveDialog(const QString& blackholeName, QWidget* parent)
: QDialog(parent),
m_blackholeName(blackholeName) {
setModal(true);
setMinimumSize(560, 420);
setWindowTitle(QStringLiteral("黑洞解决"));
auto* root = new QVBoxLayout(this);
m_pages = new QStackedWidget(this);
root->addWidget(m_pages, 1);
buildSelectPage();
buildDetailPage();
m_pages->setCurrentWidget(m_pageSelect);
}
QString BlackholeResolveDialog::promptText() const {
return m_promptEdit ? m_promptEdit->toPlainText().trimmed() : QString();
}
void BlackholeResolveDialog::buildSelectPage() {
m_pageSelect = new QWidget(this);
auto* layout = new QVBoxLayout(m_pageSelect);
layout->setContentsMargins(8, 8, 8, 8);
layout->setSpacing(12);
auto* title = new QLabel(QStringLiteral("第 1 步:选择黑洞解决算法"), m_pageSelect);
auto* sub = new QLabel(QStringLiteral("当前黑洞:%1").arg(m_blackholeName), m_pageSelect);
title->setStyleSheet("font-size: 18px; font-weight: 600;");
sub->setStyleSheet("color: palette(mid);");
layout->addWidget(title);
layout->addWidget(sub);
auto* btnCopy = makeAlgoButton(
QStringLiteral("复制背景其他区域"),
QStringLiteral("进入画布拖动取样框,直观选择复制来源。"),
m_pageSelect);
auto* btnOriginal = makeAlgoButton(
QStringLiteral("使用原始背景"),
QStringLiteral("撤销黑洞显示,恢复抠图前背景区域。"),
m_pageSelect);
auto* btnModel = makeAlgoButton(
QStringLiteral("模型补全SDXL Inpaint"),
QStringLiteral("输入提示词,自动补全缺失区域;可预览后再决定是否接受。"),
m_pageSelect);
layout->addWidget(btnCopy);
layout->addWidget(btnOriginal);
layout->addWidget(btnModel);
layout->addStretch(1);
auto* btns = new QDialogButtonBox(QDialogButtonBox::Cancel, m_pageSelect);
connect(btns, &QDialogButtonBox::rejected, this, &QDialog::reject);
layout->addWidget(btns);
connect(btnCopy, &QPushButton::clicked, this, [this]() {
enterAlgorithmPage(Algorithm::CopyBackgroundRegion);
});
connect(btnOriginal, &QPushButton::clicked, this, [this]() {
enterAlgorithmPage(Algorithm::UseOriginalBackground);
});
connect(btnModel, &QPushButton::clicked, this, [this]() {
enterAlgorithmPage(Algorithm::ModelInpaint);
});
m_pages->addWidget(m_pageSelect);
}
void BlackholeResolveDialog::buildDetailPage() {
m_pageDetail = new QWidget(this);
auto* layout = new QVBoxLayout(m_pageDetail);
layout->setContentsMargins(8, 8, 8, 8);
layout->setSpacing(10);
m_detailTitle = new QLabel(m_pageDetail);
m_detailTitle->setStyleSheet("font-size: 18px; font-weight: 600;");
m_detailHint = new QLabel(m_pageDetail);
m_detailHint->setWordWrap(true);
m_detailHint->setStyleSheet("color: palette(mid);");
layout->addWidget(m_detailTitle);
layout->addWidget(m_detailHint);
m_algoDetails = new QStackedWidget(m_pageDetail);
// 详情页 A复制背景其他区域交互布局
m_copyDetail = new QWidget(m_algoDetails);
{
auto* cLay = new QVBoxLayout(m_copyDetail);
cLay->setSpacing(8);
auto* panel = new QFrame(m_copyDetail);
panel->setFrameShape(QFrame::StyledPanel);
auto* pLay = new QVBoxLayout(panel);
pLay->setSpacing(8);
auto* tip = new QLabel(
QStringLiteral("说明:点击“应用”后进入画布拖动模式。\n在画布中拖动青色取样框,松开鼠标即可将该区域复制到黑洞位置并自动移除黑洞。"),
panel);
tip->setWordWrap(true);
tip->setStyleSheet("color: palette(mid);");
pLay->addWidget(tip);
cLay->addWidget(panel);
cLay->addStretch(1);
}
// 详情页 B使用原始背景确认布局
m_originalDetail = new QWidget(m_algoDetails);
{
auto* oLay = new QVBoxLayout(m_originalDetail);
oLay->setSpacing(8);
auto* desc = new QLabel(
QStringLiteral("该方案不会改动背景像素文件,只会将黑洞切换为不显示,从而恢复原始背景区域。"),
m_originalDetail);
desc->setWordWrap(true);
auto* note = new QLabel(
QStringLiteral("适用场景:当前黑洞区域无需二次修补,只需恢复抠图前背景;应用后黑洞会自动移除。"),
m_originalDetail);
note->setWordWrap(true);
note->setStyleSheet("color: palette(mid);");
oLay->addWidget(desc);
oLay->addWidget(note);
oLay->addStretch(1);
}
// 详情页 C模型补全提示词
m_modelDetail = new QWidget(m_algoDetails);
{
auto* mLay = new QVBoxLayout(m_modelDetail);
mLay->setSpacing(8);
auto* panel = new QFrame(m_modelDetail);
panel->setFrameShape(QFrame::StyledPanel);
auto* pLay = new QVBoxLayout(panel);
pLay->setSpacing(8);
m_promptEdit = new QPlainTextEdit(panel);
m_promptEdit->setPlainText(QStringLiteral("This is part of a Chinese painting; please complete the background for me, following the style of the other parts."));
m_promptEdit->setMinimumHeight(90);
pLay->addWidget(m_promptEdit);
mLay->addWidget(panel);
mLay->addStretch(1);
}
m_algoDetails->addWidget(m_copyDetail);
m_algoDetails->addWidget(m_originalDetail);
m_algoDetails->addWidget(m_modelDetail);
layout->addWidget(m_algoDetails, 1);
auto* btns = new QDialogButtonBox(m_pageDetail);
auto* btnBack = btns->addButton(QStringLiteral("上一步"), QDialogButtonBox::ActionRole);
auto* btnApply = btns->addButton(QStringLiteral("应用"), QDialogButtonBox::AcceptRole);
auto* btnCancel = btns->addButton(QDialogButtonBox::Cancel);
connect(btnBack, &QPushButton::clicked, this, [this]() {
m_pages->setCurrentWidget(m_pageSelect);
});
connect(btnApply, &QPushButton::clicked, this, &QDialog::accept);
connect(btnCancel, &QPushButton::clicked, this, &QDialog::reject);
layout->addWidget(btns);
m_pages->addWidget(m_pageDetail);
}
void BlackholeResolveDialog::enterAlgorithmPage(Algorithm algo) {
m_selectedAlgorithm = algo;
if (algo == Algorithm::CopyBackgroundRegion) {
m_detailTitle->setText(QStringLiteral("第 2 步:复制背景其他区域"));
m_detailHint->setText(QStringLiteral("准备进入画布拖动取样框模式。"));
m_algoDetails->setCurrentWidget(m_copyDetail);
} else if (algo == Algorithm::UseOriginalBackground) {
m_detailTitle->setText(QStringLiteral("第 2 步:使用原始背景"));
m_detailHint->setText(QStringLiteral("确认后将切换为原始背景显示。"));
m_algoDetails->setCurrentWidget(m_originalDetail);
} else {
m_detailTitle->setText(QStringLiteral("第 2 步模型补全SDXL Inpaint"));
m_detailHint->setText(QStringLiteral("输入提示词(可选),点击应用后将生成预览。"));
m_algoDetails->setCurrentWidget(m_modelDetail);
}
m_pages->setCurrentWidget(m_pageDetail);
}

View File

@@ -0,0 +1,47 @@
#pragma once
#include <QDialog>
class QLabel;
class QStackedWidget;
class BlackholeResolveDialog final : public QDialog {
Q_OBJECT
public:
enum class Algorithm {
CopyBackgroundRegion,
UseOriginalBackground,
ModelInpaint,
};
explicit BlackholeResolveDialog(const QString& blackholeName, QWidget* parent = nullptr);
Algorithm selectedAlgorithm() const { return m_selectedAlgorithm; }
QString promptText() const;
private:
void buildSelectPage();
void buildDetailPage();
void enterAlgorithmPage(Algorithm algo);
private:
QString m_blackholeName;
Algorithm m_selectedAlgorithm = Algorithm::UseOriginalBackground;
QStackedWidget* m_pages = nullptr;
QWidget* m_pageSelect = nullptr;
QWidget* m_pageDetail = nullptr;
QLabel* m_detailTitle = nullptr;
QLabel* m_detailHint = nullptr;
QStackedWidget* m_algoDetails = nullptr;
QWidget* m_copyDetail = nullptr;
QWidget* m_originalDetail = nullptr;
QWidget* m_modelDetail = nullptr;
class QPlainTextEdit* m_promptEdit = nullptr;
};

View File

@@ -0,0 +1,56 @@
#include "dialogs/EntityFinalizeDialog.h"
#include <QDialogButtonBox>
#include <QFormLayout>
#include <QLineEdit>
#include <QDoubleSpinBox>
#include <QVBoxLayout>
EntityFinalizeDialog::EntityFinalizeDialog(QWidget* parent)
: QDialog(parent) {
setWindowTitle(QStringLiteral("确认实体"));
setModal(true);
auto* root = new QVBoxLayout(this);
auto* form = new QFormLayout();
m_name = new QLineEdit(this);
m_name->setPlaceholderText(QStringLiteral("例如entity-1、人物、树、建筑…"));
form->addRow(QStringLiteral("名称"), m_name);
m_userScale = new QDoubleSpinBox(this);
m_userScale->setDecimals(3);
m_userScale->setRange(0.01, 50.0);
m_userScale->setSingleStep(0.05);
form->addRow(QStringLiteral("整体缩放"), m_userScale);
root->addLayout(form);
auto* btns = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
connect(btns, &QDialogButtonBox::accepted, this, &QDialog::accept);
connect(btns, &QDialogButtonBox::rejected, this, &QDialog::reject);
root->addWidget(btns);
}
void EntityFinalizeDialog::setDefaultName(const QString& name) {
if (m_name) {
m_name->setText(name);
m_name->selectAll();
m_name->setFocus();
}
}
QString EntityFinalizeDialog::name() const {
return m_name ? m_name->text().trimmed() : QString();
}
void EntityFinalizeDialog::setUserScale(double s) {
if (m_userScale) {
m_userScale->setValue(s);
}
}
double EntityFinalizeDialog::userScale() const {
return m_userScale ? m_userScale->value() : 1.0;
}

View File

@@ -0,0 +1,24 @@
#pragma once
#include <QDialog>
class QLineEdit;
class QDoubleSpinBox;
/// 实体落盘前的确认对话框:命名 + 常用属性调整。
class EntityFinalizeDialog final : public QDialog {
Q_OBJECT
public:
explicit EntityFinalizeDialog(QWidget* parent = nullptr);
void setDefaultName(const QString& name);
QString name() const;
void setUserScale(double s);
double userScale() const;
private:
QLineEdit* m_name = nullptr;
QDoubleSpinBox* m_userScale = nullptr;
};

View File

@@ -8,6 +8,7 @@
#include <QFileDialog> #include <QFileDialog>
#include <QFileInfo> #include <QFileInfo>
#include <QImage> #include <QImage>
#include <QImageReader>
#include <QLabel> #include <QLabel>
#include <QListWidget> #include <QListWidget>
#include <QMessageBox> #include <QMessageBox>
@@ -171,7 +172,25 @@ void FrameAnimationDialog::updatePreviewForFrame(int frame) {
} }
bool FrameAnimationDialog::applyImageToFrame(int frame, const QString& absImagePath) { bool FrameAnimationDialog::applyImageToFrame(int frame, const QString& absImagePath) {
QImage img(absImagePath); // Qt 默认的 image allocation limit 较小,超大分辨率图可能会被拒绝。
// 这里提高 limit并对极端大图按像素数上限自动缩放避免 OOM。
#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)
QImageReader::setAllocationLimit(1024); // MB
#endif
QImageReader reader(absImagePath);
reader.setAutoTransform(true);
const QSize sz = reader.size();
if (sz.isValid()) {
constexpr qint64 kMaxPixels = 120LL * 1000LL * 1000LL; // 120MP
const qint64 pixels = qint64(sz.width()) * qint64(sz.height());
if (pixels > kMaxPixels) {
const double s = std::sqrt(double(kMaxPixels) / std::max<double>(1.0, double(pixels)));
const int nw = std::max(1, int(std::lround(sz.width() * s)));
const int nh = std::max(1, int(std::lround(sz.height() * s)));
reader.setScaledSize(QSize(nw, nh));
}
}
QImage img = reader.read();
if (img.isNull()) { if (img.isNull()) {
return false; return false;
} }
@@ -185,6 +204,34 @@ void FrameAnimationDialog::onReplaceCurrentFrame() {
auto* it = m_list->currentItem(); auto* it = m_list->currentItem();
if (!it) return; if (!it) return;
const int f = it->data(Qt::UserRole).toInt(); const int f = it->data(Qt::UserRole).toInt();
// 在真正替换前先记录当前帧/下一帧的旧图像来源,用于“只影响当前帧”:
// 对于原本只在区间端点设置了关键帧、使用 Hold 采样的情况,
// 若直接改写关键帧会导致后续所有帧都跟着换图,这里通过在 f+1 上补一帧旧图像来“切断”区间。
QString prevRelPathForF;
QString prevRelPathForFPlus1;
bool hasExplicitKeyAtFPlus1 = false;
if (m_workspace.isOpen()) {
const auto& ents = m_workspace.entities();
const core::Project::Entity* hit = nullptr;
for (const auto& e : ents) {
if (e.id == m_entityId) {
hit = &e;
break;
}
}
if (hit) {
// 是否已有精确关键帧
for (const auto& k : hit->imageFrames) {
if (k.frame == f + 1) {
hasExplicitKeyAtFPlus1 = true;
}
}
prevRelPathForF = core::sampleImagePath(hit->imageFrames, f, hit->imagePath);
prevRelPathForFPlus1 = core::sampleImagePath(hit->imageFrames, f + 1, hit->imagePath);
}
}
const QString path = QFileDialog::getOpenFileName( const QString path = QFileDialog::getOpenFileName(
this, this,
QStringLiteral("选择该帧图像"), QStringLiteral("选择该帧图像"),
@@ -195,6 +242,14 @@ void FrameAnimationDialog::onReplaceCurrentFrame() {
QMessageBox::warning(this, QStringLiteral("动画帧"), QStringLiteral("写入该帧失败。")); QMessageBox::warning(this, QStringLiteral("动画帧"), QStringLiteral("写入该帧失败。"));
return; return;
} }
// “单帧替换”的强语义:无论 f 原本是否是关键帧,都不应影响 f+1 之后的帧。
// 因此在 f+1 上补一个“替换前 f+1 使用的来源”,以切断 Hold 区间(不覆盖已有关键帧)。
// 性能:这里直接写入相对路径,不读图不写盘。
if (!hasExplicitKeyAtFPlus1 && !prevRelPathForFPlus1.isEmpty()) {
m_workspace.setEntityImageFramePath(m_entityId, f + 1, prevRelPathForFPlus1);
}
rebuildFrameList(); rebuildFrameList();
updatePreviewForFrame(f); updatePreviewForFrame(f);
} }

View File

@@ -6,6 +6,7 @@
#include <QMouseEvent> #include <QMouseEvent>
#include <QPainter> #include <QPainter>
#include <QPushButton> #include <QPushButton>
#include <QImageReader>
#include <QtMath> #include <QtMath>
class ImageCropDialog::CropView final : public QWidget { class ImageCropDialog::CropView final : public QWidget {
@@ -159,7 +160,25 @@ ImageCropDialog::ImageCropDialog(const QString& imagePath, QWidget* parent)
} }
void ImageCropDialog::loadImageOrClose() { void ImageCropDialog::loadImageOrClose() {
m_image = QImage(m_imagePath); // Qt 默认的 image allocation limit 较小(常见为 256MB超大分辨率图会被拒绝。
// 这里用 QImageReader 并提高 limit同时对极端大图按像素数上限自动缩放避免 OOM。
#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)
QImageReader::setAllocationLimit(1024); // MB
#endif
QImageReader reader(m_imagePath);
reader.setAutoTransform(true);
const QSize sz = reader.size();
if (sz.isValid()) {
constexpr qint64 kMaxPixels = 120LL * 1000LL * 1000LL; // 120MP
const qint64 pixels = qint64(sz.width()) * qint64(sz.height());
if (pixels > kMaxPixels) {
const double s = std::sqrt(double(kMaxPixels) / std::max<double>(1.0, double(pixels)));
const int nw = std::max(1, int(std::lround(sz.width() * s)));
const int nh = std::max(1, int(std::lround(sz.height() * s)));
reader.setScaledSize(QSize(nw, nh));
}
}
m_image = reader.read();
if (m_image.isNull()) { if (m_image.isNull()) {
reject(); reject();
} }

View File

@@ -0,0 +1,68 @@
#include "dialogs/InpaintPreviewDialog.h"
#include <QDialogButtonBox>
#include <QHBoxLayout>
#include <QLabel>
#include <QPixmap>
#include <QScrollArea>
#include <QSplitter>
#include <QVBoxLayout>
static QLabel* makeImageLabel(QWidget* parent) {
auto* lab = new QLabel(parent);
lab->setBackgroundRole(QPalette::Base);
lab->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Ignored);
lab->setScaledContents(false);
lab->setAlignment(Qt::AlignCenter);
return lab;
}
static QScrollArea* wrapScroll(QWidget* child, QWidget* parent) {
auto* sc = new QScrollArea(parent);
sc->setWidget(child);
sc->setWidgetResizable(true);
sc->setBackgroundRole(QPalette::Dark);
return sc;
}
InpaintPreviewDialog::InpaintPreviewDialog(const QString& title, QWidget* parent)
: QDialog(parent) {
setModal(true);
setMinimumSize(860, 520);
setWindowTitle(title);
auto* root = new QVBoxLayout(this);
root->setContentsMargins(8, 8, 8, 8);
root->setSpacing(8);
m_beforeLabel = makeImageLabel(this);
m_afterLabel = makeImageLabel(this);
m_beforeScroll = wrapScroll(m_beforeLabel, this);
m_afterScroll = wrapScroll(m_afterLabel, this);
auto* splitter = new QSplitter(Qt::Horizontal, this);
splitter->addWidget(m_beforeScroll);
splitter->addWidget(m_afterScroll);
splitter->setStretchFactor(0, 1);
splitter->setStretchFactor(1, 1);
root->addWidget(splitter, 1);
auto* btns = new QDialogButtonBox(this);
btns->addButton(QStringLiteral("取消"), QDialogButtonBox::RejectRole);
btns->addButton(QStringLiteral("接受并写回"), QDialogButtonBox::AcceptRole);
connect(btns, &QDialogButtonBox::accepted, this, &QDialog::accept);
connect(btns, &QDialogButtonBox::rejected, this, &QDialog::reject);
root->addWidget(btns);
}
void InpaintPreviewDialog::setImages(const QImage& before, const QImage& after) {
if (m_beforeLabel) {
m_beforeLabel->setPixmap(QPixmap::fromImage(before));
m_beforeLabel->adjustSize();
}
if (m_afterLabel) {
m_afterLabel->setPixmap(QPixmap::fromImage(after));
m_afterLabel->adjustSize();
}
}

View File

@@ -0,0 +1,22 @@
#pragma once
#include <QDialog>
#include <QImage>
class QLabel;
class QScrollArea;
class InpaintPreviewDialog final : public QDialog {
Q_OBJECT
public:
explicit InpaintPreviewDialog(const QString& title, QWidget* parent = nullptr);
void setImages(const QImage& before, const QImage& after);
private:
QLabel* m_beforeLabel = nullptr;
QLabel* m_afterLabel = nullptr;
QScrollArea* m_beforeScroll = nullptr;
QScrollArea* m_afterScroll = nullptr;
};

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,10 @@
#include "core/domain/Project.h" #include "core/domain/Project.h"
#include <QByteArray>
#include <QJsonArray>
#include <QPixmap> #include <QPixmap>
#include <QPoint>
#include <QPointF> #include <QPointF>
#include <QImage> #include <QImage>
#include <QPainterPath> #include <QPainterPath>
@@ -12,6 +15,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
@@ -33,6 +39,17 @@ public:
void setTool(Tool tool); void setTool(Tool tool);
Tool tool() const { return m_tool; } Tool tool() const { return m_tool; }
/// 创建实体的分割方式(在「创」工具被选中后再次单击弹出面板选择)。
enum class EntityCreateSegmentMode { Manual, Snap, Sam };
Q_ENUM(EntityCreateSegmentMode)
void setEntityCreateSegmentMode(EntityCreateSegmentMode m);
EntityCreateSegmentMode entityCreateSegmentMode() const { return m_entityCreateSegmentMode; }
/// 进入/更新「待确认」实体多边形(允许用户在画布上微调)。
void setPendingEntityPolygonWorld(const QVector<QPointF>& polyWorld);
void clearPendingEntityPolygon();
bool hasPendingEntityPolygon() const { return m_pendingPolyWorld.size() >= 3; }
void resetView(); void resetView();
void zoomToFit(); void zoomToFit();
@@ -57,7 +74,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; }
@@ -65,6 +86,12 @@ public:
void selectEntityById(const QString& id); void selectEntityById(const QString& id);
void clearEntitySelection(); void clearEntitySelection();
void selectBlackholeByEntityId(const QString& entityId);
void clearBlackholeSelection();
bool startBlackholeCopyResolve(const QString& entityId);
void cancelBlackholeCopyResolve();
/// 背景图片文件内容被外部写盘更新(路径未变)时,强制重新加载缓存
void notifyBackgroundContentChanged();
// 与动画求值一致的原点/缩放(用于 K 帧与自动关键帧) // 与动画求值一致的原点/缩放(用于 K 帧与自动关键帧)
QPointF selectedAnimatedOriginWorld() const; QPointF selectedAnimatedOriginWorld() const;
@@ -80,8 +107,22 @@ 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。
void requestSamSegment(
const QByteArray& cropRgbPng,
const QByteArray& overlayPng,
const QPointF& cropTopLeftWorld,
const QJsonArray& pointCoords,
const QJsonArray& pointLabels,
const QJsonArray& boxXyxy);
/// 待确认实体:用户按回车/点击空白处确认后触发(由主窗口弹窗命名并落盘)。
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 requestResolveBlackholeCopy(const QString& entityId, const QPoint& sourceOffsetPx);
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 为实体质心在视图中的位置,用于摆放介绍浮层
@@ -97,6 +138,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;
@@ -106,6 +150,8 @@ private:
QPointF viewToWorld(const QPointF& v) const; QPointF viewToWorld(const QPointF& v) const;
QPointF worldToView(const QPointF& w) const; QPointF worldToView(const QPointF& w) const;
QRectF worldRectOfBackground() const; QRectF worldRectOfBackground() const;
bool isPointNearPendingVertex(const QPointF& worldPos, int* outIndex) const;
bool pendingPolygonContains(const QPointF& worldPos) const;
void tickPresentationZoomAnimation(); void tickPresentationZoomAnimation();
void tickPresentationHoverAnimation(); void tickPresentationHoverAnimation();
@@ -129,20 +175,27 @@ 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; bool blackholeVisible = true;
}; };
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;
mutable QPixmap m_bgPixmap; mutable QPixmap m_bgPixmap;
mutable bool m_pixmapDirty = true; mutable bool m_pixmapDirty = true;
mutable QImage m_bgImage; // 原背景(用于抠图/填充) mutable QImage m_bgImage; // 原背景(用于抠图/填充)
mutable QImage m_bgImageCutout; // 抠图后的背景(实体区域填黑) mutable QImage m_bgImageCutout; // 抠图后的背景(黑洞区域填黑)
mutable bool m_bgImageDirty = true; mutable bool m_bgImageDirty = true;
mutable bool m_bgCutoutDirty = true; mutable bool m_bgCutoutDirty = true;
@@ -159,6 +212,7 @@ private:
bool m_presentationPreviewMode = false; bool m_presentationPreviewMode = false;
Tool m_tool = Tool::Move; Tool m_tool = Tool::Move;
EntityCreateSegmentMode m_entityCreateSegmentMode = EntityCreateSegmentMode::Manual;
qreal m_scale = 1.0; qreal m_scale = 1.0;
QPointF m_pan; // world 原点对应的 view 坐标偏移view = world*scale + pan QPointF m_pan; // world 原点对应的 view 坐标偏移view = world*scale + pan
@@ -184,15 +238,36 @@ 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;
QString m_selectedBlackholeEntityId;
bool m_blackholeCopyResolveActive = false;
QString m_blackholeCopyEntityId;
QRectF m_blackholeCopyHoleRect;
QRectF m_blackholeCopySourceRect;
bool m_blackholeCopyDragging = false;
QPointF m_blackholeCopyDragOffset;
int m_selectedTool = -1;
bool m_draggingTool = false;
QPointF m_toolDragOffsetOriginWorld;
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;
// —— 创建实体:待确认多边形(可微调)——
QVector<QPointF> m_pendingPolyWorld;
bool m_pendingDragging = false;
bool m_pendingDragWhole = false;
int m_pendingDragVertex = -1;
QPointF m_pendingLastMouseWorld;
// —— 预览展示:实体悬停动效、点击聚焦缩放 —— // —— 预览展示:实体悬停动效、点击聚焦缩放 ——
QTimer* m_presZoomTimer = nullptr; QTimer* m_presZoomTimer = nullptr;
QTimer* m_presHoverTimer = nullptr; QTimer* m_presHoverTimer = nullptr;

View File

@@ -0,0 +1,110 @@
#include "editor/EntityCutoutUtils.h"
#include <algorithm>
#include <cmath>
#include <QPainter>
#include <QPolygonF>
#include <QTransform>
namespace entity_cutout {
QPainterPath pathFromWorldPolygon(const QVector<QPointF>& poly) {
QPainterPath path;
if (poly.size() < 3) {
return path;
}
path.addPolygon(QPolygonF(poly));
path.closeSubpath();
return path;
}
QPointF polygonCentroid(const QVector<QPointF>& poly) {
if (poly.size() < 3) {
return {};
}
double a2 = 0.0;
double cx6a = 0.0;
double cy6a = 0.0;
for (int i = 0; i < poly.size(); ++i) {
const QPointF p0 = poly[i];
const QPointF p1 = poly[(i + 1) % poly.size()];
const double cross = static_cast<double>(p0.x()) * static_cast<double>(p1.y()) -
static_cast<double>(p1.x()) * static_cast<double>(p0.y());
a2 += cross;
cx6a += (static_cast<double>(p0.x()) + static_cast<double>(p1.x())) * cross;
cy6a += (static_cast<double>(p0.y()) + static_cast<double>(p1.y())) * cross;
}
if (std::abs(a2) < 1e-6) {
const QRectF bb = pathFromWorldPolygon(poly).boundingRect();
return bb.center();
}
const double inv6a = 1.0 / (3.0 * a2);
return QPointF(cx6a * inv6a, cy6a * inv6a);
}
QRect clampRectToImage(const QRect& r, const QSize& size) {
QRect out = r.normalized();
if (out.isNull()) {
return {};
}
out.setLeft(std::max(0, out.left()));
out.setTop(std::max(0, out.top()));
out.setRight(std::min(size.width() - 1, out.right()));
out.setBottom(std::min(size.height() - 1, out.bottom()));
if (out.width() <= 0 || out.height() <= 0) {
return {};
}
return out;
}
QImage extractEntityImage(const QImage& bg, const QVector<QPointF>& polyWorld, QPointF& outTopLeftWorld) {
if (bg.isNull() || polyWorld.size() < 3) {
outTopLeftWorld = {};
return {};
}
const QPainterPath path = pathFromWorldPolygon(polyWorld);
if (path.isEmpty()) {
outTopLeftWorld = {};
return {};
}
const QRect bbox = clampRectToImage(path.boundingRect().toAlignedRect(), bg.size());
if (bbox.isNull()) {
outTopLeftWorld = {};
return {};
}
outTopLeftWorld = bbox.topLeft();
QImage out(bbox.size(), QImage::Format_ARGB32_Premultiplied);
out.fill(Qt::transparent);
QPainter p(&out);
p.setRenderHint(QPainter::Antialiasing, true);
QTransform tr;
tr.translate(-bbox.left(), -bbox.top());
const QPainterPath localPath = tr.map(path);
p.setClipPath(localPath);
p.drawImage(QPoint(0, 0), bg, bbox);
p.end();
return out;
}
void applyBlackFillToBackground(QImage& bgCutout, const QVector<QPointF>& polyWorld) {
if (bgCutout.isNull() || polyWorld.size() < 3) {
return;
}
QPainterPath path = pathFromWorldPolygon(polyWorld);
if (path.isEmpty()) {
return;
}
QPainter p(&bgCutout);
p.setRenderHint(QPainter::Antialiasing, true);
p.setPen(Qt::NoPen);
p.setBrush(QColor(0, 0, 0, 255));
p.drawPath(path);
p.end();
}
} // namespace entity_cutout

View File

@@ -0,0 +1,18 @@
#pragma once
#include <QImage>
#include <QPointF>
#include <QPainterPath>
#include <QRect>
#include <QSize>
#include <QVector>
namespace entity_cutout {
QPainterPath pathFromWorldPolygon(const QVector<QPointF>& poly);
QPointF polygonCentroid(const QVector<QPointF>& poly);
QRect clampRectToImage(const QRect& r, const QSize& size);
QImage extractEntityImage(const QImage& bg, const QVector<QPointF>& polyWorld, QPointF& outTopLeftWorld);
void applyBlackFillToBackground(QImage& bgCutout, const QVector<QPointF>& polyWorld);
} // namespace entity_cutout

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

@@ -9,6 +9,11 @@
#include <QIcon> #include <QIcon>
#include <QTimer> #include <QTimer>
#include <QToolButton>
#include <QSet>
class ToolOptionPopup;
class QAction; class QAction;
class QCheckBox; class QCheckBox;
class QComboBox; class QComboBox;
@@ -29,8 +34,16 @@ class EditorCanvas;
class TimelineWidget; class TimelineWidget;
namespace gui { namespace gui {
class BackgroundPropertySection; class BackgroundPropertySection;
class BlackholePropertySection;
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 {
@@ -76,8 +89,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 两态的显隐策略
@@ -86,6 +101,7 @@ private:
void showProjectRootContextMenu(const QPoint& globalPos); void showProjectRootContextMenu(const QPoint& globalPos);
void showBackgroundContextMenu(const QPoint& globalPos); void showBackgroundContextMenu(const QPoint& globalPos);
void showBlackholeContextMenu(const QPoint& globalPos, const QString& entityId);
void rebuildCentralPages(); void rebuildCentralPages();
void showWelcomePage(); void showWelcomePage();
void showEditorPage(); void showEditorPage();
@@ -95,6 +111,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();
@@ -110,8 +127,11 @@ private:
QComboBox* m_modeSelector = nullptr; QComboBox* m_modeSelector = nullptr;
QStackedWidget* m_propertyStack = nullptr; QStackedWidget* m_propertyStack = nullptr;
gui::BackgroundPropertySection* m_bgPropertySection = nullptr; gui::BackgroundPropertySection* m_bgPropertySection = nullptr;
gui::BlackholePropertySection* m_blackholePropertySection = 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;
QToolButton* m_btnToggleDepthOverlay = nullptr; QToolButton* m_btnToggleDepthOverlay = nullptr;
EditorCanvas* m_editorCanvas = nullptr; EditorCanvas* m_editorCanvas = nullptr;
@@ -120,6 +140,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;
@@ -129,6 +150,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;
@@ -147,14 +169,21 @@ 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_selectedBlackholeEntityId;
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 refreshPropertyPanel(); void refreshPropertyPanel();
void refreshEntityPropertyPanelFast(); void refreshEntityPropertyPanelFast();
void syncProjectTreeFromCanvasSelection(); void syncProjectTreeFromCanvasSelection();
@@ -168,7 +197,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;
@@ -181,4 +210,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

@@ -24,7 +24,7 @@ Float01ParamControl::Float01ParamControl(QWidget* parent)
m_spin->setRange(0.0, 1.0); m_spin->setRange(0.0, 1.0);
m_spin->setDecimals(3); m_spin->setDecimals(3);
m_spin->setSingleStep(0.01); m_spin->setSingleStep(0.01);
m_spin->setMinimumWidth(84); m_spin->setMinimumWidth(72);
row->addWidget(m_spin); row->addWidget(m_spin);
connect(m_slider, &QSlider::valueChanged, this, [this]() { syncFromSlider(); }); connect(m_slider, &QSlider::valueChanged, this, [this]() { syncFromSlider(); });
@@ -79,14 +79,14 @@ Vec2ParamControl::Vec2ParamControl(QWidget* parent)
m_x->setRange(-1e9, 1e9); m_x->setRange(-1e9, 1e9);
m_x->setDecimals(2); m_x->setDecimals(2);
m_x->setSingleStep(1.0); m_x->setSingleStep(1.0);
m_x->setMinimumWidth(88); m_x->setMinimumWidth(72);
row->addWidget(m_x, 1); row->addWidget(m_x, 1);
m_y = new QDoubleSpinBox(this); m_y = new QDoubleSpinBox(this);
m_y->setRange(-1e9, 1e9); m_y->setRange(-1e9, 1e9);
m_y->setDecimals(2); m_y->setDecimals(2);
m_y->setSingleStep(1.0); m_y->setSingleStep(1.0);
m_y->setMinimumWidth(88); m_y->setMinimumWidth(72);
row->addWidget(m_y, 1); row->addWidget(m_y, 1);
connect(m_x, qOverload<double>(&QDoubleSpinBox::valueChanged), this, [this]() { emitIfChanged(); }); connect(m_x, qOverload<double>(&QDoubleSpinBox::valueChanged), this, [this]() { emitIfChanged(); });

View File

@@ -0,0 +1,45 @@
#include "props/BlackholePropertySection.h"
#include <QFormLayout>
#include <QLabel>
#include <QVBoxLayout>
namespace gui {
BlackholePropertySection::BlackholePropertySection(QWidget* parent)
: PropertySectionWidget(parent) {
auto* lay = new QVBoxLayout(this);
lay->setContentsMargins(0, 0, 0, 0);
lay->setSpacing(6);
auto* form = new QFormLayout();
form->setContentsMargins(0, 0, 0, 0);
form->setSpacing(6);
m_name = new QLabel(this);
m_status = new QLabel(this);
m_method = new QLabel(this);
m_method->setWordWrap(true);
form->addRow(QStringLiteral("黑洞"), m_name);
form->addRow(QStringLiteral("是否解决"), m_status);
form->addRow(QStringLiteral("解决方案"), m_method);
lay->addLayout(form);
lay->addStretch(1);
}
void BlackholePropertySection::clearDisconnected() {
if (m_name) m_name->setText(QStringLiteral("-"));
if (m_status) m_status->setText(QStringLiteral("-"));
if (m_method) m_method->setText(QStringLiteral("-"));
}
void BlackholePropertySection::applyState(const BlackholePropertyUiState& s) {
if (m_name) m_name->setText(s.blackholeName.isEmpty() ? QStringLiteral("-") : s.blackholeName);
if (m_status) m_status->setText(s.statusText.isEmpty() ? QStringLiteral("-") : s.statusText);
if (m_method) m_method->setText(s.methodText.isEmpty() ? QStringLiteral("-") : s.methodText);
}
} // namespace gui

View File

@@ -0,0 +1,32 @@
#pragma once
#include "props/PropertySectionWidget.h"
#include <QString>
class QLabel;
namespace gui {
struct BlackholePropertyUiState {
QString blackholeName;
QString statusText;
QString methodText;
};
class BlackholePropertySection final : public PropertySectionWidget {
Q_OBJECT
public:
explicit BlackholePropertySection(QWidget* parent = nullptr);
void clearDisconnected();
void applyState(const BlackholePropertyUiState& s);
private:
QLabel* m_name = nullptr;
QLabel* m_status = nullptr;
QLabel* m_method = nullptr;
};
} // namespace gui

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>
@@ -39,13 +40,15 @@ EntityPropertySection::EntityPropertySection(QWidget* parent)
form->addRow(QStringLiteral("深度"), m_depth); form->addRow(QStringLiteral("深度"), m_depth);
form->addRow(QStringLiteral("距离缩放"), m_distScale); form->addRow(QStringLiteral("距离缩放"), m_distScale);
m_pivotLabel = new QLabel(QStringLiteral("中心坐标"), this);
m_pivot = new Vec2ParamControl(this); m_pivot = new Vec2ParamControl(this);
m_pivot->setToolTip(QStringLiteral("枢轴在世界坐标中的位置(限制在轮廓包络内),用于重定位局部原点")); m_pivot->setToolTip(QStringLiteral("枢轴在世界坐标中的位置(限制在轮廓包络内),用于重定位局部原点"));
form->addRow(QStringLiteral("中心坐标"), m_pivot); form->addRow(m_pivotLabel, m_pivot);
m_centroidLabel = new QLabel(QStringLiteral("位置"), this);
m_centroid = new Vec2ParamControl(this); m_centroid = new Vec2ParamControl(this);
m_centroid->setToolTip(QStringLiteral("实体几何质心的世界坐标;修改将整体平移实体")); m_centroid->setToolTip(QStringLiteral("实体几何质心的世界坐标;修改将整体平移实体"));
form->addRow(QStringLiteral("位置"), m_centroid); form->addRow(m_centroidLabel, m_centroid);
m_userScale = new QDoubleSpinBox(this); m_userScale = new QDoubleSpinBox(this);
m_userScale->setRange(0.05, 20.0); m_userScale->setRange(0.05, 20.0);
@@ -55,6 +58,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 +145,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(); });
@@ -178,11 +192,29 @@ void EntityPropertySection::clearDisconnected() {
if (m_distScale) m_distScale->setText(QStringLiteral("-")); if (m_distScale) m_distScale->setText(QStringLiteral("-"));
if (m_pivot) m_pivot->setValue(0.0, 0.0); if (m_pivot) m_pivot->setValue(0.0, 0.0);
if (m_centroid) m_centroid->setValue(0.0, 0.0); if (m_centroid) m_centroid->setValue(0.0, 0.0);
if (m_pivotLabel) m_pivotLabel->setText(QStringLiteral("中心坐标"));
if (m_centroidLabel) m_centroidLabel->setText(QStringLiteral("位置"));
if (m_pivot) {
m_pivot->setToolTip(QStringLiteral("枢轴在世界坐标中的位置(限制在轮廓包络内),用于重定位局部原点"));
}
if (m_centroid) {
m_centroid->setToolTip(QStringLiteral("实体几何质心的世界坐标;修改将整体平移实体"));
}
if (m_userScale) { if (m_userScale) {
m_userScale->blockSignals(true); m_userScale->blockSignals(true);
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();
@@ -217,6 +249,24 @@ void EntityPropertySection::applyState(const EntityPropertyUiState& s) {
} }
if (m_depth) m_depth->setText(QString::number(s.depthZ)); if (m_depth) m_depth->setText(QString::number(s.depthZ));
if (m_distScale) m_distScale->setText(s.distanceScaleText); if (m_distScale) m_distScale->setText(s.distanceScaleText);
if (m_pivotLabel) {
m_pivotLabel->setText(QStringLiteral("中心坐标"));
}
if (m_centroidLabel) {
m_centroidLabel->setText(QStringLiteral("位置"));
}
if (m_pivot) {
m_pivot->setToolTip(
s.parentRelativeMode
? QStringLiteral("枢轴相对父对象的坐标;修改将写入相对父对象的位置关键帧")
: QStringLiteral("枢轴在世界坐标中的位置(限制在轮廓包络内),用于重定位局部原点"));
}
if (m_centroid) {
m_centroid->setToolTip(
s.parentRelativeMode
? QStringLiteral("几何质心相对父对象的坐标;修改将写入相对父对象的位置关键帧")
: QStringLiteral("实体几何质心的世界坐标;修改将整体平移实体"));
}
if (m_pivot) m_pivot->setValue(s.pivot.x(), s.pivot.y()); if (m_pivot) m_pivot->setValue(s.pivot.x(), s.pivot.y());
if (m_centroid) m_centroid->setValue(s.centroid.x(), s.centroid.y()); if (m_centroid) m_centroid->setValue(s.centroid.x(), s.centroid.y());
if (m_userScale) { if (m_userScale) {
@@ -224,6 +274,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 +348,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,9 @@ struct EntityPropertyUiState {
QPointF pivot; QPointF pivot;
QPointF centroid; QPointF centroid;
double userScale = 1.0; double userScale = 1.0;
bool ignoreDistanceScale = false;
bool visible = true;
bool parentRelativeMode = false;
core::EntityIntroContent intro; core::EntityIntroContent intro;
}; };
@@ -49,6 +53,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();
@@ -60,9 +67,13 @@ private:
QLineEdit* m_name = nullptr; QLineEdit* m_name = nullptr;
QLabel* m_depth = nullptr; QLabel* m_depth = nullptr;
QLabel* m_distScale = nullptr; QLabel* m_distScale = nullptr;
QLabel* m_pivotLabel = nullptr;
QLabel* m_centroidLabel = nullptr;
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,159 @@
#include "props/ToolPropertySection.h"
#include "params/ParamControls.h"
#include <algorithm>
#include <QComboBox>
#include <QFormLayout>
#include <QCheckBox>
#include <QLabel>
#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_positionLabel = new QLabel(QStringLiteral("位置"), this);
m_position = new Vec2ParamControl(this);
m_position->setToolTip(QStringLiteral("工具在世界坐标中的位置"));
form->addRow(m_positionLabel, m_position);
m_pointerT = new QSlider(Qt::Horizontal, this);
m_pointerT->setRange(0, 1000);
m_pointerT->setSingleStep(10);
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("随帧变化:在当前帧切换会写入可见性关键帧"));
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_position, &Vec2ParamControl::valueChanged, this, &ToolPropertySection::positionEdited);
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_position),
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_positionLabel) m_positionLabel->setText(QStringLiteral("位置"));
if (m_position) {
m_position->blockSignals(true);
m_position->setToolTip(QStringLiteral("工具在世界坐标中的位置"));
m_position->setValue(0.0, 0.0);
m_position->blockSignals(false);
}
if (m_pointerT) {
m_pointerT->blockSignals(true);
m_pointerT->setValue(500);
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_positionLabel) {
m_positionLabel->setText(QStringLiteral("位置"));
}
if (m_position) {
m_position->blockSignals(true);
m_position->setToolTip(
s.parentRelativeMode
? QStringLiteral("工具相对父对象的位置;修改将写入相对父对象的位置关键帧")
: QStringLiteral("工具在世界坐标中的位置"));
m_position->setValue(s.position.x(), s.position.y());
m_position->blockSignals(false);
}
if (m_pointerT) {
m_pointerT->blockSignals(true);
m_pointerT->setValue(std::clamp(s.pointerTThousandths, 0, 1000));
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,61 @@
#pragma once
#include "props/PropertySectionWidget.h"
#include <QPointF>
#include <QString>
class QComboBox;
class QLineEdit;
class QSlider;
class QSpinBox;
class QCheckBox;
class QLabel;
namespace gui {
class Vec2ParamControl;
}
namespace gui {
struct ToolPropertyUiState {
QString displayName;
QString text;
QPointF position;
bool parentRelativeMode = false;
int pointerTThousandths = 500; // bubblePointerT01 * 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 positionEdited(double x, double y);
// 可见性(动画通道):在当前帧写关键帧
void visibleToggled(bool on);
private:
QLabel* m_positionLabel = nullptr;
Vec2ParamControl* m_position = nullptr;
QLineEdit* m_text = nullptr;
QSlider* m_pointerT = nullptr;
QSpinBox* m_fontPx = nullptr;
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();
m_scaleFrames.clear();
m_imgFrames.clear();
if (!e) {
update();
return;
}
m_locFrames.reserve(e->locationKeys.size());
for (const auto& k : e->locationKeys) m_locFrames.push_back(k.frame);
m_scaleFrames.reserve(e->userScaleKeys.size());
for (const auto& k : e->userScaleKeys) m_scaleFrames.push_back(k.frame);
m_imgFrames.reserve(e->imageFrames.size());
for (const auto& k : e->imageFrames) m_imgFrames.push_back(k.frame);
auto uniqSort = [](QVector<int>& v) {
std::sort(v.begin(), v.end()); std::sort(v.begin(), v.end());
v.erase(std::unique(v.begin(), v.end()), v.end()); v.erase(std::unique(v.begin(), v.end()), v.end());
}; }
static bool containsFrame(const QVector<int>& v, int f) {
return std::binary_search(v.begin(), v.end(), f);
}
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);
QRect sel(xa, cr.top(), xb - xa, cr.height());
sel = sel.intersected(cr);
if (sel.width() > 0) {
QColor c = palette().highlight().color(); QColor c = palette().highlight().color();
c.setAlpha(50); c.setAlpha(72);
p.setPen(Qt::NoPen);
p.setBrush(c); p.setBrush(c);
p.drawRoundedRect(sel, 4, 4); 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;
if (my < cr.top() || my > cr.bottom()) return false;
m_selKeyKind = kind; m_selKeyKind = kind;
m_selKeyFrame = nearest; m_selKeyFrame = nearest;
emit keyframeSelectionChanged(m_selKeyKind, m_selKeyFrame); emit keyframeSelectionChanged(m_selKeyKind, m_selKeyFrame);
update(); update();
return true; 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;
}; };

View File

@@ -0,0 +1,52 @@
#include "widgets/LongPressSwitchToolButton.h"
#include <QApplication>
#include <QMouseEvent>
#include <QTimer>
#include <algorithm>
LongPressSwitchToolButton::LongPressSwitchToolButton(QWidget* parent)
: QToolButton(parent) {
m_timer = new QTimer(this);
m_timer->setSingleShot(true);
m_timer->setInterval(m_intervalMs);
connect(m_timer, &QTimer::timeout, this, [this]() {
if (!(QApplication::mouseButtons() & Qt::LeftButton)) {
return;
}
m_longPressEmittedForThisPress = true;
emit longPressTriggered();
});
}
void LongPressSwitchToolButton::setLongPressInterval(int ms) {
m_intervalMs = std::max(50, ms);
if (m_timer) {
m_timer->setInterval(m_intervalMs);
}
}
void LongPressSwitchToolButton::mousePressEvent(QMouseEvent* event) {
if (event->button() == Qt::LeftButton) {
m_longPressEmittedForThisPress = false;
m_timer->start();
}
QToolButton::mousePressEvent(event);
}
void LongPressSwitchToolButton::mouseReleaseEvent(QMouseEvent* event) {
const bool left = (event->button() == Qt::LeftButton);
if (left && m_timer->isActive()) {
m_timer->stop();
}
const bool hadLongPress = left && m_longPressEmittedForThisPress;
QToolButton::mouseReleaseEvent(event);
if (hadLongPress) {
m_longPressEmittedForThisPress = false;
if (isDown()) {
setDown(false);
}
update();
}
}

View File

@@ -0,0 +1,32 @@
#pragma once
#include <QToolButton>
class QMouseEvent;
class QTimer;
/**
* 可复用的工具栏按钮:短按行为与普通 QToolButton 一致;
* 左键按住超过设定时间(默认 450ms且仍未松开时发出 longPressTriggered()
* 用于在「同一工具钮」上切换子模式(类似 Photoshop 长按切换同类工具)。
*/
class LongPressSwitchToolButton final : public QToolButton {
Q_OBJECT
public:
explicit LongPressSwitchToolButton(QWidget* parent = nullptr);
void setLongPressInterval(int ms);
int longPressInterval() const { return m_intervalMs; }
signals:
void longPressTriggered();
protected:
void mousePressEvent(QMouseEvent* event) override;
void mouseReleaseEvent(QMouseEvent* event) override;
private:
QTimer* m_timer = nullptr;
int m_intervalMs = 450;
bool m_longPressEmittedForThisPress = false;
};

View File

@@ -0,0 +1,55 @@
#include "widgets/ToolOptionPopup.h"
#include <QBoxLayout>
#include <QPushButton>
#include <QToolButton>
ToolOptionPopup::ToolOptionPopup(QWidget* parent)
: QFrame(parent) {
setWindowFlags(Qt::Popup | Qt::FramelessWindowHint);
setFrameShape(QFrame::StyledPanel);
setObjectName(QStringLiteral("ToolOptionPopup"));
setStyleSheet(QStringLiteral(
"#ToolOptionPopup { background: palette(base); border: 1px solid palette(mid); border-radius: 10px; }"
"#ToolOptionPopup QPushButton { border: 1px solid transparent; padding: 8px 10px; text-align: left; }"
"#ToolOptionPopup QPushButton:hover { background: palette(midlight); }"));
}
void ToolOptionPopup::setOptions(const QVector<Option>& opts) {
m_options = opts;
rebuildUi();
}
void ToolOptionPopup::rebuildUi() {
QLayout* old = layout();
if (old) {
delete old;
}
auto* root = new QVBoxLayout(this);
root->setContentsMargins(8, 8, 8, 8);
root->setSpacing(6);
for (const auto& opt : m_options) {
auto* b = new QPushButton(opt.text, this);
b->setFlat(true);
connect(b, &QPushButton::clicked, this, [this, id = opt.id]() {
emit optionChosen(id);
close();
});
root->addWidget(b);
}
}
void ToolOptionPopup::popupNearToolButton(QToolButton* btn) {
if (!btn) {
return;
}
if (!layout()) {
rebuildUi();
}
adjustSize();
const QPoint g = btn->mapToGlobal(QPoint(btn->width(), 0));
move(g.x() + 6, g.y());
show();
raise();
}

View File

@@ -0,0 +1,32 @@
#pragma once
#include <QFrame>
#include <QString>
#include <QVector>
class QToolButton;
/// 可复用的“按钮弹出选项面板”Qt::Popup用于同一工具按钮选择子模式。
class ToolOptionPopup final : public QFrame {
Q_OBJECT
public:
struct Option {
int id = 0;
QString text;
};
explicit ToolOptionPopup(QWidget* parent = nullptr);
void setOptions(const QVector<Option>& opts);
void popupNearToolButton(QToolButton* btn);
signals:
void optionChosen(int id);
private:
void rebuildUi();
private:
QVector<Option> m_options;
};

View File

@@ -10,13 +10,134 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from enum import Enum from enum import Enum
from typing import Callable from typing import Callable, List
import sys import sys
from pathlib import Path from pathlib import Path
import numpy as np import numpy as np
# -----------------------------
# SAM 交互式SamPredictor与 AutomaticMaskGenerator 分流缓存
# -----------------------------
_sam_prompt_predictor = None
def get_sam_prompt_predictor():
"""懒加载 SamPredictorvit_h用于点/框提示分割。"""
global _sam_prompt_predictor
if _sam_prompt_predictor is not None:
return _sam_prompt_predictor
sam_root = _ensure_sam_on_path()
ckpt_path = _download_sam_checkpoint_if_needed(sam_root)
from segment_anything import sam_model_registry, SamPredictor # type: ignore[import]
import torch
device = "cuda" if torch.cuda.is_available() else "cpu"
sam = sam_model_registry["vit_h"](checkpoint=str(ckpt_path)).to(device)
_sam_prompt_predictor = SamPredictor(sam)
return _sam_prompt_predictor
def run_sam_prompt(
image_rgb: np.ndarray,
point_coords: np.ndarray,
point_labels: np.ndarray,
box_xyxy: np.ndarray | None = None,
) -> np.ndarray:
"""
使用点提示(必选)与可选矩形框在 RGB 图上分割。
参数均为原图像素坐标:
- point_coords: (N, 2) float
- point_labels: (N,) int1=前景点0=背景
- box_xyxy: (4,) [x1, y1, x2, y2] 或 None
返回bool 掩膜 (H, W)
"""
if image_rgb.dtype != np.uint8:
image_rgb = np.ascontiguousarray(image_rgb.astype(np.uint8))
else:
image_rgb = np.ascontiguousarray(image_rgb)
if image_rgb.ndim != 3 or image_rgb.shape[2] != 3:
raise ValueError(f"image_rgb 期望 HWC RGB uint8当前 shape={image_rgb.shape}")
predictor = get_sam_prompt_predictor()
predictor.set_image(image_rgb)
pc = np.asarray(point_coords, dtype=np.float32)
pl = np.asarray(point_labels, dtype=np.int64)
if pc.ndim != 2 or pc.shape[1] != 2:
raise ValueError("point_coords 应为 Nx2")
if pl.ndim != 1 or pl.shape[0] != pc.shape[0]:
raise ValueError("point_labels 长度须与 point_coords 行数一致")
box_arg = None
if box_xyxy is not None:
b = np.asarray(box_xyxy, dtype=np.float32).reshape(4)
box_arg = b
masks, scores, _low = predictor.predict(
point_coords=pc,
point_labels=pl,
box=box_arg,
multimask_output=True,
)
# masks: C x H x W
best = int(np.argmax(scores))
m = masks[best]
if m.dtype != np.bool_:
m = m > 0.5
return m
def mask_to_contour_xy(
mask_bool: np.ndarray,
epsilon_px: float = 2.0,
) -> List[List[float]]:
"""
从二值掩膜提取最大外轮廓,并用 Douglas-Peucker 简化。
返回 [[x, y], ...](裁剪图坐标系)。
"""
u8 = (np.asarray(mask_bool, dtype=np.uint8) * 255).astype(np.uint8)
try:
import cv2 # type: ignore[import]
contours, _h = cv2.findContours(u8, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
except Exception:
contours = None
if not contours:
# 无 OpenCV 时的极简回退:外接矩形
ys, xs = np.nonzero(mask_bool)
if ys.size == 0:
return []
x0, x1 = int(xs.min()), int(xs.max())
y0, y1 = int(ys.min()), int(ys.max())
return [
[float(x0), float(y0)],
[float(x1), float(y0)],
[float(x1), float(y1)],
[float(x0), float(y1)],
]
cnt = max(contours, key=cv2.contourArea)
if cv2.contourArea(cnt) < 1.0:
return []
peri = cv2.arcLength(cnt, True)
eps = max(epsilon_px, 0.001 * peri)
approx = cv2.approxPolyDP(cnt, eps, True)
out: List[List[float]] = []
for p in approx:
out.append([float(p[0][0]), float(p[0][1])])
if len(out) >= 3 and (out[0][0] != out[-1][0] or out[0][1] != out[-1][1]):
pass # 保持开放折线,前端可自行闭合
return out
_THIS_DIR = Path(__file__).resolve().parent _THIS_DIR = Path(__file__).resolve().parent

View File

@@ -18,7 +18,13 @@ from PIL import Image, ImageDraw
from config_loader import load_app_config, get_depth_backend_from_app from config_loader import load_app_config, get_depth_backend_from_app
from model.Depth.depth_loader import UnifiedDepthConfig, DepthBackend, build_depth_predictor from model.Depth.depth_loader import UnifiedDepthConfig, DepthBackend, build_depth_predictor
from model.Seg.seg_loader import UnifiedSegConfig, SegBackend, build_seg_predictor from model.Seg.seg_loader import (
UnifiedSegConfig,
SegBackend,
build_seg_predictor,
mask_to_contour_xy,
run_sam_prompt,
)
from model.Inpaint.inpaint_loader import UnifiedInpaintConfig, InpaintBackend, build_inpaint_predictor from model.Inpaint.inpaint_loader import UnifiedInpaintConfig, InpaintBackend, build_inpaint_predictor
from model.Animation.animation_loader import ( from model.Animation.animation_loader import (
UnifiedAnimationConfig, UnifiedAnimationConfig,
@@ -47,6 +53,28 @@ class SegmentRequest(ImageInput):
pass pass
class SamPromptSegmentRequest(BaseModel):
image_b64: str = Field(..., description="裁剪后的 RGB 图 base64PNG/JPG")
overlay_b64: Optional[str] = Field(
None,
description="与裁剪同尺寸的标记叠加 PNG base64可选当前用于校验尺寸一致",
)
point_coords: list[list[float]] = Field(
...,
description="裁剪坐标系下的提示点 [[x,y], ...]",
)
point_labels: list[int] = Field(
...,
description="与 point_coords 等长1=前景0=背景",
)
box_xyxy: list[float] = Field(
...,
description="裁剪内笔画紧包围盒 [x1,y1,x2,y2](像素)",
min_length=4,
max_length=4,
)
class InpaintRequest(ImageInput): class InpaintRequest(ImageInput):
prompt: Optional[str] = Field("", description="补全 prompt") prompt: Optional[str] = Field("", description="补全 prompt")
strength: float = Field(0.8, ge=0.0, le=1.0) strength: float = Field(0.8, ge=0.0, le=1.0)
@@ -287,6 +315,54 @@ def segment(req: SegmentRequest) -> Dict[str, Any]:
return {"success": False, "error": str(e)} return {"success": False, "error": str(e)}
@app.post("/segment/sam_prompt")
def segment_sam_prompt(req: SamPromptSegmentRequest) -> Dict[str, Any]:
"""
交互式 SAM裁剪图 + 点/框提示,返回掩膜外轮廓点列(裁剪像素坐标)。
"""
try:
pil = _b64_to_pil_image(req.image_b64).convert("RGB")
rgb = np.array(pil, dtype=np.uint8)
h, w = rgb.shape[0], rgb.shape[1]
if req.overlay_b64:
ov = _b64_to_pil_image(req.overlay_b64)
if ov.size != (w, h):
return {
"success": False,
"error": f"overlay 尺寸 {ov.size} 与 image {w}x{h} 不一致",
"contour": [],
}
if len(req.point_coords) != len(req.point_labels):
return {
"success": False,
"error": "point_coords 与 point_labels 长度不一致",
"contour": [],
}
if len(req.point_coords) < 1:
return {"success": False, "error": "至少需要一个提示点", "contour": []}
pc = np.array(req.point_coords, dtype=np.float32)
if pc.ndim != 2 or pc.shape[1] != 2:
return {"success": False, "error": "point_coords 每项须为 [x,y]", "contour": []}
pl = np.array(req.point_labels, dtype=np.int64)
box = np.array(req.box_xyxy, dtype=np.float32)
mask = run_sam_prompt(rgb, pc, pl, box_xyxy=box)
if not np.any(mask):
return {"success": False, "error": "SAM 未产生有效掩膜", "contour": []}
contour = mask_to_contour_xy(mask, epsilon_px=2.0)
if len(contour) < 3:
return {"success": False, "error": "轮廓点数不足", "contour": []}
return {"success": True, "contour": contour, "error": None}
except Exception as e:
return {"success": False, "error": str(e), "contour": []}
# ----------------------------- # -----------------------------
# Inpaint # Inpaint
# ----------------------------- # -----------------------------
@@ -344,7 +420,12 @@ def inpaint(req: InpaintRequest) -> Dict[str, Any]:
out_path = out_dir / f"{model_name}_inpaint.png" out_path = out_dir / f"{model_name}_inpaint.png"
out.save(out_path) out.save(out_path)
return {"success": True, "output_path": str(out_path)} # 兼容 Qt 前端:直接返回结果图,避免前端再去读取服务器磁盘路径
return {
"success": True,
"output_path": str(out_path),
"output_image_b64": _pil_image_to_png_b64(out),
}
except Exception as e: except Exception as e:
return {"success": False, "error": str(e)} return {"success": False, "error": str(e)}