src/ui/canvas.cpp

Wed, 25 May 2022 17:48:18 +0300

author
Teemu Piippo <teemu@hecknology.net>
date
Wed, 25 May 2022 17:48:18 +0300
changeset 195
6e79c1cb83e6
parent 191
d355d4c52d51
child 197
0e729e681a2c
permissions
-rw-r--r--

added a missing const

#include <QMouseEvent>
#include <QPainter>
#include "modeleditor.h"
#include "document.h"
#include "canvas.h"
#include "linetypes/edge.h"
#include "linetypes/triangle.h"
#include "linetypes/quadrilateral.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 updatePreviewPolygon(DrawState* drawState)
{
	drawState->previewPolygon = drawState->polygon;
	drawState->previewPolygon.resize(drawState->polygon.size() + 1);
	drawState->previewPolygon.back() = drawState->previewPoint;
	if (drawState->previewPolygon.size() > 2)
	{
		drawState->isconcave = not geom::isConvex(drawState->previewPolygon);
	}
}

void removeLastPoint(DrawState* drawState)
{
	if (drawState->polygon.size() > 0)
	{
		drawState->polygon.erase(drawState->polygon.end() - 1);
		updatePreviewPolygon(drawState);
	}
}

bool isCloseToExistingPoints(const std::vector<glm::vec3>& points, const glm::vec3 &pos)
{
	return any(points, std::bind(geom::isclose, std::placeholders::_1, pos));
}

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};
	}
	switch(this->mode)
	{
	case SelectMode:
		break;
	case DrawMode:
		const auto& worldPosition = this->getWorldPosition();
		if (worldPosition.has_value())
		{
			this->drawState.previewPoint = worldPosition.value();
			updatePreviewPolygon(&this->drawState);
			this->update();
		}
		event->accept();
		break;
	}
	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)
	{
		switch(this->mode)
		{
		case SelectMode:
			if (event->button() == Qt::LeftButton)
			{
				const ldraw::id_t highlighted = this->getHighlightedObject();
				this->clearSelection();
				if (highlighted != ldraw::NULL_ID)
				{
					this->addToSelection(highlighted);
				}
				event->accept();
			}
			break;
		case DrawMode:
			if (event->button() == Qt::LeftButton and this->worldPosition.has_value())
			{
				const glm::vec3& pos = worldPosition.value();
				if (isCloseToExistingPoints(this->drawState.polygon, pos))
				{
					this->closeShape();
				}
				else
				{
					this->drawState.polygon.push_back(pos);
					updatePreviewPolygon(&this->drawState);
				}
				event->accept();
			}
			else if (true
				and event->button() == Qt::RightButton
				and this->drawState.polygon.size() > 0
			) {
				this->drawState.polygon.erase(this->drawState.polygon.end() - 1);
				updatePreviewPolygon(&this->drawState);
				event->accept();
			}
			break;
		}
	}
	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;

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);
		}
		switch(this->mode)
		{
		case SelectMode:
			break;
		case DrawMode:
			{
				painter.setPen(this->drawState.isconcave ? ::pens.badPolygonPen : ::pens.polygonPen);
				if (this->drawState.previewPolygon.size() > 2 and not this->drawState.isconcave)
				{
					if (this->worldPolygonWinding(this->drawState.previewPolygon) == Winding::Clockwise)
					{
						painter.setBrush(::pens.greenPolygonBrush);
					}
					else
					{
						painter.setBrush(::pens.redPolygonBrush);
					}
					this->drawWorldPolygon(&painter, this->drawState.previewPolygon);
				}
				else
				{
					this->drawWorldPolyline(&painter, this->drawState.previewPolygon);
				}
				painter.setBrush(::pens.pointBrush);
				painter.setPen(::pens.pointPen);
				for (const glm::vec3& point : this->drawState.polygon)
				{
					this->drawWorldPoint(&painter, point);
				}
				this->drawWorldPoint(&painter, this->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;
}

void Canvas::closeShape()
{
	if (this->drawState.polygon.size() >= 2 and this->drawState.polygon.size() <= 4)
	{
		std::unique_ptr<ModelEditor> modelEditor = this->document->editModel();
		switch (this->drawState.polygon.size())
		{
		case 2:
			modelEditor->append<ldraw::Edge>(
				vectorToArray<2>(this->drawState.polygon),
				ldraw::EDGE_COLOR);
			break;
		case 3:
			modelEditor->append<ldraw::Triangle>(
				vectorToArray<3>(this->drawState.polygon),
				ldraw::MAIN_COLOR);
			break;
		case 4:
			modelEditor->append<ldraw::Quadrilateral>(
				vectorToArray<4>(this->drawState.polygon),
				ldraw::MAIN_COLOR);
			break;
		}
	}
	this->drawState.polygon.clear();
	updatePreviewPolygon(&this->drawState);
}

/**
 * @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);
	}
}

/**
 * @brief Clears the selection.
 */
void Canvas::clearSelection()
{
	this->selection.clear();
	gl::setModelShaderSelectedObjects(&this->shaders, this->selection);
	Q_EMIT selectionChanged(this->selection);
	this->update();
}

/**
 * @brief Adds an object to selection.
 * @param id ID of object to add
 */
void Canvas::addToSelection(ldraw::id_t id)
{
	this->selection.insert(id);
	gl::setModelShaderSelectedObjects(&this->shaders, this->selection);
	Q_EMIT selectionChanged(this->selection);
	this->update();
}

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

mercurial