#include "Document/BrushStroke.h" #include "Document/EditorSession.h" #include "Document/LayerEffects+Renderer.h" #include "IO/ImageExporter.h" #include "Rendering/LayerEffectsKernel.h" #include "SessionFixtures.h" #include #include #include // Swift's LayerEffectsRenderer and the effects in the export. namespace { QImage filled(int width, int height, const QColor &colour) { QImage image = BrushRaster::context(width, height, false); image.fill(colour); return image; } LayerEffects stroke(double size, bool inside = false) { LayerEffects effects; effects.stroke = StrokeEffect{.size = size, .red = 1, .green = 1, .blue = 0, .opacity = 1, .inside = inside}; return effects; } std::array pixel(const QImage &image, int x, int y) { const QImage bytes = image.convertToFormat(QImage::Format_RGBA8888_Premultiplied); const uchar *at = bytes.constScanLine(y) + x * 4; return {at[0], at[1], at[2], at[3]}; } QImage exported(const EditorSession &session) { return ImageExporter::render(session.projectSnapshot().value()).image; } } class LayerEffectsRendererTests : public QObject { Q_OBJECT private slots: void theMarginHoldsWhatReachesOut(); void thePlacementGrowsAboutTheCentre(); void renderingPadsMasksThenRunsTheKernel(); void theCacheKeepsEightWithinItsBudget(); void theExportAndTheCompositeDrawTheEffects(); }; void LayerEffectsRendererTests::theMarginHoldsWhatReachesOut() { QCOMPARE(LayerEffectsRenderer::margin(LayerEffects()), 2.0); QCOMPARE(LayerEffectsRenderer::margin(stroke(4.2)), 7.0); // An inside stroke, overlay or inner shadow reach nowhere. LayerEffects within = stroke(40, true); within.colorOverlay = ColorOverlayEffect(); within.innerShadow = InnerShadowEffect{.distance = 90, .blur = 90}; QCOMPARE(LayerEffectsRenderer::margin(within), 2.0); // A shadow's distance and three blurs; the larger reach wins. LayerEffects cast = stroke(10); cast.shadow = ShadowEffect{.distance = 5, .blur = 2.5}; QCOMPARE(LayerEffectsRenderer::margin(cast), 15.0); cast.shadow->blur = 4; QCOMPARE(LayerEffectsRenderer::margin(cast), 19.0); // Switched off, it takes no room. cast.shadow->enabled = false; cast.stroke->enabled = false; QCOMPARE(LayerEffectsRenderer::margin(cast), 2.0); } void LayerEffectsRendererTests::thePlacementGrowsAboutTheCentre() { const LayerTransform transform{.origin = QPointF(10, 20), .size = QSizeF(40, 20), .rotation = 30, .flipX = true, .sampling = LayerSampling::nearest}; const LayerTransform grown = LayerEffectsRenderer::placed(transform, QImage(60, 30, QImage::Format_Alpha8), 5); // The 50 by 20 inner pixels keep the layer's box. QCOMPARE(grown.size, QSizeF(48, 30)); QCOMPARE(grown.center(), transform.center()); QVERIFY(grown.rotation == 30 && grown.flipX && !grown.flipY && grown.sampling == LayerSampling::nearest); // No pixels inside the margin: the transform as it was. QVERIFY(LayerEffectsRenderer::placed(transform, QImage(10, 30, QImage::Format_Alpha8), 5) == transform); QVERIFY(LayerEffectsRenderer::placed(transform, QImage(60, 10, QImage::Format_Alpha8), 5) == transform); } void LayerEffectsRendererTests::renderingPadsMasksThenRunsTheKernel() { const QImage image = filled(20, 10, QColor(0, 0, 255)); const LayerEffectsRenderer::Rendered made = LayerEffectsRenderer::render(image, std::nullopt, stroke(3)); QCOMPARE(made.inset, 5.0); QCOMPARE(made.image.size(), QSize(30, 20)); // The kernel over the pixels padded by the inset. QImage padded = filled(30, 20, Qt::transparent); { QPainter painter(&padded); painter.drawImage(QRectF(5, 5, 20, 10), image); } QVERIFY(made.image == LayerEffectsKernel::render(padded, stroke(3))); QVERIFY((pixel(made.image, 3, 10) == std::array{255, 255, 0, 255}) && (pixel(made.image, 1, 10)[3] == 0)); // A mask hides pixels first: the stroke follows what shows. QImage mask(20, 10, QImage::Format_Grayscale8); mask.fill(0); for (int y = 0; y < 10; ++y) std::fill(mask.scanLine(y), mask.scanLine(y) + 10, uchar(255)); const LayerEffectsRenderer::Rendered masked = LayerEffectsRenderer::render(image, mask, stroke(3)); QVERIFY((pixel(masked.image, 10, 10) == std::array{0, 0, 255, 255}) && (pixel(masked.image, 20, 10) == std::array{0, 0, 0, 0})); QVERIFY((pixel(masked.image, 16, 10) == std::array{255, 255, 0, 255}) && (pixel(masked.image, 18, 10)[3] == 0)); // A small mask scales smoothly, as CoreGraphics clips. QImage halves(2, 1, QImage::Format_Grayscale8); halves.scanLine(0)[0] = 0; halves.scanLine(0)[1] = 255; const QImage soft = LayerEffectsRenderer::render(filled(64, 16, QColor(0, 0, 255)), halves, stroke(0)).image; const int left = pixel(soft, 2 + 31, 10)[3], right = pixel(soft, 2 + 32, 10)[3]; QVERIFY2(left > 100 && left < right && right < 160, qPrintable(QStringLiteral("%1 %2").arg(left).arg(right))); QVERIFY(pixel(soft, 2, 10)[3] == 0 && pixel(soft, 65, 10)[3] == 255); // Switched-off effects are none; invalid or vast ones refuse. LayerEffects hidden = stroke(3); hidden.stroke->enabled = false; QCOMPARE(LayerEffectsRenderer::render(image, std::nullopt, hidden).image.size(), QSize(24, 14)); // A switched-off effect is not judged. LayerEffects wild = stroke(600); wild.stroke->enabled = false; QVERIFY(LayerEffectsRenderer::render(image, std::nullopt, wild).image == LayerEffectsRenderer::render(image, std::nullopt, hidden).image); try { LayerEffectsRenderer::render(image, std::nullopt, stroke(501)); QFAIL("an invalid stroke rendered"); } catch (const ProjectError &error) { QCOMPARE(error.kind, ProjectError::Kind::invalid); } LayerEffects shadow; shadow.shadow = ShadowEffect{.distance = 10}; try { LayerEffectsRenderer::render(QImage(9'990, 9'990, QImage::Format_Mono), std::nullopt, shadow); QFAIL("a vast layer rendered"); } catch (const ProjectError &error) { QCOMPARE(error.kind, ProjectError::Kind::tooLarge); } } void LayerEffectsRendererTests::theCacheKeepsEightWithinItsBudget() { const QImage image = filled(12, 8, Qt::red); // Nothing to draw, nothing made: the layer draws as is. QVERIFY(!LayerEffectsRenderer::cached(image, std::nullopt, std::nullopt)); QVERIFY(!LayerEffectsRenderer::cached(image, std::nullopt, LayerEffects())); LayerEffects hidden = stroke(2); hidden.stroke->enabled = false; QVERIFY(!LayerEffectsRenderer::cached(image, std::nullopt, hidden)); QVERIFY(!LayerEffectsRenderer::cached(image, std::nullopt, stroke(600))); // The same pixels, mask and effects: the same result. const auto first = LayerEffectsRenderer::cached(image, std::nullopt, stroke(2)).value(); QCOMPARE(LayerEffectsRenderer::cached(image, std::nullopt, stroke(2)).value().image.cacheKey(), first.image.cacheKey()); QCOMPARE(first.inset, 4.0); // Switched-off effects are left out of the key. LayerEffects extra = stroke(2); extra.shadow = ShadowEffect{.enabled = false}; QCOMPARE(LayerEffectsRenderer::cached(image, std::nullopt, extra).value().image.cacheKey(), first.image.cacheKey()); // Equal pixels elsewhere, or another mask: made anew. const QImage twin = image.copy(); QVERIFY(LayerEffectsRenderer::cached(twin, std::nullopt, stroke(2)).value().image.cacheKey() != first.image.cacheKey()); QImage mask(12, 8, QImage::Format_Grayscale8); mask.fill(255); const auto masked = LayerEffectsRenderer::cached(image, mask, stroke(2)).value(); QVERIFY(masked.image.cacheKey() != first.image.cacheKey() && masked.image == first.image); QCOMPARE(LayerEffectsRenderer::cached(image, mask, stroke(2)).value().image.cacheKey(), masked.image.cacheKey()); // Another mask of the same size is another key. QImage half = mask.copy(); half.fill(128); QVERIFY(LayerEffectsRenderer::cached(image, half, stroke(2)).value().image.cacheKey() != masked.image.cacheKey()); // Eight are kept: the ninth pushes the first out. for (int size = 3; size <= 6; ++size) LayerEffectsRenderer::cached(image, std::nullopt, stroke(size)); QCOMPARE(LayerEffectsRenderer::cached(image, std::nullopt, stroke(2)).value().image.cacheKey(), first.image.cacheKey()); LayerEffectsRenderer::cached(image, std::nullopt, stroke(7)); QVERIFY(LayerEffectsRenderer::cached(image, std::nullopt, stroke(2)).value().image.cacheKey() != first.image.cacheKey()); // Two 32 MiB entries fill it; a third evicts one. const QImage one = filled(2'000, 2'000, Qt::red), two = filled(2'000, 2'000, Qt::green), three = filled(2'000, 2'000, Qt::blue); const qint64 kept = LayerEffectsRenderer::cached(one, std::nullopt, stroke(1)).value().image.cacheKey(); const qint64 second = LayerEffectsRenderer::cached(two, std::nullopt, stroke(1)).value().image.cacheKey(); QCOMPARE(LayerEffectsRenderer::cached(one, std::nullopt, stroke(1)).value().image.cacheKey(), kept); LayerEffectsRenderer::cached(three, std::nullopt, stroke(1)); QCOMPARE(LayerEffectsRenderer::cached(two, std::nullopt, stroke(1)).value().image.cacheKey(), second); QVERIFY(LayerEffectsRenderer::cached(one, std::nullopt, stroke(1)).value().image.cacheKey() != kept); // Past 64 MiB: made, not kept, nothing evicted. const qint64 small = LayerEffectsRenderer::cached(image, std::nullopt, stroke(5)).value().image.cacheKey(); const QImage big = filled(3'000, 3'000, Qt::red); const auto large = LayerEffectsRenderer::cached(big, std::nullopt, stroke(1)).value(); QVERIFY(LayerEffectsRenderer::cached(big, std::nullopt, stroke(1)).value().image.cacheKey() != large.image.cacheKey()); QCOMPARE(LayerEffectsRenderer::cached(image, std::nullopt, stroke(5)).value().image.cacheKey(), small); // Too large to make: the layer draws bare. LayerEffects shadow; shadow.shadow = ShadowEffect{.distance = 10}; QVERIFY(!LayerEffectsRenderer::cached(QImage(9'990, 9'990, QImage::Format_Mono), std::nullopt, shadow)); } void LayerEffectsRendererTests::theExportAndTheCompositeDrawTheEffects() { EditorSession session; session.createDocument(40, 30); session.insert(ImportedImage(filled(10, 10, QColor(0, 0, 255)), QImage(), QStringLiteral("Square")), QPointF(20, 15)); LayerEffects effects = stroke(2); effects.shadow = ShadowEffect{.angle = 90, .distance = 6, .blur = 0, .red = 1, .green = 0, .blue = 0, .opacity = 1}; session.setEffects(effects); const QImage image = exported(session); // Square 15..25 by 10..20; stroke two out; shadow six down. QVERIFY((pixel(image, 20, 15) == std::array{0, 0, 255, 255}) && (pixel(image, 13, 15) == std::array{255, 255, 0, 255})); QVERIFY((pixel(image, 20, 24) == std::array{255, 0, 0, 255}) && (pixel(image, 20, 8) == std::array{255, 255, 0, 255})); QVERIFY(pixel(image, 20, 7)[3] == 0 && pixel(image, 12, 15)[3] == 0 && pixel(image, 20, 27)[3] == 0); // A mask hides pixels, the effects follow; opacity fades all. session.addLayerMask(false); QVERIFY(pixel(exported(session), 13, 15)[3] == 0 && pixel(exported(session), 20, 24)[3] == 0); session.deleteLayerMask(); session.setLayerOpacity(0.5); QVERIFY(std::abs(pixel(exported(session), 13, 15)[3] - 128) <= 1); // Its blend mode applies to the effects too. session.setLayerOpacity(1); const QUuid square = session.activeLayerID().value(); session.insert(ImportedImage(filled(40, 30, QColor(128, 128, 128)), QImage(), QStringLiteral("Gray"))); session.moveActiveLayer(-1); session.selectLayer(square); session.setLayerBlendMode(LayerBlendMode::multiply); QVERIFY((pixel(exported(session), 13, 15) == std::array{128, 128, 0, 255})); // A folder's mask clips the effects inside it. session.groupSelectedLayers(); session.addLayerMask(false); QVERIFY((pixel(exported(session), 13, 15) == std::array{128, 128, 128, 255})); // The composite draws what the export does. QImage live = BrushRaster::context(40, 30, false); live.fill(0); { QPainter painter(&live); session.drawLiveComposite(session.document().value(), painter); } QVERIFY(live == exported(session)); session.deleteLayerMask(); live.fill(0); { QPainter painter(&live); session.drawLiveComposite(session.document().value(), painter); } QVERIFY(live == exported(session) && pixel(live, 13, 15) == (std::array{128, 128, 0, 255})); } QTEST_GUILESS_MAIN(LayerEffectsRendererTests) #include "LayerEffectsRendererTests.moc"