Files
hfut-bishe/client/gui/editor/EditorCanvas.cpp
2026-04-07 20:55:30 +08:00

1328 lines
48 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 "core/animation/AnimationSampling.h"
#include "core/depth/DepthService.h"
#include <algorithm>
#include <cmath>
#include <QDir>
#include <QFileInfo>
#include <QCursor>
#include <QMouseEvent>
#include <QPainter>
#include <QPaintEvent>
#include <QPainterPath>
#include <QWheelEvent>
namespace {
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));
}
}
QPainterPath pathFromWorldPolygon(const QVector<QPointF>& poly) {
QPainterPath path;
if (poly.size() < 3) {
return path;
}
path.addPolygon(QPolygonF(poly));
path.closeSubpath();
return path;
}
QPointF polygonCentroid(const QVector<QPointF>& poly) {
// 简单多边形质心(面积加权)。若面积接近 0则退化为包围盒中心。
if (poly.size() < 3) {
return {};
}
double a2 = 0.0; // 2*area
double cx6a = 0.0;
double cy6a = 0.0;
for (int i = 0; i < poly.size(); ++i) {
const QPointF p0 = poly[i];
const QPointF p1 = poly[(i + 1) % poly.size()];
const double cross = static_cast<double>(p0.x()) * static_cast<double>(p1.y()) -
static_cast<double>(p1.x()) * static_cast<double>(p0.y());
a2 += cross;
cx6a += (static_cast<double>(p0.x()) + static_cast<double>(p1.x())) * cross;
cy6a += (static_cast<double>(p0.y()) + static_cast<double>(p1.y())) * cross;
}
if (std::abs(a2) < 1e-6) {
const QRectF bb = pathFromWorldPolygon(poly).boundingRect();
return bb.center();
}
const double inv6a = 1.0 / (3.0 * a2); // 1/(6A) with a2=2A -> 1/(3*a2)
return QPointF(cx6a * inv6a, cy6a * inv6a);
}
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));
}
QRect clampRectToImage(const QRect& r, const QSize& size) {
QRect out = r.normalized();
if (out.isNull()) {
return {};
}
out.setLeft(std::max(0, out.left()));
out.setTop(std::max(0, out.top()));
out.setRight(std::min(size.width() - 1, out.right()));
out.setBottom(std::min(size.height() - 1, out.bottom()));
if (out.width() <= 0 || out.height() <= 0) {
return {};
}
return out;
}
int computeAverageDepthInPolygon(const QImage& depth8, const QVector<QPointF>& polyWorld) {
if (depth8.isNull() || depth8.format() != QImage::Format_Grayscale8 || polyWorld.size() < 3) {
return 0;
}
const QPainterPath path = pathFromWorldPolygon(polyWorld);
if (path.isEmpty()) {
return 0;
}
const QRect bbox = clampRectToImage(path.boundingRect().toAlignedRect(), depth8.size());
if (bbox.isNull()) {
return 0;
}
quint64 sum = 0;
quint64 cnt = 0;
for (int y = bbox.top(); y <= bbox.bottom(); ++y) {
const uchar* row = depth8.constScanLine(y);
for (int x = bbox.left(); x <= bbox.right(); ++x) {
// 取像素中心点做包含测试
if (!path.contains(QPointF(x + 0.5, y + 0.5))) {
continue;
}
sum += static_cast<quint64>(row[x]);
++cnt;
}
}
if (cnt == 0) {
return 0;
}
return static_cast<int>(sum / cnt);
}
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;
}
QImage extractEntityImage(const QImage& bg, const QVector<QPointF>& polyWorld, QPointF& outTopLeftWorld) {
if (bg.isNull() || polyWorld.size() < 3) {
outTopLeftWorld = {};
return {};
}
const QPainterPath path = pathFromWorldPolygon(polyWorld);
if (path.isEmpty()) {
outTopLeftWorld = {};
return {};
}
const QRect bbox = clampRectToImage(path.boundingRect().toAlignedRect(), bg.size());
if (bbox.isNull()) {
outTopLeftWorld = {};
return {};
}
outTopLeftWorld = bbox.topLeft();
QImage out(bbox.size(), QImage::Format_ARGB32_Premultiplied);
out.fill(Qt::transparent);
QPainter p(&out);
p.setRenderHint(QPainter::Antialiasing, true);
// 把 path 平移到裁剪图坐标系
QTransform tr;
tr.translate(-bbox.left(), -bbox.top());
const QPainterPath localPath = tr.map(path);
p.setClipPath(localPath);
p.drawImage(QPoint(0, 0), bg, bbox);
p.end();
return out;
}
void applyBlackFillToBackground(QImage& bgCutout, const QVector<QPointF>& polyWorld) {
if (bgCutout.isNull() || polyWorld.size() < 3) {
return;
}
QPainterPath path = pathFromWorldPolygon(polyWorld);
if (path.isEmpty()) {
return;
}
QPainter p(&bgCutout);
p.setRenderHint(QPainter::Antialiasing, true);
p.setPen(Qt::NoPen);
p.setBrush(QColor(0, 0, 0, 255));
p.drawPath(path);
p.end();
}
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 {};
}
} // namespace
EditorCanvas::EditorCanvas(QWidget* parent)
: QWidget(parent) {
setAutoFillBackground(false);
setMinimumSize(480, 320);
setFocusPolicy(Qt::StrongFocus);
setMouseTracking(true);
m_previewEmitTimer.start();
updateCursor();
}
void EditorCanvas::setPresentationPreviewMode(bool on) {
if (m_presentationPreviewMode == on) {
return;
}
m_presentationPreviewMode = on;
if (on) {
m_tool = Tool::Move;
m_selectedEntity = -1;
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 QString& projectDirAbs) {
const QString prevSelectedId =
(m_selectedEntity >= 0 && m_selectedEntity < m_entities.size()) ? m_entities[m_selectedEntity].id : QString();
m_entities.clear();
m_entities.reserve(entities.size());
// 需要用深度图来自动计算 z 与缩放(逐帧)
if (!m_depthAbsPath.isEmpty()) {
if (m_depthDirty) {
m_depthDirty = false;
QImage img(m_depthAbsPath);
m_depthImage8 = img.isNull() ? QImage() : img.convertToFormat(QImage::Format_Grayscale8);
}
}
for (const auto& e : entities) {
Entity v;
v.id = e.id;
v.hiddenInEditMode = !m_presentationPreviewMode && !e.visible;
using core::KeyInterpolation;
const QPointF originWorld =
core::sampleLocation(e.locationKeys, m_currentFrame, e.originWorld, KeyInterpolation::Linear);
v.animatedOriginWorld = originWorld;
v.cutoutPolygonWorld = e.cutoutPolygonWorld;
// 逐帧自动算 z使用实体多边形质心作为锚点采样深度O(1)),避免卡顿
QVector<QPointF> polyTmp;
polyTmp.reserve(e.polygonLocal.size());
for (const auto& lp : e.polygonLocal) {
polyTmp.push_back(originWorld + lp);
}
const QPointF cTmp = polyTmp.isEmpty() ? originWorld : polygonCentroid(polyTmp);
const int z = (!m_depthImage8.isNull()) ? sampleDepthAtPoint(m_depthImage8, cTmp) : e.depth;
v.depth = z;
const double ds01 = depthToScale01(z);
v.animatedDepthScale01 = ds01;
const double userScaleAnimated =
core::sampleUserScale(e.userScaleKeys, m_currentFrame, e.userScale, core::KeyInterpolation::Linear);
v.userScale = std::max(1e-6, userScaleAnimated);
const double distScale = 0.5 + ds01 * 1.0; // 0..1 -> 0.5..1.5(深度距离)
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 = pathFromWorldPolygon(v.polygonWorld);
v.rect = v.pathWorld.boundingRect();
v.color = QColor(255, 120, 0, 70);
const QString imgRel = core::sampleImagePath(e.imageFrames, m_currentFrame, e.imagePath);
if (!imgRel.isEmpty() && !projectDirAbs.isEmpty()) {
const QString abs = QDir(projectDirAbs).filePath(imgRel);
if (QFileInfo::exists(abs)) {
QImage img(abs);
if (!img.isNull() && img.format() != QImage::Format_ARGB32_Premultiplied) {
img = img.convertToFormat(QImage::Format_ARGB32_Premultiplied);
}
v.image = img;
}
}
m_entities.push_back(v);
}
// 绘制/命中顺序:深度小(远)先画,大(近)后画,近处盖住远处
std::stable_sort(m_entities.begin(), m_entities.end(),
[](const Entity& a, const Entity& b) {
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_selectedEntity >= 0) {
const auto& ent = m_entities[m_selectedEntity];
const QPointF origin =
ent.polygonWorld.isEmpty() ? ent.rect.center() : polygonCentroid(ent.polygonWorld);
emit selectedEntityChanged(true, ent.id, ent.depth, origin);
} else if (!prevSelectedId.isEmpty()) {
emit selectedEntityChanged(false, QString(), 0, QPointF());
}
m_bgCutoutDirty = true;
update();
}
void EditorCanvas::setCurrentFrame(int frame) {
if (m_currentFrame == frame) {
return;
}
m_currentFrame = std::max(0, frame);
// 仅切帧时,实体由 MainWindow 刷新时回灌;这里也触发重绘用于坐标轴/叠加
update();
}
QPointF EditorCanvas::selectedAnimatedOriginWorld() 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 polygonCentroid(ent.polygonWorld);
}
return ent.rect.center();
}
double EditorCanvas::selectedDistanceScaleMultiplier() const {
if (m_selectedEntity < 0 || m_selectedEntity >= m_entities.size()) {
return 1.0;
}
const double ds01 = std::clamp(m_entities[m_selectedEntity].animatedDepthScale01, 0.0, 1.0);
return 0.5 + ds01 * 1.0;
}
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::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();
return;
}
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() : polygonCentroid(ent.polygonWorld);
emit selectedEntityChanged(true, ent.id, ent.depth, origin);
update();
return;
}
clearEntitySelection();
}
void EditorCanvas::setBackgroundImagePath(const QString& absolutePath) {
if (m_bgAbsPath == absolutePath) {
return;
}
m_bgAbsPath = absolutePath;
invalidatePixmap();
m_bgImageDirty = true;
m_bgCutoutDirty = true;
m_bgImage = QImage();
m_bgImageCutout = QImage();
zoomToFit();
update();
}
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::resetView() {
m_scale = 1.0;
m_pan = QPointF(0, 0);
update();
}
void EditorCanvas::zoomToFit() {
ensurePixmapLoaded();
if (m_bgPixmap.isNull() || width() <= 1 || height() <= 1) {
resetView();
return;
}
const QSizeF viewSize = size();
const QSizeF imgSize = 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>(0.05, 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);
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();
}
void EditorCanvas::ensurePixmapLoaded() const {
if (!m_pixmapDirty) {
return;
}
m_pixmapDirty = false;
m_bgPixmap = QPixmap();
if (!m_bgAbsPath.isEmpty()) {
m_bgPixmap = QPixmap(m_bgAbsPath);
}
m_bgImageDirty = true;
m_bgCutoutDirty = true;
}
void EditorCanvas::updateCursor() {
if (m_presentationPreviewMode) {
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;
}
}
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_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.hiddenInEditMode) {
continue;
}
if (!ent.polygonWorld.isEmpty()) {
const QPainterPath path = 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;
}
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()) {
p.setPen(palette().text().color());
p.drawText(r.adjusted(12, 12, -12, -12), Qt::AlignCenter,
QStringLiteral("(暂无背景)\n右键项目树中的“背景”可更换/裁剪"));
return;
}
if (m_bgPixmap.isNull()) {
p.setPen(palette().text().color());
p.drawText(r.adjusted(12, 12, -12, -12), Qt::AlignCenter,
QStringLiteral("(背景加载失败)\n%1").arg(m_bgAbsPath));
return;
}
// 预览:始终加载完整背景图,不做抠洞
const bool showBg = m_presentationPreviewMode || m_backgroundVisible;
if (showBg) {
if (m_bgImageDirty) {
m_bgImageDirty = false;
m_bgImage = QImage(m_bgAbsPath);
if (m_bgImage.format() != QImage::Format_ARGB32_Premultiplied && !m_bgImage.isNull()) {
m_bgImage = m_bgImage.convertToFormat(QImage::Format_ARGB32_Premultiplied);
}
m_bgCutoutDirty = true;
}
if (!m_presentationPreviewMode && m_bgCutoutDirty) {
m_bgCutoutDirty = false;
m_bgImageCutout = m_bgImage;
for (const auto& ent : m_entities) {
if (!ent.cutoutPolygonWorld.isEmpty() && !m_bgImageCutout.isNull()) {
applyBlackFillToBackground(m_bgImageCutout, ent.cutoutPolygonWorld);
}
}
}
}
// 以“世界坐标”绘制:支持缩放/平移
p.save();
QTransform t;
t.translate(m_pan.x(), m_pan.y());
t.scale(m_scale, m_scale);
p.setTransform(t, true);
// 背景(预览:完整图;编辑:可抠洞 / 可隐藏看深度)
if (showBg) {
if (m_presentationPreviewMode) {
if (!m_bgImage.isNull()) {
p.drawImage(QPointF(0, 0), m_bgImage);
} else {
p.drawPixmap(QPointF(0, 0), m_bgPixmap);
}
} else if (m_backgroundVisible) {
if (!m_bgImageCutout.isNull()) {
p.drawImage(QPointF(0, 0), m_bgImageCutout);
} else {
p.drawPixmap(QPointF(0, 0), m_bgPixmap);
}
}
if (!m_presentationPreviewMode && m_backgroundVisible) {
p.setPen(QPen(QColor(0, 0, 0, 80), 1.0 / std::max<qreal>(m_scale, 0.001)));
p.drawRect(QRectF(0, 0, m_bgPixmap.width(), m_bgPixmap.height()).adjusted(0, 0, -1, -1));
}
}
// 深度叠加(伪彩色):仅由「叠加深度」开关控制,与是否显示背景无关
const bool wantDepth =
!m_presentationPreviewMode && (!m_depthAbsPath.isEmpty()) && m_depthOverlayEnabled;
if (wantDepth) {
if (m_depthDirty) {
m_depthDirty = false;
QImage img(m_depthAbsPath);
m_depthImage8 = img.isNull() ? QImage() : img.convertToFormat(QImage::Format_Grayscale8);
}
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));
}
}
// 实体元素(占位):后续可替换为真实数据
for (int i = 0; i < m_entities.size(); ++i) {
const auto& ent = m_entities[i];
if (ent.hiddenInEditMode && i != m_selectedEntity) {
continue;
}
const bool isDragPreview = (!m_presentationPreviewMode && m_draggingEntity && m_dragPreviewActive && i == m_selectedEntity);
if (!ent.polygonWorld.isEmpty()) {
// 优先绘制抠出来的实体图像(独立存在)
if (!ent.image.isNull()) {
if (isDragPreview) {
// 预览:先平移,再围绕“基准质心”缩放(避免把 delta 叠加两次导致错位)
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 QSizeF sz(ent.image.width() * ent.visualScale, ent.image.height() * ent.visualScale);
const QRectF target(ent.imageTopLeft, sz);
p.drawImage(target, ent.image);
}
} 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));
}
}
}
// 创建实体手绘轨迹预览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());
}
p.restore();
// 坐标轴/刻度:绘制在画布最外层,背景越界时贴边显示
ensurePixmapLoaded();
if (!m_presentationPreviewMode && m_worldAxesVisible && !m_bgPixmap.isNull()) {
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 轴(垂直)
const int step = 100;
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 上
for (int x = 0; x <= m_bgPixmap.width(); x += step) {
const QPointF vx = worldToView(QPointF(x, 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) {
p.drawText(QPointF(tx + 2, axisY + 18), QString::number(x));
}
}
// Y 轴:用 x=0 的世界线映射到 view-y并把刻度画在 axisX 上
for (int y = 0; y <= m_bgPixmap.height(); y += step) {
const QPointF vy = worldToView(QPointF(0, y));
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) {
p.drawText(QPointF(axisX + 10, ty - 2), QString::number(y));
}
}
}
// 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 = polygonCentroid(ent.polygonWorld);
}
const QPointF originView = worldToView(originWorld);
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"));
}
}
}
void EditorCanvas::resizeEvent(QResizeEvent* e) {
QWidget::resizeEvent(e);
// 仅触发重绘pixmap 只缓存原图
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;
QImage img(m_depthAbsPath);
m_depthImage8 = img.isNull() ? QImage() : img.convertToFormat(QImage::Format_Grayscale8);
}
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) {
if (e->button() == Qt::LeftButton || e->button() == Qt::MiddleButton) {
m_dragging = true;
m_lastMouseView = e->position();
setCursor(Qt::ClosedHandCursor);
}
return;
}
if (m_tool == Tool::CreateEntity && e->button() == Qt::LeftButton) {
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) {
// 优先:若已选中实体,且点在 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 = ent.rect.center();
if (isDragPreview) {
originWorld = m_dragCentroidBase + m_dragDelta;
} else if (!ent.polygonWorld.isEmpty()) {
originWorld = polygonCentroid(ent.polygonWorld);
}
const QPointF originView = worldToView(originWorld);
const GizmoHit gh = hitTestGizmo(e->position(), originView);
if (gh.mode == DragMode::AxisX || gh.mode == DragMode::AxisY) {
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()
? pathFromWorldPolygon(m_entities[m_selectedEntity].polygonWorld).boundingRect()
: m_entities[m_selectedEntity].rect;
m_entities[m_selectedEntity].rect = r;
m_entityDragOffsetOriginWorld = viewToWorld(e->position()) - m_entities[m_selectedEntity].animatedOriginWorld;
m_entityDragStartAnimatedOrigin = m_entities[m_selectedEntity].animatedOriginWorld;
// drag preview baseline
m_dragPreviewActive = true;
m_dragDelta = QPointF(0, 0);
m_dragOriginBase = m_entities[m_selectedEntity].animatedOriginWorld;
m_dragRectBase = m_entities[m_selectedEntity].rect;
m_dragImageTopLeftBase = m_entities[m_selectedEntity].imageTopLeft;
m_dragScaleBase = std::max(1e-6, m_entities[m_selectedEntity].visualScale);
m_dragScaleRatio = 1.0;
m_dragPolyBase = m_entities[m_selectedEntity].polygonWorld;
m_dragPathBase = m_entities[m_selectedEntity].pathWorld;
m_dragCentroidBase =
m_dragPolyBase.isEmpty() ? m_dragRectBase.center() : polygonCentroid(m_dragPolyBase);
update();
return;
}
}
const int hit = hitTestEntity(worldPos);
if (hit >= 0) {
m_selectedEntity = hit;
m_draggingEntity = true;
m_dragMode = DragMode::Free;
emit entityDragActiveChanged(true);
const QRectF r = m_entities[hit].rect.isNull() && !m_entities[hit].polygonWorld.isEmpty()
? pathFromWorldPolygon(m_entities[hit].polygonWorld).boundingRect()
: m_entities[hit].rect;
m_entities[hit].rect = r;
m_entityDragOffsetOriginWorld = worldPos - m_entities[hit].animatedOriginWorld;
m_entityDragStartAnimatedOrigin = m_entities[hit].animatedOriginWorld;
// 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() : polygonCentroid(m_dragPolyBase);
const QPointF origin = !m_entities[hit].polygonWorld.isEmpty() ? polygonCentroid(m_entities[hit].polygonWorld)
: m_entities[hit].rect.center();
emit selectedEntityChanged(true, m_entities[hit].id, m_entities[hit].depth, origin);
update();
return;
}
m_selectedEntity = -1;
m_draggingEntity = false;
m_dragMode = DragMode::None;
emit selectedEntityChanged(false, QString(), 0, 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;
QImage img(m_depthAbsPath);
m_depthImage8 = img.isNull() ? QImage() : img.convertToFormat(QImage::Format_Grayscale8);
}
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_dragging) {
QWidget::mouseMoveEvent(e);
return;
}
const QPointF cur = e->position();
const QPointF deltaView = cur - m_lastMouseView;
m_lastMouseView = cur;
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_draggingEntity && m_selectedEntity >= 0 && m_selectedEntity < m_entities.size()) {
const QPointF worldPos = viewToWorld(cur);
auto& ent = m_entities[m_selectedEntity];
const QPointF newOrigin = worldPos - m_entityDragOffsetOriginWorld;
QPointF delta = newOrigin - ent.animatedOriginWorld;
// 轴约束:只允许沿 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;
QImage img(m_depthAbsPath);
m_depthImage8 = img.isNull() ? QImage() : img.convertToFormat(QImage::Format_Grayscale8);
}
if (!m_depthImage8.isNull()) {
// 深度采样点:使用“预览质心的平移后位置”
const QPointF c = (m_dragPreviewActive ? (m_dragCentroidBase + m_dragDelta) : ent.animatedOriginWorld);
const int depthZ = sampleDepthAtPoint(m_depthImage8, c);
ent.depth = depthZ;
const double ds01 = depthToScale01(depthZ);
ent.animatedDepthScale01 = ds01;
const double newScale = (0.5 + ds01 * 1.0) * ent.userScale;
ent.visualScale = newScale;
if (m_dragPreviewActive) {
m_dragScaleRatio = std::clamp(newScale / std::max(1e-6, m_dragScaleBase), 0.02, 50.0);
}
}
}
// 低频预览信号:用于属性面板同步,不做树联动等重操作
// 属性面板/状态预览:按 30Hz 刷新即可,拖动视觉仍满帧跟手
if (nowMs - m_lastPreviewEmitMs >= 33) {
m_lastPreviewEmitMs = nowMs;
emit selectedEntityPreviewChanged(ent.id, ent.depth, ent.animatedOriginWorld);
}
update();
return;
}
// 平移画布
if (m_tool == Tool::Move || (e->buttons() & Qt::MiddleButton) ||
(m_presentationPreviewMode && (e->buttons() & Qt::LeftButton))) {
m_pan += deltaView;
update();
return;
}
}
void EditorCanvas::mouseReleaseEvent(QMouseEvent* e) {
if (e->button() == Qt::LeftButton || e->button() == Qt::MiddleButton) {
if (m_tool == Tool::CreateEntity && e->button() == Qt::LeftButton && m_drawingEntity) {
m_dragging = false;
m_drawingEntity = false;
updateCursor();
if (m_strokeWorld.size() >= 8) {
// 闭合:末点连回首点(绘制时 polygon 自动闭合)
core::Project::Entity ent;
// id 由上层workspace/UI统一分配避免因过滤/隐藏导致重复
ent.id.clear();
ent.cutoutPolygonWorld = m_strokeWorld;
ent.originWorld = polygonCentroid(ent.cutoutPolygonWorld);
ent.polygonLocal.clear();
ent.polygonLocal.reserve(ent.cutoutPolygonWorld.size());
for (const auto& pt : ent.cutoutPolygonWorld) {
ent.polygonLocal.push_back(pt - ent.originWorld);
}
// 确保深度加载(用于平均深度)
if (m_depthDirty) {
m_depthDirty = false;
QImage img(m_depthAbsPath);
m_depthImage8 = img.isNull() ? QImage() : img.convertToFormat(QImage::Format_Grayscale8);
}
const QPointF c = polygonCentroid(ent.cutoutPolygonWorld);
ent.depth = m_depthImage8.isNull() ? 0 : sampleDepthAtPoint(m_depthImage8, c);
// 抠实体:从原背景抠出,背景对应区域填黑
QImage cutout;
if (!m_bgImage.isNull()) {
QPointF topLeft;
cutout = extractEntityImage(m_bgImage, ent.cutoutPolygonWorld, topLeft);
ent.imageTopLeftWorld = topLeft;
}
emit requestAddEntity(ent, cutout);
}
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 delta = ent.animatedOriginWorld - m_entityDragStartAnimatedOrigin;
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() : polygonCentroid(ent.polygonWorld);
emit selectedEntityChanged(true, ent.id, ent.depth, origin);
}
}
m_dragging = false;
if (m_draggingEntity) {
emit entityDragActiveChanged(false);
}
m_draggingEntity = false;
m_dragPreviewActive = false;
m_dragMode = DragMode::None;
updateCursor();
}
QWidget::mouseReleaseEvent(e);
}
void EditorCanvas::wheelEvent(QWheelEvent* e) {
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, 0.05, 50.0);
if (qFuzzyCompare(newScale, m_scale)) {
return;
}
m_scale = newScale;
// 让“光标指向的 world 点”缩放后仍落在光标处
const QPointF afterView = worldToView(beforeWorld);
m_pan += (cursorView - afterView);
update();
e->accept();
}