Files
hfut-bishe/client/gui/editor/EditorCanvas.cpp
T
2026-05-23 20:49:32 +08:00

3534 lines
140 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include "editor/EditorCanvas.h"
#include "editor/EntityCutoutUtils.h"
#include "core/animation/AnimationSampling.h"
#include "core/depth/DepthService.h"
#include <algorithm>
#include <cmath>
#include <QBuffer>
#include <QDir>
#include <QFileInfo>
#include <QCursor>
#include <QMouseEvent>
#include <QPainter>
#include <QPaintEvent>
#include <QPainterPath>
#include <QWheelEvent>
#include <QKeyEvent>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QMimeData>
#include <QDragEnterEvent>
#include <QDropEvent>
#include <QTextOption>
#include <QIODevice>
#include <QPen>
#include <QPolygonF>
#include <QLineF>
#include <QTimer>
#include <QtConcurrent/QtConcurrent>
#include "core/image/ImageDecodeConfig.h"
#include "core/image/ImageFileLoader.h"
#include "core/large_image/VipsBackend.h"
#include "core/library/EntityJson.h"
#include "core/library/ToolJson.h"
namespace {
// 摄像机「输出」参考分辨率:视口框与预览缩放均按此换算,避免随窗口大小改变镜头覆盖的世界范围
constexpr double kCameraRefViewportW = 1600.0;
constexpr double kCameraRefViewportH = 900.0;
/// 画布自由缩放(世界→屏幕)上下限;与摄像机 viewScale 经 applyCameraViewport 换算后的 eff 对齐
constexpr qreal kViewScaleMin = 0.001;
constexpr qreal kViewScaleMax = 400.0;
constexpr int kSamCropMargin = 32;
constexpr int kMinStrokePointsSam = 4;
constexpr int kMinStrokePointsManual = 8;
[[nodiscard]] QPointF shapeCentroidWorld(const QVector<QPointF>& polyWorld, const QRectF& rect) {
if (!polyWorld.isEmpty()) {
return entity_cutout::polygonCentroid(polyWorld);
}
return rect.center();
}
constexpr int kMaxSamPointPrompts = 32;
constexpr char kMimeHotspotAnimationJson[] = "application/x-hfut-hotspot-animation+json";
static QImage readImageTolerant(const QString& absPath) {
return core::image_file::loadImageLimited(absPath, core::image_decode::kWorkspaceMaxPixels);
}
/// 预览模式下:实体在屏上 footprint 较小时将贴图缩到约 2× 屏上最大边,降低过采样开销
static QImage entityImageForPresentationDrawLoRes(const QImage& src, qreal screenWpx, qreal screenHpx) {
if (src.isNull()) {
return src;
}
const int iw = src.width();
const int ih = src.height();
const qreal sm = std::max(screenWpx, screenHpx);
if (!(sm > 1.0)) {
return src;
}
const int cap = std::clamp(static_cast<int>(std::ceil(sm * 2.0)), 32, 4096);
if (std::max(iw, ih) <= cap) {
return src;
}
return src.scaled(QSize(cap, cap), Qt::KeepAspectRatio, Qt::SmoothTransformation);
}
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;
}
QRectF out = rect;
if (out.width() > bounds.width()) {
out.setWidth(bounds.width());
}
if (out.height() > bounds.height()) {
out.setHeight(bounds.height());
}
QPointF tl = out.topLeft();
if (tl.x() < bounds.left()) tl.setX(bounds.left());
if (tl.y() < bounds.top()) tl.setY(bounds.top());
if (tl.x() + out.width() > bounds.right()) tl.setX(bounds.right() - out.width());
if (tl.y() + out.height() > bounds.bottom()) tl.setY(bounds.bottom() - out.height());
out.moveTopLeft(tl);
return out;
}
QVector<QPointF> snapStrokeToEdgesWorld(const QVector<QPointF>& strokeWorld, const QImage& bgGray,
const QPointF& originWorld, const QSize& extentWorld,
double searchRadiusWorld) {
if (strokeWorld.size() < 3 || bgGray.isNull() || extentWorld.width() < 1 || extentWorld.height() < 1) {
return strokeWorld;
}
const int w = bgGray.width();
const int h = bgGray.height();
const double sx = static_cast<double>(w) / static_cast<double>(extentWorld.width());
const double sy = static_cast<double>(h) / static_cast<double>(extentWorld.height());
const int rPix = std::max(1, static_cast<int>(std::ceil(searchRadiusWorld * std::min(sx, sy))));
const double invSx = 1.0 / sx;
const double invSy = 1.0 / sy;
auto at = [&](int x, int y) -> int {
x = std::clamp(x, 0, w - 1);
y = std::clamp(y, 0, h - 1);
return static_cast<int>(static_cast<const uchar*>(bgGray.constScanLine(y))[x]);
};
auto gradMag = [&](int x, int y) -> int {
const int gx =
-at(x - 1, y - 1) + at(x + 1, y - 1) +
-2 * at(x - 1, y) + 2 * at(x + 1, y) +
-at(x - 1, y + 1) + at(x + 1, y + 1);
const int gy =
-at(x - 1, y - 1) - 2 * at(x, y - 1) - at(x + 1, y - 1) +
at(x - 1, y + 1) + 2 * at(x, y + 1) + at(x + 1, y + 1);
return std::abs(gx) + std::abs(gy);
};
QVector<QPointF> out;
out.reserve(strokeWorld.size());
for (const QPointF& p : strokeWorld) {
const double lx = (p.x() - originWorld.x()) * sx;
const double ly = (p.y() - originWorld.y()) * sy;
const int cx = static_cast<int>(std::round(lx));
const int cy = static_cast<int>(std::round(ly));
int bestX = cx;
int bestY = cy;
int bestG = -1;
for (int dy = -rPix; dy <= rPix; ++dy) {
for (int dx = -rPix; dx <= rPix; ++dx) {
const int x = cx + dx;
const int y = cy + dy;
if (x < 0 || y < 0 || x >= w || y >= h) {
continue;
}
const int g = gradMag(x, y);
if (g > bestG) {
bestG = g;
bestX = x;
bestY = y;
}
}
}
out.push_back(QPointF(originWorld.x() + (static_cast<double>(bestX) + 0.5) * invSx,
originWorld.y() + (static_cast<double>(bestY) + 0.5) * invSy));
}
return out;
}
bool buildSamSegmentPayloadFromStrokeMapped(const QVector<QPointF>& strokeWorld, const QImage& cropAny,
const QRect& cropWorldRect, QByteArray& outCropPng,
QByteArray& outOverlayPng, QPointF& outCropTopLeftWorld,
QJsonArray& outPointCoords, QJsonArray& outPointLabels,
QJsonArray& outBoxXyxy) {
if (strokeWorld.size() < kMinStrokePointsSam || cropAny.isNull() || cropWorldRect.width() < 1 ||
cropWorldRect.height() < 1) {
return false;
}
outCropPng.clear();
outOverlayPng.clear();
outPointCoords = QJsonArray{};
outPointLabels = QJsonArray{};
outBoxXyxy = QJsonArray{};
const QImage cropRgb = cropAny.convertToFormat(QImage::Format_RGB888);
if (cropRgb.isNull()) {
return false;
}
const QPointF origin = cropWorldRect.topLeft();
outCropTopLeftWorld = origin;
const int cw = cropRgb.width();
const int ch = cropRgb.height();
const double sx = static_cast<double>(cw) / static_cast<double>(cropWorldRect.width());
const double sy = static_cast<double>(ch) / static_cast<double>(cropWorldRect.height());
QBuffer bufCrop(&outCropPng);
if (!bufCrop.open(QIODevice::WriteOnly) || !cropRgb.save(&bufCrop, "PNG")) {
outCropPng.clear();
return false;
}
bufCrop.close();
QImage overlay(cw, ch, QImage::Format_ARGB32_Premultiplied);
overlay.fill(Qt::transparent);
{
QPainter pop(&overlay);
pop.setRenderHint(QPainter::Antialiasing, true);
QPen pen(QColor(255, 60, 60, 240));
pen.setWidthF(4.0);
pen.setCapStyle(Qt::RoundCap);
pen.setJoinStyle(Qt::RoundJoin);
pop.setPen(pen);
QPolygonF local;
local.reserve(strokeWorld.size());
for (const QPointF& w : strokeWorld) {
local.append(QPointF((w.x() - origin.x()) * sx, (w.y() - origin.y()) * sy));
}
pop.drawPolyline(local);
}
QBuffer bufOv(&outOverlayPng);
if (!bufOv.open(QIODevice::WriteOnly) || !overlay.save(&bufOv, "PNG")) {
outOverlayPng.clear();
return false;
}
bufOv.close();
auto clampD = [](double v, double lo, double hi) { return std::clamp(v, lo, hi); };
const QPointF centerWorld = QPolygonF(strokeWorld).boundingRect().center();
const QPointF centerLocal((centerWorld.x() - origin.x()) * sx, (centerWorld.y() - origin.y()) * sy);
const double fgx = clampD(centerLocal.x(), 0.0, static_cast<double>(cw - 1));
const double fgy = clampD(centerLocal.y(), 0.0, static_cast<double>(ch - 1));
outPointCoords.append(QJsonArray{fgx, fgy});
outPointLabels.append(1);
const int n = static_cast<int>(strokeWorld.size());
const int maxBg = std::max(0, kMaxSamPointPrompts - 1);
if (n >= 2 && maxBg > 0) {
const int step = std::max(1, (n + maxBg - 1) / maxBg);
for (int i = 0; i < n; i += step) {
const QPointF L((strokeWorld[i].x() - origin.x()) * sx, (strokeWorld[i].y() - origin.y()) * sy);
const double bx = clampD(L.x(), 0.0, static_cast<double>(cw - 1));
const double by = clampD(L.y(), 0.0, static_cast<double>(ch - 1));
outPointCoords.append(QJsonArray{bx, by});
outPointLabels.append(0);
}
}
const QRectF tight = QPolygonF(strokeWorld).boundingRect();
double x1 = clampD((tight.left() - origin.x()) * sx, 0.0, static_cast<double>(cw - 1));
double y1 = clampD((tight.top() - origin.y()) * sy, 0.0, static_cast<double>(ch - 1));
double x2 = clampD((tight.right() - origin.x()) * sx, 0.0, static_cast<double>(cw - 1));
double y2 = clampD((tight.bottom() - origin.y()) * sy, 0.0, static_cast<double>(ch - 1));
if (x2 <= x1) {
x2 = std::min(static_cast<double>(cw - 1), x1 + 1.0);
}
if (y2 <= y1) {
y2 = std::min(static_cast<double>(ch - 1), y1 + 1.0);
}
outBoxXyxy = QJsonArray{x1, y1, x2, y2};
return true;
}
void drawCheckerboard(QPainter& p, const QRect& r) {
// 轻量级棋盘格,让透明/纯色背景也有参照
const int cell = 16;
const QColor c1(245, 245, 245);
const QColor c2(230, 230, 230);
for (int y = r.top(); y < r.bottom(); y += cell) {
for (int x = r.left(); x < r.right(); x += cell) {
const bool odd = ((x / cell) + (y / cell)) % 2;
p.fillRect(QRect(x, y, cell, cell), odd ? c1 : c2);
}
}
}
void drawGrid(QPainter& p, const QRect& r) {
const int step = 64;
QPen pen(QColor(0, 0, 0, 24));
pen.setWidth(1);
p.setPen(pen);
for (int x = r.left(); x <= r.right(); x += step) {
p.drawLine(QPoint(x, r.top()), QPoint(x, r.bottom()));
}
for (int y = r.top(); y <= r.bottom(); y += step) {
p.drawLine(QPoint(r.left(), y), QPoint(r.right(), y));
}
}
QRectF transformedRectByScaleAndTranslate(const QRectF& r, const QPointF& center, double scaleRatio, const QPointF& delta) {
if (r.isNull()) {
return r.translated(delta);
}
const QPointF c = center + delta;
auto mapPt = [&](const QPointF& p) {
return c + (p + delta - c) * scaleRatio;
};
const QPointF p1 = mapPt(r.topLeft());
const QPointF p2 = mapPt(r.topRight());
const QPointF p3 = mapPt(r.bottomLeft());
const QPointF p4 = mapPt(r.bottomRight());
const qreal minX = std::min({p1.x(), p2.x(), p3.x(), p4.x()});
const qreal minY = std::min({p1.y(), p2.y(), p3.y(), p4.y()});
const qreal maxX = std::max({p1.x(), p2.x(), p3.x(), p4.x()});
const qreal maxY = std::max({p1.y(), p2.y(), p3.y(), p4.y()});
return QRectF(QPointF(minX, minY), QPointF(maxX, maxY));
}
int sampleDepthAtPoint(const QImage& depth8, const QPointF& worldPos) {
if (depth8.isNull() || depth8.format() != QImage::Format_Grayscale8) {
return 0;
}
const int xi = static_cast<int>(std::floor(worldPos.x()));
const int yi = static_cast<int>(std::floor(worldPos.y()));
if (xi < 0 || yi < 0 || xi >= depth8.width() || yi >= depth8.height()) {
return 0;
}
return static_cast<int>(depth8.constScanLine(yi)[xi]);
}
double depthToScale01(int depthZ) {
// 约定:depth=0 最远,depth=255 最近(与后端输出一致)。映射为 0..1(远->0,近->1)。
const int d = std::clamp(depthZ, 0, 255);
return static_cast<double>(d) / 255.0;
}
// depth01 0..1 -> 原始距离乘子 0.5..1.5calibMult>0 时除以创建时记录的基准,使「原位置」为 1.0
double distanceScaleFromDepth01(double depth01, double calibMult) {
const double d = std::clamp(depth01, 0.0, 1.0);
const double raw = 0.5 + d * 1.0;
if (calibMult > 0.0) {
return raw / std::max(calibMult, 1e-6);
}
return raw;
}
struct GizmoHit {
EditorCanvas::DragMode mode = EditorCanvas::DragMode::None;
};
GizmoHit hitTestGizmo(const QPointF& mouseView, const QPointF& originView) {
// 以 view 像素为单位的手柄大小(不随缩放变化)
const qreal len = 56.0;
const qreal halfThickness = 6.0;
const QRectF xHandle(QPointF(originView.x(), originView.y() - halfThickness),
QSizeF(len, halfThickness * 2.0));
const QRectF yHandle(QPointF(originView.x() - halfThickness, originView.y()),
QSizeF(halfThickness * 2.0, len));
if (xHandle.contains(mouseView)) {
return {EditorCanvas::DragMode::AxisX};
}
if (yHandle.contains(mouseView)) {
return {EditorCanvas::DragMode::AxisY};
}
return {};
}
struct BubbleLayoutWorld {
QPainterPath path;
QRectF bodyRect;
};
// originWorld = 朝下三角形尖端;滑块改变主体水平位置,使「平直底边」上 t01 对应点始终在尖端正上方(三角竖直、与主体一体平移)
static BubbleLayoutWorld bubbleLayoutWorld(const core::Project::Tool& tool) {
const QPointF tip = tool.originWorld;
const qreal w = 220.0;
const qreal h = 110.0;
const qreal rx = 16.0;
const qreal arrowH = 22.0;
const double t01 = std::clamp(tool.bubblePointerT01, 0.0, 1.0);
const qreal spanFlat = std::max(w - 2.0 * rx, 1.0);
const qreal bodyLeft = tip.x() - rx - static_cast<qreal>(t01) * spanFlat;
const QRectF body(bodyLeft, tip.y() - (h + arrowH), w, h);
const qreal halfTri = 14.0;
const qreal baseCx = tip.x();
QPainterPath path;
path.addRoundedRect(body, rx, rx);
QPolygonF tri;
tri << QPointF(baseCx - halfTri, body.bottom()) << QPointF(baseCx + halfTri, body.bottom()) << QPointF(tip.x(), tip.y());
path.addPolygon(tri);
return BubbleLayoutWorld{path, body};
}
static QPainterPath bubblePathWorld(const core::Project::Tool& tool) {
return bubbleLayoutWorld(tool).path;
}
} // namespace
EditorCanvas::EditorCanvas(QWidget* parent)
: QWidget(parent) {
setAutoFillBackground(false);
setMinimumSize(480, 320);
setFocusPolicy(Qt::StrongFocus);
setMouseTracking(true);
setAcceptDrops(true);
m_previewEmitTimer.start();
m_presZoomTimer = new QTimer(this);
m_presZoomTimer->setInterval(16);
connect(m_presZoomTimer, &QTimer::timeout, this, &EditorCanvas::tickPresentationZoomAnimation);
m_presHoverTimer = new QTimer(this);
m_presHoverTimer->setInterval(40);
connect(m_presHoverTimer, &QTimer::timeout, this, &EditorCanvas::tickPresentationHoverAnimation);
updateCursor();
}
void EditorCanvas::dragEnterEvent(QDragEnterEvent* e) {
if (!e || !e->mimeData()) {
return;
}
if (!m_presentationPreviewMode && m_presentationHotspotEditorActive &&
e->mimeData()->hasFormat(QString::fromUtf8(kMimeHotspotAnimationJson))) {
e->acceptProposedAction();
return;
}
if (e->mimeData()->hasFormat(QStringLiteral("application/x-hfut-resource+json"))) {
e->acceptProposedAction();
return;
}
QWidget::dragEnterEvent(e);
}
void EditorCanvas::dragMoveEvent(QDragMoveEvent* e) {
if (!e || !e->mimeData()) {
return;
}
if (!m_presentationPreviewMode && m_presentationHotspotEditorActive &&
e->mimeData()->hasFormat(QString::fromUtf8(kMimeHotspotAnimationJson))) {
e->acceptProposedAction();
return;
}
if (e->mimeData()->hasFormat(QStringLiteral("application/x-hfut-resource+json"))) {
e->acceptProposedAction();
return;
}
QWidget::dragMoveEvent(e);
}
void EditorCanvas::dropEvent(QDropEvent* e) {
if (!e || !e->mimeData()) {
QWidget::dropEvent(e);
return;
}
if (!m_presentationPreviewMode && m_presentationHotspotEditorActive &&
e->mimeData()->hasFormat(QString::fromUtf8(kMimeHotspotAnimationJson))) {
const QByteArray bytes = e->mimeData()->data(QString::fromUtf8(kMimeHotspotAnimationJson));
const auto doc = QJsonDocument::fromJson(bytes);
if (!doc.isObject()) {
e->ignore();
return;
}
const QString animationId = doc.object().value(QStringLiteral("animationId")).toString();
if (animationId.isEmpty()) {
e->ignore();
return;
}
emit requestAddPresentationHotspotForAnimation(viewToWorld(e->position()), animationId);
e->acceptProposedAction();
return;
}
if (!e->mimeData()->hasFormat(QStringLiteral("application/x-hfut-resource+json"))) {
QWidget::dropEvent(e);
return;
}
const QByteArray bytes = e->mimeData()->data(QStringLiteral("application/x-hfut-resource+json"));
const auto doc = QJsonDocument::fromJson(bytes);
if (!doc.isObject()) {
e->ignore();
return;
}
const QJsonObject root = doc.object();
const QString kind = root.value(QStringLiteral("kind")).toString(QStringLiteral("entity"));
const QPointF dropWorld = viewToWorld(e->position());
if (kind == QStringLiteral("tool")) {
if (!root.value(QStringLiteral("tool")).isObject()) {
e->ignore();
return;
}
core::Project::Tool t;
if (!core::library::toolFromJson(root.value(QStringLiteral("tool")).toObject(), t)) {
e->ignore();
return;
}
// 让主窗口分配 id,避免冲突
t.id.clear();
t.parentId.clear();
t.parentOffsetWorld = QPointF();
t.originWorld = dropWorld;
emit requestAddTool(t);
e->acceptProposedAction();
return;
}
if (!root.value(QStringLiteral("entity")).isObject()) {
e->ignore();
return;
}
core::Project::Entity ent;
if (!core::library::entityFromJson(root.value(QStringLiteral("entity")).toObject(), ent)) {
e->ignore();
return;
}
// 让主窗口分配 id,避免资源 id 与工程内冲突
ent.id.clear();
ent.imagePath.clear();
ent.entityPayloadPath.clear();
ent.originWorld = dropWorld;
// 默认把贴图左上角放到 originWorld + offset
QPointF imageOffset(-128, -128);
if (root.value(QStringLiteral("imageOffsetFromOrigin")).isArray()) {
const QJsonArray a = root.value(QStringLiteral("imageOffsetFromOrigin")).toArray();
if (a.size() >= 2) {
imageOffset = QPointF(a.at(0).toDouble(), a.at(1).toDouble());
}
}
ent.imageTopLeftWorld = ent.originWorld + imageOffset;
// 生成占位贴图(未来可替换为真实资源图片)
QSize imgSize(256, 256);
if (root.value(QStringLiteral("imageSize")).isArray()) {
const QJsonArray a = root.value(QStringLiteral("imageSize")).toArray();
if (a.size() >= 2) {
imgSize = QSize(a.at(0).toInt(256), a.at(1).toInt(256));
}
}
QColor accent(80, 160, 255);
if (root.value(QStringLiteral("accent")).isArray()) {
const QJsonArray a = root.value(QStringLiteral("accent")).toArray();
if (a.size() >= 4) {
accent = QColor(a.at(0).toInt(80), a.at(1).toInt(160), a.at(2).toInt(255), a.at(3).toInt(255));
}
}
if (!imgSize.isValid()) {
imgSize = QSize(256, 256);
}
QImage img(imgSize, QImage::Format_ARGB32_Premultiplied);
img.fill(Qt::transparent);
{
QPainter p(&img);
p.setRenderHint(QPainter::Antialiasing, true);
QRectF rr(QPointF(0, 0), QSizeF(imgSize));
rr = rr.adjusted(6, 6, -6, -6);
p.setPen(QPen(QColor(0, 0, 0, 60), 2));
p.setBrush(QBrush(accent));
p.drawRoundedRect(rr, 18, 18);
}
emit requestAddEntity(ent, img);
e->acceptProposedAction();
}
void EditorCanvas::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<double>(std::max(1, width())) / kCameraRefViewportW,
static_cast<double>(std::max(1, height())) / kCameraRefViewportH);
const double eff = std::max(1e-9, static_cast<double>(viewScale)) * pixelRatio;
m_scale = std::clamp(static_cast<qreal>(eff), kViewScaleMin, kViewScaleMax);
m_pan = QPointF(width() / 2.0, height() / 2.0) - QPointF(centerWorld.x() * m_scale, centerWorld.y() * m_scale);
invalidateViewportLod();
update();
}
QPointF EditorCanvas::viewCenterWorld() const {
return viewToWorld(QPointF(width() / 2.0, height() / 2.0));
}
void EditorCanvas::setPresentationHotspots(const QVector<core::Project::PresentationHotspot>& hotspots) {
m_presentationHotspots = hotspots;
bool found = false;
for (const auto& h : m_presentationHotspots) {
if (h.id == m_selectedPresentationHotspotId) {
found = true;
break;
}
}
if (!found) {
m_selectedPresentationHotspotId.clear();
}
update();
}
void EditorCanvas::setPresentationHotspotEditorActive(bool on) {
if (m_presentationHotspotEditorActive == on) {
return;
}
m_presentationHotspotEditorActive = on;
if (!on) {
m_draggingHotspotIndex = -1;
m_dragging = false;
}
updateCursor();
update();
}
void EditorCanvas::setPresentationHotspotPlaybackTargetEnabled(bool on) {
if (m_presentationHotspotPlaybackTargetEnabled == on) {
return;
}
m_presentationHotspotPlaybackTargetEnabled = on;
if (!on) {
if (m_presHoverTimer) {
m_presHoverTimer->stop();
}
m_presHoverEntityIndex = -1;
m_presFocusedEntityIndex = -1;
m_presHoverPhase = 0.0;
m_presZoomAnimT = 0.0;
m_presZoomFinishingRestore = false;
m_presBgPanSession = false;
m_presBgDragDist = 0.0;
emit presentationInteractionDismissed();
}
updateCursor();
update();
}
void EditorCanvas::setSelectedPresentationHotspotId(const QString& id) {
m_selectedPresentationHotspotId = id;
update();
}
void EditorCanvas::clearPresentationHotspotSelection() {
m_selectedPresentationHotspotId.clear();
m_draggingHotspotIndex = -1;
update();
}
void EditorCanvas::setPresentationPreviewMode(bool on) {
if (m_presentationPreviewMode == on) {
return;
}
m_presentationPreviewMode = on;
if (m_presZoomTimer) {
m_presZoomTimer->stop();
}
if (m_presHoverTimer) {
m_presHoverTimer->stop();
}
m_presHoverEntityIndex = -1;
m_presFocusedEntityIndex = -1;
m_presHoverPhase = 0.0;
m_presZoomAnimT = 0.0;
m_presZoomFinishingRestore = false;
m_presBgPanSession = false;
m_presBgDragDist = 0.0;
cancelBlackholeCopyResolve();
m_draggingHotspotIndex = -1;
if (on) {
m_tool = Tool::Move;
m_selectedEntity = -1;
m_selectedBlackholeEntityId.clear();
m_draggingEntity = false;
m_drawingEntity = false;
m_dragMode = DragMode::None;
emit selectedEntityChanged(false, QString(), 0, QPointF());
}
m_bgCutoutDirty = true;
updateCursor();
update();
}
void EditorCanvas::setEntities(const QVector<core::Project::Entity>& entities,
const QVector<double>& opacities01,
const QString& projectDirAbs) {
const QString prevSelectedId =
(m_selectedEntity >= 0 && m_selectedEntity < m_entities.size()) ? m_entities[m_selectedEntity].id : QString();
m_projectDirAbs = projectDirAbs;
if (m_entityRasterCacheProjectDir != projectDirAbs) {
m_entityRasterImageCache.clear();
m_entityRasterCacheProjectDir = projectDirAbs;
}
m_entities.clear();
m_entities.reserve(entities.size());
// 需要用深度图来自动计算 z 与缩放(逐帧)
if (!m_depthAbsPath.isEmpty()) {
if (m_depthDirty) {
m_depthDirty = false;
m_depthImage8 = core::image_file::loadDepthMapAlignedToBackground(m_depthAbsPath, m_bgAbsPath);
}
}
const qsizetype nEnt = entities.size();
for (qsizetype iEnt = 0; iEnt < nEnt; ++iEnt) {
const auto& e = entities[iEnt];
Entity v;
v.id = e.id;
v.opacity = (iEnt < opacities01.size()) ? std::clamp(opacities01[iEnt], 0.0, 1.0) : 1.0;
// 注意:MainWindow 传入的是“按当前帧求值后的实体”(包含父子跟随与曲线采样)。
// 这里必须直接使用 e.originWorld,不能再对 locationKeys 做二次采样,否则父子实体会在刷新时复位/跳变。
const QPointF originWorld = e.originWorld;
v.animatedOriginWorld = originWorld;
v.cutoutPolygonWorld = e.cutoutPolygonWorld;
v.blackholeVisible = e.blackholeVisible;
v.blackholeOverlayPath = e.blackholeOverlayPath;
v.blackholeOverlayRect = e.blackholeOverlayRect;
v.distanceScaleCalibMult = e.distanceScaleCalibMult;
v.ignoreDistanceScale = e.ignoreDistanceScale;
v.priority = e.priority;
const double userScaleAnimated =
core::sampleUserScale(e.userScaleKeys, m_currentFrame, e.userScale, core::KeyInterpolation::Linear);
v.userScale = std::max(1e-6, userScaleAnimated);
// 逐帧自动算 z:采样点必须对“重锚枢轴(相对位置)”不敏感,否则会导致深度→缩放变化,
// 表现为“中心没改但看起来位置/形心漂移”。
//
// 做法:先用工程给出的 depth 计算一次 scale0,构建 polygonWorld 得到稳定的形心,再用该形心采样深度,
// 然后用采样到的 z 计算最终 scale。
const double ds01Base = depthToScale01(e.depth);
const double distScale0 =
e.ignoreDistanceScale ? 1.0 : distanceScaleFromDepth01(ds01Base, e.distanceScaleCalibMult);
const double scale0 = distScale0 * v.userScale;
QVector<QPointF> poly0;
poly0.reserve(e.polygonLocal.size());
for (const auto& lp : e.polygonLocal) {
poly0.push_back(originWorld + lp * scale0);
}
const QPointF c0 = poly0.isEmpty() ? originWorld : entity_cutout::polygonCentroid(poly0);
const int z = (!m_depthImage8.isNull()) ? sampleDepthAtPoint(m_depthImage8, c0) : e.depth;
v.depth = z;
const double ds01 = depthToScale01(z);
v.animatedDepthScale01 = ds01;
const double distScale = e.ignoreDistanceScale ? 1.0 : distanceScaleFromDepth01(ds01, e.distanceScaleCalibMult);
const double scale = distScale * v.userScale;
v.visualScale = scale;
v.polygonWorld.clear();
v.polygonWorld.reserve(e.polygonLocal.size());
for (const auto& lp : e.polygonLocal) {
v.polygonWorld.push_back(originWorld + lp * scale);
}
// 贴图按 origin 缩放
v.imageTopLeft = originWorld + (e.imageTopLeftWorld - e.originWorld) * scale;
v.pathWorld = entity_cutout::pathFromWorldPolygon(v.polygonWorld);
v.rect = v.pathWorld.boundingRect();
v.color = QColor(255, 120, 0, 70);
if (!e.runtimeImagePng.isEmpty()) {
const QString cacheKey = QStringLiteral("rt:%1:%2:%3")
.arg(e.id)
.arg(e.runtimeImagePng.size())
.arg(qHash(e.runtimeImagePng));
auto cit = m_entityRasterImageCache.constFind(cacheKey);
if (cit != m_entityRasterImageCache.cend() && !cit->isNull()) {
v.image = cit.value();
} else {
QImage img;
img.loadFromData(e.runtimeImagePng, "PNG");
if (!img.isNull() && img.format() != QImage::Format_ARGB32_Premultiplied) {
img = img.convertToFormat(QImage::Format_ARGB32_Premultiplied);
}
if (!img.isNull()) {
m_entityRasterImageCache.insert(cacheKey, img);
}
v.image = img;
}
} else {
const QString imgRel = e.imagePath;
if (imgRel.startsWith(QStringLiteral("pngb64:"))) {
const QByteArray b64 = imgRel.mid(QStringLiteral("pngb64:").size()).toLatin1();
const QByteArray raw = QByteArray::fromBase64(b64);
const QString cacheKey =
QStringLiteral("b64:%1:%2").arg(raw.size()).arg(qHash(raw));
auto cit = m_entityRasterImageCache.constFind(cacheKey);
if (cit != m_entityRasterImageCache.cend() && !cit->isNull()) {
v.image = cit.value();
} else {
QImage img;
img.loadFromData(raw, "PNG");
if (!img.isNull() && img.format() != QImage::Format_ARGB32_Premultiplied) {
img = img.convertToFormat(QImage::Format_ARGB32_Premultiplied);
}
if (!img.isNull()) {
m_entityRasterImageCache.insert(cacheKey, img);
}
v.image = img;
}
} else if (!imgRel.isEmpty() && !projectDirAbs.isEmpty()) {
const QString abs = QDir::cleanPath(QDir(projectDirAbs).filePath(imgRel));
if (QFileInfo::exists(abs)) {
auto cit = m_entityRasterImageCache.constFind(abs);
if (cit != m_entityRasterImageCache.cend() && !cit->isNull()) {
v.image = cit.value();
} else {
QImage img(abs);
if (!img.isNull() && img.format() != QImage::Format_ARGB32_Premultiplied) {
img = img.convertToFormat(QImage::Format_ARGB32_Premultiplied);
}
if (!img.isNull()) {
m_entityRasterImageCache.insert(abs, img);
}
v.image = img;
}
}
}
}
m_entities.push_back(v);
}
constexpr int kMaxEntityRasterCacheEntries = 768;
if (m_entityRasterImageCache.size() > kMaxEntityRasterCacheEntries) {
m_entityRasterImageCache.clear();
}
if (!m_blackholeOverlayImageCache.isEmpty()) {
QSet<QString> aliveIds;
aliveIds.reserve(m_entities.size());
for (const auto& ent : m_entities) {
aliveIds.insert(ent.id);
}
for (auto it = m_blackholeOverlayImageCache.begin(); it != m_blackholeOverlayImageCache.end();) {
if (!aliveIds.contains(it.key())) {
it = m_blackholeOverlayImageCache.erase(it);
} else {
++it;
}
}
}
// 绘制/命中顺序:
// - priority 小(低)先画,大(高)后画,高优先级盖住低优先级
// - priority 相同:深度小(远)先画,大(近)后画,近处盖住远处(等价于按距离缩放决定上下层)
std::stable_sort(m_entities.begin(), m_entities.end(),
[](const Entity& a, const Entity& b) {
if (a.priority != b.priority) {
return a.priority < b.priority;
}
if (a.depth != b.depth) {
return a.depth < b.depth;
}
return a.id < b.id;
});
m_selectedEntity = -1;
if (!prevSelectedId.isEmpty()) {
for (int i = 0; i < m_entities.size(); ++i) {
if (m_entities[i].id == prevSelectedId) {
m_selectedEntity = i;
break;
}
}
}
if (!m_suppressSelectionSignals) {
if (m_selectedEntity >= 0) {
const auto& ent = m_entities[m_selectedEntity];
const QPointF origin =
ent.polygonWorld.isEmpty() ? ent.rect.center() : entity_cutout::polygonCentroid(ent.polygonWorld);
emit selectedEntityChanged(true, ent.id, ent.depth, origin);
} else if (!prevSelectedId.isEmpty()) {
emit selectedEntityChanged(false, QString(), 0, QPointF());
}
}
if (!m_selectedBlackholeEntityId.isEmpty()) {
bool exists = false;
for (const auto& ent : m_entities) {
if (ent.id == m_selectedBlackholeEntityId && !ent.cutoutPolygonWorld.isEmpty()) {
exists = true;
break;
}
}
if (!exists) {
m_selectedBlackholeEntityId.clear();
}
}
if (m_blackholeCopyResolveActive) {
bool exists = false;
for (const auto& ent : m_entities) {
if (ent.id == m_blackholeCopyEntityId && !ent.cutoutPolygonWorld.isEmpty()) {
exists = true;
break;
}
}
if (!exists) {
cancelBlackholeCopyResolve();
}
}
m_bgCutoutDirty = true;
update();
}
void EditorCanvas::setTools(const QVector<core::Project::Tool>& tools, const QVector<double>& opacities01) {
m_tools.clear();
// 需要用深度图为工具采样一个“近远”用于同优先级排序(与实体一致)
if (!m_depthAbsPath.isEmpty()) {
if (m_depthDirty) {
m_depthDirty = false;
m_depthImage8 = core::image_file::loadDepthMapAlignedToBackground(m_depthAbsPath, m_bgAbsPath);
}
}
const qsizetype n = tools.size();
m_tools.reserve(n);
for (qsizetype i = 0; i < n; ++i) {
ToolView tv;
tv.tool = tools[i];
tv.opacity = (i < opacities01.size()) ? std::clamp(opacities01[i], 0.0, 1.0) : 1.0;
tv.depthZ = (!m_depthImage8.isNull()) ? sampleDepthAtPoint(m_depthImage8, tv.tool.originWorld) : 0;
m_tools.push_back(tv);
}
// 轨道变更:若当前选中的工具已不存在,则清除
if (m_selectedTool >= 0) {
const QString selId = (m_selectedTool >= 0 && m_selectedTool < m_tools.size()) ? m_tools[m_selectedTool].tool.id : QString();
if (!selId.isEmpty()) {
int hit = -1;
for (int i = 0; i < m_tools.size(); ++i) {
if (m_tools[i].tool.id == selId) {
hit = i;
break;
}
}
m_selectedTool = hit;
} else {
m_selectedTool = -1;
}
}
update();
}
void EditorCanvas::setCameraOverlays(const QVector<core::Project::Camera>& cameras,
const QString& selectedId,
const QSet<QString>& tempHiddenCameraIds) {
const bool hadSelectedCamera = !m_selectedCameraId.isEmpty() || m_selectedCameraIndex >= 0 || m_draggingCamera;
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;
if (hadSelectedCamera) {
emit selectedCameraChanged(false, QString(), QPointF(), 1.0);
}
}
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<QString>& entityIds, const QSet<QString>& toolIds) {
m_tempHiddenEntityIds = entityIds;
m_tempHiddenToolIds = toolIds;
update();
}
void EditorCanvas::setTempHiddenCameraIds(const QSet<QString>& cameraIds) {
m_tempHiddenCameraIds = cameraIds;
update();
}
void EditorCanvas::setCurrentFrame(int frame) {
if (m_currentFrame == frame) {
return;
}
m_currentFrame = std::max(0, frame);
// 仅切帧时,实体由 MainWindow 刷新时回灌;这里也触发重绘用于坐标轴/叠加
update();
}
QPointF EditorCanvas::selectedAnimatedOriginWorld() const {
return selectedEntityPivotWorld();
}
QPointF EditorCanvas::selectedEntityPivotWorld() const {
if (m_selectedEntity < 0 || m_selectedEntity >= m_entities.size()) {
return {};
}
return m_entities[m_selectedEntity].animatedOriginWorld;
}
double EditorCanvas::selectedDepthScale01() const {
if (m_selectedEntity < 0 || m_selectedEntity >= m_entities.size()) {
return 0.5;
}
return m_entities[m_selectedEntity].animatedDepthScale01;
}
QPointF EditorCanvas::selectedEntityCentroidWorld() const {
if (m_selectedEntity < 0 || m_selectedEntity >= m_entities.size()) {
return {};
}
// 拖动预览:polygonWorld 不再逐点更新,质心应使用预览值,否则属性面板看起来“不跟随”
if (!m_presentationPreviewMode && m_draggingEntity && m_dragPreviewActive && m_selectedEntity >= 0) {
return m_dragCentroidBase + m_dragDelta;
}
const auto& ent = m_entities[m_selectedEntity];
if (!ent.polygonWorld.isEmpty()) {
return entity_cutout::polygonCentroid(ent.polygonWorld);
}
return ent.rect.center();
}
double EditorCanvas::selectedDistanceScaleMultiplier() const {
if (m_selectedEntity < 0 || m_selectedEntity >= m_entities.size()) {
return 1.0;
}
const auto& ent = m_entities[m_selectedEntity];
return ent.ignoreDistanceScale ? 1.0 : distanceScaleFromDepth01(ent.animatedDepthScale01, ent.distanceScaleCalibMult);
}
double EditorCanvas::selectedUserScale() const {
if (m_selectedEntity < 0 || m_selectedEntity >= m_entities.size()) {
return 1.0;
}
return m_entities[m_selectedEntity].userScale;
}
double EditorCanvas::selectedCombinedScale() const {
return selectedDistanceScaleMultiplier() * selectedUserScale();
}
void EditorCanvas::tickPresentationHoverAnimation() {
if (!m_presentationPreviewMode) {
return;
}
m_presHoverPhase += 0.35;
if (m_presHoverPhase > 6.28318530718) {
m_presHoverPhase -= 6.28318530718;
}
update();
}
void EditorCanvas::tickPresentationZoomAnimation() {
m_presZoomAnimT += 0.16;
qreal u = std::min(1.0, static_cast<qreal>(m_presZoomAnimT));
u = 1.0 - std::pow(1.0 - u, 3.0);
m_pan = m_presZoomFromPan + (m_presZoomToPan - m_presZoomFromPan) * u;
m_scale = m_presZoomFromScale + (m_presZoomToScale - m_presZoomFromScale) * u;
if (m_presZoomAnimT >= 1.0) {
m_presZoomTimer->stop();
m_pan = m_presZoomToPan;
m_scale = m_presZoomToScale;
if (m_presZoomFinishingRestore) {
m_presFocusedEntityIndex = -1;
m_presZoomFinishingRestore = false;
}
}
update();
}
void EditorCanvas::presentationComputeZoomTarget(int entityIndex, QPointF* outPan, qreal* outScale) const {
if (!outPan || !outScale || entityIndex < 0 || entityIndex >= m_entities.size()) {
return;
}
const Entity& ent = m_entities[entityIndex];
QRectF bb;
if (!ent.image.isNull()) {
const QSizeF sz(ent.image.width() * ent.visualScale, ent.image.height() * ent.visualScale);
bb = QRectF(ent.imageTopLeft, sz);
} else if (!ent.polygonWorld.isEmpty()) {
bb = entity_cutout::pathFromWorldPolygon(ent.polygonWorld).boundingRect();
} else {
bb = ent.rect;
}
const QPointF c = bb.center();
const qreal rw = std::max(1.0, bb.width());
const qreal rh = std::max(1.0, bb.height());
qreal s = std::min(static_cast<qreal>(width()) / (rw * 1.28), static_cast<qreal>(height()) / (rh * 1.28));
s = std::clamp(s, 0.12, 14.0);
*outScale = s;
*outPan = QPointF(width() / 2.0, height() / 2.0) - c * s;
}
void EditorCanvas::beginPresentationZoomTowardEntity(int entityIndex) {
if (entityIndex < 0 || entityIndex >= m_entities.size()) {
return;
}
if (m_presFocusedEntityIndex < 0) {
m_presRestorePan = m_pan;
m_presRestoreScale = m_scale;
}
m_presFocusedEntityIndex = entityIndex;
m_presZoomFromPan = m_pan;
m_presZoomFromScale = m_scale;
presentationComputeZoomTarget(entityIndex, &m_presZoomToPan, &m_presZoomToScale);
m_presZoomAnimT = 0.0;
m_presZoomFinishingRestore = false;
m_presZoomTimer->start();
}
void EditorCanvas::beginPresentationZoomRestore() {
m_presZoomFromPan = m_pan;
m_presZoomFromScale = m_scale;
m_presZoomToPan = m_presRestorePan;
m_presZoomToScale = m_presRestoreScale;
m_presZoomAnimT = 0.0;
m_presZoomFinishingRestore = true;
m_presZoomTimer->start();
}
void EditorCanvas::clearPresentationEntityFocus() {
emit presentationInteractionDismissed();
if (m_presZoomFinishingRestore) {
return;
}
if (m_presFocusedEntityIndex >= 0) {
beginPresentationZoomRestore();
}
}
void EditorCanvas::clearEntitySelection() {
if (m_selectedEntity < 0) {
return;
}
m_selectedEntity = -1;
emit selectedEntityChanged(false, QString(), 0, QPointF());
update();
}
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;
}
if (m_selectedEntity == i) {
update();
return;
}
m_selectedEntity = i;
const auto& ent = m_entities[i];
const QPointF origin =
ent.polygonWorld.isEmpty() ? ent.rect.center() : entity_cutout::polygonCentroid(ent.polygonWorld);
emit selectedEntityChanged(true, ent.id, ent.depth, origin);
update();
return;
}
clearEntitySelection();
}
void EditorCanvas::selectBlackholeByEntityId(const QString& entityId) {
if (entityId.isEmpty()) {
clearBlackholeSelection();
return;
}
for (const auto& ent : m_entities) {
if (ent.id == entityId && !ent.cutoutPolygonWorld.isEmpty()) {
if (m_selectedBlackholeEntityId == entityId) {
update();
return;
}
m_selectedBlackholeEntityId = entityId;
update();
return;
}
}
clearBlackholeSelection();
}
void EditorCanvas::clearBlackholeSelection() {
if (m_blackholeCopyResolveActive) {
cancelBlackholeCopyResolve();
}
if (m_selectedBlackholeEntityId.isEmpty()) {
return;
}
m_selectedBlackholeEntityId.clear();
update();
}
bool EditorCanvas::startBlackholeCopyResolve(const QString& entityId) {
if (entityId.isEmpty()) {
return false;
}
const Entity* hit = nullptr;
for (const auto& ent : m_entities) {
if (ent.id == entityId && !ent.cutoutPolygonWorld.isEmpty()) {
hit = &ent;
break;
}
}
if (!hit) {
return false;
}
ensurePixmapLoaded();
const QRectF bg = worldRectOfBackground();
if (bg.isNull()) {
return false;
}
QRectF holeRect = entity_cutout::pathFromWorldPolygon(hit->cutoutPolygonWorld).boundingRect();
if (holeRect.isNull()) {
return false;
}
if (holeRect.width() < 1.0 || holeRect.height() < 1.0) {
return false;
}
holeRect = holeRect.intersected(bg);
if (holeRect.isNull()) {
return false;
}
QRectF srcRect(holeRect);
const qreal shift = std::max<qreal>(24.0, holeRect.width() * 0.6);
srcRect.translate(shift, 0.0);
srcRect = clampRectTopLeftToBounds(srcRect, bg);
if (!m_bhCopyPatchValid && m_bgImageDirty) {
m_bgImageDirty = false;
m_bgImage = readImageTolerant(m_bgAbsPath);
if (m_bgImage.format() != QImage::Format_ARGB32_Premultiplied && !m_bgImage.isNull()) {
m_bgImage = m_bgImage.convertToFormat(QImage::Format_ARGB32_Premultiplied);
}
}
m_selectedBlackholeEntityId = entityId;
m_blackholeCopyResolveActive = true;
m_blackholeCopyEntityId = entityId;
m_blackholeCopyHoleRect = holeRect;
m_blackholeCopySourceRect = srcRect;
m_blackholeCopyDragging = false;
m_blackholeCopyDragOffset = QPointF();
reloadBlackholeCopyVipsPatchUnionIfNeeded();
updateCursor();
update();
return true;
}
void EditorCanvas::reloadBlackholeCopyVipsPatchUnionIfNeeded() {
if (!m_blackholeCopyResolveActive || !m_useViewportLod || m_bgAbsPath.isEmpty() || !m_bgLogicalSize.isValid() ||
m_bgLogicalSize.width() < 1 || m_bgLogicalSize.height() < 1) {
m_bhCopyPatchValid = false;
m_bhCopyPatch = QImage();
m_bhCopyPatchLogicalSize = QSize();
return;
}
const QRect bgBounds(0, 0, m_bgLogicalSize.width(), m_bgLogicalSize.height());
const QRect uni = m_blackholeCopyHoleRect.united(m_blackholeCopySourceRect)
.toAlignedRect()
.intersected(bgBounds);
if (!uni.isValid() || uni.width() < 1 || uni.height() < 1) {
m_bhCopyPatchValid = false;
m_bhCopyPatch = QImage();
m_bhCopyPatchLogicalSize = QSize();
return;
}
if (m_bhCopyPatchValid && m_bhCopyPatchLogicalSize.isValid() && !m_bhCopyPatch.isNull()) {
const QRect cached(m_bhCopyPatchOrigin, m_bhCopyPatchLogicalSize);
if (cached.contains(uni)) {
return;
}
}
QImage patch;
if (!core::image_file::loadRegionToQImageExact(m_bgAbsPath, uni, &patch) || patch.isNull()) {
m_bhCopyPatchValid = false;
m_bhCopyPatch = QImage();
m_bhCopyPatchLogicalSize = QSize();
return;
}
if (patch.format() != QImage::Format_ARGB32_Premultiplied) {
patch = patch.convertToFormat(QImage::Format_ARGB32_Premultiplied);
}
m_bhCopyPatch = std::move(patch);
m_bhCopyPatchOrigin = uni.topLeft();
m_bhCopyPatchLogicalSize = uni.size();
m_bhCopyPatchValid = true;
}
void EditorCanvas::cancelBlackholeCopyResolve() {
if (!m_blackholeCopyResolveActive) {
return;
}
m_bhCopyPatchValid = false;
m_bhCopyPatch = QImage();
m_bhCopyPatchLogicalSize = QSize();
m_blackholeCopyResolveActive = false;
m_blackholeCopyEntityId.clear();
m_blackholeCopyHoleRect = QRectF();
m_blackholeCopySourceRect = QRectF();
m_blackholeCopyDragging = false;
m_blackholeCopyDragOffset = QPointF();
updateCursor();
update();
}
void EditorCanvas::notifyBackgroundContentChanged() {
// 背景路径不变时,setBackgroundImagePath 不会触发刷新;这里显式让 pixmap/image 缓存失效并重载。
invalidatePixmap();
m_bgImageDirty = true;
m_bgCutoutDirty = true;
update();
}
void EditorCanvas::notifyBlackholeOverlaysChanged() {
m_blackholeOverlayImageCache.clear();
update();
}
void EditorCanvas::setBackgroundImagePath(const QString& absolutePath) {
if (m_bgAbsPath == absolutePath) {
return;
}
cancelBlackholeCopyResolve();
m_bgAbsPath = absolutePath;
m_initialBgLoadFinishedEmitted = false;
invalidatePixmap();
m_bgImageDirty = true;
m_bgCutoutDirty = true;
m_bgImage = QImage();
m_bgImageCutout = QImage();
zoomToFit();
update();
tryEmitInitialBackgroundLoadFinished();
}
void EditorCanvas::tryEmitInitialBackgroundLoadFinished() {
if (m_initialBgLoadFinishedEmitted) {
return;
}
if (m_bgAbsPath.isEmpty()) {
m_initialBgLoadFinishedEmitted = true;
emit initialBackgroundLoadFinished();
return;
}
ensurePixmapLoaded();
if (m_useViewportLod) {
if (!m_bgViewportImage.isNull()) {
m_initialBgLoadFinishedEmitted = true;
emit initialBackgroundLoadFinished();
}
return;
}
m_initialBgLoadFinishedEmitted = true;
emit initialBackgroundLoadFinished();
}
void EditorCanvas::setBackgroundVisible(bool on) {
if (m_backgroundVisible == on) {
return;
}
m_backgroundVisible = on;
update();
}
void EditorCanvas::setDepthMapPath(const QString& absolutePath) {
if (m_depthAbsPath == absolutePath) {
return;
}
m_depthAbsPath = absolutePath;
m_depthDirty = true;
m_depthImage8 = QImage();
update();
}
void EditorCanvas::setDepthOverlayEnabled(bool on) {
if (m_depthOverlayEnabled == on) {
return;
}
m_depthOverlayEnabled = on;
update();
}
void EditorCanvas::setTool(Tool tool) {
if (m_tool == tool) {
return;
}
m_tool = tool;
m_draggingEntity = false;
m_drawingEntity = false;
m_strokeWorld.clear();
updateCursor();
update();
}
void EditorCanvas::setEntityCreateSegmentMode(EntityCreateSegmentMode m) {
if (m_entityCreateSegmentMode == m) {
return;
}
m_entityCreateSegmentMode = m;
update();
}
void EditorCanvas::setPendingEntityPolygonWorld(const QVector<QPointF>& polyWorld) {
m_pendingPolyWorld = polyWorld;
m_pendingDragging = false;
m_pendingDragWhole = false;
m_pendingDragVertex = -1;
update();
}
void EditorCanvas::clearPendingEntityPolygon() {
m_pendingPolyWorld.clear();
m_pendingDragging = false;
m_pendingDragWhole = false;
m_pendingDragVertex = -1;
update();
}
bool EditorCanvas::isPointNearPendingVertex(const QPointF& worldPos, int* outIndex) const {
if (outIndex) *outIndex = -1;
if (m_pendingPolyWorld.size() < 3) return false;
const qreal rView = 10.0;
const qreal rWorld = rView / std::max<qreal>(m_scale, 0.001);
const qreal r2 = rWorld * rWorld;
int best = -1;
qreal bestD2 = r2;
for (int i = 0; i < m_pendingPolyWorld.size(); ++i) {
const QPointF d = m_pendingPolyWorld[i] - worldPos;
const qreal d2 = d.x() * d.x() + d.y() * d.y();
if (d2 <= bestD2) {
bestD2 = d2;
best = i;
}
}
if (best >= 0) {
if (outIndex) *outIndex = best;
return true;
}
return false;
}
bool EditorCanvas::pendingPolygonContains(const QPointF& worldPos) const {
if (m_pendingPolyWorld.size() < 3) return false;
return entity_cutout::pathFromWorldPolygon(m_pendingPolyWorld).contains(worldPos);
}
void EditorCanvas::resetView() {
m_scale = 1.0;
m_pan = QPointF(0, 0);
invalidateViewportLod();
update();
}
void EditorCanvas::zoomToFit() {
ensurePixmapLoaded();
if ((!m_bgLogicalSize.isValid() && m_bgPixmap.isNull()) || width() <= 1 || height() <= 1) {
resetView();
return;
}
const QSizeF viewSize = size();
const QSizeF imgSize =
m_bgLogicalSize.isValid() ? QSizeF(m_bgLogicalSize) : QSizeF(m_bgPixmap.size());
const qreal sx = (viewSize.width() - 24.0) / imgSize.width();
const qreal sy = (viewSize.height() - 24.0) / imgSize.height();
const qreal s = std::max<qreal>(kViewScaleMin, std::min(sx, sy));
m_scale = s;
// 让 world(0,0) 的图像左上角居中显示
const QSizeF draw(imgSize.width() * s, imgSize.height() * s);
m_pan = QPointF((viewSize.width() - draw.width()) / 2.0, (viewSize.height() - draw.height()) / 2.0);
invalidateViewportLod();
update();
}
void EditorCanvas::setWorldAxesVisible(bool on) {
if (m_worldAxesVisible == on) {
return;
}
m_worldAxesVisible = on;
update();
}
void EditorCanvas::setAxisLabelsVisible(bool on) {
if (m_axisLabelsVisible == on) {
return;
}
m_axisLabelsVisible = on;
update();
}
void EditorCanvas::setGizmoLabelsVisible(bool on) {
if (m_gizmoLabelsVisible == on) {
return;
}
m_gizmoLabelsVisible = on;
update();
}
void EditorCanvas::setGridVisible(bool on) {
if (m_gridVisible == on) {
return;
}
m_gridVisible = on;
update();
}
void EditorCanvas::setCheckerboardVisible(bool on) {
if (m_checkerboardVisible == on) {
return;
}
m_checkerboardVisible = on;
update();
}
void EditorCanvas::invalidatePixmap() {
m_pixmapDirty = true;
m_bgPixmap = QPixmap();
m_bgLogicalSize = QSize();
m_useViewportLod = false;
m_bgViewportImage = QImage();
m_bgViewportCacheRect = QRect();
m_bgViewportDirty = true;
m_vpCachedDensity = 0.0;
m_vpPreferRegionDecode = false;
++m_vpToken;
}
void EditorCanvas::invalidateViewportLod() {
m_bgViewportDirty = true;
++m_vpToken;
}
void EditorCanvas::syncFullBackgroundImageFromDiskIfDirty() {
if (m_bgAbsPath.isEmpty() || m_useViewportLod) {
return;
}
if (!m_bgImageDirty) {
return;
}
m_bgImageDirty = false;
m_bgImage = readImageTolerant(m_bgAbsPath);
if (!m_bgImage.isNull() && m_bgImage.format() != QImage::Format_ARGB32_Premultiplied) {
m_bgImage = m_bgImage.convertToFormat(QImage::Format_ARGB32_Premultiplied);
}
m_bgCutoutDirty = true;
}
void EditorCanvas::rebuildFullBackgroundCutoutIfDirty() {
if (m_useViewportLod || m_bgAbsPath.isEmpty()) {
return;
}
if (!m_bgCutoutDirty) {
return;
}
if (m_bgImage.isNull()) {
syncFullBackgroundImageFromDiskIfDirty();
}
if (m_bgImage.isNull()) {
return;
}
m_bgCutoutDirty = false;
m_bgImageCutout = m_bgImage;
for (const auto& ent : m_entities) {
if (ent.blackholeVisible && !ent.cutoutPolygonWorld.isEmpty()) {
entity_cutout::applyBlackFillToBackground(m_bgImageCutout, ent.cutoutPolygonWorld);
}
}
}
QVector<QVector<QPointF>> EditorCanvas::visibleBlackholeCutoutPolysWorld() const {
QVector<QVector<QPointF>> out;
for (const auto& ent : m_entities) {
if (ent.blackholeVisible && ent.cutoutPolygonWorld.size() >= 3) {
out.push_back(ent.cutoutPolygonWorld);
}
}
return out;
}
void EditorCanvas::applyBlackHolesWorldToCrop(QImage& cropPremul, const QRect& cropWorld) const {
entity_cutout::applyBlackHolesToImageRegion(cropPremul, cropWorld, visibleBlackholeCutoutPolysWorld());
}
bool EditorCanvas::loadRgbCropForEntitySegmentStroke(const QRectF& polyBrWorld, QRect& outCropWorld,
QImage& outCropRgb) {
outCropRgb = QImage();
if (m_bgAbsPath.isEmpty()) {
return false;
}
ensurePixmapLoaded();
QRect cropWorld = entity_cutout::clampRectToImage(
polyBrWorld.adjusted(-kSamCropMargin, -kSamCropMargin, kSamCropMargin, kSamCropMargin).toAlignedRect(),
QSize(backgroundLogicalWidth(), backgroundLogicalHeight()));
if (cropWorld.isEmpty()) {
return false;
}
outCropWorld = cropWorld;
if (!m_useViewportLod) {
syncFullBackgroundImageFromDiskIfDirty();
rebuildFullBackgroundCutoutIfDirty();
if (!m_bgImageCutout.isNull()) {
outCropRgb = m_bgImageCutout.copy(cropWorld);
}
}
if (outCropRgb.isNull()) {
(void)core::image_file::loadRegionToQImageExact(m_bgAbsPath, cropWorld, &outCropRgb);
if (!outCropRgb.isNull() && outCropRgb.format() != QImage::Format_ARGB32_Premultiplied) {
outCropRgb = outCropRgb.convertToFormat(QImage::Format_ARGB32_Premultiplied);
}
}
if (outCropRgb.isNull() && !m_bgImage.isNull()) {
const QRect cw = entity_cutout::clampRectToImage(
polyBrWorld.adjusted(-kSamCropMargin, -kSamCropMargin, kSamCropMargin, kSamCropMargin).toAlignedRect(),
m_bgImage.size());
if (!cw.isEmpty()) {
outCropRgb = m_bgImage.copy(cw);
outCropWorld = cw;
}
}
if (outCropRgb.isNull()) {
syncFullBackgroundImageFromDiskIfDirty();
rebuildFullBackgroundCutoutIfDirty();
if (!m_bgImage.isNull()) {
const QRect cw2 = entity_cutout::clampRectToImage(
polyBrWorld.adjusted(-kSamCropMargin, -kSamCropMargin, kSamCropMargin, kSamCropMargin).toAlignedRect(),
m_bgImage.size());
if (!cw2.isEmpty()) {
outCropRgb = m_bgImage.copy(cw2);
outCropWorld = cw2;
}
}
}
if (outCropRgb.isNull()) {
return false;
}
if (outCropRgb.format() != QImage::Format_ARGB32_Premultiplied) {
outCropRgb = outCropRgb.convertToFormat(QImage::Format_ARGB32_Premultiplied);
}
applyBlackHolesWorldToCrop(outCropRgb, outCropWorld);
return true;
}
void EditorCanvas::ensurePixmapLoaded() const {
if (!m_pixmapDirty) {
return;
}
m_pixmapDirty = false;
m_bgPixmap = QPixmap();
m_bgLogicalSize = QSize();
m_useViewportLod = false;
if (m_bgAbsPath.isEmpty()) {
return;
}
if (core::VipsBackend::isAvailable() &&
core::image_file::probeImagePixelSize(m_bgAbsPath, &m_bgLogicalSize) && m_bgLogicalSize.isValid()) {
m_useViewportLod = true;
m_bgViewportDirty = true;
m_bgImageDirty = true;
m_bgCutoutDirty = true;
return;
}
const QImage img = readImageTolerant(m_bgAbsPath);
if (!img.isNull()) {
m_bgPixmap = QPixmap::fromImage(img);
m_bgLogicalSize = img.size();
}
m_bgImageDirty = true;
m_bgCutoutDirty = true;
}
void EditorCanvas::ensureBackgroundViewport() {
if (!m_useViewportLod || m_bgAbsPath.isEmpty() || !m_bgLogicalSize.isValid()) {
return;
}
const QRectF bgRect(0, 0, m_bgLogicalSize.width(), m_bgLogicalSize.height());
QRectF vis = visibleWorldRectF().intersected(bgRect);
if (vis.isEmpty()) {
m_bgViewportImage = QImage();
m_bgViewportCacheRect = QRect();
m_bgViewportDirty = false;
m_vpDecodeInFlight = false;
return;
}
const QRect visI = vis.toAlignedRect();
const qreal pr = devicePixelRatio();
const qreal density = static_cast<qreal>(m_scale) * pr;
const bool previewCameraHighQ = m_presentationPreviewMode && m_previewCameraViewLocked;
const qreal decodeBoost = previewCameraHighQ ? 1.35 : 1.0;
// 视口背景在屏幕上通常会经历一次缩放(世界坐标 -> 视图像素)。
// 为了减少“发糊”,静止时做适度超采样:用更高分辨率解码,再由绘制阶段缩小。
// 拖拽交互时降低超采样,优先帧率。
const qreal superSample = m_draggingEntity ? 1.15 : (previewCameraHighQ ? 1.75 : 1.55);
const qreal densityTol = previewCameraHighQ ? 0.045 : 0.09;
// 缓存仍覆盖视口且清晰度档位未变:直接复用(避免 invalidateViewportLod 仅置 dirty 又触发二次解码失败把画面清空)
// m_vpPreferRegionDecode 时不短路:必须在缩略图显示后立刻做一次区域细化
if (!m_vpPreferRegionDecode && !m_bgViewportImage.isNull() && m_bgViewportCacheRect.contains(visI) &&
m_vpCachedDensity > 1e-9 &&
std::abs(density - m_vpCachedDensity) / m_vpCachedDensity < densityTol) {
m_bgViewportDirty = false;
return;
}
if (m_vpDecodeInFlight.exchange(true)) {
return;
}
const QString path = m_bgAbsPath;
const QSize logicalSize = m_bgLogicalSize;
const uint32_t myToken = m_vpToken.load(std::memory_order_relaxed);
const qint64 imgArea = qint64(logicalSize.width()) * qint64(logicalSize.height());
const qint64 visArea = qint64(visI.width()) * qint64(visI.height());
// 预览且镜头锁定时禁用全图缩略路径,避免画面明显发糊
const bool overviewMode =
!m_vpPreferRegionDecode && !previewCameraHighQ && imgArea > 0 &&
(static_cast<double>(visArea) / static_cast<double>(imgArea) >= 0.68) &&
core::VipsBackend::isAvailable();
// 扩大解码缓存:平移时尽量仍在同一张纹理内,避免每帧触发新的后台解码导致卡顿
const qreal screenWorldW = (static_cast<qreal>(width()) * pr) / std::max(m_scale, static_cast<qreal>(1e-9));
const qreal screenWorldH = (static_cast<qreal>(height()) * pr) / std::max(m_scale, static_cast<qreal>(1e-9));
const qreal padX = std::max(vis.width() * 0.95, screenWorldW * 0.75);
const qreal padY = std::max(vis.height() * 0.95, screenWorldH * 0.75);
QRect req =
vis.adjusted(-padX, -padY, padX, padY).toAlignedRect().intersected(bgRect.toAlignedRect());
if (req.width() <= 0 || req.height() <= 0) {
m_vpDecodeInFlight = false;
if (m_bgViewportImage.isNull()) {
m_bgViewportCacheRect = QRect();
}
m_bgViewportDirty = false;
return;
}
// 关键:解码目标分辨率必须以“req 区域在当前缩放下投影到屏幕的像素数”为基准。
// 否则后台解码完成后替换纹理时,画面会因为额外的 drawImage 缩放而突然变糊。
const int maxW = std::clamp(static_cast<int>(std::ceil(req.width() * density * superSample * decodeBoost)), 256, 8192);
const int maxH = std::clamp(static_cast<int>(std::ceil(req.height() * density * superSample * decodeBoost)), 256, 8192);
const qint64 budget =
qint64(maxW) * qint64(maxH) * (overviewMode ? 2 : 1); // 缩略图阶段提高像素预算,减少首帧模糊感
(void)QtConcurrent::run([this, path, logicalSize, req, maxW, maxH, budget, density, overviewMode,
myToken]() {
QImage img;
bool ok = false;
if (overviewMode) {
ok = core::VipsBackend::loadThumbnailQImage(path, budget, &img) && !img.isNull();
}
if (!ok) {
ok = core::image_file::loadRegionToQImage(path, req, maxW, maxH, &img) && !img.isNull();
}
QPointer<EditorCanvas> self(this);
QTimer::singleShot(
0,
this,
[self, myToken, path, img = std::move(img), ok, logicalSize, req, density, overviewMode]() mutable {
if (!self) {
return;
}
self->m_vpDecodeInFlight = false;
if (myToken != self->m_vpToken.load(std::memory_order_relaxed)) {
self->update();
return;
}
if (path != self->m_bgAbsPath) {
self->update();
return;
}
if (!ok || img.isNull()) {
// 保留已成功显示过的纹理,避免后续一次失败把整屏打成「加载失败」
if (self->m_bgViewportImage.isNull()) {
self->m_bgViewportCacheRect = QRect();
}
self->m_vpPreferRegionDecode = false;
self->m_bgViewportDirty = false;
self->update();
self->tryEmitInitialBackgroundLoadFinished();
return;
}
if (img.format() != QImage::Format_ARGB32_Premultiplied) {
img = img.convertToFormat(QImage::Format_ARGB32_Premultiplied);
}
self->m_bgViewportImage = std::move(img);
self->m_bgViewportCacheRect =
overviewMode ? QRect(QPoint(0, 0), logicalSize) : req;
self->m_bgViewportDirty = false;
self->m_vpCachedDensity = density;
if (overviewMode) {
self->m_vpPreferRegionDecode = true;
} else {
self->m_vpPreferRegionDecode = false;
}
self->update();
self->tryEmitInitialBackgroundLoadFinished();
});
});
}
QRectF EditorCanvas::visibleWorldRectF() const {
const QPointF c0 = viewToWorld(QPointF(0, 0));
const QPointF c1 = viewToWorld(QPointF(width(), 0));
const QPointF c2 = viewToWorld(QPointF(width(), height()));
const QPointF c3 = viewToWorld(QPointF(0, height()));
const qreal minx = std::min({c0.x(), c1.x(), c2.x(), c3.x()});
const qreal maxx = std::max({c0.x(), c1.x(), c2.x(), c3.x()});
const qreal miny = std::min({c0.y(), c1.y(), c2.y(), c3.y()});
const qreal maxy = std::max({c0.y(), c1.y(), c2.y(), c3.y()});
QRectF rf(minx, miny, maxx - minx, maxy - miny);
return rf.normalized();
}
int EditorCanvas::backgroundLogicalWidth() const {
ensurePixmapLoaded();
if (m_bgLogicalSize.isValid()) {
return m_bgLogicalSize.width();
}
return m_bgPixmap.isNull() ? 0 : m_bgPixmap.width();
}
int EditorCanvas::backgroundLogicalHeight() const {
ensurePixmapLoaded();
if (m_bgLogicalSize.isValid()) {
return m_bgLogicalSize.height();
}
return m_bgPixmap.isNull() ? 0 : m_bgPixmap.height();
}
void EditorCanvas::updateCursor() {
if (m_blackholeCopyResolveActive) {
setCursor(m_blackholeCopyDragging ? Qt::ClosedHandCursor : Qt::OpenHandCursor);
return;
}
if (m_presentationPreviewMode) {
if (!m_presentationHotspotPlaybackTargetEnabled) {
setCursor(Qt::ArrowCursor);
return;
}
if (m_presHoverEntityIndex >= 0) {
setCursor(Qt::PointingHandCursor);
} else {
setCursor(Qt::OpenHandCursor);
}
return;
}
switch (m_tool) {
case Tool::Move:
setCursor(Qt::OpenHandCursor);
break;
case Tool::Zoom:
setCursor(Qt::CrossCursor);
break;
case Tool::CreateEntity:
setCursor(Qt::CrossCursor);
break;
case Tool::AddHotspot:
setCursor(Qt::CrossCursor);
break;
case Tool::MoveHotspot:
setCursor(Qt::OpenHandCursor);
break;
}
}
QPointF EditorCanvas::viewToWorld(const QPointF& v) const {
if (m_scale <= 0.0) {
return {};
}
return (v - m_pan) / m_scale;
}
QPointF EditorCanvas::worldToView(const QPointF& w) const {
return w * m_scale + m_pan;
}
QRectF EditorCanvas::worldRectOfBackground() const {
ensurePixmapLoaded();
if (m_bgLogicalSize.isValid()) {
return QRectF(0, 0, m_bgLogicalSize.width(), m_bgLogicalSize.height());
}
if (m_bgPixmap.isNull()) {
return {};
}
return QRectF(0, 0, m_bgPixmap.width(), m_bgPixmap.height());
}
int EditorCanvas::hitTestEntity(const QPointF& worldPos) const {
for (qsizetype i = m_entities.size(); i > 0; --i) {
const qsizetype idx = i - 1;
const auto& ent = m_entities[idx];
if (ent.opacity <= 0.001) {
continue;
}
if (!ent.id.isEmpty() && m_tempHiddenEntityIds.contains(ent.id)) {
continue;
}
if (!ent.polygonWorld.isEmpty()) {
const QPainterPath path = entity_cutout::pathFromWorldPolygon(ent.polygonWorld);
if (path.contains(worldPos)) {
return static_cast<int>(idx);
}
continue;
}
if (ent.rect.contains(worldPos)) {
return static_cast<int>(idx);
}
}
return -1;
}
int EditorCanvas::hitTestPresentationHotspot(const QPointF& worldPos) const {
const QPointF pv = worldToView(worldPos);
for (int i = static_cast<int>(m_presentationHotspots.size()) - 1; i >= 0; --i) {
const QPointF cv = worldToView(m_presentationHotspots[i].centerWorld);
if (QLineF(pv, cv).length() <= 22.0) {
return i;
}
}
return -1;
}
int EditorCanvas::hitTestHotspotPlayControl(const QPointF& worldPos) const {
if (!m_presentationHotspotPlaybackTargetEnabled) {
return -1;
}
const QPointF pv = worldToView(worldPos);
for (int i = static_cast<int>(m_presentationHotspots.size()) - 1; i >= 0; --i) {
const QPointF cv = worldToView(m_presentationHotspots[i].centerWorld);
const QPointF playCenter = cv + QPointF(14.0, 0.0);
if (QLineF(pv, playCenter).length() <= 17.0) {
return i;
}
}
return -1;
}
void EditorCanvas::paintEvent(QPaintEvent* e) {
Q_UNUSED(e);
QPainter p(this);
p.setRenderHint(QPainter::Antialiasing, false);
// 拖动时优先保证交互帧率:缩放贴图使用 nearest 以减少开销
p.setRenderHint(QPainter::SmoothPixmapTransform, !m_draggingEntity);
const QRect r = rect();
if (!m_presentationPreviewMode) {
if (m_checkerboardVisible) {
drawCheckerboard(p, r);
}
if (m_gridVisible) {
drawGrid(p, r);
}
}
ensurePixmapLoaded();
if (m_bgAbsPath.isEmpty()) {
return;
}
if (m_useViewportLod) {
ensureBackgroundViewport();
if (m_bgViewportImage.isNull()) {
return;
}
} else if (m_bgPixmap.isNull()) {
tryEmitInitialBackgroundLoadFinished();
return;
}
const bool showBg = m_presentationPreviewMode || m_backgroundVisible;
if (showBg && !m_useViewportLod) {
syncFullBackgroundImageFromDiskIfDirty();
rebuildFullBackgroundCutoutIfDirty();
}
// 以“世界坐标”绘制:支持缩放/平移
p.save();
QTransform t;
t.translate(m_pan.x(), m_pan.y());
t.scale(m_scale, m_scale);
p.setTransform(t, true);
const qreal bw = static_cast<qreal>(backgroundLogicalWidth());
const qreal bh = static_cast<qreal>(backgroundLogicalHeight());
if (showBg) {
if (m_useViewportLod) {
p.drawImage(QRectF(m_bgViewportCacheRect), m_bgViewportImage);
if (showBg) {
for (const auto& ent : m_entities) {
if (ent.blackholeVisible && !ent.cutoutPolygonWorld.isEmpty()) {
p.fillPath(entity_cutout::pathFromWorldPolygon(ent.cutoutPolygonWorld), Qt::black);
}
}
}
} else if (m_backgroundVisible || m_presentationPreviewMode) {
if (!m_bgImageCutout.isNull()) {
p.drawImage(QPointF(0, 0), m_bgImageCutout);
} else if (!m_bgImage.isNull()) {
p.drawImage(QPointF(0, 0), m_bgImage);
} else {
p.drawPixmap(QPointF(0, 0), m_bgPixmap);
}
}
if (!m_presentationPreviewMode && m_backgroundVisible && bw > 0 && bh > 0) {
p.setPen(QPen(QColor(0, 0, 0, 80), 1.0 / std::max<qreal>(m_scale, 0.001)));
p.drawRect(QRectF(0, 0, bw, bh).adjusted(0, 0, -1, -1));
}
if (!m_projectDirAbs.isEmpty()) {
for (const auto& ent : m_entities) {
if (ent.blackholeOverlayPath.isEmpty() || ent.blackholeOverlayRect.width() <= 0 ||
ent.blackholeOverlayRect.height() <= 0) {
continue;
}
QImage ovl;
if (m_blackholeOverlayImageCache.contains(ent.id)) {
ovl = m_blackholeOverlayImageCache.value(ent.id);
} else {
const QString abs = QDir(m_projectDirAbs).filePath(ent.blackholeOverlayPath);
ovl = readImageTolerant(abs);
if (!ovl.isNull() && ovl.format() != QImage::Format_ARGB32_Premultiplied) {
ovl = ovl.convertToFormat(QImage::Format_ARGB32_Premultiplied);
}
if (!ovl.isNull()) {
m_blackholeOverlayImageCache.insert(ent.id, ovl);
}
}
if (ovl.isNull()) {
continue;
}
p.drawImage(QPointF(ent.blackholeOverlayRect.topLeft()), ovl);
}
}
}
// 深度叠加(伪彩色):仅由「叠加深度」开关控制,与是否显示背景无关
const bool wantDepth =
!m_presentationPreviewMode && (!m_depthAbsPath.isEmpty()) && m_depthOverlayEnabled;
if (wantDepth) {
if (m_depthDirty) {
m_depthDirty = false;
m_depthImage8 = core::image_file::loadDepthMapAlignedToBackground(m_depthAbsPath, m_bgAbsPath);
}
if (!m_depthImage8.isNull()) {
const int overlayAlpha = m_backgroundVisible ? m_depthOverlayAlpha : 255;
const QImage overlay =
core::DepthService::depthToColormapOverlay(m_depthImage8, overlayAlpha);
if (!overlay.isNull()) {
p.drawImage(QPointF(0, 0), overlay);
} else {
p.drawImage(QPointF(0, 0), m_depthImage8);
}
p.setPen(QPen(QColor(0, 0, 0, 80), 1.0 / std::max<qreal>(m_scale, 0.001)));
p.drawRect(QRectF(0, 0, m_depthImage8.width(), m_depthImage8.height()).adjusted(0, 0, -1, -1));
}
}
if (!m_presentationPreviewMode && m_blackholeCopyResolveActive &&
!m_blackholeCopyHoleRect.isNull() && !m_blackholeCopySourceRect.isNull()) {
const QRectF holeF = m_blackholeCopyHoleRect;
const QRectF srcF(m_blackholeCopySourceRect.topLeft(), QSizeF(holeF.width(), holeF.height()));
const bool havePreview =
(m_bhCopyPatchValid && !m_bhCopyPatch.isNull()) || !m_bgImage.isNull();
if (havePreview && holeF.width() >= 1.0 && holeF.height() >= 1.0) {
QPainterPath holePath;
for (const auto& ent : m_entities) {
if (ent.id == m_blackholeCopyEntityId && !ent.cutoutPolygonWorld.isEmpty()) {
holePath = entity_cutout::pathFromWorldPolygon(ent.cutoutPolygonWorld);
break;
}
}
p.save();
if (!holePath.isEmpty()) {
p.setClipPath(holePath);
}
p.setOpacity(0.75);
if (m_bhCopyPatchValid && !m_bhCopyPatch.isNull() && m_bhCopyPatchLogicalSize.isValid() &&
m_bhCopyPatchLogicalSize.width() > 0 && m_bhCopyPatchLogicalSize.height() > 0) {
const QRectF srcWorldInPatch = srcF.translated(-m_bhCopyPatchOrigin);
const QRectF bound(0, 0, m_bhCopyPatchLogicalSize.width(), m_bhCopyPatchLogicalSize.height());
const QRectF srcVis = srcWorldInPatch.intersected(bound);
if (!srcVis.isEmpty()) {
const double sx = double(m_bhCopyPatch.width()) / double(m_bhCopyPatchLogicalSize.width());
const double sy = double(m_bhCopyPatch.height()) / double(m_bhCopyPatchLogicalSize.height());
const QRectF srcPixels(srcVis.x() * sx, srcVis.y() * sy, srcVis.width() * sx, srcVis.height() * sy);
const QPointF dVis = srcVis.topLeft() - srcWorldInPatch.topLeft();
const QRectF dstFrag(holeF.topLeft() + dVis, srcVis.size());
p.drawImage(dstFrag, m_bhCopyPatch, srcPixels);
}
} else if (!m_bgImage.isNull() && m_bgLogicalSize.isValid() && m_bgLogicalSize.width() > 0 &&
m_bgLogicalSize.height() > 0) {
const QRectF bgB(0, 0, m_bgLogicalSize.width(), m_bgLogicalSize.height());
const double bx = double(m_bgImage.width()) / double(m_bgLogicalSize.width());
const double by = double(m_bgImage.height()) / double(m_bgLogicalSize.height());
const QRectF srcFW = srcF.intersected(bgB);
if (!srcFW.isEmpty()) {
const QPointF dVis = srcFW.topLeft() - srcF.topLeft();
const QRectF dstFrag(holeF.topLeft() + dVis, srcFW.size());
const QRectF srcPixels(srcFW.x() * bx, srcFW.y() * by, srcFW.width() * bx, srcFW.height() * by);
p.drawImage(dstFrag, m_bgImage, srcPixels);
}
}
p.setOpacity(1.0);
p.restore();
}
p.setBrush(Qt::NoBrush);
QPen holePen(QColor(255, 120, 0, 220), 2.0 / std::max<qreal>(m_scale, 0.001));
holePen.setStyle(Qt::DashLine);
p.setPen(holePen);
p.drawRect(m_blackholeCopyHoleRect);
QPen srcPen(QColor(70, 200, 255, 230), 2.0 / std::max<qreal>(m_scale, 0.001));
p.setPen(srcPen);
p.drawRect(m_blackholeCopySourceRect);
}
struct DrawItem {
enum class Kind { Entity, Tool };
Kind kind = Kind::Entity;
int index = -1;
int priority = 0;
int depth = 0;
};
QVector<DrawItem> drawOrder;
drawOrder.reserve(m_entities.size() + m_tools.size());
for (int i = 0; i < m_entities.size(); ++i) {
const auto& ent = m_entities[i];
if (ent.opacity <= 0.001) continue;
if (!ent.id.isEmpty() && m_tempHiddenEntityIds.contains(ent.id)) continue;
drawOrder.push_back(DrawItem{DrawItem::Kind::Entity, i, ent.priority, ent.depth});
}
for (int i = 0; i < m_tools.size(); ++i) {
const auto& tv = m_tools[i];
if (tv.opacity <= 0.001) continue;
if (!tv.tool.id.isEmpty() && m_tempHiddenToolIds.contains(tv.tool.id)) continue;
if (tv.tool.type != core::Project::Tool::Type::Bubble) continue;
drawOrder.push_back(DrawItem{DrawItem::Kind::Tool, i, tv.tool.priority, tv.depthZ});
}
// priority 低先画、高后画;同 priority:远先画、近后画
std::stable_sort(drawOrder.begin(), drawOrder.end(), [](const DrawItem& a, const DrawItem& b) {
if (a.priority != b.priority) return a.priority < b.priority;
if (a.depth != b.depth) return a.depth < b.depth;
if (a.kind != b.kind) return int(a.kind) < int(b.kind);
return a.index < b.index;
});
for (const auto& it : drawOrder) {
if (it.kind == DrawItem::Kind::Entity) {
const int i = it.index;
const auto& ent = m_entities[i];
const bool isDragPreview =
(!m_presentationPreviewMode && m_draggingEntity && m_dragPreviewActive && i == m_selectedEntity);
if (!ent.polygonWorld.isEmpty()) {
if (!ent.image.isNull()) {
if (isDragPreview) {
const QPointF cBase = m_dragCentroidBase;
p.save();
QTransform tr;
tr.translate(m_dragDelta.x(), m_dragDelta.y());
tr.translate(cBase.x(), cBase.y());
tr.scale(m_dragScaleRatio, m_dragScaleRatio);
tr.translate(-cBase.x(), -cBase.y());
p.setTransform(tr, true);
const QSizeF sz(ent.image.width() * m_dragScaleBase, ent.image.height() * m_dragScaleBase);
const QRectF target(m_dragImageTopLeftBase, sz);
p.drawImage(target, ent.image);
p.restore();
} else {
const qreal pop =
(m_presentationPreviewMode && i == m_presFocusedEntityIndex) ? 1.1 : 1.0;
const QSizeF sz0(ent.image.width() * ent.visualScale, ent.image.height() * ent.visualScale);
QRectF target;
if (pop > 1.001) {
const QRectF orig(ent.imageTopLeft, sz0);
const QPointF cen = orig.center();
const QSizeF sz = orig.size() * pop;
target = QRectF(QPointF(cen.x() - sz.width() * 0.5, cen.y() - sz.height() * 0.5), sz);
} else {
target = QRectF(ent.imageTopLeft, sz0);
}
QImage drawImg = ent.image;
if (m_presentationPreviewMode && !ent.image.isNull()) {
const qreal pr = devicePixelRatio();
const qreal pxW = std::abs(target.width() * m_scale * pr);
const qreal pxH = std::abs(target.height() * m_scale * pr);
drawImg = entityImageForPresentationDrawLoRes(ent.image, pxW, pxH);
}
p.drawImage(target, drawImg);
}
} else {
const QPolygonF poly(isDragPreview ? QPolygonF(m_dragPolyBase) : QPolygonF(ent.polygonWorld));
p.setPen(Qt::NoPen);
p.setBrush(ent.color);
if (isDragPreview) {
const QPointF cBase = m_dragCentroidBase;
QTransform tr;
tr.translate(m_dragDelta.x(), m_dragDelta.y());
tr.translate(cBase.x(), cBase.y());
tr.scale(m_dragScaleRatio, m_dragScaleRatio);
tr.translate(-cBase.x(), -cBase.y());
p.save();
p.setTransform(tr, true);
p.drawPolygon(poly);
p.restore();
} else {
p.drawPolygon(poly);
}
}
if (!m_presentationPreviewMode) {
p.setBrush(Qt::NoBrush);
p.setPen(QPen(QColor(0, 0, 0, 160), 1.0 / std::max<qreal>(m_scale, 0.001)));
if (isDragPreview) {
const QPointF cBase = m_dragCentroidBase;
QTransform tr;
tr.translate(m_dragDelta.x(), m_dragDelta.y());
tr.translate(cBase.x(), cBase.y());
tr.scale(m_dragScaleRatio, m_dragScaleRatio);
tr.translate(-cBase.x(), -cBase.y());
p.save();
p.setTransform(tr, true);
p.drawPath(m_dragPathBase);
p.restore();
} else {
p.drawPath(ent.pathWorld);
}
}
} else {
p.fillRect(ent.rect, ent.color);
if (!m_presentationPreviewMode) {
p.setPen(QPen(QColor(0, 0, 0, 120), 1.0 / std::max<qreal>(m_scale, 0.001)));
p.drawRect(ent.rect);
}
}
if (!m_presentationPreviewMode && i == m_selectedEntity) {
p.setPen(QPen(QColor(255, 120, 0, 220), 2.0 / std::max<qreal>(m_scale, 0.001)));
if (!ent.polygonWorld.isEmpty()) {
if (isDragPreview) {
const QPointF cBase = m_dragCentroidBase;
QTransform tr;
tr.translate(m_dragDelta.x(), m_dragDelta.y());
tr.translate(cBase.x(), cBase.y());
tr.scale(m_dragScaleRatio, m_dragScaleRatio);
tr.translate(-cBase.x(), -cBase.y());
p.save();
p.setTransform(tr, true);
p.drawPath(m_dragPathBase);
p.restore();
} else {
p.drawPath(ent.pathWorld);
}
} else {
p.drawRect(ent.rect.adjusted(-2, -2, 2, 2));
}
}
if (!m_presentationPreviewMode && ent.id == m_selectedBlackholeEntityId && !ent.cutoutPolygonWorld.isEmpty()) {
p.setBrush(Qt::NoBrush);
QPen holePen(QColor(70, 200, 255, 230), 2.2 / std::max<qreal>(m_scale, 0.001));
holePen.setStyle(Qt::DashLine);
p.setPen(holePen);
p.drawPath(entity_cutout::pathFromWorldPolygon(ent.cutoutPolygonWorld));
}
if (m_presentationPreviewMode && ent.opacity > 0.001) {
const bool showHover = (i == m_presHoverEntityIndex);
const bool showFocus = (i == m_presFocusedEntityIndex);
if (showHover || showFocus) {
p.setBrush(Qt::NoBrush);
if (showHover) {
const qreal pulse = 0.45 + 0.55 * std::sin(static_cast<double>(m_presHoverPhase));
const qreal lw =
(2.0 + 2.8 * pulse) / std::max(static_cast<qreal>(m_scale), static_cast<qreal>(0.001));
p.setPen(QPen(QColor(255, 210, 80, static_cast<int>(65 + 110 * pulse)), lw));
if (!ent.pathWorld.isEmpty()) {
p.drawPath(ent.pathWorld);
} else {
p.drawRect(ent.rect);
}
}
if (showFocus) {
const qreal lw = 2.8 / std::max(static_cast<qreal>(m_scale), static_cast<qreal>(0.001));
p.setPen(QPen(QColor(255, 120, 40, 230), lw));
if (!ent.pathWorld.isEmpty()) {
p.drawPath(ent.pathWorld);
} else {
p.drawRect(ent.rect);
}
}
}
}
} else {
const int i = it.index;
const auto& tv = m_tools[i];
const auto& tool = tv.tool;
const double opacity = std::clamp(tv.opacity, 0.0, 1.0);
const BubbleLayoutWorld lay = bubbleLayoutWorld(tool);
const QPainterPath& path = lay.path;
const QRectF& body = lay.bodyRect;
QColor fill(255, 255, 255, int(220 * opacity));
QColor border(0, 0, 0, int(120 * opacity));
p.setBrush(fill);
p.setPen(QPen(border, 1.2 / std::max<qreal>(m_scale, 0.001)));
p.drawPath(path);
if (!tool.text.trimmed().isEmpty()) {
p.setPen(QColor(10, 10, 10, int(230 * opacity)));
QFont f = p.font();
f.setPixelSize(std::clamp(tool.fontPx, 8, 120));
p.setFont(f);
QTextOption opt;
opt.setWrapMode(QTextOption::WrapAtWordBoundaryOrAnywhere);
if (tool.align == core::Project::Tool::TextAlign::Left) opt.setAlignment(Qt::AlignLeft | Qt::AlignVCenter);
else if (tool.align == core::Project::Tool::TextAlign::Right) opt.setAlignment(Qt::AlignRight | Qt::AlignVCenter);
else opt.setAlignment(Qt::AlignHCenter | Qt::AlignVCenter);
const QRectF textRect = body.adjusted(10, 8, -10, -8);
p.drawText(textRect, tool.text, opt);
}
if (!m_presentationPreviewMode && i == m_selectedTool) {
p.setBrush(Qt::NoBrush);
p.setPen(QPen(QColor(80, 160, 255, 220), 2.0 / std::max<qreal>(m_scale, 0.001)));
p.drawPath(path);
}
}
}
// 摄像机视口框(编辑模式)
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<qreal>(m_scale, 0.001));
p.setPen(pen);
p.setBrush(Qt::NoBrush);
p.drawPolyline(QPolygonF(m_strokeWorld));
// 提示闭合
p.setPen(QPen(QColor(255, 120, 0, 140), 1.0 / std::max<qreal>(m_scale, 0.001), Qt::DashLine));
p.drawLine(m_strokeWorld.first(), m_strokeWorld.last());
}
// 待确认实体:多边形预览 + 顶点
if (!m_presentationPreviewMode && m_pendingPolyWorld.size() >= 3) {
const qreal lw = 2.5 / std::max<qreal>(m_scale, 0.001);
p.setPen(QPen(QColor(60, 180, 255, 230), lw));
p.setBrush(QColor(60, 180, 255, 45));
const QPainterPath path = entity_cutout::pathFromWorldPolygon(m_pendingPolyWorld);
p.drawPath(path);
const qreal vr = 5.0 / std::max<qreal>(m_scale, 0.001);
p.setPen(QPen(QColor(0, 0, 0, 120), lw));
p.setBrush(QColor(255, 255, 255, 220));
for (const QPointF& v : m_pendingPolyWorld) {
p.drawEllipse(v, vr, vr);
}
}
p.restore();
// 坐标轴/刻度:绘制在画布最外层,背景越界时贴边显示
ensurePixmapLoaded();
if (!m_presentationPreviewMode && m_worldAxesVisible && backgroundLogicalWidth() > 0 &&
backgroundLogicalHeight() > 0) {
const QPointF originView = worldToView(QPointF(0, 0));
const qreal axisX = std::clamp(originView.x(), 0.0, static_cast<qreal>(width()));
const qreal axisY = std::clamp(originView.y(), 0.0, static_cast<qreal>(height()));
QPen axisPen(QColor(20, 20, 20, 180));
axisPen.setWidth(2);
p.setPen(axisPen);
p.drawLine(QPointF(0, axisY), QPointF(width(), axisY)); // X 轴(水平)
p.drawLine(QPointF(axisX, 0), QPointF(axisX, height())); // Y 轴(垂直)
// 根据缩放与视口大小动态调整刻度密度:使相邻刻度在屏幕上保持“够密但不挤”的间距
auto niceStep = [](double raw) -> double {
if (!(raw > 0.0) || !std::isfinite(raw)) {
return 1.0;
}
const double p10 = std::pow(10.0, std::floor(std::log10(raw)));
const double m = raw / p10; // 1..10
double n = 1.0;
if (m <= 1.0) n = 1.0;
else if (m <= 2.0) n = 2.0;
else if (m <= 5.0) n = 5.0;
else n = 10.0;
return n * p10;
};
const double sView = std::max<double>(0.001, double(m_scale));
const double targetTickPx = 86.0; // 目标:相邻刻度约 86px
const double minTickPx = 44.0; // 太密则自动放大 step
const double stepWorld = std::max(1.0, niceStep(targetTickPx / sView));
const double stepPx = stepWorld * sView;
const int labelEvery = (stepPx < minTickPx) ? int(std::ceil(minTickPx / std::max(1.0, stepPx))) : 1;
auto visibleWorldXRange = [&]() -> std::pair<double, double> {
const QPointF w0 = viewToWorld(QPointF(0, 0));
const QPointF w1 = viewToWorld(QPointF(width(), height()));
double a = std::min<double>(w0.x(), w1.x());
double b = std::max<double>(w0.x(), w1.x());
a = std::clamp(a, 0.0, double(backgroundLogicalWidth()));
b = std::clamp(b, 0.0, double(backgroundLogicalWidth()));
return {a, b};
};
auto visibleWorldYRange = [&]() -> std::pair<double, double> {
const QPointF w0 = viewToWorld(QPointF(0, 0));
const QPointF w1 = viewToWorld(QPointF(width(), height()));
double a = std::min<double>(w0.y(), w1.y());
double b = std::max<double>(w0.y(), w1.y());
a = std::clamp(a, 0.0, double(backgroundLogicalHeight()));
b = std::clamp(b, 0.0, double(backgroundLogicalHeight()));
return {a, b};
};
QPen tickPen(QColor(20, 20, 20, 140));
tickPen.setWidth(1);
p.setPen(tickPen);
if (m_axisLabelsVisible) {
QFont f = p.font();
f.setPointSize(std::max(7, f.pointSize() - 1));
p.setFont(f);
}
// X 轴:用 y=0 的世界线映射到 view-x,并把刻度画在 axisY 上
{
const auto [xmin, xmax] = visibleWorldXRange();
const double start = std::floor(xmin / stepWorld) * stepWorld;
int iTick = 0;
for (double x = start; x <= xmax + 1e-9; x += stepWorld, ++iTick) {
const double xc = std::clamp(x, 0.0, double(backgroundLogicalWidth()));
const QPointF vx = worldToView(QPointF(xc, 0));
if (vx.x() < -50 || vx.x() > width() + 50) {
continue;
}
const qreal tx = std::clamp(vx.x(), 0.0, static_cast<qreal>(width()));
p.drawLine(QPointF(tx, axisY), QPointF(tx, axisY + 6));
if (m_axisLabelsVisible) {
if (labelEvery <= 1 || (iTick % labelEvery) == 0) {
p.drawText(QPointF(tx + 2, axisY + 18), QString::number(int(std::lround(xc))));
}
}
}
}
// Y 轴:用 x=0 的世界线映射到 view-y,并把刻度画在 axisX 上
{
const auto [ymin, ymax] = visibleWorldYRange();
const double start = std::floor(ymin / stepWorld) * stepWorld;
int iTick = 0;
for (double y = start; y <= ymax + 1e-9; y += stepWorld, ++iTick) {
const double yc = std::clamp(y, 0.0, double(backgroundLogicalHeight()));
const QPointF vy = worldToView(QPointF(0, yc));
if (vy.y() < -50 || vy.y() > height() + 50) {
continue;
}
const qreal ty = std::clamp(vy.y(), 0.0, static_cast<qreal>(height()));
p.drawLine(QPointF(axisX, ty), QPointF(axisX + 6, ty));
if (m_axisLabelsVisible) {
if (labelEvery <= 1 || (iTick % labelEvery) == 0) {
p.drawText(QPointF(axisX + 10, ty - 2), QString::number(int(std::lround(yc))));
}
}
}
}
}
// Gizmo:选中实体时显示(仿 Blender:约束 X/Y 轴移动)
if (!m_presentationPreviewMode && m_selectedEntity >= 0 && m_selectedEntity < m_entities.size()) {
const auto& ent = m_entities[m_selectedEntity];
const bool isDragPreview = (m_draggingEntity && m_dragPreviewActive);
QPointF originWorld = ent.rect.center();
if (isDragPreview) {
originWorld = m_dragCentroidBase + m_dragDelta;
} else if (!ent.polygonWorld.isEmpty()) {
originWorld = entity_cutout::polygonCentroid(ent.polygonWorld);
}
const QPointF originView = worldToView(originWorld);
// 中心(形心)十字锚点:独立于坐标轴手柄,始终可见
{
const qreal h = 7.0;
QPen outline(QColor(15, 15, 18, 235));
outline.setWidth(3);
outline.setCapStyle(Qt::FlatCap);
QPen core(QColor(255, 248, 220, 252));
core.setWidth(1);
core.setCapStyle(Qt::FlatCap);
p.setPen(outline);
p.drawLine(QPointF(originView.x() - h, originView.y()), QPointF(originView.x() + h, originView.y()));
p.drawLine(QPointF(originView.x(), originView.y() - h), QPointF(originView.x(), originView.y() + h));
p.setPen(core);
p.drawLine(QPointF(originView.x() - h, originView.y()), QPointF(originView.x() + h, originView.y()));
p.drawLine(QPointF(originView.x(), originView.y() - h), QPointF(originView.x(), originView.y() + h));
}
// 变换原点(枢轴):与形心不同时显示
{
const QPointF pivotWorld = ent.animatedOriginWorld;
const double sepWorld = QLineF(originWorld, pivotWorld).length();
if (sepWorld > 0.5) {
const QPointF pv = worldToView(pivotWorld);
const qreal pivotDotR = 4.5;
p.setPen(QPen(QColor(40, 40, 48, 220), 1));
p.setBrush(QColor(255, 120, 210, 235));
p.drawEllipse(pv, pivotDotR, pivotDotR);
p.setBrush(Qt::NoBrush);
}
}
const qreal len = 56.0;
QPen xPen(QColor(220, 60, 60, 220));
xPen.setWidth(2);
QPen yPen(QColor(60, 180, 90, 220));
yPen.setWidth(2);
// X 轴(从形心出发,用于拖拽平移)
p.setPen(xPen);
p.drawLine(originView, QPointF(originView.x() + len, originView.y()));
p.drawRect(QRectF(QPointF(originView.x() + len - 4, originView.y() - 4), QSizeF(8, 8)));
if (m_gizmoLabelsVisible) {
p.drawText(QPointF(originView.x() + len + 6, originView.y() + 4), QStringLiteral("X"));
}
// Y 轴
p.setPen(yPen);
p.drawLine(originView, QPointF(originView.x(), originView.y() + len));
p.drawRect(QRectF(QPointF(originView.x() - 4, originView.y() + len - 4), QSizeF(8, 8)));
if (m_gizmoLabelsVisible) {
p.drawText(QPointF(originView.x() + 6, originView.y() + len + 14), QStringLiteral("Y"));
}
}
if (!m_presentationHotspots.isEmpty()) {
if (m_presentationPreviewMode && m_presentationHotspotPlaybackTargetEnabled) {
p.setRenderHint(QPainter::Antialiasing, true);
for (const auto& h : m_presentationHotspots) {
const QPointF cv = worldToView(h.centerWorld);
p.setPen(QPen(QColor(255, 248, 220, 235), 2));
p.setBrush(QColor(30, 34, 42, 170));
p.drawEllipse(cv, 16.0, 16.0);
QPolygonF tri;
tri << (cv + QPointF(-3.0, -6.0)) << (cv + QPointF(-3.0, 6.0)) << (cv + QPointF(8.0, 0.0));
p.setBrush(QColor(255, 248, 220, 250));
p.drawPolygon(tri);
}
p.setRenderHint(QPainter::Antialiasing, false);
} else if (m_presentationHotspotEditorActive) {
p.setRenderHint(QPainter::Antialiasing, true);
for (const auto& h : m_presentationHotspots) {
const QPointF cv = worldToView(h.centerWorld);
const bool sel = (h.id == m_selectedPresentationHotspotId);
p.setPen(QPen(sel ? QColor(255, 210, 72) : QColor(92, 200, 255), 2));
p.setBrush(QColor(24, 28, 36, 150));
p.drawEllipse(cv, 14.0, 14.0);
}
p.setRenderHint(QPainter::Antialiasing, false);
}
}
tryEmitInitialBackgroundLoadFinished();
}
void EditorCanvas::resizeEvent(QResizeEvent* e) {
QWidget::resizeEvent(e);
invalidateViewportLod();
update();
}
void EditorCanvas::mousePressEvent(QMouseEvent* e) {
if (e->button() != Qt::LeftButton && e->button() != Qt::MiddleButton) {
QWidget::mousePressEvent(e);
return;
}
const QPointF wp0 = viewToWorld(e->position());
emit hoveredWorldPosChanged(wp0);
int z0 = -1;
if (!m_depthAbsPath.isEmpty()) {
if (m_depthDirty) {
m_depthDirty = false;
m_depthImage8 = core::image_file::loadDepthMapAlignedToBackground(m_depthAbsPath, m_bgAbsPath);
}
if (!m_depthImage8.isNull()) {
const int xi = static_cast<int>(std::floor(wp0.x()));
const int yi = static_cast<int>(std::floor(wp0.y()));
if (xi >= 0 && yi >= 0 && xi < m_depthImage8.width() && yi < m_depthImage8.height()) {
z0 = static_cast<int>(m_depthImage8.constScanLine(yi)[xi]);
}
}
}
emit hoveredWorldPosDepthChanged(wp0, z0);
if (!m_presentationPreviewMode && m_presentationHotspotEditorActive && e->button() == Qt::LeftButton) {
if (m_tool == Tool::AddHotspot) {
emit requestAddPresentationHotspot(wp0);
e->accept();
return;
}
if (m_tool == Tool::MoveHotspot) {
const int hi = hitTestPresentationHotspot(wp0);
if (hi >= 0) {
m_dragging = true;
m_draggingHotspotIndex = hi;
m_hotspotDragOffsetWorld = wp0 - m_presentationHotspots[hi].centerWorld;
m_selectedPresentationHotspotId = m_presentationHotspots[hi].id;
m_lastMouseView = e->position();
setCursor(Qt::ClosedHandCursor);
emit selectedPresentationHotspotChanged(m_selectedPresentationHotspotId);
} else {
m_selectedPresentationHotspotId.clear();
emit selectedPresentationHotspotChanged(QString());
}
update();
e->accept();
return;
}
if (m_tool == Tool::Move) {
m_dragging = true;
m_lastMouseView = e->position();
setCursor(Qt::ClosedHandCursor);
e->accept();
return;
}
}
if (m_blackholeCopyResolveActive) {
if (e->button() == Qt::LeftButton) {
QRectF src = m_blackholeCopySourceRect;
if (!src.contains(wp0)) {
src.moveCenter(wp0);
src = clampRectTopLeftToBounds(src, worldRectOfBackground());
m_blackholeCopySourceRect = src;
}
m_blackholeCopyDragging = true;
m_blackholeCopyDragOffset = wp0 - m_blackholeCopySourceRect.topLeft();
updateCursor();
update();
e->accept();
return;
}
e->accept();
return;
}
if (m_presentationPreviewMode) {
if (!m_presentationHotspotPlaybackTargetEnabled) {
e->accept();
return;
}
if (e->button() == Qt::LeftButton) {
if (m_presentationHotspotPlaybackTargetEnabled) {
const int playIx = hitTestHotspotPlayControl(wp0);
if (playIx >= 0 && playIx < m_presentationHotspots.size()) {
emit hotspotPlayRequested(m_presentationHotspots[playIx].id);
e->accept();
return;
}
}
const int hit = hitTestEntity(wp0);
if (hit >= 0) {
const auto& ent = m_entities[hit];
const QPointF cWorld =
ent.polygonWorld.isEmpty() ? ent.rect.center() : entity_cutout::polygonCentroid(ent.polygonWorld);
const QPointF anchorView = worldToView(cWorld);
beginPresentationZoomTowardEntity(hit);
emit presentationEntityIntroRequested(ent.id, anchorView);
return;
}
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) {
m_dragging = true;
m_presBgPanSession = false;
m_lastMouseView = e->position();
setCursor(Qt::ClosedHandCursor);
}
return;
}
if (m_tool == Tool::CreateEntity && e->button() == Qt::LeftButton) {
// 若已有待确认多边形:进入微调(顶点/整体拖拽),或点击空白直接确认
if (!m_presentationPreviewMode && m_pendingPolyWorld.size() >= 3) {
m_dragging = true;
m_lastMouseView = e->position();
const QPointF w = viewToWorld(e->position());
m_pendingLastMouseWorld = w;
int vi = -1;
if (isPointNearPendingVertex(w, &vi)) {
m_pendingDragging = true;
m_pendingDragVertex = vi;
m_pendingDragWhole = false;
e->accept();
return;
}
if (pendingPolygonContains(w)) {
m_pendingDragging = true;
m_pendingDragWhole = true;
m_pendingDragVertex = -1;
e->accept();
return;
}
// 点击空白:确认
emit requestFinalizePendingEntity(m_pendingPolyWorld);
e->accept();
return;
}
m_dragging = true;
m_drawingEntity = true;
m_draggingEntity = false;
m_selectedEntity = -1;
emit selectedEntityChanged(false, QString(), 0, QPointF());
m_lastMouseView = e->position();
m_strokeWorld.clear();
m_strokeWorld.push_back(viewToWorld(e->position()));
update();
return;
}
m_dragging = true;
m_lastMouseView = e->position();
const QPointF worldPos = viewToWorld(e->position());
if (e->button() == Qt::MiddleButton || m_tool == Tool::Move) {
setCursor(Qt::ClosedHandCursor);
}
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<int>(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;
}
}
// 命中测试:按绘制顺序从上到下(priority/深度),找到顶层的实体或工具
struct HitItem {
enum class Kind { Entity, Tool };
Kind kind = Kind::Entity;
int index = -1;
int priority = 0;
int depth = 0;
};
QVector<HitItem> order;
order.reserve(m_entities.size() + m_tools.size());
for (int i = 0; i < m_entities.size(); ++i) {
const auto& ent = m_entities[i];
if (ent.opacity <= 0.001) continue;
if (!ent.id.isEmpty() && m_tempHiddenEntityIds.contains(ent.id)) continue;
order.push_back(HitItem{HitItem::Kind::Entity, i, ent.priority, ent.depth});
}
for (int i = 0; i < m_tools.size(); ++i) {
const auto& tv = m_tools[i];
if (tv.opacity <= 0.001) continue;
if (!tv.tool.id.isEmpty() && m_tempHiddenToolIds.contains(tv.tool.id)) continue;
if (tv.tool.type != core::Project::Tool::Type::Bubble) continue;
order.push_back(HitItem{HitItem::Kind::Tool, i, tv.tool.priority, tv.depthZ});
}
std::stable_sort(order.begin(), order.end(), [](const HitItem& a, const HitItem& b) {
if (a.priority != b.priority) return a.priority < b.priority;
if (a.depth != b.depth) return a.depth < b.depth;
if (a.kind != b.kind) return int(a.kind) < int(b.kind);
return a.index < b.index;
});
for (qsizetype i = order.size(); i > 0; --i) {
const auto& it = order[i - 1];
if (it.kind == HitItem::Kind::Tool) {
const auto& tv = m_tools[it.index];
const QPainterPath path = bubblePathWorld(tv.tool);
if (!path.contains(worldPos)) continue;
clearCameraSelection();
m_selectedTool = it.index;
m_selectedEntity = -1;
m_draggingTool = true;
m_dragMode = DragMode::Free;
m_toolDragOffsetOriginWorld = worldPos - m_tools[m_selectedTool].tool.originWorld;
m_toolDragStartOriginWorld = m_tools[m_selectedTool].tool.originWorld;
emit selectedEntityChanged(false, QString(), 0, QPointF());
emit selectedToolChanged(true, m_tools[m_selectedTool].tool.id, m_tools[m_selectedTool].tool.originWorld);
update();
return;
}
const auto& ent = m_entities[it.index];
bool hit = false;
if (!ent.pathWorld.isEmpty()) hit = ent.pathWorld.contains(worldPos);
else if (!ent.polygonWorld.isEmpty()) hit = entity_cutout::pathFromWorldPolygon(ent.polygonWorld).contains(worldPos);
else hit = ent.rect.contains(worldPos);
if (!hit) continue;
// 选择实体(保持原逻辑)
clearCameraSelection();
m_selectedEntity = it.index;
m_selectedTool = -1;
m_draggingTool = false;
m_draggingEntity = true;
m_dragMode = DragMode::Free;
emit entityDragActiveChanged(true);
const QRectF r = m_entities[m_selectedEntity].rect.isNull() && !m_entities[m_selectedEntity].polygonWorld.isEmpty()
? entity_cutout::pathFromWorldPolygon(m_entities[m_selectedEntity].polygonWorld).boundingRect()
: m_entities[m_selectedEntity].rect;
m_entities[m_selectedEntity].rect = r;
{
const auto& he = m_entities[m_selectedEntity];
const QPointF cRef = shapeCentroidWorld(he.polygonWorld, he.rect);
const double s = std::max(1e-6, he.visualScale);
m_entityDragAnchorLocal = (worldPos - cRef) / s;
m_entityDragStartCentroidWorld = cRef;
}
// drag preview baseline
m_dragPreviewActive = true;
m_dragDelta = QPointF(0, 0);
m_dragOriginBase = m_entities[m_selectedEntity].animatedOriginWorld;
m_dragRectBase = m_entities[m_selectedEntity].rect;
m_dragImageTopLeftBase = m_entities[m_selectedEntity].imageTopLeft;
m_dragScaleBase = std::max(1e-6, m_entities[m_selectedEntity].visualScale);
m_dragScaleRatio = 1.0;
m_dragPolyBase = m_entities[m_selectedEntity].polygonWorld;
m_dragPathBase = m_entities[m_selectedEntity].pathWorld;
m_dragCentroidBase =
m_dragPolyBase.isEmpty() ? m_dragRectBase.center() : entity_cutout::polygonCentroid(m_dragPolyBase);
const QPointF origin = !m_entities[m_selectedEntity].polygonWorld.isEmpty()
? entity_cutout::polygonCentroid(m_entities[m_selectedEntity].polygonWorld)
: m_entities[m_selectedEntity].rect.center();
emit selectedEntityChanged(true, m_entities[m_selectedEntity].id, m_entities[m_selectedEntity].depth, origin);
emit selectedToolChanged(false, QString(), QPointF());
update();
return;
}
// 优先:若已选中实体,且点在 gizmo 手柄上,则开启轴约束拖动
if (m_selectedEntity >= 0 && m_selectedEntity < m_entities.size()) {
const auto& ent = m_entities[m_selectedEntity];
const bool isDragPreview = (m_draggingEntity && m_dragPreviewActive);
QPointF originWorld = shapeCentroidWorld(ent.polygonWorld, ent.rect);
if (isDragPreview) {
originWorld = m_dragCentroidBase + m_dragDelta;
}
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;
m_lastMouseView = e->position();
m_dragStartMouseWorld = viewToWorld(e->position());
// 为了统一复用 move 逻辑:初始化 rect 与基准点
const QRectF r = m_entities[m_selectedEntity].rect.isNull() && !m_entities[m_selectedEntity].polygonWorld.isEmpty()
? entity_cutout::pathFromWorldPolygon(m_entities[m_selectedEntity].polygonWorld).boundingRect()
: m_entities[m_selectedEntity].rect;
m_entities[m_selectedEntity].rect = r;
{
const auto& se = m_entities[m_selectedEntity];
const QPointF cRef = shapeCentroidWorld(se.polygonWorld, se.rect);
const double s = std::max(1e-6, se.visualScale);
m_entityDragAnchorLocal = (viewToWorld(e->position()) - cRef) / s;
m_entityDragStartCentroidWorld = cRef;
}
// drag preview baseline
m_dragPreviewActive = true;
m_dragDelta = QPointF(0, 0);
m_dragOriginBase = m_entities[m_selectedEntity].animatedOriginWorld;
m_dragRectBase = m_entities[m_selectedEntity].rect;
m_dragImageTopLeftBase = m_entities[m_selectedEntity].imageTopLeft;
m_dragScaleBase = std::max(1e-6, m_entities[m_selectedEntity].visualScale);
m_dragScaleRatio = 1.0;
m_dragPolyBase = m_entities[m_selectedEntity].polygonWorld;
m_dragPathBase = m_entities[m_selectedEntity].pathWorld;
m_dragCentroidBase =
m_dragPolyBase.isEmpty() ? m_dragRectBase.center() : entity_cutout::polygonCentroid(m_dragPolyBase);
update();
return;
}
}
// 若已选中实体:点击命中该实体本体时,优先拖动“已选中实体”。
// 这对父子层级很重要:子实体可能被父实体遮挡,但用户在项目树中选中子实体后仍应可拖动它。
if (m_selectedEntity >= 0 && m_selectedEntity < m_entities.size()) {
const auto& ent = m_entities[m_selectedEntity];
bool hitSelected = false;
if (!ent.pathWorld.isEmpty()) {
hitSelected = ent.pathWorld.contains(worldPos);
} else if (!ent.polygonWorld.isEmpty()) {
hitSelected = entity_cutout::pathFromWorldPolygon(ent.polygonWorld).contains(worldPos);
} else {
hitSelected = ent.rect.contains(worldPos);
}
if (hitSelected) {
clearCameraSelection();
m_draggingEntity = true;
m_dragMode = DragMode::Free;
emit entityDragActiveChanged(true);
const QRectF r = ent.rect.isNull() && !ent.polygonWorld.isEmpty()
? entity_cutout::pathFromWorldPolygon(ent.polygonWorld).boundingRect()
: ent.rect;
m_entities[m_selectedEntity].rect = r;
{
const auto& se = m_entities[m_selectedEntity];
const QPointF cRef = shapeCentroidWorld(se.polygonWorld, se.rect);
const double s = std::max(1e-6, se.visualScale);
m_entityDragAnchorLocal = (worldPos - cRef) / s;
m_entityDragStartCentroidWorld = cRef;
}
// drag preview baseline
m_dragPreviewActive = true;
m_dragDelta = QPointF(0, 0);
m_dragOriginBase = m_entities[m_selectedEntity].animatedOriginWorld;
m_dragRectBase = m_entities[m_selectedEntity].rect;
m_dragImageTopLeftBase = m_entities[m_selectedEntity].imageTopLeft;
m_dragScaleBase = std::max(1e-6, m_entities[m_selectedEntity].visualScale);
m_dragScaleRatio = 1.0;
m_dragPolyBase = m_entities[m_selectedEntity].polygonWorld;
m_dragPathBase = m_entities[m_selectedEntity].pathWorld;
m_dragCentroidBase =
m_dragPolyBase.isEmpty() ? m_dragRectBase.center() : entity_cutout::polygonCentroid(m_dragPolyBase);
update();
return;
}
}
const int hit = hitTestEntity(worldPos);
if (hit >= 0) {
clearCameraSelection();
m_selectedEntity = hit;
m_selectedTool = -1;
m_draggingTool = false;
m_draggingEntity = true;
m_dragMode = DragMode::Free;
emit entityDragActiveChanged(true);
const QRectF r = m_entities[hit].rect.isNull() && !m_entities[hit].polygonWorld.isEmpty()
? entity_cutout::pathFromWorldPolygon(m_entities[hit].polygonWorld).boundingRect()
: m_entities[hit].rect;
m_entities[hit].rect = r;
{
const auto& he = m_entities[hit];
const QPointF cRef = shapeCentroidWorld(he.polygonWorld, he.rect);
const double s = std::max(1e-6, he.visualScale);
m_entityDragAnchorLocal = (worldPos - cRef) / s;
m_entityDragStartCentroidWorld = cRef;
}
// drag preview baseline
m_dragPreviewActive = true;
m_dragDelta = QPointF(0, 0);
m_dragOriginBase = m_entities[hit].animatedOriginWorld;
m_dragRectBase = m_entities[hit].rect;
m_dragImageTopLeftBase = m_entities[hit].imageTopLeft;
m_dragScaleBase = std::max(1e-6, m_entities[hit].visualScale);
m_dragScaleRatio = 1.0;
m_dragPolyBase = m_entities[hit].polygonWorld;
m_dragPathBase = m_entities[hit].pathWorld;
m_dragCentroidBase =
m_dragPolyBase.isEmpty() ? m_dragRectBase.center() : entity_cutout::polygonCentroid(m_dragPolyBase);
const QPointF origin = !m_entities[hit].polygonWorld.isEmpty() ? entity_cutout::polygonCentroid(m_entities[hit].polygonWorld)
: m_entities[hit].rect.center();
emit selectedEntityChanged(true, m_entities[hit].id, m_entities[hit].depth, origin);
emit selectedToolChanged(false, QString(), QPointF());
update();
return;
}
m_selectedEntity = -1;
m_draggingEntity = false;
m_selectedTool = -1;
m_draggingTool = false;
m_dragMode = DragMode::None;
clearCameraSelection();
emit selectedEntityChanged(false, QString(), 0, QPointF());
emit selectedToolChanged(false, QString(), QPointF());
update();
}
}
void EditorCanvas::mouseMoveEvent(QMouseEvent* e) {
const QPointF wp = viewToWorld(e->position());
emit hoveredWorldPosChanged(wp);
int z = -1;
if (!m_depthAbsPath.isEmpty()) {
if (m_depthDirty) {
m_depthDirty = false;
m_depthImage8 = core::image_file::loadDepthMapAlignedToBackground(m_depthAbsPath, m_bgAbsPath);
}
if (!m_depthImage8.isNull()) {
const int xi = static_cast<int>(std::floor(wp.x()));
const int yi = static_cast<int>(std::floor(wp.y()));
if (xi >= 0 && yi >= 0 && xi < m_depthImage8.width() && yi < m_depthImage8.height()) {
z = static_cast<int>(m_depthImage8.constScanLine(yi)[xi]);
}
}
}
emit hoveredWorldPosDepthChanged(wp, z);
if (m_blackholeCopyResolveActive) {
if (m_blackholeCopyDragging && (e->buttons() & Qt::LeftButton)) {
QRectF src = m_blackholeCopySourceRect;
src.moveTopLeft(wp - m_blackholeCopyDragOffset);
src = clampRectTopLeftToBounds(src, worldRectOfBackground());
m_blackholeCopySourceRect = src;
reloadBlackholeCopyVipsPatchUnionIfNeeded();
update();
}
e->accept();
return;
}
if (m_presentationPreviewMode) {
if (!m_presentationHotspotPlaybackTargetEnabled) {
if (m_presHoverEntityIndex >= 0 || (m_presHoverTimer && m_presHoverTimer->isActive()) ||
m_presHoverPhase != 0.0) {
if (m_presHoverTimer) {
m_presHoverTimer->stop();
}
m_presHoverEntityIndex = -1;
m_presHoverPhase = 0.0;
updateCursor();
update();
}
} else {
const int h = hitTestEntity(wp);
if (h != m_presHoverEntityIndex) {
m_presHoverEntityIndex = h;
updateCursor();
update();
}
if (h >= 0) {
if (m_presHoverTimer && !m_presHoverTimer->isActive()) {
m_presHoverTimer->start();
}
} else if (m_presHoverTimer) {
m_presHoverTimer->stop();
m_presHoverPhase = 0.0;
}
}
}
if (!m_dragging) {
QWidget::mouseMoveEvent(e);
return;
}
const QPointF cur = e->position();
const QPointF deltaView = cur - m_lastMouseView;
m_lastMouseView = cur;
if (m_draggingHotspotIndex >= 0 && m_draggingHotspotIndex < m_presentationHotspots.size()) {
const QPointF worldPos = viewToWorld(cur);
m_presentationHotspots[m_draggingHotspotIndex].centerWorld = worldPos - m_hotspotDragOffsetWorld;
update();
return;
}
if (m_pendingDragging && !m_presentationPreviewMode && m_pendingPolyWorld.size() >= 3) {
const QPointF curWorld = viewToWorld(e->position());
const QPointF delta = curWorld - m_pendingLastMouseWorld;
m_pendingLastMouseWorld = curWorld;
if (m_pendingDragWhole) {
for (auto& pt : m_pendingPolyWorld) {
pt += delta;
}
} else if (m_pendingDragVertex >= 0 && m_pendingDragVertex < m_pendingPolyWorld.size()) {
m_pendingPolyWorld[m_pendingDragVertex] += delta;
}
update();
return;
}
if (m_tool == Tool::CreateEntity && m_drawingEntity) {
const QPointF w = viewToWorld(cur);
if (m_strokeWorld.isEmpty()) {
m_strokeWorld.push_back(w);
update();
return;
}
const QPointF last = m_strokeWorld.last();
const qreal dx = w.x() - last.x();
const qreal dy = w.y() - last.y();
// 简单抽样:world 距离至少 1.5 像素才记录,避免点过密
if ((dx * dx + dy * dy) >= (1.5 * 1.5)) {
m_strokeWorld.push_back(w);
update();
}
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];
const double curScale = std::max(1e-6, ent.visualScale);
const QPointF centroidNow =
m_dragPreviewActive ? (m_dragCentroidBase + m_dragDelta) : shapeCentroidWorld(ent.polygonWorld, ent.rect);
const QPointF newCentroid = worldPos - m_entityDragAnchorLocal * curScale;
QPointF delta = newCentroid - centroidNow;
// 轴约束:只允许沿 X 或 Y 平移(world 坐标)
if (m_dragMode == DragMode::AxisX) {
delta.setY(0.0);
} else if (m_dragMode == DragMode::AxisY) {
delta.setX(0.0);
}
// 约束到背景范围内(若有背景),按“预览变换后”的包围盒约束
const QRectF bg = worldRectOfBackground();
if (!bg.isNull()) {
const QRectF moved = transformedRectByScaleAndTranslate(
m_dragPreviewActive ? m_dragRectBase : ent.rect,
m_dragPreviewActive ? m_dragCentroidBase : ent.rect.center(),
m_dragPreviewActive ? m_dragScaleRatio : 1.0,
(m_dragPreviewActive ? m_dragDelta : QPointF(0, 0)) + delta);
QPointF d = delta;
if (moved.left() < bg.left()) d.setX(d.x() + (bg.left() - moved.left()));
if (moved.top() < bg.top()) d.setY(d.y() + (bg.top() - moved.top()));
if (moved.right() > bg.right()) d.setX(d.x() - (moved.right() - bg.right()));
if (moved.bottom() > bg.bottom()) d.setY(d.y() - (moved.bottom() - bg.bottom()));
delta = d;
}
// 轻量:仅更新增量参数与原点,不逐点修改 polygonWorld
if (m_dragPreviewActive) {
m_dragDelta += delta;
}
ent.animatedOriginWorld += delta;
// 拖动中实时按深度图更新 z 与距离缩放(近大远小)
// 但不必每个鼠标事件都重算:位置平移满帧跟手;深度/距离缩放重算较重,节流到 ~30Hz。
const qint64 nowMs = m_previewEmitTimer.elapsed();
if (nowMs - m_lastDepthScaleRecalcMs >= 33) {
m_lastDepthScaleRecalcMs = nowMs;
if (!m_depthAbsPath.isEmpty() && m_depthDirty) {
m_depthDirty = false;
m_depthImage8 = core::image_file::loadDepthMapAlignedToBackground(m_depthAbsPath, m_bgAbsPath);
}
if (!m_depthImage8.isNull()) {
const QPointF c =
m_dragPreviewActive ? (m_dragCentroidBase + m_dragDelta) : shapeCentroidWorld(ent.polygonWorld, ent.rect);
const int depthZ = sampleDepthAtPoint(m_depthImage8, c);
ent.depth = depthZ;
const double ds01 = depthToScale01(depthZ);
ent.animatedDepthScale01 = ds01;
const double newScale =
(ent.ignoreDistanceScale ? 1.0 : distanceScaleFromDepth01(ds01, ent.distanceScaleCalibMult)) * ent.userScale;
ent.visualScale = newScale;
if (m_dragPreviewActive) {
m_dragScaleRatio =
std::clamp(newScale / std::max(1e-6, m_dragScaleBase), 0.02, static_cast<double>(kViewScaleMax));
}
}
}
// 低频预览信号:用于属性面板同步,不做树联动等重操作
// 属性面板/状态预览:按 30Hz 刷新即可,拖动视觉仍满帧跟手
if (nowMs - m_lastPreviewEmitMs >= 33) {
m_lastPreviewEmitMs = nowMs;
const QPointF previewCenter =
m_dragPreviewActive ? (m_dragCentroidBase + m_dragDelta)
: shapeCentroidWorld(ent.polygonWorld, ent.rect);
emit selectedEntityPreviewChanged(ent.id, ent.depth, previewCenter);
}
update();
return;
}
if (m_draggingTool && m_selectedTool >= 0 && m_selectedTool < m_tools.size()) {
const QPointF worldPos = viewToWorld(cur);
const QPointF newOrigin = worldPos - m_toolDragOffsetOriginWorld;
QPointF delta = newOrigin - m_tools[m_selectedTool].tool.originWorld;
m_tools[m_selectedTool].tool.originWorld += delta;
emit selectedToolChanged(true, m_tools[m_selectedTool].tool.id, m_tools[m_selectedTool].tool.originWorld);
update();
return;
}
// 平移画布
if (m_tool == Tool::Move || (e->buttons() & Qt::MiddleButton) ||
(m_presentationPreviewMode && (e->buttons() & Qt::LeftButton))) {
if (!(m_presentationPreviewMode && m_previewCameraViewLocked)) {
if (m_presentationPreviewMode && m_presBgPanSession) {
m_presBgDragDist += std::abs(deltaView.x()) + std::abs(deltaView.y());
}
m_pan += deltaView;
}
update();
return;
}
}
void EditorCanvas::mouseReleaseEvent(QMouseEvent* e) {
if (m_blackholeCopyResolveActive && e->button() == Qt::LeftButton) {
if (m_blackholeCopyDragging) {
m_blackholeCopyDragging = false;
QRect targetW;
QRect srcW;
if (m_bgLogicalSize.isValid() && m_bgLogicalSize.width() > 0 && m_bgLogicalSize.height() > 0) {
const QRect bounds(0, 0, m_bgLogicalSize.width(), m_bgLogicalSize.height());
// 洞与源不得各自 toAlignedRect:浮点外接框相同宽高时取整常会差 1px,导致 emit 空矩形。
// 以洞的对齐矩形为准,源矩形与之同尺寸,仅平移量取 hole/source 左上角差值的四舍五入。
targetW = m_blackholeCopyHoleRect.toAlignedRect().intersected(bounds);
if (targetW.isValid() && targetW.width() > 0 && targetW.height() > 0) {
const QPointF deltaF =
m_blackholeCopySourceRect.topLeft() - m_blackholeCopyHoleRect.topLeft();
const QPoint d(int(std::lround(deltaF.x())), int(std::lround(deltaF.y())));
srcW = QRect(targetW.topLeft() + d, targetW.size());
if (srcW.left() < bounds.left()) {
srcW.moveLeft(bounds.left());
}
if (srcW.top() < bounds.top()) {
srcW.moveTop(bounds.top());
}
if (srcW.right() > bounds.right()) {
srcW.moveRight(bounds.right());
}
if (srcW.bottom() > bounds.bottom()) {
srcW.moveBottom(bounds.bottom());
}
}
}
if (targetW.isValid() && srcW.isValid() && targetW.width() > 0 && targetW.height() > 0 &&
targetW.width() == srcW.width() && targetW.height() == srcW.height()) {
emit requestResolveBlackholeCopy(m_blackholeCopyEntityId, targetW, srcW);
} else {
emit requestResolveBlackholeCopy(m_blackholeCopyEntityId, QRect(), QRect());
}
}
cancelBlackholeCopyResolve();
e->accept();
return;
}
if (e->button() == Qt::LeftButton || e->button() == Qt::MiddleButton) {
if (m_presentationPreviewMode && e->button() == Qt::LeftButton) {
if (m_presBgPanSession && m_presBgDragDist < 8.0) {
clearPresentationEntityFocus();
}
m_presBgPanSession = false;
m_presBgDragDist = 0.0;
}
if (m_tool == Tool::CreateEntity && e->button() == Qt::LeftButton && m_drawingEntity) {
m_dragging = false;
m_drawingEntity = false;
updateCursor();
if (m_entityCreateSegmentMode == EntityCreateSegmentMode::Manual) {
if (m_strokeWorld.size() >= kMinStrokePointsManual) {
setPendingEntityPolygonWorld(m_strokeWorld);
}
} else if (m_entityCreateSegmentMode == EntityCreateSegmentMode::Snap) {
if (m_strokeWorld.size() >= kMinStrokePointsManual) {
const QRectF polyBr = QPolygonF(m_strokeWorld).boundingRect();
QRect cropWorld;
QImage crop;
if (loadRgbCropForEntitySegmentStroke(polyBr, cropWorld, crop)) {
const QImage gray = crop.convertToFormat(QImage::Format_Grayscale8);
const QVector<QPointF> snapped =
snapStrokeToEdgesWorld(m_strokeWorld, gray, cropWorld.topLeft(), cropWorld.size(), 6.0);
setPendingEntityPolygonWorld(snapped);
}
}
} else if (m_strokeWorld.size() >= kMinStrokePointsSam) {
const QRectF polyBr = QPolygonF(m_strokeWorld).boundingRect();
QRect cropWorld;
QImage crop;
QByteArray cropPng;
QByteArray ovPng;
QPointF cropOrigin;
QJsonArray pts;
QJsonArray labs;
QJsonArray box;
if (loadRgbCropForEntitySegmentStroke(polyBr, cropWorld, crop) &&
buildSamSegmentPayloadFromStrokeMapped(m_strokeWorld, crop, cropWorld, cropPng, ovPng, cropOrigin,
pts, labs, box)) {
emit requestSamSegment(cropPng, ovPng, cropOrigin, pts, labs, box);
}
}
m_strokeWorld.clear();
update();
return;
}
if (m_draggingEntity && m_selectedEntity >= 0 && m_selectedEntity < m_entities.size() && e->button() == Qt::LeftButton) {
const auto& ent = m_entities[m_selectedEntity];
// 关键:提交“平移量”必须与缩放无关。
// 拖动过程中可能实时按深度重算 visualScale(导致 rect/topLeft 随缩放变化),
// 因此不能用 rect.topLeft 差值作为移动 delta,否则松手会错位。
const QPointF endCentroid = m_dragPreviewActive ? (m_dragCentroidBase + m_dragDelta)
: shapeCentroidWorld(ent.polygonWorld, ent.rect);
const QPointF delta = endCentroid - m_entityDragStartCentroidWorld;
bool sentMove = false;
if (!ent.id.isEmpty() && (!qFuzzyIsNull(delta.x()) || !qFuzzyIsNull(delta.y()))) {
emit requestMoveEntity(ent.id, delta);
sentMove = true;
}
if (!sentMove && !ent.id.isEmpty()) {
const QPointF origin =
ent.polygonWorld.isEmpty() ? ent.rect.center() : entity_cutout::polygonCentroid(ent.polygonWorld);
emit selectedEntityChanged(true, ent.id, ent.depth, origin);
}
}
if (m_draggingTool && m_selectedTool >= 0 && m_selectedTool < m_tools.size() && e->button() == Qt::LeftButton) {
const auto& tv = m_tools[m_selectedTool];
const QPointF delta = tv.tool.originWorld - m_toolDragStartOriginWorld;
if (!tv.tool.id.isEmpty() && (!qFuzzyIsNull(delta.x()) || !qFuzzyIsNull(delta.y()))) {
emit requestMoveTool(tv.tool.id, delta);
} else if (!tv.tool.id.isEmpty()) {
emit selectedToolChanged(true, tv.tool.id, tv.tool.originWorld);
}
}
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;
// 先于 requestMoveCamera 清除:MainWindow::refreshEditorPage 需刷新 overlays,否则会误判“仍在拖动”而跳过 setCameraOverlays
m_draggingCamera = false;
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);
}
}
if (m_draggingHotspotIndex >= 0 && e->button() == Qt::LeftButton &&
m_draggingHotspotIndex < m_presentationHotspots.size()) {
const auto& h = m_presentationHotspots[m_draggingHotspotIndex];
emit presentationHotspotMoved(h.id, h.centerWorld);
m_draggingHotspotIndex = -1;
}
m_dragging = false;
if (m_pendingDragging && e->button() == Qt::LeftButton) {
m_pendingDragging = false;
m_pendingDragWhole = false;
m_pendingDragVertex = -1;
}
if (m_draggingEntity) {
emit entityDragActiveChanged(false);
}
m_draggingEntity = false;
m_draggingTool = false;
m_draggingCamera = false;
m_dragPreviewActive = false;
m_dragMode = DragMode::None;
updateCursor();
}
QWidget::mouseReleaseEvent(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)
}
const QPointF cursorView = e->position();
const QPointF beforeWorld = viewToWorld(cursorView);
// 约定:滚轮一步约 15°,这里做平滑指数缩放
const qreal steps = e->angleDelta().y() / 120.0;
const qreal factor = std::pow(1.15, steps);
const qreal newScale = std::clamp(m_scale * factor, kViewScaleMin, kViewScaleMax);
if (qFuzzyCompare(newScale, m_scale)) {
return;
}
m_scale = newScale;
// 让“光标指向的 world 点”缩放后仍落在光标处
const QPointF afterView = worldToView(beforeWorld);
m_pan += (cursorView - afterView);
invalidateViewportLod();
update();
e->accept();
}
void EditorCanvas::keyPressEvent(QKeyEvent* e) {
if (m_blackholeCopyResolveActive && e->key() == Qt::Key_Escape) {
cancelBlackholeCopyResolve();
e->accept();
return;
}
if (m_presentationPreviewMode && e->key() == Qt::Key_Escape) {
clearPresentationEntityFocus();
e->accept();
return;
}
if (!m_presentationPreviewMode && m_pendingPolyWorld.size() >= 3) {
if (e->key() == Qt::Key_Return || e->key() == Qt::Key_Enter) {
emit requestFinalizePendingEntity(m_pendingPolyWorld);
e->accept();
return;
}
if (e->key() == Qt::Key_Escape) {
clearPendingEntityPolygon();
e->accept();
return;
}
}
QWidget::keyPressEvent(e);
}