src/ui/canvas.cpp

Wed, 25 May 2022 20:36:34 +0300

author
Teemu Piippo <teemu@hecknology.net>
date
Wed, 25 May 2022 20:36:34 +0300
changeset 199
6988973515d2
parent 198
eb9d900dc79a
child 200
ca23936b455b
permissions
-rw-r--r--

Fix pick() picking from weird places on the screen with high DPI scaling

glReadPixels reads data from the frame buffer, which contains data after
high DPI scaling, so any reads to that need to take this scaling into account

#include <QMouseEvent>
#include <QPainter>
#include "modeleditor.h"
#include "document.h"
#include "canvas.h"

Canvas::Canvas(
	Model* model,
	Document *document,
	DocumentManager* documents,
	const ldraw::ColorTable& colorTable,
	QWidget* parent) :
	PartRenderer{model, documents, colorTable, parent},
	document{document}
{
	this->setMouseTracking(true);
}

/**
 * @brief Handles a change of selection
 * @param selectedIds IDs of objects to select
 * @param deselectedIds IDs of objects to deselect.
 */
void Canvas::handleSelectionChange(const QSet<ldraw::id_t>& selectedIds, const QSet<ldraw::id_t>& deselectedIds)
{
	Q_ASSERT(not selectedIds.contains(ldraw::NULL_ID));
	this->selection.subtract(deselectedIds);
	this->selection.unite(selectedIds);
	gl::setModelShaderSelectedObjects(&this->shaders, this->selection);
	this->update();
}

/**
 * @brief Updates vertex rendering
 * @param document Document to get vertices from
 */
void Canvas::rebuildVertices(Document* document)
{
	if (this->vertexProgram.has_value())
	{
		this->vertexProgram->build(document);
		this->update();
	}
}

void Canvas::mouseMoveEvent(QMouseEvent* event)
{
	const ldraw::id_t id = this->pick(event->pos());
	this->highlighted = id;
	this->totalMouseMove += (event->pos() - this->lastMousePosition).manhattanLength();
	this->worldPosition = this->screenToModelCoordinates(event->pos(), this->gridPlane);
	if (this->worldPosition.has_value())
	{
		/*
		 * Snap the position to grid. This procedure is basically the "change of basis" and almost follows the
		 * Aโปยน ร— M ร— A formula which is used to perform a transformation in some other coordinate system, except
		 * we actually use the inverted matrix first and the regular one last to perform the transformation of
		 * grid coordinates in our XY coordinate system. Also, we're rounding the coordinates which is obviously
		 * not a linear transformation, but fits the pattern anyway.
		 */
		// First transform the coordinates to the XY plane...
		this->worldPosition = glm::inverse(this->gridMatrix) * glm::vec4{*this->worldPosition, 1};
		// Then round the coordinates to integer precision...
		this->worldPosition = glm::round(*this->worldPosition);
		// And finally transform it back to grid coordinates by transforming it with the
		// grid matrix.
		this->worldPosition = this->gridMatrix * glm::vec4{*this->worldPosition, 1};
	}
	Q_EMIT this->mouseMove(event);
	PartRenderer::mouseMoveEvent(event);
	this->update();
}

void Canvas::mousePressEvent(QMouseEvent* event)
{
	this->totalMouseMove = 0;
	this->lastMousePosition = event->pos();
	PartRenderer::mousePressEvent(event);
}

void Canvas::mouseReleaseEvent(QMouseEvent* event)
{
	if (this->totalMouseMove < (2.0 / sqrt(2)) * 5.0)
	{
		Q_EMIT this->mouseClick(event);
	}
	PartRenderer::mouseReleaseEvent(event);
	this->update();
}

void Canvas::initializeGL()
{
	// We first create the grid program and connect everything and only then call the part renderer's initialization
	// functions so that when initialization sets up, the signals also set up the matrices on our side.
	this->gridProgram.emplace(this);
	this->gridProgram->initialize();
	this->axesProgram.emplace(this);
	this->axesProgram->initialize();
	this->vertexProgram.emplace(this);
	this->vertexProgram->initialize();
	for (AbstractBasicShaderProgram* program : {
		static_cast<AbstractBasicShaderProgram*>(&*this->gridProgram),
		static_cast<AbstractBasicShaderProgram*>(&*this->axesProgram),
		static_cast<AbstractBasicShaderProgram*>(&*this->vertexProgram),
	})
	{
		connect(this, &PartRenderer::projectionMatrixChanged,
			program, &AbstractBasicShaderProgram::setProjectionMatrix);
		connect(this, &PartRenderer::modelMatrixChanged,
			program, &AbstractBasicShaderProgram::setModelMatrix);
		connect(this, &PartRenderer::viewMatrixChanged,
			program, &AbstractBasicShaderProgram::setViewMatrix);
	}
	connect(this, &PartRenderer::renderPreferencesChanged, this, &Canvas::updateCanvasRenderPreferences);
	PartRenderer::initializeGL();
	// Set up XZ grid matrix
	this->setGridMatrix({{1, 0, 0, 0}, {0, 0, 1, 0}, {0, 1, 0, 0}, {0, 0, 0, 1}});
	this->updateCanvasRenderPreferences();
}

static const struct
{
	const QBrush pointBrush = {Qt::white};
	const QPen polygonPen = {QBrush{Qt::black}, 2.0, Qt::DashLine};
	const QPen badPolygonPen = {QBrush{Qt::red}, 2.0, Qt::DashLine};
	const QPen pointPen = {QBrush{Qt::black}, 2.0};
	const QBrush greenPolygonBrush = {QColor{64, 255, 128, 192}};
	const QBrush redPolygonBrush = {QColor{255, 96, 96, 192}};
} pens;

static void renderDrawState(
	QPainter* painter,
	Canvas* canvas,
	DrawState* drawState);

void Canvas::paintGL()
{
	PartRenderer::paintGL();
	if (this->renderPreferences.style != gl::RenderStyle::PickScene)
	{
		// Render axes
		if (this->renderPreferences.drawAxes)
		{
			glLineWidth(5);
			glEnable(GL_LINE_SMOOTH);
			glHint(GL_LINE_SMOOTH_HINT, GL_NICEST);
			this->axesProgram->draw();
			glDisable(GL_LINE_SMOOTH);
		}
		// Render vertices
		{
			glCullFace(GL_FRONT);
			this->vertexProgram->draw();
		}
		// Render grid
		{
			glLineWidth(1);
			glEnable(GL_BLEND);
			glLineStipple(1, 0x8888);
			glEnable(GL_LINE_STIPPLE);
			glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
			this->gridProgram->draw();
			glDisable(GL_BLEND);
			glDisable(GL_LINE_STIPPLE);
		}
		if (this->worldPosition.has_value())
		{
			QPainter painter{this};
			painter.setRenderHint(QPainter::Antialiasing);
			painter.setPen(this->isDark ? Qt::white : Qt::black);
			painter.setBrush(Qt::green);
			const QPointF pos = this->modelToScreenCoordinates(*this->worldPosition);
			painter.drawEllipse(pos, 5, 5);
			painter.drawText(pos + QPointF{5, 5}, vectorToString(*this->worldPosition));
		}
		QPainter painter{this};
		painter.setRenderHint(QPainter::Antialiasing);
		if (this->renderPreferences.drawAxes)
		{
			this->renderAxesLabels(painter);
		}
		if (this->drawState != nullptr) {
			renderDrawState(&painter, this, this->drawState);
		}
	}
}

static void renderDrawState(
	QPainter* painter,
	Canvas* canvas,
	DrawState* drawState)
{
	switch(drawState->mode)
	{
	case SelectMode:
		break;
	case DrawMode:
		{
			painter->setPen(drawState->isconcave ? ::pens.badPolygonPen : ::pens.polygonPen);
			if (drawState->previewPolygon.size() > 2 and not drawState->isconcave)
			{
				if (canvas->worldPolygonWinding(drawState->previewPolygon) == Winding::Clockwise)
				{
					painter->setBrush(::pens.greenPolygonBrush);
				}
				else
				{
					painter->setBrush(::pens.redPolygonBrush);
				}
				canvas->drawWorldPolygon(painter, drawState->previewPolygon);
			}
			else
			{
				canvas->drawWorldPolyline(painter, drawState->previewPolygon);
			}
			painter->setBrush(::pens.pointBrush);
			painter->setPen(::pens.pointPen);
			for (const glm::vec3& point : drawState->polygon)
			{
				canvas->drawWorldPoint(painter, point);
			}
			canvas->drawWorldPoint(painter, drawState->previewPoint);
		}
		break;
	}
}

/**
 * @brief Renders labels such as +x at the ends of axes at the screen
 * @param painter
 */
void Canvas::renderAxesLabels(QPainter& painter)
{
	QFont font;
	//font.setStyle(QFont::StyleItalic);
	painter.setFont(font);
	QFontMetrics fontMetrics{font};
	const auto renderText = [&](const QString& text, const geom::PointOnRectagle& intersection)
	{
		QPointF position = toQPointF(intersection.position);
		const geom::RectangleSide side = intersection.side;
		switch (side)
		{
		case geom::RectangleSide::Top:
			position += QPointF{0, static_cast<qreal>(fontMetrics.ascent())};
			break;
		case geom::RectangleSide::Left:
			break;
		case geom::RectangleSide::Bottom:
			position += QPointF{0, static_cast<qreal>(-fontMetrics.descent())};
			break;
		case geom::RectangleSide::Right:
			position += QPointF{static_cast<qreal>(-fontMetrics.horizontalAdvance(text)), 0};
			break;
		}
		painter.drawText(position, text);
	};
	const QRectF box {QPointF{0, 0}, sizeToSizeF(this->size())};
	const QPointF p1 = this->modelToScreenCoordinates(glm::vec3{0, 0, 0});
	static const struct
	{
		QString text;
		glm::vec3 direction;
	} directions[] =
	{
		{"+๐‘ฅ", {1, 0, 0}},
		{"-๐‘ฅ", {-1, 0, 0}},
		{"+๐‘ฆ", {0, 1, 0}},
		{"-๐‘ฆ", {0, -1, 0}},
		{"+๐‘ง", {0, 0, 1}},
		{"-๐‘ง", {0, 0, -1}},
	};
	for (const auto& axis : directions)
	{
		const QPointF x_p = this->modelToScreenCoordinates(axis.direction);
		const auto intersection = geom::rayRectangleIntersection(
			geom::rayFromPoints(toVec2(p1), toVec2(x_p)),
			box);
		if (intersection.has_value())
		{
			renderText(axis.text, *intersection);
		}
	}
}

/**
 * @brief Draws a polyline to where the specified vector of 3D points would appear on the screen.
 * @param painter Painter to use to draw with
 * @param points 3D points to render
 */
void Canvas::drawWorldPolyline(QPainter *painter, const std::vector<glm::vec3> &points)
{
	painter->drawPolyline(QPolygonF{this->convertWorldPointsToScreenPoints(points)});
}

/**
 * @brief Draws a polygon to where the specified vector of 3D points would appear on the screen.
 * @param painter Painter to use to draw with
 * @param points 3D points to render
 */
void Canvas::drawWorldPolygon(QPainter* painter, const std::vector<glm::vec3> &points)
{
	painter->drawPolygon(QPolygonF{this->convertWorldPointsToScreenPoints(points)});
}

Winding Canvas::worldPolygonWinding(const std::vector<glm::vec3> &points) const
{
	return geom::winding(QPolygonF{this->convertWorldPointsToScreenPoints(points)});
}

/**
 * @brief Gets the current position of the cursor in the model
 * @return 3D vector
 */
const std::optional<glm::vec3>& Canvas::getWorldPosition() const
{
	return this->worldPosition;
}

/**
 * @brief Adjusts the grid to be so that it is perpendicular to the camera.
 */
void adjustGridToView(Canvas* canvas)
{
	const glm::vec3 cameraDirection = canvas->cameraVector();
	const glm::mat4& grid = canvas->getGridMatrix();
	const glm::vec3 vector_x = glm::normalize(grid * glm::vec4{1, 0, 0, 1});
	const glm::vec3 vector_y = glm::normalize(grid * glm::vec4{0, 1, 0, 1});
	const float angle_x = std::abs(glm::dot(vector_x, cameraDirection));
	const float angle_y = std::abs(glm::dot(vector_y, cameraDirection));
	canvas->setGridMatrix(glm::rotate(
		grid,
		pi<> * 0.5f,
		(angle_x < angle_y) ? glm::vec3{1, 0, 0} : glm::vec3{0, 1, 0}
	));
	canvas->update();
}

/**
 * @returns the ids of the currently selected objects 
 */
const QSet<ldraw::id_t> Canvas::selectedObjects() const
{
	return this->selection;
}

const glm::mat4 &Canvas::getGridMatrix() const
{
	return this->gridMatrix;
}

/**
 * @brief Paints a circle at where @c worldPoint is located on the screen.
 * @param painter Painter to use to render
 * @param worldPoint Point to render
 */
void Canvas::drawWorldPoint(QPainter* painter, const glm::vec3& worldPoint) const
{
	const QPointF center = this->modelToScreenCoordinates(worldPoint);
	painter->drawEllipse(geom::inscribe(geom::CircleF{center, 5}));
}

/**
 * @brief Changes the grid matrix to the one specified. Updates relevant member variables.
 * @param newMatrix New matrix to use
 */
void Canvas::setGridMatrix(const glm::mat4& newMatrix)
{
	this->gridMatrix = newMatrix;
	const geom::Triangle triangle {
		this->gridMatrix * glm::vec4{0, 0, 0, 1},
		this->gridMatrix * glm::vec4{1, 0, 0, 1},
		this->gridMatrix * glm::vec4{0, 1, 0, 1},
	};
	this->gridPlane = geom::planeFromTriangle(triangle);
	this->gridProgram->setGridMatrix(this->gridMatrix);
	this->update();
}

/**
 * @brief Gets the current camera vector, i.e. the vector from the camera to the grid origin.
 * @return vector
 */
glm::vec3 Canvas::cameraVector() const
{
	// Find out where the grid is projected on the screen
	const QPoint gridOrigin2d = pointFToPoint(this->modelToScreenCoordinates(this->gridPlane.anchor));
	// Find out which direction the camera is looking at the grid origin in 3d
	return glm::normalize(this->cameraLine(gridOrigin2d).direction);
}

/**
 * @brief Calculates whether the screen is perpendicular to the current grid
 * @return bool
 */
bool Canvas::isGridPerpendicularToScreen(float threshold) const
{
	const glm::vec3 cameraDirection = this->cameraVector();
	// Compute the dot product. The parameters given are:
	// - the normal of the grid plane, which is the vector from the grid origin perpendicular to the grid
	// - the direction of the camera looking at the grid, which is the inverse of the vector from the grid
	//   origin towards the camera
	// If the dot product between these two vectors is 0, the grid normal is perpendicular to the camera vector
	// and the grid is perpendicular to the screen.
	const float dot = glm::dot(glm::normalize(this->gridPlane.normal), glm::normalize(cameraDirection));
	return std::abs(dot) < threshold;
}

QVector<QPointF> Canvas::convertWorldPointsToScreenPoints(const std::vector<glm::vec3> &worldPoints) const
{
	QVector<QPointF> points2d;
	points2d.reserve(worldPoints.size());
	for (const glm::vec3& point : worldPoints)
	{
		points2d.push_back(this->modelToScreenCoordinates(point));
	}
	return points2d;
}

void Canvas::updateCanvasRenderPreferences()
{
	this->isDark = luma(this->renderPreferences.backgroundColor) < 0.25;
	if (this->gridProgram.has_value())
	{
		this->gridProgram->setGridColor(this->isDark ? Qt::white : Qt::black);
	}
}

void Canvas::setOverpaintCallback(Canvas::OverpaintCallback fn)
{
	this->overpaintCallback = fn;
}

mercurial