14.4: Image › Rotate Canvas 90° Clockwise and Counterclockwise; quarter turns on whole pixels copy exactly

This commit is contained in:
ZacharyZhang-NY committed 2026-10-11 03:55:25 -04:00
1 parent 28cbe856e6
commit d8bb7b38b2
21 files changed
+419 -54

No files matched your search

+1 -1
View File
@@ -92,7 +92,7 @@ QTransform BrushRaster::pixelToDocument(const LayerTransform &transform, int wid
const QPointF center = transform.center();
QTransform map;
map.translate(center.x(), center.y());
map.rotateRadians(transform.radians());
map.rotate(transform.degrees());
map.scale(transform.size.width() / width * (transform.flipX ? -1 : 1),
transform.size.height() / height * (transform.flipY ? -1 : 1));
map.translate(-width / 2.0, -height / 2.0);
+4
View File
@@ -0,0 +1,4 @@
// Swift's CanvasRotation extension: Image › Rotate Canvas 90°.
public:
// A quarter turn of everything; layers placed, not resampled.
void rotateCanvas(bool clockwise);
+51
View File
@@ -0,0 +1,51 @@
#include "Document/EditorSession.h"
#include <cmath>
LayerTransform LayerTransform::quarterTurned(bool clockwise, QSizeF canvas) const
{
LayerTransform result = *this;
// The middle moves where the turn takes it.
const QPointF middle = clockwise ? QPointF(canvas.height() - center().y(), center().x()) : QPointF(center().y(), canvas.width() - center().x());
result.origin = QPointF(middle.x() - size.width() / 2, middle.y() - size.height() / 2);
result.rotation = std::fmod(rotation + (clockwise ? 90 : -90), 360.0);
return result;
}
CanvasGuide CanvasGuide::quarterTurned(bool clockwise, QSizeF canvas) const
{
CanvasGuide guide = *this;
if (axis == Axis::vertical) {
guide.axis = Axis::horizontal;
if (!clockwise)
guide.position = canvas.width() - position;
} else {
guide.axis = Axis::vertical;
if (clockwise)
guide.position = canvas.height() - position;
}
return guide;
}
void EditorSession::rotateCanvas(bool clockwise)
{
commitTransform();
cancelCrop();
if (!canEditLayers())
return;
const QSizeF canvas = m_document->size();
beginEdit(clockwise ? QStringLiteral("Rotate Canvas 90° Clockwise") : QStringLiteral("Rotate Canvas 90° Counterclockwise"));
for (ImageLayer &layer : m_document->layers) {
layer.transform = layer.transform.quarterTurned(clockwise, canvas);
if (layer.mask && layer.mask->placement)
layer.mask->placement = layer.mask->placement->quarterTurned(clockwise, canvas);
}
std::swap(m_document->width, m_document->height);
for (CanvasGuide &guide : m_document->guides)
guide = guide.quarterTurned(clockwise, canvas);
if (m_document->selection) {
const QTransform turn = clockwise ? QTransform(0, 1, -1, 0, canvas.height(), 0) : QTransform(0, -1, 1, 0, 0, canvas.width());
m_document->selection->path = turn.map(m_document->selection->path);
}
viewport.fit(m_document->size());
endEdit();
}
+1 -1
View File
@@ -222,7 +222,7 @@ Corners DistortWarp::carried(const LayerTransform &placement, const LayerTransfo
{
QTransform toDocument;
toDocument.translate(transform.center().x(), transform.center().y());
toDocument.rotateRadians(transform.radians());
toDocument.rotate(transform.degrees());
toDocument.scale(transform.size.width(), transform.size.height());
toDocument.translate(-0.5, -0.5);
const QTransform toUnit = toDocument.inverted();
+5 -1
View File
@@ -5,6 +5,7 @@
#include "IO/ImageExporter.h"
#include "Logging.h"
#include <cmath>
#include <tuple>
EditorSession::EditorSession(QObject *parent) : QObject(parent), m_busyTimer(this)
{
@@ -229,7 +230,10 @@ void EditorSession::restore(const DocumentHistory::Snapshot &snapshot)
{
cancelCrop();
cancelGradient();
const auto canvas = [](const std::optional<CanvasDocument> &document) { return document ? std::optional(document->id) : std::nullopt; };
const auto canvas = [](const std::optional<CanvasDocument> &document) {
return document ? std::optional(std::tuple(document->id, document->width, document->height)) : std::nullopt;
};
// Another canvas, or another size (a rotation): fit anew.
const bool changedCanvas = canvas(m_document) != canvas(snapshot.document);
// The mask stays the target while layer and mask remain.
const bool keepsMaskTarget = m_isMaskSelected && m_activeLayerID == snapshot.activeLayerID;
+1
View File
@@ -276,6 +276,7 @@ public:
#include "Document/Distort+Session.h"
#include "Document/LayerMerge+Session.h"
#include "Document/LayerFlip+Session.h"
#include "Document/CanvasRotation+Session.h"
#include "Document/Selection+Session.h"
#include "Document/MagicWand+Session.h"
#include "Document/SubjectRemoval+Session.h"
+2
View File
@@ -19,6 +19,8 @@ struct CanvasGuide {
CanvasGuide scaled(double x, double y) const;
// Mirrored when it crosses the flip, staying on content.
CanvasGuide mirrored(bool horizontally, double center) const;
// Turned with the canvas: the other axis, same content.
CanvasGuide quarterTurned(bool clockwise, QSizeF canvas) const;
friend bool operator==(const CanvasGuide &, const CanvasGuide &) = default;
};
+1 -1
View File
@@ -200,7 +200,7 @@ void FolderMaskClip::apply(QPointF center, const QPainter &context, QImage &cove
{
QTransform placement;
placement.translate(center.x(), center.y());
placement.rotateRadians(transform.radians());
placement.rotate(transform.degrees());
placement.scale(transform.flipX ? -1 : 1, transform.flipY ? -1 : 1);
QPainter masking(&veil);
masking.setRenderHint(QPainter::SmoothPixmapTransform, quality(transform.sampling) != InterpolationQuality::none);
+5
View File
@@ -89,6 +89,11 @@ double LayerTransform::radians() const
return std::fmod(rotation, 360) * std::numbers::pi / 180;
}
double LayerTransform::degrees() const
{
return std::fmod(rotation, 360);
}
bool LayerTransform::isValid() const
{
const std::array<double, 5> values = {origin.x(), origin.y(), size.width(), size.height(), rotation};
+4
View File
@@ -36,6 +36,8 @@ struct LayerTransform {
friend bool operator==(const LayerTransform &lhs, const LayerTransform &rhs);
QPointF center() const;
double radians() const;
// The turn in degrees: QTransform::rotate makes quarter turns exact.
double degrees() const;
bool isValid() const;
QPointF point(QPointF unit) const;
bool contains(QPointF point) const;
@@ -48,6 +50,8 @@ struct LayerTransform {
bool samePlacement(const LayerTransform &other) const;
// Mirrored across a line at `axis`; defined in LayerFlip.cpp.
LayerTransform mirrored(bool horizontally, double axis) const;
// Turned a quarter with its canvas; nothing resampled.
LayerTransform quarterTurned(bool clockwise, QSizeF canvas) const;
};
// Several layers under one box; each follows it.
+21 -5
View File
@@ -53,13 +53,13 @@ void LayerRenderer::draw(const QImage &image, const LayerTransform &transform, Q
// Large reductions draw from sharp halvings; QPainter does the rest.
const double device = deviceScale(context);
const Reduced source = reduced(image, width, device, transform.sampling);
const InterpolationQuality quality = interpolation(
transform.sampling, width * device / std::max(1, image.width()) * (1 << source.level), transform.radians() == 0);
const QRectF bounds(-width / 2, -height / 2, width, height);
QTransform placement;
placement.translate(center.x(), center.y());
placement.rotateRadians(transform.radians());
placement.rotate(transform.degrees());
placement.scale(transform.flipX ? -1 : 1, transform.flipY ? -1 : 1);
const InterpolationQuality quality = interpolation(transform.sampling, width * device / std::max(1, image.width()) * (1 << source.level),
upright(transform, placement, bounds, context));
if (options.mask.isNull() && options.clip.isNull() && !HslBlend::handles(options.blendMode)) {
const QPainter::CompositionMode mode = compositionMode(options.blendMode);
@@ -115,7 +115,7 @@ void LayerRenderer::drawBrushPreview(const QImage &image, const LayerTransform &
};
QTransform placement;
placement.translate(center.x(), center.y());
placement.rotateRadians(transform.radians());
placement.rotate(transform.degrees());
placement.scale(transform.flipX ? -1 : 1, transform.flipY ? -1 : 1);
QRectF extent = bounds;
for (const BrushPatch &patch : preview.patches)
@@ -282,7 +282,7 @@ void LayerRenderer::drawCoverage(const QImage &image, const LayerTransform &tran
}
QTransform placement;
placement.translate(transform.center().x(), transform.center().y());
placement.rotateRadians(transform.radians());
placement.rotate(transform.degrees());
placement.scale(transform.flipX ? -1 : 1, transform.flipY ? -1 : 1);
context.save();
setSampling(context, quality(transform.sampling), transform.sampling);
@@ -292,6 +292,22 @@ void LayerRenderer::drawCoverage(const QImage &image, const LayerTransform &tran
context.restore();
}
// QPainter's fixed-point bilinear strays a level where CoreGraphics copies.
bool LayerRenderer::upright(const LayerTransform &transform, const QTransform &placement, const QRectF &bounds, const QPainter &context)
{
if (transform.radians() == 0)
return true;
if (std::remainder(transform.rotation, 90) != 0)
return false;
const QTransform device = placement * context.deviceTransform();
for (const QPointF corner : {bounds.topLeft(), bounds.bottomRight()}) {
const QPointF mapped = device.map(corner);
if (std::abs(mapped.x() - std::round(mapped.x())) > 0.001 || std::abs(mapped.y() - std::round(mapped.y())) > 0.001)
return false;
}
return true;
}
InterpolationQuality LayerRenderer::interpolation(LayerSampling sampling, double finalFactor, bool upright)
{
if (sampling == LayerSampling::nearest || (upright && std::abs(finalFactor - 1) < 0.001))
+2
View File
@@ -59,6 +59,8 @@ void composite(QPainter &context, const QTransform &placement, const QRectF &ext
const std::function<void(QPainter &)> &body, const std::function<void(QPainter &)> &veil = {});
// An upright layer drawn pixel for pixel copies straight across.
InterpolationQuality interpolation(LayerSampling sampling, double finalFactor, bool upright = false);
// Swift's upright, or a quarter turn on whole pixels.
bool upright(const LayerTransform &transform, const QTransform &placement, const QRectF &bounds, const QPainter &context);
Reduced reduced(const QImage &image, double width, double device, LayerSampling sampling);
QRectF coverage(const Reduced &reduced, const QRectF &bounds);
double deviceScale(const QPainter &context);
+3 -3
View File
@@ -150,13 +150,13 @@ void withFrame(int pixelWidth, int pixelHeight, const QRectF &painted, const QRe
return;
const double device = LayerRenderer::deviceScale(context);
const int level = transform.sampling == LayerSampling::nearest ? 0 : DownsampleCache::level(width * device / pixelWidth);
const InterpolationQuality quality = LayerRenderer::interpolation(transform.sampling, width * device / pixelWidth * (1 << level),
transform.radians() == 0);
QTransform placement;
placement.translate(center.x(), center.y());
placement.rotateRadians(transform.radians());
placement.rotate(transform.degrees());
placement.scale(transform.flipX ? -1 : 1, transform.flipY ? -1 : 1);
const QRectF bounds(-width / 2, -height / 2, width, height);
const InterpolationQuality quality = LayerRenderer::interpolation(transform.sampling, width * device / pixelWidth * (1 << level),
LayerRenderer::upright(transform, placement, bounds, context));
const QRectF shown = placement.inverted().mapRect(BrushRaster::visibleRect(context));
const double sx = pixelWidth / width, sy = pixelHeight / height;
const Frame frame{bounds, double(pixelWidth), double(pixelHeight), level, device, transform.sampling,
+5
View File
@@ -213,6 +213,9 @@ CompositorMenus::CompositorMenus(ProjectWorkspace &workspace, QMenuBar &bar, QWi
add(image, QStringLiteral("imageSize"), QStringLiteral("Image Size…"), QKeySequence(Qt::CTRL | Qt::ALT | Qt::Key_I), [this] { projects().imageSize(); });
add(image, QStringLiteral("trim"), QStringLiteral("Trim…"), QKeySequence(), [this] { projects().trim(); });
image->addSeparator();
add(image, QStringLiteral("rotateCanvasClockwise"), QStringLiteral("Rotate Canvas 90° Clockwise"), QKeySequence(), [this] { session().rotateCanvas(true); });
add(image, QStringLiteral("rotateCanvasCounterclockwise"), QStringLiteral("Rotate Canvas 90° Counterclockwise"), QKeySequence(),
[this] { session().rotateCanvas(false); });
add(image, QStringLiteral("flipCanvasHorizontal"), QStringLiteral("Flip Canvas Horizontal"), QKeySequence(), [this] { session().flipCanvas(true); });
add(image, QStringLiteral("flipCanvasVertical"), QStringLiteral("Flip Canvas Vertical"), QKeySequence(), [this] { session().flipCanvas(false); });
@@ -386,6 +389,8 @@ void CompositorMenus::synchronize()
action(QStringLiteral("canvasSize"))->setEnabled(drawn && p.canStart());
action(QStringLiteral("imageSize"))->setEnabled(drawn && p.canStart());
action(QStringLiteral("trim"))->setEnabled(drawn && p.canStart());
action(QStringLiteral("rotateCanvasClockwise"))->setEnabled(s.canEditLayers());
action(QStringLiteral("rotateCanvasCounterclockwise"))->setEnabled(s.canEditLayers());
action(QStringLiteral("flipCanvasHorizontal"))->setEnabled(s.canEditLayers());
action(QStringLiteral("flipCanvasVertical"))->setEnabled(s.canEditLayers());
action(QStringLiteral("newAdjustmentLayer"))->setEnabled(s.canEditLayers());