282 lines
11 KiB
C++
282 lines
11 KiB
C++
#include "Document/EditorSession.h"
|
|
#include "Document/DocumentLimits.h"
|
|
#include "Document/PixelAdjust.h"
|
|
#include "IO/ImageExporter.h"
|
|
#include "Logging.h"
|
|
#include <QPainter>
|
|
#include <QPainterPathStroker>
|
|
#include <cmath>
|
|
#include <numbers>
|
|
#include <stdexcept>
|
|
|
|
QString rawValue(ShapeKind kind)
|
|
{
|
|
switch (kind) {
|
|
case ShapeKind::rectangle:
|
|
return QStringLiteral("Rectangle");
|
|
case ShapeKind::ellipse:
|
|
return QStringLiteral("Ellipse");
|
|
case ShapeKind::line:
|
|
return QStringLiteral("Line");
|
|
}
|
|
throw std::logic_error("unknown shape kind");
|
|
}
|
|
|
|
std::optional<ShapeKind> shapeKind(const QString &text)
|
|
{
|
|
for (const ShapeKind kind : allShapeKinds) {
|
|
if (rawValue(kind) == text)
|
|
return kind;
|
|
}
|
|
return std::nullopt;
|
|
}
|
|
|
|
QPainterPath path(ShapeKind kind, const QRectF &rect, double cornerRadius)
|
|
{
|
|
QPainterPath made;
|
|
if (kind == ShapeKind::ellipse) {
|
|
made.addEllipse(rect);
|
|
return made;
|
|
}
|
|
// Below zero, like zero, keeps the corners square.
|
|
const double radius = std::min({cornerRadius, rect.width() / 2, rect.height() / 2});
|
|
if (radius > 0)
|
|
made.addRoundedRect(rect, radius, radius);
|
|
else
|
|
made.addRect(rect);
|
|
return made;
|
|
}
|
|
|
|
QPainterPath strokedLine(QPointF from, QPointF to, double width)
|
|
{
|
|
QPainterPath outline;
|
|
if (from == to) {
|
|
// CoreGraphics caps a zero-length line as a dot.
|
|
outline.addEllipse(from, width / 2, width / 2);
|
|
return outline;
|
|
}
|
|
QPainterPath segment(from);
|
|
segment.lineTo(to);
|
|
QPainterPathStroker stroker;
|
|
stroker.setWidth(width);
|
|
stroker.setCapStyle(Qt::RoundCap);
|
|
return stroker.createStroke(segment);
|
|
}
|
|
|
|
std::optional<LayerShape> LayerShape::loaded(const std::optional<LayerShapeStyle> &style, const std::optional<ImportedImage> &image)
|
|
{
|
|
if (!style || !image)
|
|
return std::nullopt;
|
|
return LayerShape{*style, image->identity()};
|
|
}
|
|
|
|
std::optional<LayerShape> ImageLayer::liveShape() const
|
|
{
|
|
if (!shape || !asset || asset->identity() != shape->image)
|
|
return std::nullopt;
|
|
return shape;
|
|
}
|
|
|
|
void EditorSession::setShapeKind(ShapeKind kind)
|
|
{
|
|
m_shapeKind = kind;
|
|
notify();
|
|
}
|
|
|
|
void EditorSession::setShapeCornerRadius(double radius)
|
|
{
|
|
m_shapeCornerRadius = radius;
|
|
notify();
|
|
}
|
|
|
|
void EditorSession::setShapeLineWidth(double width)
|
|
{
|
|
m_shapeLineWidth = width;
|
|
notify();
|
|
}
|
|
|
|
void EditorSession::beginShape(QPointF point)
|
|
{
|
|
if (m_tool != NavigationTool::shape || !canEditLayers() || !std::isfinite(point.x()) || !std::isfinite(point.y()))
|
|
return;
|
|
const QPointF anchor(std::round(point.x()), std::round(point.y()));
|
|
m_shapeDraft = ShapeDraft{.kind = m_shapeKind, .anchor = anchor, .rect = QRectF(anchor, QSizeF(0, 0)),
|
|
.cornerRadius = m_shapeKind == ShapeKind::rectangle ? m_shapeCornerRadius : 0};
|
|
notify();
|
|
}
|
|
|
|
std::optional<std::pair<QPointF, QPointF>> EditorSession::shapeLineEnds() const
|
|
{
|
|
// Only a line's drag sets an end.
|
|
if (!m_shapeDraft || !m_shapeDraft->end)
|
|
return std::nullopt;
|
|
return std::pair(m_shapeDraft->anchor, *m_shapeDraft->end);
|
|
}
|
|
|
|
void EditorSession::dragShape(QPointF point, bool square, bool fromCenter)
|
|
{
|
|
if (!m_shapeDraft || !std::isfinite(point.x()) || !std::isfinite(point.y()))
|
|
return;
|
|
ShapeDraft &draft = *m_shapeDraft;
|
|
if (draft.kind == ShapeKind::line && square) {
|
|
// Shift snaps a line to eighths of a turn.
|
|
const double dx = point.x() - draft.anchor.x(), dy = point.y() - draft.anchor.y();
|
|
const double angle = std::round(std::atan2(dy, dx) / (std::numbers::pi / 4)) * (std::numbers::pi / 4);
|
|
const double length = std::hypot(dx, dy);
|
|
const QPointF snapped(draft.anchor.x() + std::cos(angle) * length, draft.anchor.y() + std::sin(angle) * length);
|
|
draft.end = snapped;
|
|
draft.rect = DragBox::rect(draft.anchor, snapped, false, fromCenter);
|
|
} else {
|
|
if (draft.kind == ShapeKind::line)
|
|
draft.end = point;
|
|
draft.rect = DragBox::rect(draft.anchor, point, square, fromCenter);
|
|
}
|
|
notify();
|
|
}
|
|
|
|
void EditorSession::cancelShape()
|
|
{
|
|
if (!m_shapeDraft)
|
|
return;
|
|
m_shapeDraft = std::nullopt;
|
|
notify();
|
|
}
|
|
|
|
void EditorSession::toggleShapeKind()
|
|
{
|
|
cancelShape();
|
|
setShapeKind(allShapeKinds[(size_t(m_shapeKind) + 1) % allShapeKinds.size()]);
|
|
}
|
|
|
|
void EditorSession::finishShape()
|
|
{
|
|
if (!m_shapeDraft)
|
|
return;
|
|
const ShapeDraft draft = *std::exchange(m_shapeDraft, std::nullopt);
|
|
notify();
|
|
QRectF rect = draft.rect;
|
|
const double thickness = m_shapeLineWidth;
|
|
// A line's box holds its two points and its thickness.
|
|
std::optional<std::pair<QPointF, QPointF>> ends;
|
|
if (draft.kind == ShapeKind::line) {
|
|
const QPointF from = draft.anchor, to = draft.end.value_or(draft.anchor);
|
|
rect = QRectF(std::min(from.x(), to.x()), std::min(from.y(), to.y()), std::abs(to.x() - from.x()), std::abs(to.y() - from.y()))
|
|
.adjusted(-thickness / 2, -thickness / 2, thickness / 2, thickness / 2);
|
|
ends = std::pair(from, to);
|
|
}
|
|
if (!canEditLayers() || !m_document || rect.width() < 1 || rect.height() < 1)
|
|
return;
|
|
// Swift's Int(): each side truncated before multiplying.
|
|
if (std::trunc(rect.width()) * std::trunc(rect.height()) > double(maxShapePixels)) {
|
|
setBrushError(QStringLiteral("That shape is too large. A shape can cover up to %1 megapixels.").arg(DocumentLimits::maxSurfaceMegapixels()));
|
|
return;
|
|
}
|
|
// The ends as fractions of the box, for resizing.
|
|
const auto unit = [&rect](QPointF point) { return QPointF((point.x() - rect.left()) / rect.width(), (point.y() - rect.top()) / rect.height()); };
|
|
const std::optional<QPointF> start = ends ? std::optional(unit(ends->first)) : std::nullopt;
|
|
const std::optional<QPointF> finish = ends ? std::optional(unit(ends->second)) : std::nullopt;
|
|
const PaletteColor color = foregroundColor();
|
|
try {
|
|
const QImage image = shapeImage(draft.kind, rect.size(), color, draft.cornerRadius, thickness, start, finish);
|
|
const LayerShapeStyle style{.kind = draft.kind, .red = color.red, .green = color.green, .blue = color.blue,
|
|
.cornerRadius = draft.cornerRadius,
|
|
.lineWidth = draft.kind == ShapeKind::line ? std::optional(thickness) : std::nullopt,
|
|
.start = start, .end = finish};
|
|
addPixelLayer(image, rect.topLeft(), nextShapeName(draft.kind), rawValue(draft.kind), false, style);
|
|
} catch (const ExportError &error) {
|
|
setBrushError(QString::fromUtf8(error.what()));
|
|
}
|
|
}
|
|
|
|
QString EditorSession::nextShapeName(ShapeKind kind) const
|
|
{
|
|
QSet<QString> names;
|
|
if (m_document) {
|
|
for (const ImageLayer &layer : m_document->layers)
|
|
names.insert(layer.name);
|
|
}
|
|
int number = 1;
|
|
while (names.contains(QStringLiteral("%1 %2").arg(rawValue(kind)).arg(number)))
|
|
++number;
|
|
return QStringLiteral("%1 %2").arg(rawValue(kind)).arg(number);
|
|
}
|
|
|
|
void EditorSession::redrawShape(int index)
|
|
{
|
|
ImageLayer &layer = m_document->layers[size_t(index)];
|
|
const std::optional<LayerShape> shape = layer.liveShape();
|
|
if (!shape)
|
|
return;
|
|
// A valid transform is a pixel wide at least.
|
|
const int width = int(std::round(layer.transform.size.width())), height = int(std::round(layer.transform.size.height()));
|
|
if ((width == layer.asset->size().width() && height == layer.asset->size().height()) || qint64(width) * height > maxShapePixels)
|
|
return;
|
|
QImage image, thumbnail;
|
|
try {
|
|
image = shapeImage(shape->style.kind, QSizeF(width, height), shape->style.color(), shape->style.cornerRadius,
|
|
shape->style.lineWidth.value_or(0), shape->style.start, shape->style.end);
|
|
thumbnail = PixelAdjust::thumbnail(image);
|
|
} catch (const ExportError &error) {
|
|
// Swift's try? keeps the stretched pixels without a word.
|
|
qCWarning(lcApp) << "a shape could not be drawn at its new size:" << error.what();
|
|
return;
|
|
}
|
|
// The mask stays where it shows while the grid changes.
|
|
if (layer.mask)
|
|
layer.mask->placement = layer.maskTransform();
|
|
layer.asset = ImportedImage(image, thumbnail, layer.asset->name);
|
|
layer.shape = LayerShape{shape->style, layer.asset->identity()};
|
|
}
|
|
|
|
QImage EditorSession::shapeTransformPreview(const ImageLayer &layer, const LayerTransform &transform) const
|
|
{
|
|
const std::optional<LayerShape> shape = layer.liveShape();
|
|
if (!m_transformEdit || !shape || shape->style.kind != ShapeKind::rectangle || shape->style.cornerRadius <= 0) {
|
|
if (!m_shapeTransformPreviewCache.empty() && !m_transformEdit)
|
|
m_shapeTransformPreviewCache.clear();
|
|
return QImage();
|
|
}
|
|
const QSizeF size = transform.size;
|
|
const QSize pixels = layer.asset->size();
|
|
if (size.width() < 1 || size.height() < 1
|
|
|| (std::abs(size.width() - pixels.width()) < 0.5 && std::abs(size.height() - pixels.height()) < 0.5))
|
|
return QImage();
|
|
const double factor = std::min(1.0, 2048 / std::max(size.width(), size.height()));
|
|
const QSizeF drawn(std::max(1.0, std::round(size.width() * factor)), std::max(1.0, std::round(size.height() * factor)));
|
|
if (m_shapeTransformPreviewCache.contains(layer.id) && m_shapeTransformPreviewCache.at(layer.id).size == drawn)
|
|
return m_shapeTransformPreviewCache.at(layer.id).image;
|
|
QImage image;
|
|
try {
|
|
image = shapeImage(ShapeKind::rectangle, drawn, shape->style.color(), shape->style.cornerRadius * factor);
|
|
} catch (const ExportError &error) {
|
|
qCWarning(lcRendering) << "no rounded preview for a resized shape:" << error.what();
|
|
return QImage();
|
|
}
|
|
m_shapeTransformPreviewCache.insert_or_assign(layer.id, ShapePreview{drawn, image});
|
|
return image;
|
|
}
|
|
|
|
QImage EditorSession::shapeImage(ShapeKind kind, QSizeF size, const PaletteColor &color, double cornerRadius, double lineWidth,
|
|
std::optional<QPointF> start, std::optional<QPointF> end)
|
|
{
|
|
QImage context = BrushRaster::context(int(size.width()), int(size.height()), false);
|
|
const QRectF bounds(QPointF(0, 0), size);
|
|
QPainter painter(&context);
|
|
painter.setRenderHint(QPainter::Antialiasing, true);
|
|
if (kind != ShapeKind::line) {
|
|
painter.fillPath(path(kind, bounds, cornerRadius), color.color());
|
|
painter.end();
|
|
return context;
|
|
}
|
|
const double thickness = std::max(1.0, lineWidth);
|
|
// Lines saved without ends ran corner to corner, inset.
|
|
const double dx = std::min(thickness, size.width()) / 2, dy = std::min(thickness, size.height()) / 2;
|
|
const QRectF inset = bounds.adjusted(dx, dy, -dx, -dy);
|
|
const QPointF from = start ? QPointF(start->x() * size.width(), start->y() * size.height()) : inset.topLeft();
|
|
const QPointF to = end ? QPointF(end->x() * size.width(), end->y() * size.height()) : inset.bottomRight();
|
|
// An outline, not a pen: Qt thins a one-pixel pen.
|
|
painter.fillPath(strokedLine(from, to, thickness), color.color());
|
|
painter.end();
|
|
return context;
|
|
}
|