diff --git a/client/core/domain/Project.h b/client/core/domain/Project.h index 1fb4163..c033777 100644 --- a/client/core/domain/Project.h +++ b/client/core/domain/Project.h @@ -148,6 +148,24 @@ public: void setTools(const QVector& tools) { m_tools = tools; } const QVector& tools() const { return m_tools; } + // —— 摄像机(正交俯视;centerWorld 为视口中心;viewScale 为相对参考分辨率 1600×900 的像素/世界单位比, + // 预览与视口框按该参考换算,不随实际窗口宽高改变「镜头」所覆盖的世界范围)—— + struct Camera { + QString id; + QString displayName; + bool visible = true; + QPointF centerWorld; + double viewScale = 1.0; + QVector locationKeys; + QVector scaleKeys; + }; + + void setCameras(const QVector& cameras) { m_cameras = cameras; } + const QVector& cameras() const { return m_cameras; } + + void setActiveCameraId(const QString& id) { m_activeCameraId = id; } + const QString& activeCameraId() const { return m_activeCameraId; } + // —— 动画系统(Blender/NLA 风格简化版,工程级)—— struct AnimationClip { QString id; @@ -162,6 +180,9 @@ public: // Tool channels (keyed by tool id) QHash> toolLocationKeys; QHash> toolVisibilityKeys; + + QHash> cameraLocationKeys; + QHash> cameraScaleKeys; }; struct NlaStrip { @@ -247,6 +268,8 @@ private: int m_fps = 60; QVector m_entities; QVector m_tools; + QVector m_cameras; + QString m_activeCameraId; QVector m_clips; QVector m_schemes; diff --git a/client/core/eval/ProjectEvaluator.cpp b/client/core/eval/ProjectEvaluator.cpp index cc288d8..9ffb186 100644 --- a/client/core/eval/ProjectEvaluator.cpp +++ b/client/core/eval/ProjectEvaluator.cpp @@ -45,6 +45,28 @@ QPointF sampledOriginForTool(const core::Project::Tool& t, return core::sampleLocation(t.locationKeys, localFrame, t.originWorld, core::KeyInterpolation::Linear); } +QPointF sampledCenterForCamera(const core::Project::Camera& c, + const core::Project::AnimationClip* clipOrNull, + int localFrame) { + if (clipOrNull && clipOrNull->cameraLocationKeys.contains(c.id)) { + const auto& keys = clipOrNull->cameraLocationKeys.value(c.id); + return core::sampleLocation(keys, localFrame, c.centerWorld, core::KeyInterpolation::Linear); + } + return core::sampleLocation(c.locationKeys, localFrame, c.centerWorld, core::KeyInterpolation::Linear); +} + +double sampledViewScaleForCamera(const core::Project::Camera& c, + const core::Project::AnimationClip* clipOrNull, + int localFrame) { + if (clipOrNull && clipOrNull->cameraScaleKeys.contains(c.id)) { + const auto& keys = clipOrNull->cameraScaleKeys.value(c.id); + if (!keys.isEmpty()) { + return core::sampleUserScale(keys, localFrame, c.viewScale, core::KeyInterpolation::Linear); + } + } + return core::sampleUserScale(c.scaleKeys, localFrame, c.viewScale, core::KeyInterpolation::Linear); +} + QPointF sampledRelativeForTool(const core::Project::Tool& t, const core::Project::AnimationClip* clipOrNull, int localFrame) { @@ -201,8 +223,10 @@ ResolvedProjectFrame evaluateAtFrame(const core::Project& project, int frame, in ResolvedProjectFrame out; const auto& ents = project.entities(); const auto& tools = project.tools(); + const auto& cams = project.cameras(); out.entities.reserve(ents.size()); out.tools.reserve(tools.size()); + out.cameras.reserve(cams.size()); const StripEvalCtx ctx = resolveStripCtx(project, frame); const int localFrame = ctx.localFrame; @@ -324,6 +348,13 @@ ResolvedProjectFrame evaluateAtFrame(const core::Project& project, int frame, in out.tools.push_back(ResolvedTool{t, op}); } + for (const auto& c : cams) { + core::Project::Camera cam = c; + cam.centerWorld = sampledCenterForCamera(c, clip, localFrame); + cam.viewScale = sampledViewScaleForCamera(c, clip, localFrame); + out.cameras.push_back(ResolvedCamera{std::move(cam)}); + } + return out; } diff --git a/client/core/eval/ProjectEvaluator.h b/client/core/eval/ProjectEvaluator.h index 14bb8b7..13fd82f 100644 --- a/client/core/eval/ProjectEvaluator.h +++ b/client/core/eval/ProjectEvaluator.h @@ -19,9 +19,14 @@ struct ResolvedTool { double opacity = 1.0; // 0..1 }; +struct ResolvedCamera { + core::Project::Camera camera; // 拷贝:centerWorld / viewScale 已替换为求值后的值 +}; + struct ResolvedProjectFrame { QVector entities; QVector tools; + QVector cameras; }; /// 逐帧求值:处理父子跟随与工具可见性淡入淡出。 diff --git a/client/core/workspace/ProjectWorkspace.cpp b/client/core/workspace/ProjectWorkspace.cpp index c1aa648..57796dc 100644 --- a/client/core/workspace/ProjectWorkspace.cpp +++ b/client/core/workspace/ProjectWorkspace.cpp @@ -360,9 +360,55 @@ bool ProjectWorkspace::createNew(const QString& projectDir, const QString& name, m_project.setSelectedStripId(strip.id); } + if (!ensureDefaultCameraIfMissing(nullptr)) { + return false; + } return writeIndexJson(); } +bool ProjectWorkspace::ensureDefaultCameraIfMissing(bool* outAdded) { + if (m_projectDir.isEmpty()) { + return false; + } + if (!m_project.cameras().isEmpty()) { + if (outAdded) { + *outAdded = false; + } + return true; + } + Project::Camera c; + c.id = QStringLiteral("camera-default"); + c.displayName = QStringLiteral("主摄像机"); + c.visible = true; + const QString bgAbs = backgroundAbsolutePath(); + QSize sz; + if (!bgAbs.isEmpty() && QFileInfo::exists(bgAbs)) { + QImageReader reader(bgAbs); + reader.setAutoTransform(true); + sz = reader.size(); + } + if (!sz.isValid() || sz.width() < 1 || sz.height() < 1) { + c.centerWorld = QPointF(512.0, 384.0); + c.viewScale = 1.0; + } else { + c.centerWorld = QPointF(sz.width() * 0.5, sz.height() * 0.5); + // 参考视口下「适配整张背景」的像素/世界单位比,与画布 zoomToFit 语义一致 + constexpr double kRefViewportW = 1600.0; + constexpr double kRefViewportH = 900.0; + const double sw = kRefViewportW / static_cast(sz.width()); + const double sh = kRefViewportH / static_cast(sz.height()); + c.viewScale = std::clamp(std::min(sw, sh), 1e-6, 1e3); + } + m_project.setCameras({c}); + if (m_project.activeCameraId().isEmpty()) { + m_project.setActiveCameraId(c.id); + } + if (outAdded) { + *outAdded = true; + } + return true; +} + bool ProjectWorkspace::openExisting(const QString& projectDir) { const auto dir = normalizedProjectDir(projectDir); const auto indexPath = QDir(dir).filePath(QString::fromUtf8(kProjectIndexFileName)); @@ -440,6 +486,13 @@ bool ProjectWorkspace::openExisting(const QString& projectDir) { // 迁移后立即落盘,避免后续求值出现双来源 writeIndexJson(); } + bool cameraAdded = false; + if (!ensureDefaultCameraIfMissing(&cameraAdded)) { + return false; + } + if (cameraAdded && !writeIndexJson()) { + return false; + } return true; } @@ -489,6 +542,11 @@ bool ProjectWorkspace::undo() { m_undoStack.push_back(op); return false; } + } else if (op.type == Operation::Type::SetCameras) { + if (!applyCameras(op.beforeCameras, false, QString())) { + m_undoStack.push_back(op); + return false; + } } else if (op.type == Operation::Type::SetProjectTitle) { m_project.setName(op.beforeProjectTitle); if (!writeIndexJson()) { @@ -532,6 +590,11 @@ bool ProjectWorkspace::redo() { m_redoStack.push_back(op); return false; } + } else if (op.type == Operation::Type::SetCameras) { + if (!applyCameras(op.afterCameras, false, QString())) { + m_redoStack.push_back(op); + return false; + } } else if (op.type == Operation::Type::SetProjectTitle) { m_project.setName(op.afterProjectTitle); if (!writeIndexJson()) { @@ -726,6 +789,13 @@ QJsonObject ProjectWorkspace::projectToJson(const Project& project) { } root.insert("tools", tools); + QJsonArray cams; + for (const auto& c : project.cameras()) { + cams.append(cameraToJson(c)); + } + root.insert("cameras", cams); + root.insert("activeCameraId", project.activeCameraId()); + // —— 动画(v3)—— root.insert("activeSchemeId", project.activeSchemeId()); root.insert("selectedStripId", project.selectedStripId()); @@ -792,6 +862,8 @@ QJsonObject ProjectWorkspace::projectToJson(const Project& project) { co.insert("entityVisibilityKeys", encodeHash(c.entityVisibilityKeys, encodeBool)); co.insert("toolLocationKeys", encodeHash(c.toolLocationKeys, encodeVec2)); co.insert("toolVisibilityKeys", encodeHash(c.toolVisibilityKeys, encodeBool)); + co.insert("cameraLocationKeys", encodeHash(c.cameraLocationKeys, encodeVec2)); + co.insert("cameraScaleKeys", encodeHash(c.cameraScaleKeys, encodeDouble)); clips.append(co); } @@ -836,7 +908,7 @@ bool ProjectWorkspace::projectFromJson(const QJsonObject& root, Project& outProj return false; } const int version = root.value("version").toInt(); - if (version != 1 && version != 2 && version != 3) { + if (version != 1 && version != 2 && version != 3 && version != 4) { return false; } if (outFileVersion) { @@ -895,6 +967,24 @@ bool ProjectWorkspace::projectFromJson(const QJsonObject& root, Project& outProj } outProject.setTools(tools); + QVector cameras; + if (version >= 2) { + const auto camsVal = root.value("cameras"); + if (camsVal.isArray()) { + const QJsonArray arr = camsVal.toArray(); + cameras.reserve(arr.size()); + for (const auto& v : arr) { + if (!v.isObject()) continue; + Project::Camera c; + if (cameraFromJsonV4(v.toObject(), c)) { + cameras.push_back(c); + } + } + } + } + outProject.setCameras(cameras); + outProject.setActiveCameraId(root.value("activeCameraId").toString()); + // —— 动画(v3,可选)—— if (version >= 3) { outProject.setActiveSchemeId(root.value("activeSchemeId").toString()); @@ -988,6 +1078,8 @@ bool ProjectWorkspace::projectFromJson(const QJsonObject& root, Project& outProj decodeHash(co.value("entityVisibilityKeys"), decodeBool, c.entityVisibilityKeys); decodeHash(co.value("toolLocationKeys"), decodeVec2, c.toolLocationKeys); decodeHash(co.value("toolVisibilityKeys"), decodeBool, c.toolVisibilityKeys); + decodeHash(co.value("cameraLocationKeys"), decodeVec2, c.cameraLocationKeys); + decodeHash(co.value("cameraScaleKeys"), decodeDouble, c.cameraScaleKeys); clips.push_back(std::move(c)); } @@ -1176,6 +1268,76 @@ bool ProjectWorkspace::toolFromJsonV2(const QJsonObject& o, Project::Tool& out) return true; } +QJsonObject ProjectWorkspace::cameraToJson(const Project::Camera& c) { + QJsonObject o; + o.insert("id", c.id); + o.insert("displayName", c.displayName); + o.insert("visible", c.visible); + o.insert("centerX", c.centerWorld.x()); + o.insert("centerY", c.centerWorld.y()); + o.insert("viewScale", c.viewScale); + QJsonArray loc; + for (const auto& k : c.locationKeys) { + QJsonObject ko; + ko.insert("frame", k.frame); + ko.insert("x", k.value.x()); + ko.insert("y", k.value.y()); + loc.append(ko); + } + o.insert("locationKeys", loc); + QJsonArray sc; + for (const auto& k : c.scaleKeys) { + QJsonObject ko; + ko.insert("frame", k.frame); + ko.insert("value", k.value); + sc.append(ko); + } + o.insert("scaleKeys", sc); + return o; +} + +bool ProjectWorkspace::cameraFromJsonV4(const QJsonObject& o, Project::Camera& out) { + Project::Camera c; + c.id = o.value("id").toString(); + if (c.id.isEmpty()) { + return false; + } + c.displayName = o.value("displayName").toString(); + c.visible = o.value("visible").toBool(true); + c.centerWorld = QPointF(o.value("centerX").toDouble(0.0), o.value("centerY").toDouble(0.0)); + c.viewScale = std::clamp(o.value("viewScale").toDouble(1.0), 1e-6, 1e3); + c.locationKeys.clear(); + const auto locVal = o.value("locationKeys"); + if (locVal.isArray()) { + const QJsonArray arr = locVal.toArray(); + c.locationKeys.reserve(arr.size()); + for (const auto& v : arr) { + if (!v.isObject()) continue; + const auto ko = v.toObject(); + Project::Entity::KeyframeVec2 k; + k.frame = ko.value("frame").toInt(0); + k.value = QPointF(ko.value("x").toDouble(0.0), ko.value("y").toDouble(0.0)); + c.locationKeys.push_back(k); + } + } + c.scaleKeys.clear(); + const auto scVal = o.value("scaleKeys"); + if (scVal.isArray()) { + const QJsonArray arr = scVal.toArray(); + c.scaleKeys.reserve(arr.size()); + for (const auto& v : arr) { + if (!v.isObject()) continue; + const auto ko = v.toObject(); + Project::Entity::KeyframeDouble k; + k.frame = ko.value("frame").toInt(0); + k.value = std::clamp(ko.value("value").toDouble(1.0), 1e-6, 1e3); + c.scaleKeys.push_back(k); + } + } + out = std::move(c); + return true; +} + bool ProjectWorkspace::entityStubFromJsonV2(const QJsonObject& o, Project::Entity& out) { out = Project::Entity{}; out.id = o.value("id").toString(); @@ -1412,6 +1574,27 @@ bool ProjectWorkspace::applyTools(const QVector& tools, return true; } +bool ProjectWorkspace::applyCameras(const QVector& cameras, + bool recordHistory, + const QString& label) { + const auto before = m_project.cameras(); + m_project.setCameras(cameras); + if (!writeIndexJson()) { + m_project.setCameras(before); + return false; + } + if (recordHistory) { + Operation op; + op.type = Operation::Type::SetCameras; + op.label = label; + op.beforeCameras = before; + op.afterCameras = cameras; + pushOperation(op); + m_redoStack.clear(); + } + return true; +} + QString ProjectWorkspace::ensureEntitiesDir() const { const auto assets = assetsDirPath(); if (assets.isEmpty()) { @@ -2400,6 +2583,214 @@ bool ProjectWorkspace::addTool(const Project::Tool& tool) { return applyTools(tools, true, QStringLiteral("添加工具")); } +bool ProjectWorkspace::setCameraVisible(const QString& id, bool on) { + if (m_projectDir.isEmpty() || id.isEmpty()) { + return false; + } + auto cams = m_project.cameras(); + bool found = false; + bool changed = false; + for (auto& c : cams) { + if (c.id != id) continue; + found = true; + if (c.visible != on) { + c.visible = on; + changed = true; + } + break; + } + if (!found) return false; + if (!changed) return true; + return applyCameras(cams, true, on ? QStringLiteral("显示摄像机") : QStringLiteral("隐藏摄像机")); +} + +bool ProjectWorkspace::setActiveCameraId(const QString& id) { + if (m_projectDir.isEmpty()) { + return false; + } + if (!id.isEmpty()) { + bool ok = false; + for (const auto& c : m_project.cameras()) { + if (c.id == id) { + ok = true; + break; + } + } + if (!ok) { + return false; + } + } + if (m_project.activeCameraId() == id) { + return true; + } + m_project.setActiveCameraId(id); + return writeIndexJson(); +} + +bool ProjectWorkspace::setCameraDisplayName(const QString& id, const QString& displayName) { + if (m_projectDir.isEmpty() || id.isEmpty()) { + return false; + } + auto cams = m_project.cameras(); + bool found = false; + for (auto& c : cams) { + if (c.id != id) continue; + found = true; + c.displayName = displayName; + break; + } + if (!found) return false; + return applyCameras(cams, true, QStringLiteral("摄像机名称")); +} + +bool ProjectWorkspace::setCameraCenterWorld(const QString& id, const QPointF& centerWorld) { + if (m_projectDir.isEmpty() || id.isEmpty()) { + return false; + } + auto cams = m_project.cameras(); + bool found = false; + for (auto& c : cams) { + if (c.id != id) continue; + found = true; + c.centerWorld = centerWorld; + break; + } + if (!found) return false; + return applyCameras(cams, true, QStringLiteral("摄像机位置")); +} + +bool ProjectWorkspace::setCameraViewScaleValue(const QString& id, double viewScale, int keyframeAtFrame) { + if (m_projectDir.isEmpty() || id.isEmpty()) { + return false; + } + const double v = std::clamp(viewScale, 1e-6, 1e3); + auto cams = m_project.cameras(); + bool found = false; + for (auto& c : cams) { + if (c.id != id) continue; + found = true; + const bool baseSame = qFuzzyCompare(c.viewScale + 1.0, v + 1.0); + c.viewScale = v; + if (keyframeAtFrame >= 0) { + Project::AnimationClip* clip = activeClipOrNull(m_project); + if (!clip) return false; + auto& keys = clip->cameraScaleKeys[id]; + upsertKey(keys, keyframeAtFrame, v); + } else if (baseSame) { + return true; + } + break; + } + if (!found) return false; + return applyCameras(cams, true, QStringLiteral("摄像机缩放")); +} + +bool ProjectWorkspace::moveCameraBy(const QString& id, const QPointF& delta, int currentFrame, bool autoKeyLocation) { + if (m_projectDir.isEmpty() || id.isEmpty()) { + return false; + } + if (qFuzzyIsNull(delta.x()) && qFuzzyIsNull(delta.y())) { + return true; + } + auto cams = m_project.cameras(); + bool found = false; + for (auto& c : cams) { + if (c.id != id) continue; + found = true; + Project::AnimationClip* clip = activeClipOrNull(m_project); + QVector* keys = nullptr; + if (clip) { + keys = &clip->cameraLocationKeys[c.id]; + } + const bool hasCurve = (keys && !keys->isEmpty()) || (!c.locationKeys.isEmpty()); + if (autoKeyLocation && currentFrame >= 0) { + const QPointF sampled = + (keys) ? sampleLocation(*keys, currentFrame, c.centerWorld, KeyInterpolation::Linear) + : sampleLocation(c.locationKeys, currentFrame, c.centerWorld, KeyInterpolation::Linear); + if (keys) upsertKey(*keys, currentFrame, sampled + delta); + else upsertKey(c.locationKeys, currentFrame, sampled + delta); + } else if (!hasCurve) { + c.centerWorld += delta; + } else if (currentFrame >= 0) { + const QPointF sampled = + (keys) ? sampleLocation(*keys, currentFrame, c.centerWorld, KeyInterpolation::Linear) + : sampleLocation(c.locationKeys, currentFrame, c.centerWorld, KeyInterpolation::Linear); + if (keys) upsertKey(*keys, currentFrame, sampled + delta); + else upsertKey(c.locationKeys, currentFrame, sampled + delta); + } else { + c.centerWorld += delta; + } + break; + } + if (!found) return false; + return applyCameras(cams, true, QStringLiteral("移动摄像机")); +} + +bool ProjectWorkspace::setCameraLocationKey(const QString& id, int frame, const QPointF& centerWorld) { + if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) { + return false; + } + Project::AnimationClip* clip = activeClipOrNull(m_project); + if (!clip) return false; + auto& keys = clip->cameraLocationKeys[id]; + upsertKey(keys, frame, centerWorld); + return writeIndexJson(); +} + +bool ProjectWorkspace::setCameraScaleKey(const QString& id, int frame, double viewScale) { + if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) { + return false; + } + const double v = std::clamp(viewScale, 1e-6, 1e3); + Project::AnimationClip* clip = activeClipOrNull(m_project); + if (!clip) return false; + auto& keys = clip->cameraScaleKeys[id]; + upsertKey(keys, frame, v); + return writeIndexJson(); +} + +bool ProjectWorkspace::removeCameraLocationKey(const QString& id, int frame) { + if (m_projectDir.isEmpty() || id.isEmpty()) { + return false; + } + Project::AnimationClip* clip = activeClipOrNull(m_project); + if (!clip) return false; + if (!clip->cameraLocationKeys.contains(id)) return false; + auto keys = clip->cameraLocationKeys.value(id); + bool removed = false; + for (int i = 0; i < keys.size(); ++i) { + if (keys[i].frame == frame) { + keys.removeAt(i); + removed = true; + break; + } + } + if (!removed) return false; + clip->cameraLocationKeys.insert(id, keys); + return writeIndexJson(); +} + +bool ProjectWorkspace::removeCameraScaleKey(const QString& id, int frame) { + if (m_projectDir.isEmpty() || id.isEmpty()) { + return false; + } + Project::AnimationClip* clip = activeClipOrNull(m_project); + if (!clip) return false; + if (!clip->cameraScaleKeys.contains(id)) return false; + auto keys = clip->cameraScaleKeys.value(id); + bool removed = false; + for (int i = 0; i < keys.size(); ++i) { + if (keys[i].frame == frame) { + keys.removeAt(i); + removed = true; + break; + } + } + if (!removed) return false; + clip->cameraScaleKeys.insert(id, keys); + return writeIndexJson(); +} + bool ProjectWorkspace::setToolVisible(const QString& id, bool on) { if (m_projectDir.isEmpty() || id.isEmpty()) { return false; diff --git a/client/core/workspace/ProjectWorkspace.h b/client/core/workspace/ProjectWorkspace.h index 03b70f8..6ff88bc 100644 --- a/client/core/workspace/ProjectWorkspace.h +++ b/client/core/workspace/ProjectWorkspace.h @@ -17,7 +17,7 @@ public: static constexpr const char* kProjectIndexFileName = "project.json"; static constexpr const char* kAssetsDirName = "assets"; // 写入 project.json 的 version 字段;仍可读 version 1(内嵌实体 + 可选 .anim)。 - static constexpr int kProjectIndexFormatVersion = 3; + static constexpr int kProjectIndexFormatVersion = 4; ProjectWorkspace() = default; @@ -83,6 +83,7 @@ public: const QVector& entities() const { return m_project.entities(); } const QVector& tools() const { return m_project.tools(); } + const QVector& cameras() const { return m_project.cameras(); } bool addTool(const Project::Tool& tool); bool setToolVisible(const QString& id, bool on); @@ -94,6 +95,17 @@ public: 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 setCameraVisible(const QString& id, bool on); + bool setActiveCameraId(const QString& id); + bool setCameraDisplayName(const QString& id, const QString& displayName); + bool setCameraCenterWorld(const QString& id, const QPointF& centerWorld); + /// keyframeAtFrame >= 0 时同时写入当前活动 clip 的缩放关键帧(与实体 userScale 行为一致) + bool setCameraViewScaleValue(const QString& id, double viewScale, int keyframeAtFrame = -1); + bool moveCameraBy(const QString& id, const QPointF& delta, int currentFrame, bool autoKeyLocation); + bool setCameraLocationKey(const QString& id, int frame, const QPointF& centerWorld); + bool setCameraScaleKey(const QString& id, int frame, double viewScale); + bool removeCameraLocationKey(const QString& id, int frame); + bool removeCameraScaleKey(const QString& id, int frame); bool addEntity(const Project::Entity& entity, const QImage& image); bool setEntityVisible(const QString& id, bool on); bool setEntityBlackholeVisible(const QString& id, bool on); @@ -155,9 +167,14 @@ private: 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); + static QJsonObject cameraToJson(const Project::Camera& c); + static bool cameraFromJsonV4(const QJsonObject& o, Project::Camera& out); + + /// 若工程尚无摄像机,则创建默认主摄像机(居中背景、缩放适配整张背景图)。不写盘;若创建了摄像机且 outAdded 非空则 *outAdded=true。 + bool ensureDefaultCameraIfMissing(bool* outAdded = nullptr); struct Operation { - enum class Type { ImportBackground, SetEntities, SetTools, SetProjectTitle, SetProjectFrameRange }; + enum class Type { ImportBackground, SetEntities, SetTools, SetCameras, SetProjectTitle, SetProjectFrameRange }; Type type {Type::ImportBackground}; QString label; QString beforeBackgroundPath; @@ -166,6 +183,8 @@ private: QVector afterEntities; QVector beforeTools; QVector afterTools; + QVector beforeCameras; + QVector afterCameras; QString beforeProjectTitle; QString afterProjectTitle; int beforeFrameStart = 0; @@ -180,6 +199,7 @@ private: bool applyBackgroundPath(const QString& relativePath, bool recordHistory, const QString& label); bool applyEntities(const QVector& entities, bool recordHistory, const QString& label); bool applyTools(const QVector& tools, bool recordHistory, const QString& label); + bool applyCameras(const QVector& cameras, bool recordHistory, const QString& label); QString copyIntoAssetsAsBackground(const QString& sourceFilePath, const QRect& cropRectInSourceImage); bool writeDepthMap(const QImage& depth8); bool writeDepthMapBytes(const QByteArray& pngBytes); diff --git a/client/gui/CMakeLists.txt b/client/gui/CMakeLists.txt index a13cf7e..cfd3203 100644 --- a/client/gui/CMakeLists.txt +++ b/client/gui/CMakeLists.txt @@ -23,6 +23,7 @@ set(GUI_SOURCES ${GUI_ROOT}/props/BlackholePropertySection.cpp ${GUI_ROOT}/props/EntityPropertySection.cpp ${GUI_ROOT}/props/ToolPropertySection.cpp + ${GUI_ROOT}/props/CameraPropertySection.cpp ${GUI_ROOT}/timeline/TimelineWidget.cpp ) @@ -47,6 +48,7 @@ set(GUI_HEADERS ${GUI_ROOT}/props/BlackholePropertySection.h ${GUI_ROOT}/props/EntityPropertySection.h ${GUI_ROOT}/props/ToolPropertySection.h + ${GUI_ROOT}/props/CameraPropertySection.h ${GUI_ROOT}/props/PropertySectionWidget.h ${GUI_ROOT}/timeline/TimelineWidget.h ) diff --git a/client/gui/editor/EditorCanvas.cpp b/client/gui/editor/EditorCanvas.cpp index e3aa164..6de3944 100644 --- a/client/gui/editor/EditorCanvas.cpp +++ b/client/gui/editor/EditorCanvas.cpp @@ -33,6 +33,10 @@ namespace { +// 摄像机「输出」参考分辨率:视口框与预览缩放均按此换算,避免随窗口大小改变镜头覆盖的世界范围 +constexpr double kCameraRefViewportW = 1600.0; +constexpr double kCameraRefViewportH = 900.0; + constexpr int kSamCropMargin = 32; constexpr int kMinStrokePointsSam = 4; constexpr int kMinStrokePointsManual = 8; @@ -63,6 +67,13 @@ static QImage readImageTolerant(const QString& absPath) { return reader.read(); } +QRectF cameraWorldViewportRect(const core::Project::Camera& cam) { + const double s = std::max(1e-9, cam.viewScale); + const double halfW = (kCameraRefViewportW * 0.5) / s; + const double halfH = (kCameraRefViewportH * 0.5) / s; + return QRectF(cam.centerWorld.x() - halfW, cam.centerWorld.y() - halfH, 2.0 * halfW, 2.0 * halfH); +} + QRectF clampRectTopLeftToBounds(const QRectF& rect, const QRectF& bounds) { if (rect.isNull() || bounds.isNull()) { return rect; @@ -518,6 +529,26 @@ void EditorCanvas::dropEvent(QDropEvent* e) { e->acceptProposedAction(); } +void EditorCanvas::setPreviewCameraViewLocked(bool on) { + m_previewCameraViewLocked = on; +} + +void EditorCanvas::applyCameraViewport(const QPointF& centerWorld, double viewScale) { + // 与 cameraWorldViewportRect 一致:viewScale 表示在 1600×900 参考视口下的像素/世界比; + // 实际画布用 min(宽/1600, 高/900) 将参考视口适配进当前控件,使可见世界宽高恒为 1600/s × 900/s。 + const double pixelRatio = + std::min(static_cast(std::max(1, width())) / kCameraRefViewportW, + static_cast(std::max(1, height())) / kCameraRefViewportH); + const double eff = std::max(1e-9, static_cast(viewScale)) * pixelRatio; + m_scale = std::clamp(static_cast(eff), 0.05, 50.0); + m_pan = QPointF(width() / 2.0, height() / 2.0) - QPointF(centerWorld.x() * m_scale, centerWorld.y() * m_scale); + update(); +} + +QPointF EditorCanvas::viewCenterWorld() const { + return viewToWorld(QPointF(width() / 2.0, height() / 2.0)); +} + void EditorCanvas::setPresentationPreviewMode(bool on) { if (m_presentationPreviewMode == on) { return; @@ -715,12 +746,73 @@ void EditorCanvas::setTools(const QVector& tools, const QVe update(); } +void EditorCanvas::setCameraOverlays(const QVector& cameras, + const QString& selectedId, + const QSet& tempHiddenCameraIds) { + m_cameraOverlays = cameras; + m_tempHiddenCameraIds = tempHiddenCameraIds; + m_selectedCameraId = selectedId; + m_selectedCameraIndex = -1; + if (!selectedId.isEmpty()) { + for (int i = 0; i < m_cameraOverlays.size(); ++i) { + if (m_cameraOverlays[i].id == selectedId) { + m_selectedCameraIndex = i; + break; + } + } + } + if (m_selectedCameraIndex < 0) { + m_selectedCameraId.clear(); + m_draggingCamera = false; + } + update(); +} + +void EditorCanvas::selectCameraById(const QString& id) { + if (id.isEmpty()) { + clearCameraSelection(); + return; + } + clearEntitySelection(); + m_selectedTool = -1; + m_draggingTool = false; + emit selectedToolChanged(false, QString(), QPointF()); + clearBlackholeSelection(); + for (int i = 0; i < m_cameraOverlays.size(); ++i) { + if (m_cameraOverlays[i].id == id) { + m_selectedCameraIndex = i; + m_selectedCameraId = id; + const auto& c = m_cameraOverlays[i]; + emit selectedCameraChanged(true, id, c.centerWorld, c.viewScale); + update(); + return; + } + } + clearCameraSelection(); +} + +void EditorCanvas::clearCameraSelection() { + if (m_selectedCameraId.isEmpty() && m_selectedCameraIndex < 0 && !m_draggingCamera) { + return; + } + m_selectedCameraId.clear(); + m_selectedCameraIndex = -1; + m_draggingCamera = false; + emit selectedCameraChanged(false, QString(), QPointF(), 1.0); + update(); +} + void EditorCanvas::setTempHiddenIds(const QSet& entityIds, const QSet& toolIds) { m_tempHiddenEntityIds = entityIds; m_tempHiddenToolIds = toolIds; update(); } +void EditorCanvas::setTempHiddenCameraIds(const QSet& cameraIds) { + m_tempHiddenCameraIds = cameraIds; + update(); +} + void EditorCanvas::setCurrentFrame(int frame) { if (m_currentFrame == frame) { return; @@ -879,8 +971,10 @@ void EditorCanvas::clearEntitySelection() { void EditorCanvas::selectEntityById(const QString& id) { if (id.isEmpty()) { clearEntitySelection(); + clearCameraSelection(); return; } + clearCameraSelection(); for (int i = 0; i < m_entities.size(); ++i) { if (m_entities[i].id != id) { continue; @@ -1608,6 +1702,30 @@ void EditorCanvas::paintEvent(QPaintEvent* e) { } } + // 摄像机视口框(编辑模式) + if (!m_presentationPreviewMode) { + const qreal handleRWorld = 10.0 / std::max(m_scale, 0.001); + for (int i = 0; i < m_cameraOverlays.size(); ++i) { + const auto& cam = m_cameraOverlays[i]; + if (!cam.visible || cam.id.isEmpty() || m_tempHiddenCameraIds.contains(cam.id)) { + continue; + } + const QRectF camRect = cameraWorldViewportRect(cam); + QColor fill(80, 140, 255, 38); + QColor border(80, 140, 255, 170); + if (i == m_selectedCameraIndex) { + border = QColor(255, 170, 60, 230); + fill = QColor(255, 170, 60, 48); + } + p.setBrush(fill); + p.setPen(QPen(border, 1.5 / std::max(m_scale, 0.001))); + p.drawRect(camRect); + p.setBrush(QColor(255, 210, 120, 230)); + p.setPen(QPen(QColor(30, 30, 30, 160), 1.0 / std::max(m_scale, 0.001))); + p.drawEllipse(cam.centerWorld, handleRWorld, handleRWorld); + } + } + // 创建实体:手绘轨迹预览(world 坐标) if (!m_presentationPreviewMode && m_tool == Tool::CreateEntity && m_drawingEntity && m_strokeWorld.size() >= 2) { QPen pen(QColor(255, 120, 0, 220), 2.0 / std::max(m_scale, 0.001)); @@ -1840,11 +1958,13 @@ void EditorCanvas::mousePressEvent(QMouseEvent* e) { emit presentationEntityIntroRequested(ent.id, anchorView); return; } - m_dragging = true; - m_presBgPanSession = true; - m_presBgDragDist = 0.0; - m_lastMouseView = e->position(); - setCursor(Qt::ClosedHandCursor); + if (!m_previewCameraViewLocked) { + m_dragging = true; + m_presBgPanSession = true; + m_presBgDragDist = 0.0; + m_lastMouseView = e->position(); + setCursor(Qt::ClosedHandCursor); + } return; } if (e->button() == Qt::MiddleButton) { @@ -1905,6 +2025,42 @@ void EditorCanvas::mousePressEvent(QMouseEvent* e) { } if (m_tool == Tool::Move && e->button() == Qt::LeftButton) { + // 摄像机:绘制在工具之上,命中优先于工具 + if (!m_presentationPreviewMode) { + const qreal handleRWorld = 12.0 / std::max(m_scale, 0.001); + for (int idx = static_cast(m_cameraOverlays.size()) - 1; idx >= 0; --idx) { + const auto& cam = m_cameraOverlays[idx]; + if (!cam.visible || cam.id.isEmpty() || m_tempHiddenCameraIds.contains(cam.id)) { + continue; + } + const QRectF camRect = cameraWorldViewportRect(cam); + const double dist = QLineF(worldPos, cam.centerWorld).length(); + const bool inHandle = dist <= handleRWorld; + const bool inRect = camRect.contains(worldPos); + if (!inHandle && !inRect) { + continue; + } + m_selectedCameraIndex = idx; + m_selectedCameraId = cam.id; + m_selectedEntity = -1; + m_selectedTool = -1; + m_draggingTool = false; + m_draggingEntity = false; + emit selectedEntityChanged(false, QString(), 0, QPointF()); + emit selectedToolChanged(false, QString(), QPointF()); + if (inHandle) { + m_draggingCamera = true; + m_cameraDragOffsetWorld = worldPos - cam.centerWorld; + m_cameraDragStartCenterWorld = cam.centerWorld; + } else { + m_draggingCamera = false; + } + emit selectedCameraChanged(true, cam.id, cam.centerWorld, cam.viewScale); + update(); + return; + } + } + // 工具(气泡)优先命中:绘制在实体之后,交互也应优先 for (qsizetype i = m_tools.size(); i > 0; --i) { const qsizetype idx = i - 1; @@ -1914,6 +2070,7 @@ void EditorCanvas::mousePressEvent(QMouseEvent* e) { if (tv.tool.type != core::Project::Tool::Type::Bubble) continue; const QPainterPath path = bubblePathWorld(tv.tool); if (path.contains(worldPos)) { + clearCameraSelection(); m_selectedTool = static_cast(idx); m_selectedEntity = -1; m_draggingTool = true; @@ -1940,6 +2097,7 @@ void EditorCanvas::mousePressEvent(QMouseEvent* e) { const QPointF originView = worldToView(originWorld); const GizmoHit gh = hitTestGizmo(e->position(), originView); if (gh.mode == DragMode::AxisX || gh.mode == DragMode::AxisY) { + clearCameraSelection(); m_dragging = true; m_draggingEntity = true; m_dragMode = gh.mode; @@ -1983,6 +2141,7 @@ void EditorCanvas::mousePressEvent(QMouseEvent* e) { hitSelected = ent.rect.contains(worldPos); } if (hitSelected) { + clearCameraSelection(); m_draggingEntity = true; m_dragMode = DragMode::Free; emit entityDragActiveChanged(true); @@ -2011,6 +2170,7 @@ void EditorCanvas::mousePressEvent(QMouseEvent* e) { const int hit = hitTestEntity(worldPos); if (hit >= 0) { + clearCameraSelection(); m_selectedEntity = hit; m_selectedTool = -1; m_draggingTool = false; @@ -2047,6 +2207,7 @@ void EditorCanvas::mousePressEvent(QMouseEvent* e) { m_selectedTool = -1; m_draggingTool = false; m_dragMode = DragMode::None; + clearCameraSelection(); emit selectedEntityChanged(false, QString(), 0, QPointF()); emit selectedToolChanged(false, QString(), QPointF()); update(); @@ -2144,6 +2305,17 @@ void EditorCanvas::mouseMoveEvent(QMouseEvent* e) { return; } + if (m_draggingCamera && m_selectedCameraIndex >= 0 && m_selectedCameraIndex < m_cameraOverlays.size()) { + const QPointF worldPos = viewToWorld(cur); + const QPointF newCenter = worldPos - m_cameraDragOffsetWorld; + QPointF delta = newCenter - m_cameraOverlays[m_selectedCameraIndex].centerWorld; + m_cameraOverlays[m_selectedCameraIndex].centerWorld += delta; + const auto& c = m_cameraOverlays[m_selectedCameraIndex]; + emit selectedCameraChanged(true, c.id, c.centerWorld, c.viewScale); + update(); + return; + } + if (m_draggingEntity && m_selectedEntity >= 0 && m_selectedEntity < m_entities.size()) { const QPointF worldPos = viewToWorld(cur); auto& ent = m_entities[m_selectedEntity]; @@ -2229,10 +2401,12 @@ void EditorCanvas::mouseMoveEvent(QMouseEvent* e) { // 平移画布 if (m_tool == Tool::Move || (e->buttons() & Qt::MiddleButton) || (m_presentationPreviewMode && (e->buttons() & Qt::LeftButton))) { - if (m_presentationPreviewMode && m_presBgPanSession) { - m_presBgDragDist += std::abs(deltaView.x()) + std::abs(deltaView.y()); + if (!(m_presentationPreviewMode && m_previewCameraViewLocked)) { + if (m_presentationPreviewMode && m_presBgPanSession) { + m_presBgDragDist += std::abs(deltaView.x()) + std::abs(deltaView.y()); + } + m_pan += deltaView; } - m_pan += deltaView; update(); return; } @@ -2321,6 +2495,17 @@ void EditorCanvas::mouseReleaseEvent(QMouseEvent* e) { } } + if (m_draggingCamera && m_selectedCameraIndex >= 0 && m_selectedCameraIndex < m_cameraOverlays.size() && + e->button() == Qt::LeftButton) { + const auto& cam = m_cameraOverlays[m_selectedCameraIndex]; + const QPointF delta = cam.centerWorld - m_cameraDragStartCenterWorld; + if (!cam.id.isEmpty() && (!qFuzzyIsNull(delta.x()) || !qFuzzyIsNull(delta.y()))) { + emit requestMoveCamera(cam.id, delta); + } else if (!cam.id.isEmpty()) { + emit selectedCameraChanged(true, cam.id, cam.centerWorld, cam.viewScale); + } + } + m_dragging = false; if (m_pendingDragging && e->button() == Qt::LeftButton) { m_pendingDragging = false; @@ -2332,6 +2517,7 @@ void EditorCanvas::mouseReleaseEvent(QMouseEvent* e) { } m_draggingEntity = false; m_draggingTool = false; + m_draggingCamera = false; m_dragPreviewActive = false; m_dragMode = DragMode::None; updateCursor(); @@ -2340,6 +2526,19 @@ void EditorCanvas::mouseReleaseEvent(QMouseEvent* e) { } void EditorCanvas::wheelEvent(QWheelEvent* e) { + if (m_presentationPreviewMode && m_previewCameraViewLocked) { + e->accept(); + return; + } + if (!m_presentationPreviewMode && !m_selectedCameraId.isEmpty() && m_tool == Tool::Move) { + const qreal steps = e->angleDelta().y() / 120.0; + const qreal factor = std::pow(1.15, steps); + if (!qFuzzyCompare(factor, 1.0)) { + emit requestCameraViewScaleAdjust(m_selectedCameraId, factor); + } + e->accept(); + return; + } if (m_tool != Tool::Zoom && !(e->modifiers() & Qt::ControlModifier)) { // 默认仍允许滚轮缩放:不强制用户切换工具 //(若你希望仅在 Zoom 工具下才缩放,可在此 return) diff --git a/client/gui/editor/EditorCanvas.h b/client/gui/editor/EditorCanvas.h index dc59d51..bcd765a 100644 --- a/client/gui/editor/EditorCanvas.h +++ b/client/gui/editor/EditorCanvas.h @@ -71,6 +71,13 @@ public: // 预览呈现:完整背景 + 全部实体(忽略显隐开关),隐藏编辑辅助元素,仅可平移/缩放查看 void setPresentationPreviewMode(bool on); bool presentationPreviewMode() const { return m_presentationPreviewMode; } + /// 预览展示由活动摄像机驱动时禁止画布平移/滚轮缩放,避免与镜头关键帧冲突 + void setPreviewCameraViewLocked(bool on); + bool previewCameraViewLocked() const { return m_previewCameraViewLocked; } + /// 将视口对齐到摄像机;viewScale 为相对参考分辨率 1600×900 的像素/世界比(见 Project::Camera) + void applyCameraViewport(const QPointF& centerWorld, double viewScale); + QPointF viewCenterWorld() const; + double viewWorldScale() const { return m_scale; } /// 退出「点击实体放大」状态并平滑回到进入前的视图(预览模式) void clearPresentationEntityFocus(); @@ -78,7 +85,13 @@ public: const QVector& opacities01, const QString& projectDirAbs); void setTools(const QVector& tools, const QVector& opacities01); + void setCameraOverlays(const QVector& cameras, + const QString& selectedId, + const QSet& tempHiddenCameraIds); + void selectCameraById(const QString& id); + void clearCameraSelection(); void setTempHiddenIds(const QSet& entityIds, const QSet& toolIds); + void setTempHiddenCameraIds(const QSet& cameraIds); void setCurrentFrame(int frame); int currentFrame() const { return m_currentFrame; } @@ -122,6 +135,9 @@ signals: void requestFinalizePendingEntity(const QVector& polyWorld); void requestMoveEntity(const QString& id, const QPointF& delta); void requestMoveTool(const QString& id, const QPointF& delta); + void requestMoveCamera(const QString& id, const QPointF& delta); + void requestCameraViewScaleAdjust(const QString& id, double factor); + void selectedCameraChanged(bool hasSelection, const QString& id, const QPointF& centerWorld, double viewScale); void requestResolveBlackholeCopy(const QString& entityId, const QPoint& sourceOffsetPx); void entityDragActiveChanged(bool on); void selectedEntityPreviewChanged(const QString& id, int depth, const QPointF& originWorld); @@ -255,6 +271,14 @@ private: QVector m_entities; QVector m_tools; + QVector m_cameraOverlays; + QSet m_tempHiddenCameraIds; + QString m_selectedCameraId; + int m_selectedCameraIndex = -1; + bool m_draggingCamera = false; + QPointF m_cameraDragOffsetWorld; + QPointF m_cameraDragStartCenterWorld; + bool m_previewCameraViewLocked = false; QSet m_tempHiddenEntityIds; QSet m_tempHiddenToolIds; QVector m_strokeWorld; diff --git a/client/gui/main_window/MainWindow.cpp b/client/gui/main_window/MainWindow.cpp index 1f40e9d..e0cef96 100644 --- a/client/gui/main_window/MainWindow.cpp +++ b/client/gui/main_window/MainWindow.cpp @@ -15,6 +15,7 @@ #include "props/BlackholePropertySection.h" #include "props/EntityPropertySection.h" #include "props/ToolPropertySection.h" +#include "props/CameraPropertySection.h" #include "timeline/TimelineWidget.h" #include "dialogs/FrameAnimationDialog.h" #include "dialogs/EntityIntroPopup.h" @@ -559,7 +560,7 @@ void MainWindow::createTimelineDock() { // 合并后的关键帧按钮:一次写入位置 + userScale auto* btnKeyCombined = new QToolButton(bar); setToolButtonIconOrText(btnKeyCombined, QStringLiteral("media-record"), QStringLiteral("关键帧")); - btnKeyCombined->setToolTip(QStringLiteral("在当前帧记录实体的位置与缩放关键帧")); + btnKeyCombined->setToolTip(QStringLiteral("在当前帧记录实体或摄像机的位置与缩放关键帧")); polishCompactToolButton(btnKeyCombined, 34); layout->addWidget(btnKeyCombined); @@ -611,7 +612,27 @@ void MainWindow::createTimelineDock() { toolOps.push_back(rt.opacity); } m_editorCanvas->setTools(tools, toolOps); + QVector cams; + cams.reserve(rf.cameras.size()); + for (const auto& rc : rf.cameras) { + cams.push_back(rc.camera); + } + m_editorCanvas->setCameraOverlays(cams, m_selectedCameraId, m_tempHiddenCameraIds); m_editorCanvas->setTempHiddenIds(m_tempHiddenEntityIds, m_tempHiddenToolIds); + const bool presentation = m_previewRequested && m_workspace.hasBackground(); + m_editorCanvas->setPreviewCameraViewLocked(false); + if (presentation) { + const QString acid = m_workspace.project().activeCameraId(); + if (!acid.isEmpty()) { + for (const auto& rc : rf.cameras) { + if (rc.camera.id == acid) { + m_editorCanvas->setPreviewCameraViewLocked(true); + m_editorCanvas->applyCameraViewport(rc.camera.centerWorld, rc.camera.viewScale); + break; + } + } + } + } m_timelineScrubbing = false; } else if (m_editorCanvas) { m_editorCanvas->setCurrentFrame(m_currentFrame); @@ -710,7 +731,9 @@ void MainWindow::createTimelineDock() { const bool entityKeyUi = (m_workspace.isOpen() && !m_selectedEntityId.isEmpty()); const bool toolKeyUi = (m_workspace.isOpen() && m_hasSelectedTool && !m_selectedToolId.isEmpty()); - actDeleteKey->setEnabled(m_workspace.isOpen() && m_timeline->hasSelectedKeyframe() && (entityKeyUi || toolKeyUi)); + const bool cameraKeyUi = (m_workspace.isOpen() && m_hasSelectedCamera && !m_selectedCameraId.isEmpty()); + actDeleteKey->setEnabled(m_workspace.isOpen() && m_timeline->hasSelectedKeyframe() && + (entityKeyUi || toolKeyUi || cameraKeyUi)); const int selA = m_timeline->selectionStart(); const int selB = m_timeline->selectionEnd(); const bool hasRange = (selA >= 0 && selB >= 0); @@ -734,10 +757,18 @@ void MainWindow::createTimelineDock() { bool ok = false; switch (m_timeline->selectedKeyKind()) { case TimelineWidget::KeyKind::Location: - if (!m_selectedEntityId.isEmpty()) ok = m_workspace.removeEntityLocationKey(m_selectedEntityId, f); + if (!m_selectedEntityId.isEmpty()) { + ok = m_workspace.removeEntityLocationKey(m_selectedEntityId, f); + } else if (m_hasSelectedCamera && !m_selectedCameraId.isEmpty()) { + ok = m_workspace.removeCameraLocationKey(m_selectedCameraId, f); + } break; case TimelineWidget::KeyKind::UserScale: - if (!m_selectedEntityId.isEmpty()) ok = m_workspace.removeEntityUserScaleKey(m_selectedEntityId, f); + if (!m_selectedEntityId.isEmpty()) { + ok = m_workspace.removeEntityUserScaleKey(m_selectedEntityId, f); + } else if (m_hasSelectedCamera && !m_selectedCameraId.isEmpty()) { + ok = m_workspace.removeCameraScaleKey(m_selectedCameraId, f); + } break; case TimelineWidget::KeyKind::Image: if (!m_selectedEntityId.isEmpty()) ok = m_workspace.removeEntityImageFrame(m_selectedEntityId, f); @@ -1007,16 +1038,33 @@ void MainWindow::syncPreviewPlaybackBar() { } void MainWindow::onInsertCombinedKey() { - if (m_selectedEntityId.isEmpty() || !m_editorCanvas) { + if (!m_editorCanvas || !m_workspace.isOpen()) { + return; + } + const int lf = m_currentFrame % core::Project::kClipFixedFrames; + if (m_hasSelectedCamera && !m_selectedCameraId.isEmpty()) { + const core::eval::ResolvedProjectFrame rf = + core::eval::evaluateAtFrame(m_workspace.project(), m_currentFrame, 10); + for (const auto& rc : rf.cameras) { + if (rc.camera.id == m_selectedCameraId) { + m_workspace.setCameraLocationKey(m_selectedCameraId, lf, rc.camera.centerWorld); + m_workspace.setCameraScaleKey(m_selectedCameraId, lf, rc.camera.viewScale); + refreshEditorPage(); + return; + } + } + return; + } + if (m_selectedEntityId.isEmpty()) { return; } // 位置关键帧:使用当前帧下的动画原点 const QPointF o = m_editorCanvas->selectedAnimatedOriginWorld(); - m_workspace.setEntityLocationKey(m_selectedEntityId, m_currentFrame % core::Project::kClipFixedFrames, o); + m_workspace.setEntityLocationKey(m_selectedEntityId, lf, o); // 缩放关键帧:使用当前帧下的 userScale(而非 depthScale01) const double s = m_editorCanvas->selectedUserScale(); - m_workspace.setEntityUserScaleKey(m_selectedEntityId, m_currentFrame % core::Project::kClipFixedFrames, s); + m_workspace.setEntityUserScaleKey(m_selectedEntityId, lf, s); refreshEditorPage(); } @@ -1380,6 +1428,7 @@ void MainWindow::createProjectTreeDock() { m_bgPropertySection = new gui::BackgroundPropertySection(); m_blackholePropertySection = new gui::BlackholePropertySection(); m_entityPropertySection = new gui::EntityPropertySection(); + m_cameraPropertySection = new gui::CameraPropertySection(); m_toolPropertySection = new gui::ToolPropertySection(); m_propertyStack = new QStackedWidget(); m_propertyStack->setContentsMargins(4, 4, 4, 4); @@ -1387,6 +1436,7 @@ void MainWindow::createProjectTreeDock() { m_propertyStack->addWidget(m_bgPropertySection); m_propertyStack->addWidget(m_blackholePropertySection); m_propertyStack->addWidget(m_entityPropertySection); + m_propertyStack->addWidget(m_cameraPropertySection); m_propertyStack->addWidget(m_toolPropertySection); connect(m_bgPropertySection, &gui::BackgroundPropertySection::backgroundVisibleToggled, this, [this](bool on) { @@ -1600,6 +1650,37 @@ void MainWindow::createProjectTreeDock() { refreshEditorPage(); }); + connect(m_cameraPropertySection, &gui::CameraPropertySection::displayNameCommitted, this, [this](const QString& text) { + if (m_selectedCameraId.isEmpty() || !m_workspace.isOpen()) return; + if (!m_workspace.setCameraDisplayName(m_selectedCameraId, text)) return; + refreshProjectTree(); + refreshPropertyPanel(); + }); + connect(m_cameraPropertySection, &gui::CameraPropertySection::centerEdited, this, [this](double x, double y) { + if (m_selectedCameraId.isEmpty() || !m_workspace.isOpen()) return; + if (!m_workspace.setCameraCenterWorld(m_selectedCameraId, QPointF(x, y))) return; + refreshEditorPage(); + }); + connect(m_cameraPropertySection, &gui::CameraPropertySection::viewScaleEdited, this, [this](double vs) { + if (m_selectedCameraId.isEmpty() || !m_workspace.isOpen()) return; + const int f = std::clamp(m_currentFrame, 0, core::Project::kClipFixedFrames - 1); + if (!m_workspace.setCameraViewScaleValue(m_selectedCameraId, vs, f)) return; + refreshEditorPage(); + refreshDopeSheet(); + }); + connect(m_cameraPropertySection, &gui::CameraPropertySection::activePreviewToggled, this, [this](bool on) { + if (m_selectedCameraId.isEmpty() || !m_workspace.isOpen()) return; + if (on) { + if (!m_workspace.setActiveCameraId(m_selectedCameraId)) return; + } else { + if (m_workspace.project().activeCameraId() == m_selectedCameraId) { + if (!m_workspace.setActiveCameraId(QString())) return; + } + } + refreshEditorPage(); + refreshPropertyPanel(); + }); + auto* propScroll = new SpinFriendlyScrollArea(m_dockProperties); propScroll->setWidgetResizable(true); propScroll->setFrameShape(QFrame::NoFrame); @@ -1652,7 +1733,7 @@ void MainWindow::createProjectTreeDock() { void MainWindow::refreshPropertyPanel() { if (!m_bgPropertySection || !m_blackholePropertySection || !m_entityPropertySection || - !m_toolPropertySection || !m_propertyStack) { + !m_cameraPropertySection || !m_toolPropertySection || !m_propertyStack) { return; } @@ -1735,8 +1816,31 @@ void MainWindow::refreshPropertyPanel() { return cur; }; + const bool cameraUi = m_hasSelectedCamera && m_workspace.isOpen() && !m_selectedCameraId.isEmpty(); + if (cameraUi) { + m_entityPropertySection->clearDisconnected(); + m_toolPropertySection->clearDisconnected(); + gui::CameraPropertyUiState st; + const auto rf = core::eval::evaluateAtFrame(m_workspace.project(), m_currentFrame, 10); + for (const auto& rc : rf.cameras) { + if (rc.camera.id != m_selectedCameraId) { + continue; + } + st.displayName = rc.camera.displayName.isEmpty() ? rc.camera.id : rc.camera.displayName; + st.centerWorld = rc.camera.centerWorld; + st.viewScale = rc.camera.viewScale; + st.isActivePreviewCamera = (m_workspace.project().activeCameraId() == m_selectedCameraId); + break; + } + m_cameraPropertySection->applyState(st); + m_propertyStack->setCurrentWidget(m_cameraPropertySection); + m_dockProperties->setWindowTitle(QStringLiteral("属性 — 摄像机")); + return; + } + const bool toolUi = m_hasSelectedTool && m_workspace.isOpen() && !m_selectedToolId.isEmpty(); if (toolUi) { + m_cameraPropertySection->clearDisconnected(); gui::ToolPropertyUiState st; const int f = std::clamp(m_currentFrame, 0, core::Project::kClipFixedFrames - 1); const auto* clip = activeClipForUi(); @@ -1796,6 +1900,7 @@ void MainWindow::refreshPropertyPanel() { const bool holeUi = m_workspace.isOpen() && !m_selectedBlackholeEntityId.isEmpty(); if (holeUi) { + m_cameraPropertySection->clearDisconnected(); gui::BlackholePropertyUiState st; for (const auto& e : m_workspace.entities()) { if (e.id != m_selectedBlackholeEntityId) { @@ -1826,11 +1931,13 @@ void MainWindow::refreshPropertyPanel() { if (!entUi) { m_entityPropertySection->clearDisconnected(); m_toolPropertySection->clearDisconnected(); + m_cameraPropertySection->clearDisconnected(); m_propertyStack->setCurrentWidget(m_bgPropertySection); m_dockProperties->setWindowTitle(QStringLiteral("属性 — 背景")); return; } + m_cameraPropertySection->clearDisconnected(); QString displayName; double userScale = 1.0; bool ignoreDist = false; @@ -2147,6 +2254,28 @@ void MainWindow::refreshProjectTree() { }; addSubtree(QString(), nullptr); + + if (m_workspace.isOpen()) { + for (const auto& c : m_workspace.cameras()) { + auto* it = new QTreeWidgetItem(m_projectTree); + it->setText(1, c.displayName.isEmpty() ? QStringLiteral("摄像机") : c.displayName); + it->setTextAlignment(1, Qt::AlignRight | Qt::AlignVCenter); + it->setData(0, Qt::UserRole, QStringLiteral("camera")); + it->setData(0, Qt::UserRole + 1, c.id); + it->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled); + auto* eye = makeEye(!m_tempHiddenCameraIds.contains(c.id), true); + m_projectTree->setItemWidget(it, 0, eye); + connect(eye, &QToolButton::toggled, this, [this, id = c.id](bool on) { + if (!m_workspace.isOpen()) return; + if (on) m_tempHiddenCameraIds.remove(id); + else m_tempHiddenCameraIds.insert(id); + if (m_editorCanvas) { + m_editorCanvas->setTempHiddenCameraIds(m_tempHiddenCameraIds); + } + }); + } + } + m_projectTree->expandAll(); if (m_projectTree->header()) { @@ -2165,6 +2294,7 @@ void MainWindow::syncProjectTreeFromCanvasSelection() { m_projectTree->blockSignals(true); if ((!m_hasSelectedEntity || m_selectedEntityId.isEmpty()) && (!m_hasSelectedTool || m_selectedToolId.isEmpty()) && + (!m_hasSelectedCamera || m_selectedCameraId.isEmpty()) && m_selectedBlackholeEntityId.isEmpty()) { m_projectTree->clearSelection(); } else { @@ -2181,6 +2311,11 @@ void MainWindow::syncProjectTreeFromCanvasSelection() { found = node; break; } + if (m_hasSelectedCamera && !m_selectedCameraId.isEmpty() && kind == QStringLiteral("camera") && + id == m_selectedCameraId) { + found = node; + break; + } if (!m_selectedBlackholeEntityId.isEmpty() && kind == QStringLiteral("blackhole") && id == m_selectedBlackholeEntityId) { found = node; @@ -2213,9 +2348,14 @@ void MainWindow::onProjectTreeItemClicked(QTreeWidgetItem* item, int column) { } m_hasSelectedTool = false; m_selectedToolId.clear(); + m_hasSelectedCamera = false; + m_selectedCameraId.clear(); if (m_timeline) { m_timeline->setToolKeyframeTracks({}, {}); } + if (m_editorCanvas) { + m_editorCanvas->clearCameraSelection(); + } m_editorCanvas->selectEntityById(id); } } else if (kind == QStringLiteral("tool")) { @@ -2229,8 +2369,33 @@ void MainWindow::onProjectTreeItemClicked(QTreeWidgetItem* item, int column) { m_selectedToolId = id; m_hasSelectedEntity = false; m_selectedEntityId.clear(); + m_hasSelectedCamera = false; + m_selectedCameraId.clear(); if (m_editorCanvas) { m_editorCanvas->clearEntitySelection(); + m_editorCanvas->clearCameraSelection(); + } + if (m_timeline) { + updateTimelineTracks(); + } + refreshPropertyPanel(); + } + } else if (kind == QStringLiteral("camera")) { + const QString id = item->data(0, Qt::UserRole + 1).toString(); + if (!id.isEmpty()) { + m_selectedBlackholeEntityId.clear(); + if (m_editorCanvas) { + m_editorCanvas->clearBlackholeSelection(); + } + m_hasSelectedTool = false; + m_selectedToolId.clear(); + m_hasSelectedEntity = false; + m_selectedEntityId.clear(); + m_hasSelectedCamera = true; + m_selectedCameraId = id; + if (m_editorCanvas) { + m_editorCanvas->clearEntitySelection(); + m_editorCanvas->selectCameraById(id); } if (m_timeline) { updateTimelineTracks(); @@ -2246,8 +2411,11 @@ void MainWindow::onProjectTreeItemClicked(QTreeWidgetItem* item, int column) { m_hasSelectedEntity = false; m_selectedEntityId.clear(); m_selectedEntityDisplayNameCache.clear(); + m_hasSelectedCamera = false; + m_selectedCameraId.clear(); if (m_editorCanvas) { m_editorCanvas->clearEntitySelection(); + m_editorCanvas->clearCameraSelection(); m_editorCanvas->selectBlackholeByEntityId(entityId); } updateTimelineTracks(); @@ -2257,8 +2425,11 @@ void MainWindow::onProjectTreeItemClicked(QTreeWidgetItem* item, int column) { m_selectedBlackholeEntityId.clear(); m_hasSelectedTool = false; m_selectedToolId.clear(); + m_hasSelectedCamera = false; + m_selectedCameraId.clear(); m_editorCanvas->clearEntitySelection(); m_editorCanvas->clearBlackholeSelection(); + m_editorCanvas->clearCameraSelection(); updateTimelineTracks(); } } @@ -2751,6 +2922,8 @@ void MainWindow::rebuildCentralPages() { m_selectedEntityOrigin = origin; m_hasSelectedTool = false; m_selectedToolId.clear(); + m_hasSelectedCamera = false; + m_selectedCameraId.clear(); if (hasSel && !id.isEmpty()) { for (const auto& e : m_workspace.entities()) { if (e.id == id) { @@ -2776,6 +2949,8 @@ void MainWindow::rebuildCentralPages() { m_hasSelectedEntity = false; m_selectedEntityId.clear(); m_selectedEntityDisplayNameCache.clear(); + m_hasSelectedCamera = false; + m_selectedCameraId.clear(); } updateTimelineTracks(); if (!m_timelineScrubbing) { @@ -3176,6 +3351,50 @@ void MainWindow::rebuildCentralPages() { refreshProjectTree(); updateUiEnabledState(); }); + connect(m_editorCanvas, &EditorCanvas::requestMoveCamera, this, [this](const QString& id, const QPointF& delta) { + const bool autoKey = true; + if (!m_workspace.moveCameraBy(id, delta, m_currentFrame % core::Project::kClipFixedFrames, autoKey)) { + return; + } + refreshEditorPage(); + refreshProjectTree(); + updateUiEnabledState(); + }); + connect(m_editorCanvas, &EditorCanvas::requestCameraViewScaleAdjust, this, [this](const QString& id, double factor) { + if (id.isEmpty() || !m_workspace.isOpen()) return; + const core::eval::ResolvedProjectFrame resFrame = + core::eval::evaluateAtFrame(m_workspace.project(), m_currentFrame, 10); + for (const auto& rc : resFrame.cameras) { + if (rc.camera.id != id) continue; + const double ns = std::clamp(rc.camera.viewScale * factor, 1e-6, 1e3); + const int kf = std::clamp(m_currentFrame, 0, core::Project::kClipFixedFrames - 1); + if (!m_workspace.setCameraViewScaleValue(id, ns, kf)) return; + refreshEditorPage(); + refreshDopeSheet(); + return; + } + }); + connect(m_editorCanvas, &EditorCanvas::selectedCameraChanged, this, + [this](bool hasSel, const QString& id, const QPointF& centerWorld, double viewScale) { + Q_UNUSED(centerWorld); + Q_UNUSED(viewScale); + m_hasSelectedCamera = hasSel; + m_selectedCameraId = id; + if (hasSel) { + m_hasSelectedEntity = false; + m_selectedEntityId.clear(); + m_selectedEntityDisplayNameCache.clear(); + m_hasSelectedTool = false; + m_selectedToolId.clear(); + m_selectedBlackholeEntityId.clear(); + } + updateTimelineTracks(); + if (!m_timelineScrubbing) { + updateStatusBarText(); + refreshPropertyPanel(); + syncProjectTreeFromCanvasSelection(); + } + }); connect(m_editorCanvas, &EditorCanvas::requestResolveBlackholeCopy, this, [this](const QString& entityId, const QPoint& sourceOffsetPx) { if (!m_workspace.resolveBlackholeByCopyBackground(entityId, sourceOffsetPx, true)) { @@ -3406,12 +3625,32 @@ void MainWindow::refreshEditorPage() { opacities.push_back(rt.opacity); } m_editorCanvas->setTools(tools, opacities); + QVector cams; + cams.reserve(rf.cameras.size()); + for (const auto& rc : rf.cameras) { + cams.push_back(rc.camera); + } + m_editorCanvas->setCameraOverlays(cams, m_selectedCameraId, m_tempHiddenCameraIds); m_editorCanvas->setCurrentFrame(m_currentFrame); m_editorCanvas->setTempHiddenIds(m_tempHiddenEntityIds, m_tempHiddenToolIds); + m_editorCanvas->setPreviewCameraViewLocked(false); + if (presentation) { + const QString acid = m_workspace.project().activeCameraId(); + if (!acid.isEmpty()) { + for (const auto& rc : rf.cameras) { + if (rc.camera.id == acid) { + m_editorCanvas->setPreviewCameraViewLocked(true); + m_editorCanvas->applyCameraViewport(rc.camera.centerWorld, rc.camera.viewScale); + break; + } + } + } + } updateTimelineTracks(); } else { m_editorCanvas->setEntities({}, {}, QString()); + m_editorCanvas->setCameraOverlays({}, QString(), {}); if (m_timeline) { m_timeline->setKeyframeTracks({}, {}, {}, {}); m_timeline->setToolKeyframeTracks({}, {}); @@ -3438,11 +3677,13 @@ void MainWindow::updateTimelineTracks() { const bool wantEntity = !m_selectedEntityId.isEmpty(); const bool wantTool = (m_hasSelectedTool && !m_selectedToolId.isEmpty()); + const bool wantCamera = (m_hasSelectedCamera && !m_selectedCameraId.isEmpty()); - // 未选中时不显示关键帧(按需求) - if (!wantEntity) m_timeline->setKeyframeTracks({}, {}, {}, {}); - if (!wantTool) m_timeline->setToolKeyframeTracks({}, {}); - if (!wantEntity && !wantTool) return; + if (!wantEntity && !wantTool && !wantCamera) { + m_timeline->setKeyframeTracks({}, {}, {}, {}); + m_timeline->setToolKeyframeTracks({}, {}); + return; + } // 选择当前 clip(与 workspace 写入规则一致) const core::Project::AnimationClip* clip = nullptr; @@ -3513,7 +3754,16 @@ void MainWindow::updateTimelineTracks() { const auto im = clip->entityImageFrames.value(m_selectedEntityId); const auto vis = clip->entityVisibilityKeys.value(m_selectedEntityId); m_timeline->setKeyframeTracks(framesOfVec2(loc), framesOfDouble(sc), framesOfImage(im), framesOfBool(vis)); + } else if (wantCamera) { + const auto loc = clip->cameraLocationKeys.value(m_selectedCameraId); + const auto sc = clip->cameraScaleKeys.value(m_selectedCameraId); + m_timeline->setKeyframeTracks(framesOfVec2(loc), framesOfDouble(sc), {}, {}); + } else { + m_timeline->setKeyframeTracks({}, {}, {}, {}); } + + // 注意:未选中工具时不能调用 setToolKeyframeTracks({}, {}),其实现会清空 m_locFrames/m_scaleFrames, + // 从而冲掉上面已为实体/摄像机写入的轨道数据。 if (wantTool) { const auto loc = clip->toolLocationKeys.value(m_selectedToolId); const auto vis = clip->toolVisibilityKeys.value(m_selectedToolId); @@ -4082,6 +4332,9 @@ void MainWindow::onCloseProject() { m_selectedEntityDepth = 0; m_selectedEntityOrigin = QPointF(); m_selectedEntityId.clear(); + m_hasSelectedCamera = false; + m_selectedCameraId.clear(); + m_tempHiddenCameraIds.clear(); m_currentFrame = 0; statusBar()->showMessage(QStringLiteral("工程已关闭")); diff --git a/client/gui/main_window/MainWindow.h b/client/gui/main_window/MainWindow.h index 26dc2d6..459728c 100644 --- a/client/gui/main_window/MainWindow.h +++ b/client/gui/main_window/MainWindow.h @@ -37,6 +37,7 @@ class BackgroundPropertySection; class BlackholePropertySection; class EntityPropertySection; class ToolPropertySection; +class CameraPropertySection; class EntityIntroPopup; class ResourceLibraryDock; } @@ -130,6 +131,7 @@ private: gui::BlackholePropertySection* m_blackholePropertySection = nullptr; gui::EntityPropertySection* m_entityPropertySection = nullptr; gui::ToolPropertySection* m_toolPropertySection = nullptr; + gui::CameraPropertySection* m_cameraPropertySection = nullptr; QToolButton* m_btnCreateEntity = nullptr; ToolOptionPopup* m_createEntityPopup = nullptr; QToolButton* m_btnToggleDepthOverlay = nullptr; @@ -170,11 +172,13 @@ private: int m_lastWorldZ = -1; bool m_hasSelectedEntity = false; bool m_hasSelectedTool = false; + bool m_hasSelectedCamera = false; bool m_syncingTreeSelection = false; int m_selectedEntityDepth = 0; QPointF m_selectedEntityOrigin; QString m_selectedEntityId; QString m_selectedToolId; + QString m_selectedCameraId; QString m_selectedBlackholeEntityId; QString m_selectedEntityDisplayNameCache; QString m_bgAbsCache; @@ -182,6 +186,7 @@ private: // 项目树“眼睛”:仅用于画布临时隐藏(不持久化、不进时间轴) QSet m_tempHiddenEntityIds; QSet m_tempHiddenToolIds; + QSet m_tempHiddenCameraIds; void updateStatusBarText(); void syncCreateEntityToolButtonTooltip(); void refreshPropertyPanel(); diff --git a/client/gui/props/CameraPropertySection.cpp b/client/gui/props/CameraPropertySection.cpp new file mode 100644 index 0000000..90ac1cb --- /dev/null +++ b/client/gui/props/CameraPropertySection.cpp @@ -0,0 +1,111 @@ +#include "props/CameraPropertySection.h" + +#include "params/ParamControls.h" + +#include +#include +#include +#include +#include +#include + +namespace gui { + +CameraPropertySection::CameraPropertySection(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 QLineEdit(this); + m_name->setPlaceholderText(QStringLiteral("显示名称…")); + form->addRow(QStringLiteral("名称"), m_name); + + m_center = new Vec2ParamControl(this); + m_center->setToolTip(QStringLiteral("摄像机中心(世界坐标),与画布上黄色圆点一致")); + form->addRow(QStringLiteral("中心"), m_center); + + m_viewScale = new QDoubleSpinBox(this); + m_viewScale->setRange(1e-4, 1000.0); + m_viewScale->setDecimals(5); + m_viewScale->setSingleStep(0.01); + m_viewScale->setToolTip(QStringLiteral( + "视口缩放:在参考分辨率 1600×900 下的像素/世界单位比(与预览、画布上镜头框一致);" + "不随当前窗口大小改变镜头覆盖的世界范围。数值越小,可见的世界范围越大。")); + form->addRow(QStringLiteral("缩放"), m_viewScale); + + m_activePreview = new QCheckBox(QStringLiteral("用作预览展示镜头"), this); + m_activePreview->setToolTip(QStringLiteral("进入预览展示时,按该摄像机在当前帧的位置与缩放呈现画面")); + form->addRow(QStringLiteral("预览"), m_activePreview); + + lay->addLayout(form); + lay->addStretch(1); + + connect(m_name, &QLineEdit::editingFinished, this, [this]() { + if (m_name) emit displayNameCommitted(m_name->text()); + }); + connect(m_center, &Vec2ParamControl::valueChanged, this, [this](double x, double y) { emit centerEdited(x, y); }); + connect(m_viewScale, qOverload(&QDoubleSpinBox::valueChanged), this, + &CameraPropertySection::viewScaleEdited); + connect(m_activePreview, &QCheckBox::toggled, this, &CameraPropertySection::activePreviewToggled); +} + +void CameraPropertySection::setEditingEnabled(bool on) { + for (auto* w : {static_cast(m_name), static_cast(m_center), static_cast(m_viewScale), + static_cast(m_activePreview)}) { + if (w) w->setEnabled(on); + } +} + +void CameraPropertySection::clearDisconnected() { + setEditingEnabled(false); + if (m_name) { + m_name->blockSignals(true); + m_name->clear(); + m_name->blockSignals(false); + } + if (m_center) { + m_center->blockSignals(true); + m_center->setValue(0.0, 0.0); + m_center->blockSignals(false); + } + if (m_viewScale) { + m_viewScale->blockSignals(true); + m_viewScale->setValue(1.0); + m_viewScale->blockSignals(false); + } + if (m_activePreview) { + m_activePreview->blockSignals(true); + m_activePreview->setChecked(false); + m_activePreview->blockSignals(false); + } +} + +void CameraPropertySection::applyState(const CameraPropertyUiState& s) { + setEditingEnabled(true); + if (m_name) { + m_name->blockSignals(true); + m_name->setText(s.displayName); + m_name->blockSignals(false); + } + if (m_center) { + m_center->blockSignals(true); + m_center->setValue(s.centerWorld.x(), s.centerWorld.y()); + m_center->blockSignals(false); + } + if (m_viewScale) { + m_viewScale->blockSignals(true); + m_viewScale->setValue(s.viewScale); + m_viewScale->blockSignals(false); + } + if (m_activePreview) { + m_activePreview->blockSignals(true); + m_activePreview->setChecked(s.isActivePreviewCamera); + m_activePreview->blockSignals(false); + } +} + +} // namespace gui diff --git a/client/gui/props/CameraPropertySection.h b/client/gui/props/CameraPropertySection.h new file mode 100644 index 0000000..c810e32 --- /dev/null +++ b/client/gui/props/CameraPropertySection.h @@ -0,0 +1,47 @@ +#pragma once + +#include "props/PropertySectionWidget.h" + +#include +#include + +class QCheckBox; +class QDoubleSpinBox; +class QLineEdit; + +namespace gui { +class Vec2ParamControl; +} + +namespace gui { + +struct CameraPropertyUiState { + QString displayName; + QPointF centerWorld; + double viewScale = 1.0; + bool isActivePreviewCamera = false; +}; + +class CameraPropertySection final : public PropertySectionWidget { + Q_OBJECT +public: + explicit CameraPropertySection(QWidget* parent = nullptr); + + void clearDisconnected(); + void applyState(const CameraPropertyUiState& s); + void setEditingEnabled(bool on); + +signals: + void displayNameCommitted(const QString& text); + void centerEdited(double x, double y); + void viewScaleEdited(double viewScale); + void activePreviewToggled(bool on); + +private: + QLineEdit* m_name = nullptr; + Vec2ParamControl* m_center = nullptr; + QDoubleSpinBox* m_viewScale = nullptr; + QCheckBox* m_activePreview = nullptr; +}; + +} // namespace gui