Compare commits
5 Commits
088dd91e27
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0710090b4d | |||
| 6cb82cec57 | |||
| d67d7dc0c5 | |||
| e116a9ec79 | |||
| a79c31a056 |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
331
client/core/eval/ProjectEvaluator.cpp
Normal file
331
client/core/eval/ProjectEvaluator.cpp
Normal 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);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Entities:resolved origin + opacity(可见性轨道)
|
||||||
|
for (int i = 0; i < ents.size(); ++i) {
|
||||||
|
core::Project::Entity e = ents[i];
|
||||||
|
const QPointF base = e.originWorld;
|
||||||
|
const QPointF ro = (!e.id.isEmpty()) ? resolve(e.id) : sampledOriginForEntity(e, clip, localFrame);
|
||||||
|
const QPointF delta = ro - base;
|
||||||
|
e.originWorld = ro;
|
||||||
|
e.imageTopLeftWorld += delta;
|
||||||
|
|
||||||
|
// Clip channels: userScale / imagePath(迁移后仍能逐帧显示)
|
||||||
|
if (clip && clip->entityUserScaleKeys.contains(e.id)) {
|
||||||
|
const auto& keys = clip->entityUserScaleKeys.value(e.id);
|
||||||
|
e.userScale = core::sampleUserScale(keys, localFrame, e.userScale, core::KeyInterpolation::Linear);
|
||||||
|
}
|
||||||
|
if (clip && clip->entityImageFrames.contains(e.id)) {
|
||||||
|
const auto& frames = clip->entityImageFrames.value(e.id);
|
||||||
|
e.imagePath = core::sampleImagePath(frames, localFrame, e.imagePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
QVector<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});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tools:resolved origin + opacity(可见性轨道)
|
||||||
|
for (int i = 0; i < tools.size(); ++i) {
|
||||||
|
core::Project::Tool t = tools[i];
|
||||||
|
const QPointF base = t.originWorld;
|
||||||
|
const QPointF ro = (!t.id.isEmpty()) ? resolve(t.id) : sampledOriginForTool(t, clip, localFrame);
|
||||||
|
const QPointF delta = ro - base;
|
||||||
|
t.originWorld = ro;
|
||||||
|
// parentOffsetWorld 已包含相对关系,不在这里改
|
||||||
|
QVector<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
|
||||||
|
|
||||||
31
client/core/eval/ProjectEvaluator.h
Normal file
31
client/core/eval/ProjectEvaluator.h
Normal 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
|
||||||
|
|
||||||
314
client/core/library/EntityJson.cpp
Normal file
314
client/core/library/EntityJson.cpp
Normal 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
|
||||||
|
|
||||||
13
client/core/library/EntityJson.h
Normal file
13
client/core/library/EntityJson.h
Normal 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
|
||||||
|
|
||||||
19
client/core/library/OnlineResourceLibraryProvider.cpp
Normal file
19
client/core/library/OnlineResourceLibraryProvider.cpp
Normal 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
|
||||||
|
|
||||||
16
client/core/library/OnlineResourceLibraryProvider.h
Normal file
16
client/core/library/OnlineResourceLibraryProvider.h
Normal 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
|
||||||
|
|
||||||
95
client/core/library/ResourceLibraryProvider.cpp
Normal file
95
client/core/library/ResourceLibraryProvider.cpp
Normal 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
|
||||||
|
|
||||||
59
client/core/library/ResourceLibraryProvider.h
Normal file
59
client/core/library/ResourceLibraryProvider.h
Normal 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
|
||||||
|
|
||||||
113
client/core/library/ToolJson.cpp
Normal file
113
client/core/library/ToolJson.cpp
Normal 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
|
||||||
|
|
||||||
13
client/core/library/ToolJson.h
Normal file
13
client/core/library/ToolJson.h
Normal 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
|
||||||
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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_prompt,JSON 响应由调用方解析(success / contour / error)。
|
||||||
|
QNetworkReply* segmentSamPromptAsync(
|
||||||
|
const QByteArray& cropRgbPngBytes,
|
||||||
|
const QByteArray& overlayPngBytes,
|
||||||
|
const QJsonArray& pointCoords,
|
||||||
|
const QJsonArray& pointLabels,
|
||||||
|
const QJsonArray& boxXyxy,
|
||||||
|
QString* outImmediateError = nullptr);
|
||||||
|
|
||||||
|
// POST /inpaint,JSON 响应由调用方解析(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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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 必须 >=0;end 会被归一化为 >= start
|
||||||
|
bool setProjectFrameRange(int start, int end);
|
||||||
|
// 自动扩展 end(用于“无限延伸”):仅在 end 变大时写盘;recordHistory=false 时不占用 undo 栈
|
||||||
|
bool ensureProjectFrameEndAtLeast(int end, bool recordHistory = false);
|
||||||
|
|
||||||
Project& project() { return m_project; }
|
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);
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
208
client/gui/dialogs/BlackholeResolveDialog.cpp
Normal file
208
client/gui/dialogs/BlackholeResolveDialog.cpp
Normal 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);
|
||||||
|
}
|
||||||
|
|
||||||
47
client/gui/dialogs/BlackholeResolveDialog.h
Normal file
47
client/gui/dialogs/BlackholeResolveDialog.h
Normal 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;
|
||||||
|
};
|
||||||
|
|
||||||
56
client/gui/dialogs/EntityFinalizeDialog.cpp
Normal file
56
client/gui/dialogs/EntityFinalizeDialog.cpp
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
24
client/gui/dialogs/EntityFinalizeDialog.h
Normal file
24
client/gui/dialogs/EntityFinalizeDialog.h
Normal 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;
|
||||||
|
};
|
||||||
|
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
68
client/gui/dialogs/InpaintPreviewDialog.cpp
Normal file
68
client/gui/dialogs/InpaintPreviewDialog.cpp
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
22
client/gui/dialogs/InpaintPreviewDialog.h
Normal file
22
client/gui/dialogs/InpaintPreviewDialog.h
Normal 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
@@ -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;
|
||||||
|
|||||||
110
client/gui/editor/EntityCutoutUtils.cpp
Normal file
110
client/gui/editor/EntityCutoutUtils.cpp
Normal 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
|
||||||
18
client/gui/editor/EntityCutoutUtils.h
Normal file
18
client/gui/editor/EntityCutoutUtils.h
Normal 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
|
||||||
264
client/gui/library/ResourceLibraryDock.cpp
Normal file
264
client/gui/library/ResourceLibraryDock.cpp
Normal 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
|
||||||
|
|
||||||
38
client/gui/library/ResourceLibraryDock.h
Normal file
38
client/gui/library/ResourceLibraryDock.h
Normal 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
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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(); });
|
||||||
|
|||||||
45
client/gui/props/BlackholePropertySection.cpp
Normal file
45
client/gui/props/BlackholePropertySection.cpp
Normal 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
|
||||||
|
|
||||||
32
client/gui/props/BlackholePropertySection.h
Normal file
32
client/gui/props/BlackholePropertySection.h
Normal 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
|
||||||
|
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
159
client/gui/props/ToolPropertySection.cpp
Normal file
159
client/gui/props/ToolPropertySection.cpp
Normal 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
|
||||||
61
client/gui/props/ToolPropertySection.h
Normal file
61
client/gui/props/ToolPropertySection.h
Normal 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 * 1000,0=左 1000=右
|
||||||
|
int fontPx = 18;
|
||||||
|
int alignIndex = 1; // 0=left,1=center,2=right
|
||||||
|
bool visible = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
class ToolPropertySection final : public PropertySectionWidget {
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
explicit ToolPropertySection(QWidget* parent = nullptr);
|
||||||
|
|
||||||
|
void clearDisconnected();
|
||||||
|
void applyState(const ToolPropertyUiState& s);
|
||||||
|
void setEditingEnabled(bool on);
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void textCommitted(const QString& text);
|
||||||
|
void pointerTChanged(int thousandths);
|
||||||
|
void fontPxChanged(int px);
|
||||||
|
void alignChanged(int alignIndex);
|
||||||
|
void 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
|
||||||
|
|
||||||
@@ -1,16 +1,33 @@
|
|||||||
#include "timeline/TimelineWidget.h"
|
#include "timeline/TimelineWidget.h"
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
|
#include <QFontMetrics>
|
||||||
#include <QMouseEvent>
|
#include <QMouseEvent>
|
||||||
#include <QPainter>
|
#include <QPainter>
|
||||||
|
#include <QResizeEvent>
|
||||||
#include <QWheelEvent>
|
#include <QWheelEvent>
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
int clampFrame(int f, int a, int b) {
|
static int pickMajorStep(double pixelsPerFrame) {
|
||||||
if (a > b) std::swap(a, b);
|
// 主刻度间距(帧):保证屏幕上大约 ≥ 48px
|
||||||
return std::clamp(f, a, b);
|
const double targetPx = 48.0;
|
||||||
|
const double raw = targetPx / std::max(pixelsPerFrame, 1e-6);
|
||||||
|
static const int cand[] = {1, 2, 5, 10, 15, 20, 25, 50, 75, 100, 150, 200, 250, 500, 1000, 2000, 5000};
|
||||||
|
for (int c : cand) {
|
||||||
|
if (c >= raw) return c;
|
||||||
|
}
|
||||||
|
return cand[sizeof(cand) / sizeof(cand[0]) - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
static int pickMinorStep(int major) {
|
||||||
|
if (major >= 100) return major / 5;
|
||||||
|
if (major >= 20) return major / 4;
|
||||||
|
if (major >= 10) return major / 5;
|
||||||
|
if (major >= 5) return 1;
|
||||||
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
@@ -18,17 +35,20 @@ int clampFrame(int f, int a, int b) {
|
|||||||
TimelineWidget::TimelineWidget(QWidget* parent)
|
TimelineWidget::TimelineWidget(QWidget* parent)
|
||||||
: QWidget(parent) {
|
: QWidget(parent) {
|
||||||
setMouseTracking(true);
|
setMouseTracking(true);
|
||||||
setMinimumHeight(28);
|
// 单行紧凑:标尺 + 轨道(帧号画在播放头处,随坐标轴滚动)
|
||||||
|
setMinimumHeight(kRulerHeight + 18 + 6);
|
||||||
setFocusPolicy(Qt::StrongFocus);
|
setFocusPolicy(Qt::StrongFocus);
|
||||||
|
setToolTip(QStringLiteral("片段时间轴(固定 0-600):左键拖动播放头;滚轮:逐帧"));
|
||||||
|
}
|
||||||
|
|
||||||
|
void TimelineWidget::resizeEvent(QResizeEvent* e) {
|
||||||
|
QWidget::resizeEvent(e);
|
||||||
|
update();
|
||||||
}
|
}
|
||||||
|
|
||||||
void TimelineWidget::setFrameRange(int start, int end) {
|
void TimelineWidget::setFrameRange(int start, int end) {
|
||||||
if (m_start == start && m_end == end) {
|
(void)start;
|
||||||
return;
|
(void)end;
|
||||||
}
|
|
||||||
m_start = start;
|
|
||||||
m_end = end;
|
|
||||||
m_currentFrame = clampFrame(m_currentFrame, m_start, m_end);
|
|
||||||
update();
|
update();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,6 +56,15 @@ void TimelineWidget::setCurrentFrame(int frame) {
|
|||||||
setFrameInternal(frame, false);
|
setFrameInternal(frame, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void TimelineWidget::setCurrentFrameProgrammatic(int frame) {
|
||||||
|
const int f = std::clamp(frame, kStart, kEnd - 1);
|
||||||
|
if (m_currentFrame == f) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
m_currentFrame = f;
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
|
||||||
void TimelineWidget::setSelectionRange(int start, int end) {
|
void TimelineWidget::setSelectionRange(int start, int end) {
|
||||||
if (start < 0 || end < 0) {
|
if (start < 0 || end < 0) {
|
||||||
m_selStart = -1;
|
m_selStart = -1;
|
||||||
@@ -43,41 +72,40 @@ void TimelineWidget::setSelectionRange(int start, int end) {
|
|||||||
update();
|
update();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
m_selStart = clampFrame(std::min(start, end), m_start, m_end);
|
const int lo = std::min(start, end);
|
||||||
m_selEnd = clampFrame(std::max(start, end), m_start, m_end);
|
const int hi = std::max(start, end);
|
||||||
|
m_selStart = std::clamp(lo, kStart, kEnd - 1);
|
||||||
|
m_selEnd = std::clamp(hi, m_selStart, kEnd - 1);
|
||||||
update();
|
update();
|
||||||
}
|
}
|
||||||
|
|
||||||
void TimelineWidget::setKeyframeTracks(const core::Project::Entity* e) {
|
static void uniqSort(QVector<int>& v) {
|
||||||
m_locFrames.clear();
|
std::sort(v.begin(), v.end());
|
||||||
m_scaleFrames.clear();
|
v.erase(std::unique(v.begin(), v.end()), v.end());
|
||||||
m_imgFrames.clear();
|
}
|
||||||
if (!e) {
|
|
||||||
update();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
m_locFrames.reserve(e->locationKeys.size());
|
|
||||||
for (const auto& k : e->locationKeys) m_locFrames.push_back(k.frame);
|
|
||||||
m_scaleFrames.reserve(e->userScaleKeys.size());
|
|
||||||
for (const auto& k : e->userScaleKeys) m_scaleFrames.push_back(k.frame);
|
|
||||||
m_imgFrames.reserve(e->imageFrames.size());
|
|
||||||
for (const auto& k : e->imageFrames) m_imgFrames.push_back(k.frame);
|
|
||||||
|
|
||||||
auto uniqSort = [](QVector<int>& v) {
|
static bool containsFrame(const QVector<int>& v, int f) {
|
||||||
std::sort(v.begin(), v.end());
|
return std::binary_search(v.begin(), v.end(), f);
|
||||||
v.erase(std::unique(v.begin(), v.end()), v.end());
|
}
|
||||||
};
|
|
||||||
|
void TimelineWidget::setKeyframeTracks(const QVector<int>& locFrames,
|
||||||
|
const QVector<int>& scaleFrames,
|
||||||
|
const QVector<int>& imgFrames,
|
||||||
|
const QVector<int>& visFrames) {
|
||||||
|
m_locFrames = locFrames;
|
||||||
|
m_scaleFrames = scaleFrames;
|
||||||
|
m_imgFrames = imgFrames;
|
||||||
|
m_visFrames = visFrames;
|
||||||
uniqSort(m_locFrames);
|
uniqSort(m_locFrames);
|
||||||
uniqSort(m_scaleFrames);
|
uniqSort(m_scaleFrames);
|
||||||
uniqSort(m_imgFrames);
|
uniqSort(m_imgFrames);
|
||||||
// 轨道变了:若当前选中的关键帧不再存在,则清除
|
uniqSort(m_visFrames);
|
||||||
auto contains = [](const QVector<int>& v, int f) {
|
|
||||||
return std::binary_search(v.begin(), v.end(), f);
|
|
||||||
};
|
|
||||||
bool ok = true;
|
bool ok = true;
|
||||||
if (m_selKeyKind == KeyKind::Location) ok = contains(m_locFrames, m_selKeyFrame);
|
if (m_selKeyKind == KeyKind::Location) ok = containsFrame(m_locFrames, m_selKeyFrame);
|
||||||
if (m_selKeyKind == KeyKind::UserScale) ok = contains(m_scaleFrames, m_selKeyFrame);
|
if (m_selKeyKind == KeyKind::UserScale) ok = containsFrame(m_scaleFrames, m_selKeyFrame);
|
||||||
if (m_selKeyKind == KeyKind::Image) ok = contains(m_imgFrames, m_selKeyFrame);
|
if (m_selKeyKind == KeyKind::Image) ok = containsFrame(m_imgFrames, m_selKeyFrame);
|
||||||
|
if (m_selKeyKind == KeyKind::Visibility) ok = containsFrame(m_visFrames, m_selKeyFrame);
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
m_selKeyKind = KeyKind::None;
|
m_selKeyKind = KeyKind::None;
|
||||||
m_selKeyFrame = -1;
|
m_selKeyFrame = -1;
|
||||||
@@ -86,33 +114,67 @@ void TimelineWidget::setKeyframeTracks(const core::Project::Entity* e) {
|
|||||||
update();
|
update();
|
||||||
}
|
}
|
||||||
|
|
||||||
QRect TimelineWidget::trackRect() const {
|
void TimelineWidget::setToolKeyframeTracks(const QVector<int>& locFrames,
|
||||||
const int pad = 8;
|
const QVector<int>& visFrames) {
|
||||||
const int h = height();
|
m_locFrames = locFrames;
|
||||||
return QRect(pad, 0, std::max(1, width() - pad * 2), h);
|
m_scaleFrames.clear();
|
||||||
|
m_imgFrames.clear();
|
||||||
|
m_visFrames = visFrames;
|
||||||
|
uniqSort(m_locFrames);
|
||||||
|
uniqSort(m_visFrames);
|
||||||
|
|
||||||
|
bool ok = true;
|
||||||
|
if (m_selKeyKind == KeyKind::Location) ok = containsFrame(m_locFrames, m_selKeyFrame);
|
||||||
|
if (m_selKeyKind == KeyKind::Visibility) ok = containsFrame(m_visFrames, m_selKeyFrame);
|
||||||
|
if (!ok) {
|
||||||
|
m_selKeyKind = KeyKind::None;
|
||||||
|
m_selKeyFrame = -1;
|
||||||
|
emit keyframeSelectionChanged(m_selKeyKind, m_selKeyFrame);
|
||||||
|
}
|
||||||
|
update();
|
||||||
}
|
}
|
||||||
|
|
||||||
int TimelineWidget::xToFrame(int x) const {
|
QRect TimelineWidget::contentRect() const {
|
||||||
const QRect r = trackRect();
|
return QRect(contentLeft(), 3, contentWidth(), std::max(24, height() - 6));
|
||||||
if (r.width() <= 1) return m_start;
|
}
|
||||||
const double t = std::clamp((x - r.left()) / double(r.width() - 1), 0.0, 1.0);
|
|
||||||
const int span = std::max(1, m_end - m_start);
|
QRect TimelineWidget::rulerRect() const {
|
||||||
const int f = m_start + int(std::round(t * span));
|
const QRect c = contentRect();
|
||||||
return clampFrame(f, m_start, m_end);
|
return QRect(c.left(), c.top(), c.width(), kRulerHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
QRect TimelineWidget::keyAreaRect() const {
|
||||||
|
const QRect c = contentRect();
|
||||||
|
const QRect r = rulerRect();
|
||||||
|
const int top = r.bottom();
|
||||||
|
return QRect(c.left(), top, c.width(), std::max(1, c.bottom() - top));
|
||||||
|
}
|
||||||
|
|
||||||
|
double TimelineWidget::frameToXf(double frame) const {
|
||||||
|
const double pxf = double(contentWidth()) / double(std::max(1, kEnd - kStart));
|
||||||
|
return double(contentLeft()) + (frame - double(kStart)) * pxf;
|
||||||
}
|
}
|
||||||
|
|
||||||
int TimelineWidget::frameToX(int frame) const {
|
int TimelineWidget::frameToX(int frame) const {
|
||||||
const QRect r = trackRect();
|
return int(std::lround(frameToXf(double(frame))));
|
||||||
if (r.width() <= 1) return r.left();
|
}
|
||||||
const int f = clampFrame(frame, m_start, m_end);
|
|
||||||
const int span = std::max(1, m_end - m_start);
|
double TimelineWidget::xToFramef(int x) const {
|
||||||
const double t = double(f - m_start) / double(span);
|
const double pxf = double(contentWidth()) / double(std::max(1, kEnd - kStart));
|
||||||
return r.left() + int(std::round(t * (r.width() - 1)));
|
return double(kStart) + double(x - contentLeft()) / std::max(pxf, 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
int TimelineWidget::xToFrame(int x) const {
|
||||||
|
return int(std::lround(xToFramef(x)));
|
||||||
}
|
}
|
||||||
|
|
||||||
void TimelineWidget::setFrameInternal(int frame, bool commit) {
|
void TimelineWidget::setFrameInternal(int frame, bool commit) {
|
||||||
const int f = clampFrame(frame, m_start, m_end);
|
const int f = std::clamp(frame, kStart, kEnd - 1);
|
||||||
if (m_currentFrame == f && !commit) {
|
// 松手时若帧未变:只发 committed,禁止再发 scrubbed,否则主窗口会双次求值/刷新导致帧号与红线闪烁
|
||||||
|
if (m_currentFrame == f) {
|
||||||
|
if (commit) {
|
||||||
|
emit frameCommitted(f);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
m_currentFrame = f;
|
m_currentFrame = f;
|
||||||
@@ -127,64 +189,164 @@ void TimelineWidget::paintEvent(QPaintEvent*) {
|
|||||||
QPainter p(this);
|
QPainter p(this);
|
||||||
p.setRenderHint(QPainter::Antialiasing, true);
|
p.setRenderHint(QPainter::Antialiasing, true);
|
||||||
|
|
||||||
const QRect r = rect();
|
p.fillRect(rect(), palette().base());
|
||||||
p.fillRect(r, palette().base());
|
|
||||||
|
|
||||||
const QRect tr = trackRect().adjusted(0, 8, 0, -8);
|
const QRect cr = contentRect();
|
||||||
const QColor rail = palette().mid().color();
|
const QRect kr = keyAreaRect();
|
||||||
p.setPen(Qt::NoPen);
|
const QRect rr = rulerRect();
|
||||||
p.setBrush(rail);
|
const double fLeft = double(kStart);
|
||||||
p.drawRoundedRect(tr, 6, 6);
|
const int visMin = kStart;
|
||||||
|
const int visMax = kEnd;
|
||||||
|
|
||||||
// selection range
|
auto frameVisible = [&](int fr) { return fr >= visMin && fr <= visMax; };
|
||||||
|
|
||||||
|
// —— 区间:贯穿标尺+轨道,贴在时间坐标上 ——
|
||||||
if (m_selStart >= 0 && m_selEnd >= 0) {
|
if (m_selStart >= 0 && m_selEnd >= 0) {
|
||||||
const int x0 = frameToX(m_selStart);
|
const int x0 = frameToX(m_selStart);
|
||||||
const int x1 = frameToX(m_selEnd);
|
const int x1 = frameToX(m_selEnd);
|
||||||
QRect sel(QPoint(std::min(x0, x1), tr.top()), QPoint(std::max(x0, x1), tr.bottom()));
|
const int xa = std::min(x0, x1);
|
||||||
sel = sel.adjusted(0, 2, 0, -2);
|
const int xb = std::max(x0, x1);
|
||||||
QColor c = palette().highlight().color();
|
QRect sel(xa, cr.top(), xb - xa, cr.height());
|
||||||
c.setAlpha(50);
|
sel = sel.intersected(cr);
|
||||||
p.setBrush(c);
|
if (sel.width() > 0) {
|
||||||
p.drawRoundedRect(sel, 4, 4);
|
QColor c = palette().highlight().color();
|
||||||
|
c.setAlpha(72);
|
||||||
|
p.setPen(Qt::NoPen);
|
||||||
|
p.setBrush(c);
|
||||||
|
p.drawRoundedRect(sel, 3, 3);
|
||||||
|
p.setPen(QPen(palette().highlight().color(), 1));
|
||||||
|
p.setBrush(Qt::NoBrush);
|
||||||
|
p.drawRoundedRect(sel, 3, 3);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
auto drawDots = [&](const QVector<int>& frames, const QColor& c, int y) {
|
// —— 关键帧切分背景(仅轨道):按可见范围切分,不因 m_end 隐藏尾部关键帧 ——
|
||||||
p.setBrush(c);
|
QVector<int> allK;
|
||||||
p.setPen(Qt::NoPen);
|
allK.reserve(m_locFrames.size() + m_scaleFrames.size() + m_imgFrames.size() + m_visFrames.size());
|
||||||
for (int f : frames) {
|
allK += m_locFrames;
|
||||||
if (f < m_start || f > m_end) continue;
|
allK += m_scaleFrames;
|
||||||
const int x = frameToX(f);
|
allK += m_imgFrames;
|
||||||
const bool sel =
|
allK += m_visFrames;
|
||||||
(m_selKeyFrame == f)
|
std::sort(allK.begin(), allK.end());
|
||||||
&& ((m_selKeyKind == KeyKind::Image && &frames == &m_imgFrames)
|
allK.erase(std::unique(allK.begin(), allK.end()), allK.end());
|
||||||
|| (m_selKeyKind == KeyKind::Location && &frames == &m_locFrames)
|
|
||||||
|| (m_selKeyKind == KeyKind::UserScale && &frames == &m_scaleFrames));
|
const int v0 = visMin;
|
||||||
if (sel) {
|
const int v1 = visMax;
|
||||||
p.setPen(QPen(palette().highlight().color(), 2.0));
|
QVector<int> cuts;
|
||||||
p.setBrush(c);
|
cuts.reserve(allK.size() + 4);
|
||||||
p.drawEllipse(QPointF(x, y), 4.4, 4.4);
|
cuts.push_back(v0);
|
||||||
p.setPen(Qt::NoPen);
|
for (int k : allK) {
|
||||||
} else {
|
if (k > v0 && k < v1) {
|
||||||
p.drawEllipse(QPointF(x, y), 2.6, 2.6);
|
cuts.push_back(k);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
cuts.push_back(v1);
|
||||||
|
std::sort(cuts.begin(), cuts.end());
|
||||||
|
cuts.erase(std::unique(cuts.begin(), cuts.end()), cuts.end());
|
||||||
|
|
||||||
|
for (int i = 0; i + 1 < cuts.size(); ++i) {
|
||||||
|
const int a = cuts[i];
|
||||||
|
const int b = cuts[i + 1];
|
||||||
|
if (a >= b) continue;
|
||||||
|
const int x0 = frameToX(a);
|
||||||
|
const int x1 = frameToX(b);
|
||||||
|
if (x1 < kr.left() || x0 > kr.right()) continue;
|
||||||
|
QRect seg(std::max(x0, kr.left()), kr.top(), std::min(x1, kr.right()) - std::max(x0, kr.left()), kr.height());
|
||||||
|
if (seg.width() <= 0) continue;
|
||||||
|
QColor c = (i % 2) ? QColor(255, 255, 255, 28) : QColor(0, 0, 0, 12);
|
||||||
|
p.setPen(Qt::NoPen);
|
||||||
|
p.setBrush(c);
|
||||||
|
p.drawRect(seg);
|
||||||
|
}
|
||||||
|
|
||||||
|
const QColor rail = palette().mid().color();
|
||||||
|
p.setPen(Qt::NoPen);
|
||||||
|
p.setBrush(rail);
|
||||||
|
p.drawRoundedRect(kr, 4, 4);
|
||||||
|
|
||||||
|
// —— 关键帧:竖线贯穿标尺+轨道(位置钉在帧坐标上,随平移/缩放移动)——
|
||||||
|
auto drawKeyLine = [&](int fr, const QColor& col, KeyKind kind, int xOffPx) {
|
||||||
|
if (!frameVisible(fr)) return;
|
||||||
|
const int x = frameToX(fr) + xOffPx;
|
||||||
|
if (x < cr.left() - 4 || x > cr.right() + 4) return;
|
||||||
|
const bool sel = (m_selKeyFrame == fr && m_selKeyKind == kind);
|
||||||
|
QPen pen(col, sel ? 3.2 : 2.0);
|
||||||
|
pen.setCapStyle(Qt::FlatCap);
|
||||||
|
p.setPen(pen);
|
||||||
|
p.drawLine(x, cr.top() + 1, x, cr.bottom() - 1);
|
||||||
|
const int yb = kr.bottom() - 4;
|
||||||
|
QPolygonF dia;
|
||||||
|
dia << QPointF(x, yb - 5) << QPointF(x + 4, yb) << QPointF(x, yb + 4) << QPointF(x - 4, yb);
|
||||||
|
p.setPen(Qt::NoPen);
|
||||||
|
p.setBrush(col);
|
||||||
|
p.drawPolygon(dia);
|
||||||
};
|
};
|
||||||
|
|
||||||
const int yMid = tr.center().y();
|
for (int fr : m_imgFrames) drawKeyLine(fr, QColor(70, 130, 240), KeyKind::Image, -3);
|
||||||
drawDots(m_imgFrames, QColor(80, 160, 255, 230), yMid - 6);
|
for (int fr : m_locFrames) drawKeyLine(fr, QColor(240, 110, 40), KeyKind::Location, -1);
|
||||||
drawDots(m_locFrames, QColor(255, 120, 0, 230), yMid);
|
for (int fr : m_scaleFrames) drawKeyLine(fr, QColor(80, 190, 90), KeyKind::UserScale, 1);
|
||||||
drawDots(m_scaleFrames, QColor(140, 220, 140, 230), yMid + 6);
|
for (int fr : m_visFrames) drawKeyLine(fr, QColor(160, 100, 230), KeyKind::Visibility, 3);
|
||||||
|
|
||||||
// current frame caret
|
// —— 标尺底与刻度文字(与帧一一对应,随 view 滚动)——
|
||||||
|
p.setPen(Qt::NoPen);
|
||||||
|
p.setBrush(palette().alternateBase());
|
||||||
|
p.drawRoundedRect(rr, 3, 3);
|
||||||
|
|
||||||
|
const double pxf = double(contentWidth()) / double(std::max(1, kEnd - kStart));
|
||||||
|
const int major = pickMajorStep(pxf);
|
||||||
|
const int minor = pickMinorStep(major);
|
||||||
|
QPen minorPen(QColor(60, 60, 60, 100));
|
||||||
|
minorPen.setWidth(1);
|
||||||
|
QPen majorPen(QColor(35, 35, 35, 170));
|
||||||
|
majorPen.setWidth(1);
|
||||||
|
QFont rulerFont = font();
|
||||||
|
rulerFont.setPointSize(std::max(7, rulerFont.pointSize() - 1));
|
||||||
|
p.setFont(rulerFont);
|
||||||
|
|
||||||
|
for (int f = static_cast<int>(std::floor(fLeft / minor) * minor); f <= visMax + minor; f += minor) {
|
||||||
|
if (f < visMin - minor) continue;
|
||||||
|
const double xf = frameToXf(f);
|
||||||
|
if (xf < rr.left() - 2 || xf > rr.right() + 2) continue;
|
||||||
|
const int xi = int(std::lround(xf));
|
||||||
|
const bool isMajor = (major > 0) && (f % major == 0);
|
||||||
|
p.setPen(isMajor ? majorPen : minorPen);
|
||||||
|
const int tickH = isMajor ? 8 : 4;
|
||||||
|
p.drawLine(xi, rr.bottom() - tickH, xi, rr.bottom());
|
||||||
|
if (isMajor && pxf > 0.28) {
|
||||||
|
p.setPen(QColor(25, 25, 25, 235));
|
||||||
|
const QString txt = QString::number(f);
|
||||||
|
const int tw = QFontMetrics(rulerFont).horizontalAdvance(txt);
|
||||||
|
int tx = xi - tw / 2;
|
||||||
|
tx = std::clamp(tx, rr.left() + 1, rr.right() - tw - 1);
|
||||||
|
p.drawText(tx, rr.top() + QFontMetrics(rulerFont).ascent() + 1, txt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 播放头 + 帧号(钉在轴上,与红线同一 x)
|
||||||
const int cx = frameToX(m_currentFrame);
|
const int cx = frameToX(m_currentFrame);
|
||||||
p.setPen(QPen(palette().highlight().color(), 2.0));
|
p.setPen(QPen(QColor(220, 55, 55, 250), 2.0));
|
||||||
p.drawLine(QPoint(cx, tr.top() - 6), QPoint(cx, tr.bottom() + 6));
|
p.drawLine(QPoint(cx, cr.top()), QPoint(cx, cr.bottom()));
|
||||||
}
|
|
||||||
|
|
||||||
static bool hitDot(const QPoint& pos, int dotX, int dotY, int radiusPx) {
|
QFont bf = font();
|
||||||
const int dx = pos.x() - dotX;
|
bf.setBold(true);
|
||||||
const int dy = pos.y() - dotY;
|
p.setFont(bf);
|
||||||
return (dx * dx + dy * dy) <= (radiusPx * radiusPx);
|
QFontMetrics fm(bf);
|
||||||
|
const QString ft = QString::number(m_currentFrame);
|
||||||
|
const int tw = fm.horizontalAdvance(ft);
|
||||||
|
const int ph = fm.height() + 3;
|
||||||
|
const int pw = tw + 10;
|
||||||
|
int px = cx - pw / 2;
|
||||||
|
if (pw <= cr.width() - 4) {
|
||||||
|
px = std::clamp(px, cr.left() + 2, cr.right() - pw - 2);
|
||||||
|
} else {
|
||||||
|
px = cr.left() + 2;
|
||||||
|
}
|
||||||
|
const int py = cr.top() + 1;
|
||||||
|
p.setPen(QPen(QColor(180, 40, 40), 1));
|
||||||
|
p.setBrush(QColor(255, 245, 245, 245));
|
||||||
|
p.drawRoundedRect(px, py, pw, ph, 3, 3);
|
||||||
|
p.setPen(QColor(20, 20, 20));
|
||||||
|
p.drawText(px + 5, py + fm.ascent() + 1, ft);
|
||||||
}
|
}
|
||||||
|
|
||||||
static int findNearestFrameInTrack(const QVector<int>& frames, int frame) {
|
static int findNearestFrameInTrack(const QVector<int>& frames, int frame) {
|
||||||
@@ -241,44 +403,36 @@ void TimelineWidget::mouseReleaseEvent(QMouseEvent* e) {
|
|||||||
const int f = xToFrame(e->pos().x());
|
const int f = xToFrame(e->pos().x());
|
||||||
setFrameInternal(f, true);
|
setFrameInternal(f, true);
|
||||||
|
|
||||||
// 点击(非拖拽)时做选中:关键帧或区间
|
|
||||||
if (!m_moved) {
|
if (!m_moved) {
|
||||||
const QRect tr = trackRect().adjusted(0, 8, 0, -8);
|
const QRect cr = contentRect();
|
||||||
const int yMid = tr.center().y();
|
const int mx = e->pos().x();
|
||||||
const int yImg = yMid - 6;
|
const int my = e->pos().y();
|
||||||
const int yLoc = yMid;
|
|
||||||
const int ySc = yMid + 6;
|
|
||||||
const int rad = 7;
|
|
||||||
|
|
||||||
auto trySelectKey = [&](KeyKind kind, const QVector<int>& frames, int laneY) -> bool {
|
auto trySelectKey = [&](KeyKind kind, const QVector<int>& frames, int xOff) -> bool {
|
||||||
const int nearest = findNearestFrameInTrack(frames, f);
|
const int nearest = findNearestFrameInTrack(frames, f);
|
||||||
if (nearest < 0) return false;
|
if (nearest < 0) return false;
|
||||||
const int x = frameToX(nearest);
|
const int xk = frameToX(nearest) + xOff;
|
||||||
if (hitDot(e->pos(), x, laneY, rad)) {
|
if (std::abs(mx - xk) > 9) return false;
|
||||||
m_selKeyKind = kind;
|
if (my < cr.top() || my > cr.bottom()) return false;
|
||||||
m_selKeyFrame = nearest;
|
m_selKeyKind = kind;
|
||||||
emit keyframeSelectionChanged(m_selKeyKind, m_selKeyFrame);
|
m_selKeyFrame = nearest;
|
||||||
update();
|
emit keyframeSelectionChanged(m_selKeyKind, m_selKeyFrame);
|
||||||
return true;
|
update();
|
||||||
}
|
return true;
|
||||||
return false;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 先尝试命中关键帧(按 lane 优先)
|
if (trySelectKey(KeyKind::Visibility, m_visFrames, 3) || trySelectKey(KeyKind::Image, m_imgFrames, -3)
|
||||||
if (trySelectKey(KeyKind::Image, m_imgFrames, yImg)
|
|| trySelectKey(KeyKind::Location, m_locFrames, -1) || trySelectKey(KeyKind::UserScale, m_scaleFrames, 1)) {
|
||||||
|| trySelectKey(KeyKind::Location, m_locFrames, yLoc)
|
|
||||||
|| trySelectKey(KeyKind::UserScale, m_scaleFrames, ySc)) {
|
|
||||||
// 选中关键帧时清掉区间
|
|
||||||
if (m_selStart >= 0 && m_selEnd >= 0) {
|
if (m_selStart >= 0 && m_selEnd >= 0) {
|
||||||
m_selStart = -1;
|
m_selStart = -1;
|
||||||
m_selEnd = -1;
|
m_selEnd = -1;
|
||||||
emit intervalSelectionChanged(m_selStart, m_selEnd);
|
emit intervalSelectionChanged(m_selStart, m_selEnd);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 未命中关键帧:尝试选中由关键帧切分出的区间(使用三轨道的并集)
|
|
||||||
QVector<int> all = m_locFrames;
|
QVector<int> all = m_locFrames;
|
||||||
all += m_scaleFrames;
|
all += m_scaleFrames;
|
||||||
all += m_imgFrames;
|
all += m_imgFrames;
|
||||||
|
all += m_visFrames;
|
||||||
std::sort(all.begin(), all.end());
|
std::sort(all.begin(), all.end());
|
||||||
all.erase(std::unique(all.begin(), all.end()), all.end());
|
all.erase(std::unique(all.begin(), all.end()), all.end());
|
||||||
int a = -1, b = -1;
|
int a = -1, b = -1;
|
||||||
@@ -286,7 +440,6 @@ void TimelineWidget::mouseReleaseEvent(QMouseEvent* e) {
|
|||||||
if (a >= 0 && b >= 0) {
|
if (a >= 0 && b >= 0) {
|
||||||
setSelectionRange(a, b);
|
setSelectionRange(a, b);
|
||||||
emit intervalSelectionChanged(m_selStart, m_selEnd);
|
emit intervalSelectionChanged(m_selStart, m_selEnd);
|
||||||
// 选中区间时清掉关键帧选中
|
|
||||||
if (m_selKeyKind != KeyKind::None) {
|
if (m_selKeyKind != KeyKind::None) {
|
||||||
m_selKeyKind = KeyKind::None;
|
m_selKeyKind = KeyKind::None;
|
||||||
m_selKeyFrame = -1;
|
m_selKeyFrame = -1;
|
||||||
@@ -303,8 +456,9 @@ void TimelineWidget::mouseReleaseEvent(QMouseEvent* e) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void TimelineWidget::wheelEvent(QWheelEvent* e) {
|
void TimelineWidget::wheelEvent(QWheelEvent* e) {
|
||||||
const int delta = (e->angleDelta().y() > 0) ? 1 : -1;
|
const double steps = e->angleDelta().y() / 120.0;
|
||||||
setFrameInternal(m_currentFrame + delta, true);
|
const int delta = (steps > 0) ? 1 : -1;
|
||||||
|
// 滚轮只走 scrubbed:避免每格同时 scrub+committed 造成双次 refresh 与帧号闪烁
|
||||||
|
setFrameInternal(m_currentFrame + delta, false);
|
||||||
e->accept();
|
e->accept();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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..600(local 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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
52
client/gui/widgets/LongPressSwitchToolButton.cpp
Normal file
52
client/gui/widgets/LongPressSwitchToolButton.cpp
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
32
client/gui/widgets/LongPressSwitchToolButton.h
Normal file
32
client/gui/widgets/LongPressSwitchToolButton.h
Normal 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;
|
||||||
|
};
|
||||||
55
client/gui/widgets/ToolOptionPopup.cpp
Normal file
55
client/gui/widgets/ToolOptionPopup.cpp
Normal 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();
|
||||||
|
}
|
||||||
|
|
||||||
32
client/gui/widgets/ToolOptionPopup.h
Normal file
32
client/gui/widgets/ToolOptionPopup.h
Normal 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;
|
||||||
|
};
|
||||||
|
|
||||||
@@ -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():
|
||||||
|
"""懒加载 SamPredictor(vit_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,) int,1=前景点,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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 图 base64(PNG/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)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user