Merge branch 'port-e'
This commit is contained in:
commit
a004db172b
28 files changed
+1775
-175
No files matched your search
@@ -137,6 +137,7 @@ add_library(compositor_core STATIC
|
||||
src/UI/TransformInspector.cpp
|
||||
src/UI/EffectsSheet.cpp
|
||||
src/UI/CameraRawColorControls.cpp
|
||||
src/UI/CameraRawColorControls+Curve.cpp
|
||||
src/UI/CameraRawColorControls+Grading.cpp
|
||||
src/UI/CameraRawColorControls+Mixer.cpp
|
||||
src/UI/CameraRawControls.cpp
|
||||
|
||||
+16
-10
@@ -729,7 +729,7 @@ static double point_weight(double h, double s, double l, const float *point) {
|
||||
}
|
||||
|
||||
void adjust_camera_raw_curve_color(uint8_t *rgba, size_t width, size_t height, size_t stride,
|
||||
const float *lumaLut, const float *redLut, const float *greenLut, const float *blueLut,
|
||||
const float *toneLut, const float *redLut, const float *greenLut, const float *blueLut,
|
||||
double refineSaturation, const float *mixer, int pointCount, const float *points,
|
||||
const float *grade, double blending, double balance, int visualize) {
|
||||
for (size_t y = 0; y < height; ++y) {
|
||||
@@ -739,16 +739,22 @@ void adjust_camera_raw_curve_color(uint8_t *rgba, size_t width, size_t height, s
|
||||
double alpha = p[3];
|
||||
if (!alpha) continue;
|
||||
double r = fmin(1.0, p[0] / alpha), g = fmin(1.0, p[1] / alpha), b = fmin(1.0, p[2] / alpha);
|
||||
double tone = rec709(r, g, b);
|
||||
double mapped = lut_at(lumaLut, tone);
|
||||
scale_luminance(&r, &g, &b, mapped);
|
||||
if (refineSaturation != 0 && tone > 1e-4) {
|
||||
double factor = 1 + refineSaturation * (mapped / tone - 1);
|
||||
double lum = rec709(r, g, b);
|
||||
r = camera_clamp(lum + (r - lum) * factor);
|
||||
g = camera_clamp(lum + (g - lum) * factor);
|
||||
b = camera_clamp(lum + (b - lum) * factor);
|
||||
// The tone curve works on red, green and blue alike, as Photoshop's does, so contrast brings color strength
|
||||
// with it. Refine Saturation below zero eases toward changing brightness alone (−100), and above zero adds
|
||||
// more color.
|
||||
double curvedR = lut_at(toneLut, r), curvedG = lut_at(toneLut, g), curvedB = lut_at(toneLut, b);
|
||||
if (refineSaturation < 0) {
|
||||
double br = r, bg = g, bb = b;
|
||||
scale_luminance(&br, &bg, &bb, lut_at(toneLut, rec709(r, g, b)));
|
||||
double k = -refineSaturation;
|
||||
curvedR += (br - curvedR) * k; curvedG += (bg - curvedG) * k; curvedB += (bb - curvedB) * k;
|
||||
} else if (refineSaturation > 0) {
|
||||
double lum = rec709(curvedR, curvedG, curvedB), factor = 1 + refineSaturation;
|
||||
curvedR = camera_clamp(lum + (curvedR - lum) * factor);
|
||||
curvedG = camera_clamp(lum + (curvedG - lum) * factor);
|
||||
curvedB = camera_clamp(lum + (curvedB - lum) * factor);
|
||||
}
|
||||
r = curvedR; g = curvedG; b = curvedB;
|
||||
r = lut_at(redLut, r); g = lut_at(greenLut, g); b = lut_at(blueLut, b);
|
||||
double h, s, l;
|
||||
rgb_to_hsl(r, g, b, &h, &s, &l);
|
||||
|
||||
@@ -48,12 +48,12 @@ void adjust_camera_raw(uint8_t *rgba, size_t width, size_t height, size_t stride
|
||||
// the radii match a full-size render. Grain is applied separately. Alpha is kept.
|
||||
// Blue over clipped shadows and red over clipped highlights, on top of the grade. Preview only.
|
||||
void adjust_camera_raw_clip_overlay(uint8_t *rgba, size_t width, size_t height, size_t stride, int shadows, int highlights);
|
||||
// Curve, Color Mixer, and Color Grading after the basic grade. `lumaLut` and the channel LUTs are 256
|
||||
// Curve, Color Mixer, and Color Grading after the basic grade. `toneLut` and the channel LUTs are 256
|
||||
// entries. `mixer` is 24 floats: hue, saturation, luminance for eight families, −1…1. Each point color is
|
||||
// 9 floats (hue, saturation, luminance, three shifts −1…1, three range half-widths). `grade` is four wheels
|
||||
// of hue turns, saturation 0…1, and luminance −1…1. `visualize` darkens pixels outside that point color.
|
||||
void adjust_camera_raw_curve_color(uint8_t *rgba, size_t width, size_t height, size_t stride,
|
||||
const float *lumaLut, const float *redLut, const float *greenLut, const float *blueLut,
|
||||
const float *toneLut, const float *redLut, const float *greenLut, const float *blueLut,
|
||||
double refineSaturation, const float *mixer, int pointCount, const float *points,
|
||||
const float *grade, double blending, double balance, int visualize);
|
||||
void adjust_camera_raw_effects(uint8_t *rgba, size_t width, size_t height, size_t stride,
|
||||
|
||||
+151
-12
@@ -1,9 +1,30 @@
|
||||
#include "DitherPixels.h"
|
||||
#include <math.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdatomic.h>
|
||||
|
||||
// Linux: GCD's dispatch_apply, run on QtConcurrent by Document/Dither.cpp. Blocks become functions with a context.
|
||||
void dither_bands(size_t bands, void (*band)(void *context, size_t index), void *context);
|
||||
|
||||
static inline float clamp01(float v) { return v < 0 ? 0 : v > 1 ? 1 : v; }
|
||||
|
||||
typedef void (*band_body)(void *context, size_t start, size_t end);
|
||||
typedef struct { size_t count, size; band_body body; void *context; } band_job;
|
||||
|
||||
static void run_band(void *context, size_t band) {
|
||||
const band_job *job = context;
|
||||
size_t count = job->count, size = job->size;
|
||||
size_t start = band * size, end = start + size < count ? start + size : count;
|
||||
if (start < end) job->body(job->context, start, end);
|
||||
}
|
||||
|
||||
// Runs `body` over `count` items split into a few runs per core, each a (start, end) range, all at once.
|
||||
static void in_bands(size_t count, band_body body, void *context) {
|
||||
size_t bands = count < 64 ? 1 : 32, size = (count + bands - 1) / bands;
|
||||
band_job job = { count, size, body, context };
|
||||
dither_bands(bands, run_band, &job);
|
||||
}
|
||||
|
||||
// Density darkens (positive) or lightens as a gamma, so black and white stay put; contrast pivots on mid gray.
|
||||
static inline float adjust_tone(float v, float gamma, float contrast) {
|
||||
v = powf(clamp01(v), gamma);
|
||||
@@ -114,19 +135,17 @@ static inline void write_pixel(uint8_t *px, float r, float g, float b) {
|
||||
px[2] = (uint8_t)lroundf(clamp01(b) * a * 255.0f);
|
||||
}
|
||||
|
||||
int dither_apply(uint8_t *rgba, size_t width, size_t height, size_t stride, const DitherParams *p) {
|
||||
size_t count = width * height;
|
||||
if (!count) return 1;
|
||||
int planes = p->originalColors ? 3 : 1;
|
||||
float *tone = malloc(count * sizeof(float) * (size_t)planes);
|
||||
uint8_t *alpha = malloc(count);
|
||||
// The image's own colors, unadjusted: halftone dots and glyphs take them in Original mode.
|
||||
float *source = p->originalColors ? malloc(count * sizeof(float) * 3) : NULL;
|
||||
if (!tone || !alpha || (p->originalColors && !source)) { free(tone); free(alpha); free(source); return 0; }
|
||||
typedef struct {
|
||||
uint8_t *rgba; size_t stride, width, count; uint8_t *alpha; float *tone, *source; const DitherParams *p;
|
||||
float gamma, contrast;
|
||||
} tone_job;
|
||||
|
||||
float gamma = exp2f(p->density * 1.5f);
|
||||
float contrast = p->contrast >= 0 ? 1.0f / (1.0f - 0.95f * p->contrast) : 1.0f + p->contrast;
|
||||
for (size_t y = 0; y < height; ++y) {
|
||||
static void tone_band(void *context, size_t first, size_t last) {
|
||||
const tone_job *job = context;
|
||||
uint8_t *rgba = job->rgba; size_t stride = job->stride, width = job->width, count = job->count;
|
||||
uint8_t *alpha = job->alpha; float *tone = job->tone, *source = job->source; const DitherParams *p = job->p;
|
||||
float gamma = job->gamma, contrast = job->contrast;
|
||||
for (size_t y = first; y < last; ++y) {
|
||||
const uint8_t *row = rgba + y * stride;
|
||||
for (size_t x = 0; x < width; ++x) {
|
||||
const uint8_t *px = row + x * 4;
|
||||
@@ -147,6 +166,90 @@ int dither_apply(uint8_t *rgba, size_t width, size_t height, size_t stride, cons
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
typedef struct {
|
||||
uint8_t *rgba; size_t stride, width, height, count; int planes; const uint8_t *alpha; const float *tone;
|
||||
const DitherParams *p; size_t spacing; float middle, dots; const float *screen, *phosphor; atomic_int failed;
|
||||
} scan_job;
|
||||
|
||||
static void scan_band(void *context, size_t firstLine, size_t lastLine) {
|
||||
scan_job *job = context;
|
||||
uint8_t *rgba = job->rgba; size_t stride = job->stride, width = job->width, height = job->height, count = job->count;
|
||||
int planes = job->planes; const uint8_t *alpha = job->alpha; const float *tone = job->tone; const DitherParams *p = job->p;
|
||||
size_t spacing = job->spacing; float middle = job->middle, dots = job->dots;
|
||||
const float *screen = job->screen, *phosphor = job->phosphor;
|
||||
float *scan = malloc(width * sizeof(float) * (size_t)planes);
|
||||
if (!scan) { atomic_store(&job->failed, 1); return; }
|
||||
for (size_t line = firstLine; line < lastLine; ++line) {
|
||||
size_t top = line * spacing;
|
||||
size_t bottom = top + spacing < height ? top + spacing : height;
|
||||
// Wobble: each line is pushed sideways, a slow wave down the screen with a quicker one over it, as a
|
||||
// CRT's picture wavers when its sync drifts.
|
||||
float wave = sinf((float)line * 0.45f) * 0.7f + sinf((float)line * 1.7f + 1.3f) * 0.3f;
|
||||
long shift = lroundf(p->wobble * wave);
|
||||
for (size_t x = 0; x < width; ++x) {
|
||||
float sum[3] = { 0, 0, 0 }; int n = 0;
|
||||
long sx = (long)x - shift;
|
||||
if (sx >= 0 && sx < (long)width)
|
||||
for (size_t y = top; y < bottom; ++y) {
|
||||
size_t at = y * width + (size_t)sx;
|
||||
if (!alpha[at]) continue;
|
||||
for (int c = 0; c < planes; ++c) sum[c] += tone[(size_t)c * count + at];
|
||||
++n;
|
||||
}
|
||||
for (int c = 0; c < planes; ++c) scan[(size_t)c * width + x] = n ? sum[c] / (float)n : 0;
|
||||
}
|
||||
for (size_t y = top; y < bottom; ++y) {
|
||||
uint8_t *row = rgba + y * stride;
|
||||
float offset = fabsf((float)(y - top) + 0.5f - middle);
|
||||
for (size_t x = 0; x < width; ++x) {
|
||||
if (!alpha[y * width + x]) continue;
|
||||
// Dots: the line breaks into beads, one every line spacing, each lit in the color at its middle.
|
||||
float along = fmodf((float)x + 0.5f, (float)spacing) - middle;
|
||||
long centered = lroundf((float)x - along * dots);
|
||||
size_t at = centered < 0 ? 0 : (size_t)centered >= width ? width - 1 : (size_t)centered;
|
||||
float r, g, b, t;
|
||||
if (p->originalColors) {
|
||||
r = scan[at]; g = scan[width + at]; b = scan[2 * width + at];
|
||||
t = 0.2126f * r + 0.7152f * g + 0.0722f * b;
|
||||
} else {
|
||||
t = scan[at];
|
||||
r = screen[0] + (phosphor[0] - screen[0]) * t;
|
||||
g = screen[1] + (phosphor[1] - screen[1]) * t;
|
||||
b = screen[2] + (phosphor[2] - screen[2]) * t;
|
||||
}
|
||||
// The beam is driven brighter than the picture, making up for the dark screen between lines.
|
||||
r *= 1.35f; g *= 1.35f; b *= 1.35f;
|
||||
// Half the beam's height: a thin line in the shadows, most of the way across in the highlights,
|
||||
// always leaving dark screen between lines.
|
||||
float beam = middle * (0.2f + 0.5f * sqrtf(clamp01(t)));
|
||||
float across = along * dots, distance = sqrtf(offset * offset + across * across);
|
||||
float cover = clamp01(beam - distance + 0.5f);
|
||||
// Between the lines, the screen: black in Original, else the dark color.
|
||||
float br = p->originalColors ? 0 : screen[0], bg = p->originalColors ? 0 : screen[1];
|
||||
float bb = p->originalColors ? 0 : screen[2];
|
||||
write_pixel(row + x * 4, br + (r - br) * cover, bg + (g - bg) * cover, bb + (b - bb) * cover);
|
||||
}
|
||||
}
|
||||
}
|
||||
free(scan);
|
||||
}
|
||||
|
||||
int dither_apply(uint8_t *rgba, size_t width, size_t height, size_t stride, const DitherParams *p) {
|
||||
size_t count = width * height;
|
||||
if (!count) return 1;
|
||||
int planes = p->originalColors ? 3 : 1;
|
||||
float *tone = malloc(count * sizeof(float) * (size_t)planes);
|
||||
uint8_t *alpha = malloc(count);
|
||||
// The image's own colors, unadjusted: halftone dots and glyphs take them in Original mode.
|
||||
float *source = p->originalColors ? malloc(count * sizeof(float) * 3) : NULL;
|
||||
if (!tone || !alpha || (p->originalColors && !source)) { free(tone); free(alpha); free(source); return 0; }
|
||||
|
||||
float gamma = exp2f(p->density * 1.5f);
|
||||
float contrast = p->contrast >= 0 ? 1.0f / (1.0f - 0.95f * p->contrast) : 1.0f + p->contrast;
|
||||
tone_job toning = { rgba, stride, width, count, alpha, tone, source, p, gamma, contrast };
|
||||
in_bands(height, tone_band, &toning);
|
||||
|
||||
float dark[3] = { p->dark[0] / 255.0f, p->dark[1] / 255.0f, p->dark[2] / 255.0f };
|
||||
float light[3] = { p->light[0] / 255.0f, p->light[1] / 255.0f, p->light[2] / 255.0f };
|
||||
@@ -183,6 +286,18 @@ int dither_apply(uint8_t *rgba, size_t width, size_t height, size_t stride, cons
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (style == DITHER_SCANLINES) {
|
||||
// A CRT: each line scans the image, its tone along the line the average of the rows it covers. The beam glows
|
||||
// brighter and blooms thicker where the picture is light, and the screen between lines stays dark.
|
||||
size_t spacing = (size_t)(p->cell < 2 ? 2 : p->cell);
|
||||
float middle = (float)spacing / 2, dots = clamp01(p->dots);
|
||||
// Each line is drawn on its own, so the lines are shared out across the cores.
|
||||
size_t lines = (height + spacing - 1) / spacing;
|
||||
const float *screen = dark, *phosphor = light;
|
||||
scan_job scanning = { rgba, stride, width, height, count, planes, alpha, tone, p, spacing, middle, dots, screen, phosphor, 0 };
|
||||
in_bands(lines, scan_band, &scanning);
|
||||
int failed = atomic_load(&scanning.failed);
|
||||
if (failed) { free(tone); free(alpha); free(source); return 0; }
|
||||
} else {
|
||||
// Marks (halftone shapes, patterns, glyphs) cover as much of each spot as the tone calls for. On light, they
|
||||
// stand for darkness and are drawn in the dark color; light on dark, the reverse.
|
||||
@@ -277,3 +392,27 @@ void dither_dots(uint8_t *rgba, size_t width, size_t height, size_t stride, int
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
typedef struct { uint8_t *rgba; const uint8_t *glow; size_t width, stride; float amount; } glow_job;
|
||||
|
||||
static void glow_band(void *context, size_t first, size_t last) {
|
||||
const glow_job *job = context;
|
||||
uint8_t *rgba = job->rgba; const uint8_t *glow = job->glow; size_t width = job->width, stride = job->stride;
|
||||
float amount = job->amount;
|
||||
for (size_t y = first; y < last; ++y) {
|
||||
uint8_t *row = rgba + y * stride;
|
||||
const uint8_t *light = glow + y * stride;
|
||||
for (size_t x = 0; x < width * 4; x += 4) {
|
||||
float a = row[x + 3];
|
||||
for (int c = 0; c < 3; ++c) {
|
||||
float v = (float)row[x + c] + (float)light[x + c] * amount * a / 255.0f;
|
||||
row[x + c] = (uint8_t)lroundf(v > a ? a : v);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void dither_glow(uint8_t *rgba, const uint8_t *glow, size_t width, size_t height, size_t stride, float amount) {
|
||||
glow_job glowing = { rgba, glow, width, stride, amount };
|
||||
in_bands(height, glow_band, &glowing);
|
||||
}
|
||||
@@ -8,7 +8,7 @@ enum {
|
||||
DITHER_ATKINSON, DITHER_FLOYD_STEINBERG,
|
||||
DITHER_BAYER_2, DITHER_BAYER_4, DITHER_BAYER_8,
|
||||
DITHER_DOTS, DITHER_LINES, DITHER_DIAMONDS,
|
||||
DITHER_PATTERNS, DITHER_GLYPHS
|
||||
DITHER_PATTERNS, DITHER_GLYPHS, DITHER_SCANLINES
|
||||
};
|
||||
|
||||
typedef struct {
|
||||
@@ -20,7 +20,8 @@ typedef struct {
|
||||
// −1…1: darker (more ink) or lighter, and flatter or punchier, before dithering.
|
||||
float density;
|
||||
float contrast;
|
||||
// Halftone and glyph cells, in pixels, and the halftone screen's angle in radians.
|
||||
// Halftone and glyph cells, in pixels (scanlines: the distance between lines), and the halftone screen's angle in
|
||||
// radians.
|
||||
int cell;
|
||||
float angle;
|
||||
// Halftone dots, patterns and glyphs mark the light tones on the dark color instead of the dark on the light.
|
||||
@@ -37,6 +38,9 @@ typedef struct {
|
||||
const uint8_t *glyphs;
|
||||
const float *glyphCoverage;
|
||||
int glyphCount;
|
||||
// Scanlines: how far each line breaks into round dots (0–1), and how many pixels its wobble pushes it sideways.
|
||||
float dots;
|
||||
float wobble;
|
||||
} DitherParams;
|
||||
|
||||
// Dithers premultiplied RGBA pixels (4 bytes per pixel, `stride` bytes per row) in place. Alpha is kept and fully
|
||||
@@ -45,4 +49,6 @@ int dither_apply(uint8_t *rgba, size_t width, size_t height, size_t stride, cons
|
||||
// Turns each `block` × `block` square of premultiplied RGBA pixels into a round dot in its own color on `gap` (straight
|
||||
// sRGB), like the lit pixels of a dot-matrix screen. The dot's edge is smoothed and alpha is kept.
|
||||
void dither_dots(uint8_t *rgba, size_t width, size_t height, size_t stride, int block, const uint8_t *gap);
|
||||
// Adds `glow` (premultiplied RGBA, same layout) over the pixels at `amount`, never past their own alpha.
|
||||
void dither_glow(uint8_t *rgba, const uint8_t *glow, size_t width, size_t height, size_t stride, float amount);
|
||||
#endif
|
||||
@@ -79,25 +79,34 @@ bool CameraRawCurveSettings::adjusts() const
|
||||
|| !isLinear(green) || !isLinear(blue);
|
||||
}
|
||||
|
||||
double CameraRawCurveSettings::parametric(double tone) const
|
||||
// Bends tones below `lower` by `low`, above `upper` by `high`.
|
||||
double CameraRawCurveSettings::bend(double tone, double lower, double low, double upper, double high)
|
||||
{
|
||||
const double shadow = shadowSplit / 100, dark = darkSplit / 100, light = lightSplit / 100;
|
||||
double amount, lo, hi;
|
||||
if (tone < shadow)
|
||||
amount = shadows, lo = 0, hi = shadow;
|
||||
else if (tone < dark)
|
||||
amount = darks, lo = shadow, hi = dark;
|
||||
else if (tone < light)
|
||||
amount = lights, lo = dark, hi = light;
|
||||
else
|
||||
amount = highlights, lo = light, hi = 1;
|
||||
const double span = std::max(0.02, hi - lo);
|
||||
const double weight = 1 - std::abs(tone - (lo + hi) / 2) / (span / 2);
|
||||
// A tone in its region weighs 0 to 1, unfloored.
|
||||
return std::min(1.0, std::max(0.0, tone + (amount / 100) * weight * 0.22));
|
||||
// Fitted to Photoshop: Darks −51 dips 0.1 there.
|
||||
const double strength = 1.66;
|
||||
if (tone < lower)
|
||||
return lower * std::pow(tone / lower, std::pow(2, -low / 100 * strength));
|
||||
if (tone > upper) {
|
||||
const double rest = 1 - upper;
|
||||
return 1 - rest * std::pow((1 - tone) / rest, std::pow(2, high / 100 * strength));
|
||||
}
|
||||
return tone;
|
||||
}
|
||||
|
||||
std::vector<float> CameraRawCurveSettings::lumaTable() const
|
||||
double CameraRawCurveSettings::parametric(double tone) const
|
||||
{
|
||||
if (shadows == 0 && darks == 0 && lights == 0 && highlights == 0)
|
||||
return tone;
|
||||
// Gamma bends, smoothed as Image › Curves smooths.
|
||||
std::vector<CurvePoint> anchors;
|
||||
for (int index = 0; index <= 32; ++index) {
|
||||
const double x = index / 32.0;
|
||||
anchors.push_back({x, bend(bend(x, shadowSplit / 100, shadows, lightSplit / 100, highlights), darkSplit / 100, darks, darkSplit / 100, lights)});
|
||||
}
|
||||
return point(tone, anchors);
|
||||
}
|
||||
|
||||
std::vector<float> CameraRawCurveSettings::toneTable() const
|
||||
{
|
||||
std::vector<float> table(256);
|
||||
for (int index = 0; index < 256; ++index)
|
||||
@@ -262,10 +271,10 @@ void CameraRawSettings::applyCurveColor(uchar *pixels, int width, int height, qs
|
||||
const CameraRawCurveSettings shape = curve.normalized();
|
||||
const CameraRawMixerSettings mixing = mixer.normalized();
|
||||
const CameraRawGradingSettings grade = grading.normalized();
|
||||
const std::vector<float> luma = shape.lumaTable(), redTable = shape.channelTable(shape.red), greenTable = shape.channelTable(shape.green),
|
||||
const std::vector<float> tone = shape.toneTable(), redTable = shape.channelTable(shape.red), greenTable = shape.channelTable(shape.green),
|
||||
blueTable = shape.channelTable(shape.blue), mixerFloats = mixing.mixerFloats(), pointFloats = mixing.pointFloats(),
|
||||
gradeFloats = grade.gradeFloats();
|
||||
adjust_camera_raw_curve_color(pixels, size_t(width), size_t(height), size_t(stride), luma.data(), redTable.data(), greenTable.data(),
|
||||
adjust_camera_raw_curve_color(pixels, size_t(width), size_t(height), size_t(stride), tone.data(), redTable.data(), greenTable.data(),
|
||||
blueTable.data(), shape.refineSaturation / 100, mixerFloats.data(), int(mixing.points.size()), pointFloats.data(),
|
||||
gradeFloats.data(), grade.blending / 100, grade.balance / 100, visualize);
|
||||
}
|
||||
@@ -33,15 +33,19 @@ struct CameraRawCurveSettings {
|
||||
static std::vector<CurvePoint> strongContrast();
|
||||
static bool isLinear(const std::vector<CurvePoint> &points);
|
||||
bool adjusts() const;
|
||||
// Lifts or lowers the region a tone belongs to.
|
||||
// Photoshop's parametric curve: gamma bends, smoothed as Curves.
|
||||
double parametric(double tone) const;
|
||||
std::vector<float> lumaTable() const;
|
||||
// Applied to red, green and blue alike.
|
||||
std::vector<float> toneTable() const;
|
||||
std::vector<float> channelTable(const std::vector<CurvePoint> &points) const;
|
||||
CameraRawCurveSettings nudged(CameraRawPointChannel channel, double tone, double delta) const;
|
||||
// Swift's region(for:): the parametric amount a tone moves.
|
||||
double ®ion(double tone);
|
||||
CameraRawCurveSettings normalized() const;
|
||||
friend bool operator==(const CameraRawCurveSettings &, const CameraRawCurveSettings &) = default;
|
||||
|
||||
private:
|
||||
static double bend(double tone, double lower, double low, double upper, double high);
|
||||
};
|
||||
|
||||
// One picked colour and how far its adjustment reaches.
|
||||
|
||||
+62
-5
@@ -1,17 +1,28 @@
|
||||
#include "Document/Dither.h"
|
||||
#include "Document/BrushStroke.h"
|
||||
#include "Document/PixelAdjust.h"
|
||||
#include "IO/ImageExporter.h"
|
||||
#include "Rendering/PoolMap.h"
|
||||
#include <QFontMetricsF>
|
||||
#include <QPainter>
|
||||
#include <QPainterPath>
|
||||
#include <QTextBoundaryFinder>
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <numeric>
|
||||
#include <set>
|
||||
extern "C" {
|
||||
#include "DitherPixels.h"
|
||||
}
|
||||
|
||||
// The kernel's bands, where Swift's kernel calls dispatch_apply.
|
||||
extern "C" void dither_bands(size_t bands, void (*band)(void *context, size_t index), void *context)
|
||||
{
|
||||
std::vector<size_t> indices(bands);
|
||||
std::iota(indices.begin(), indices.end(), size_t(0));
|
||||
PoolMap::blocking(indices, [band, context](size_t index) { band(context, index); });
|
||||
}
|
||||
|
||||
// Swift's Dither.swift: the settings, the chunky pixels, the glyphs.
|
||||
namespace {
|
||||
// Swift's Characters: the text's graphemes.
|
||||
@@ -53,6 +64,7 @@ QString rawValue(DitherStyle style)
|
||||
case DitherStyle::diamonds: return QStringLiteral("Halftone Diamonds");
|
||||
case DitherStyle::patterns: return QStringLiteral("Mac Patterns");
|
||||
case DitherStyle::ascii: return QStringLiteral("ASCII");
|
||||
case DitherStyle::scanlines: return QStringLiteral("Scanlines (CRT)");
|
||||
}
|
||||
throw std::logic_error("unknown dither style");
|
||||
}
|
||||
@@ -80,7 +92,12 @@ bool isHalftone(DitherStyle style)
|
||||
|
||||
bool drawsMarks(DitherStyle style)
|
||||
{
|
||||
return !hasTones(style);
|
||||
return !hasTones(style) && style != DitherStyle::scanlines;
|
||||
}
|
||||
|
||||
bool usesPixelSize(DitherStyle style)
|
||||
{
|
||||
return style != DitherStyle::ascii && style != DitherStyle::scanlines;
|
||||
}
|
||||
|
||||
QString rawValue(DitherPixelShape shape)
|
||||
@@ -105,6 +122,10 @@ DitherSettings DitherSettings::normalized() const
|
||||
result.pixelSize = std::round(clamp(pixelSize, pixelSizeLow, pixelSizeHigh, 2));
|
||||
result.cellSize = std::round(clamp(cellSize, cellSizeLow, cellSizeHigh, 8));
|
||||
result.textSize = std::round(clamp(textSize, textSizeLow, textSizeHigh, 14));
|
||||
result.lineSpacing = std::round(clamp(lineSpacing, lineSpacingLow, lineSpacingHigh, 4));
|
||||
result.glow = clamp(glow, 0, 100, 35);
|
||||
result.dots = clamp(dots, 0, 100, 0);
|
||||
result.wobble = clamp(wobble, wobbleLow, wobbleHigh, 0);
|
||||
result.angle = clamp(angle, -90, 90, 45);
|
||||
result.levels = std::round(clamp(levels, levelsLow, levelsHigh, 2));
|
||||
result.diffusion = clamp(diffusion, 0, 100, 100);
|
||||
@@ -125,8 +146,8 @@ DitherSettings DitherSettings::normalized() const
|
||||
QImage DitherSettings::apply(const QImage &image) const
|
||||
{
|
||||
const DitherSettings settings = normalized();
|
||||
// ASCII draws at full resolution: chunks would blur its letters.
|
||||
const int block = settings.style == DitherStyle::ascii ? 1 : int(settings.pixelSize);
|
||||
// ASCII and Scanlines draw full size: chunks would blur them.
|
||||
const int block = usesPixelSize(settings.style) ? int(settings.pixelSize) : 1;
|
||||
const QImage source = image.convertToFormat(QImage::Format_RGBA8888_Premultiplied);
|
||||
if (source.isNull())
|
||||
throw ExportError(ExportError::Kind::render);
|
||||
@@ -153,6 +174,8 @@ QImage DitherSettings::apply(const QImage &image) const
|
||||
}
|
||||
}
|
||||
const QImage dithered = settings.dither(working);
|
||||
if (settings.style == DitherStyle::scanlines && settings.glow > 0)
|
||||
return settings.glowing(dithered);
|
||||
if (block <= 1)
|
||||
return dithered;
|
||||
// Blown back up without smoothing.
|
||||
@@ -171,9 +194,42 @@ QImage DitherSettings::apply(const QImage &image) const
|
||||
return full;
|
||||
}
|
||||
|
||||
// The lines' light blurred wide and added back: phosphor bloom.
|
||||
QImage DitherSettings::glowing(const QImage &image) const
|
||||
{
|
||||
// Blurred small, scaled back up: same light, less work.
|
||||
const double sigma = lineSpacing * 3 + 3, shrink = std::max(1.0, std::floor(sigma / 4));
|
||||
const int width = image.width(), height = image.height();
|
||||
// Swift clamps first: edges repeat outward before shrinking.
|
||||
const int step = int(shrink), pad = int(std::ceil(sigma * 3 / shrink)) * step;
|
||||
QImage padded = BrushRaster::context(width + 2 * pad, height + 2 * pad, false);
|
||||
for (int y = 0; y < padded.height(); ++y) {
|
||||
const uchar *source = image.constScanLine(std::clamp(y - pad, 0, height - 1));
|
||||
uchar *out = padded.scanLine(y);
|
||||
for (int x = 0; x < padded.width(); ++x)
|
||||
std::copy_n(source + std::clamp(x - pad, 0, width - 1) * 4, 4, out + x * 4);
|
||||
}
|
||||
const int smallWidth = (padded.width() + step - 1) / step, smallHeight = (padded.height() + step - 1) / step;
|
||||
const QImage small = padded.scaled(smallWidth, smallHeight, Qt::IgnoreAspectRatio, Qt::SmoothTransformation);
|
||||
if (small.isNull())
|
||||
throw ExportError(ExportError::Kind::render);
|
||||
const QImage blurred = PixelAdjust::gaussianBlur(small, sigma / shrink, true)
|
||||
.scaled(smallWidth * step, smallHeight * step, Qt::IgnoreAspectRatio, Qt::SmoothTransformation)
|
||||
.convertToFormat(QImage::Format_RGBA8888_Premultiplied);
|
||||
QImage result = image.copy();
|
||||
if (blurred.isNull() || result.isNull())
|
||||
throw ExportError(ExportError::Kind::render);
|
||||
// The scaled light's own rows, cropped to the image.
|
||||
QImage bloom = BrushRaster::context(width, height, false);
|
||||
for (int y = 0; y < height; ++y)
|
||||
std::copy_n(blurred.constScanLine(y + pad) + pad * 4, width * 4, bloom.scanLine(y));
|
||||
dither_glow(result.bits(), bloom.constBits(), size_t(width), size_t(height), size_t(result.bytesPerLine()), float(glow / 100 * 2.5));
|
||||
return result;
|
||||
}
|
||||
|
||||
QImage DitherSettings::dither(const QImage &image) const
|
||||
{
|
||||
const int cell = int(cellSize);
|
||||
const int cell = int(style == DitherStyle::scanlines ? lineSpacing : cellSize);
|
||||
const Glyphs glyphs = style == DitherStyle::ascii ? DitherSettings::glyphs(characters.isEmpty() ? defaultCharacters() : characters, int(textSize)) : Glyphs();
|
||||
const bool two = colors == DitherColors::twoColors;
|
||||
const std::array<uint8_t, 3> darkColor = two ? bytes(dark) : std::array<uint8_t, 3>{0, 0, 0};
|
||||
@@ -184,7 +240,8 @@ QImage DitherSettings::dither(const QImage &image) const
|
||||
.contrast = float(contrast / 100), .cell = cell, .angle = float(angle * M_PI / 180), .lightOnDark = lightOnDark ? 1 : 0,
|
||||
.originalColors = colors == DitherColors::original ? 1 : 0, .dark = {darkColor[0], darkColor[1], darkColor[2]},
|
||||
.light = {lightColor[0], lightColor[1], lightColor[2]}, .glyphWidth = glyphs.width, .glyphHeight = glyphs.height, .glyphs = glyphs.maps.data(),
|
||||
.glyphCoverage = glyphs.coverage.data(), .glyphCount = int(glyphs.coverage.size())};
|
||||
.glyphCoverage = glyphs.coverage.data(), .glyphCount = int(glyphs.coverage.size()), .dots = float(dots / 100),
|
||||
.wobble = float(wobble)};
|
||||
failed = dither_apply(pixels, size_t(width), size_t(height), size_t(stride), ¶ms) == 0;
|
||||
});
|
||||
if (failed)
|
||||
|
||||
+15
-4
@@ -6,10 +6,10 @@
|
||||
#include <vector>
|
||||
|
||||
// Filter › Dither's looks, in the panel's order and DitherPixels.h's.
|
||||
enum class DitherStyle { atkinson, floydSteinberg, bayer2, bayer4, bayer8, dots, lines, diamonds, patterns, ascii };
|
||||
inline constexpr std::array allDitherStyles{DitherStyle::atkinson, DitherStyle::floydSteinberg, DitherStyle::bayer2, DitherStyle::bayer4,
|
||||
DitherStyle::bayer8, DitherStyle::dots, DitherStyle::lines, DitherStyle::diamonds,
|
||||
DitherStyle::patterns, DitherStyle::ascii};
|
||||
enum class DitherStyle { atkinson, floydSteinberg, bayer2, bayer4, bayer8, dots, lines, diamonds, patterns, ascii, scanlines };
|
||||
inline constexpr std::array allDitherStyles{DitherStyle::atkinson, DitherStyle::floydSteinberg, DitherStyle::bayer2, DitherStyle::bayer4,
|
||||
DitherStyle::bayer8, DitherStyle::dots, DitherStyle::lines, DitherStyle::diamonds,
|
||||
DitherStyle::patterns, DitherStyle::ascii, DitherStyle::scanlines};
|
||||
QString rawValue(DitherStyle style);
|
||||
// Swift's groups: diffusion, ordered, halftone, marks.
|
||||
int ditherGroup(DitherStyle style);
|
||||
@@ -19,6 +19,8 @@ bool diffuses(DitherStyle style);
|
||||
bool hasTones(DitherStyle style);
|
||||
bool isHalftone(DitherStyle style);
|
||||
bool drawsMarks(DitherStyle style);
|
||||
// ASCII and Scanlines draw at full resolution, not chunky.
|
||||
bool usesPixelSize(DitherStyle style);
|
||||
|
||||
// A chunky pixel: a square, or a dot on dark.
|
||||
enum class DitherPixelShape { square, dot };
|
||||
@@ -35,6 +37,8 @@ struct DitherSettings {
|
||||
static constexpr double cellSizeLow = 4, cellSizeHigh = 64;
|
||||
static constexpr double textSizeLow = 6, textSizeHigh = 64;
|
||||
static constexpr double levelsLow = 2, levelsHigh = 8;
|
||||
static constexpr double lineSpacingLow = 2, lineSpacingHigh = 32;
|
||||
static constexpr double wobbleLow = 0, wobbleHigh = 64;
|
||||
static QString defaultCharacters() { return QStringLiteral(" .:-=+*#%@"); }
|
||||
DitherStyle style = DitherStyle::atkinson;
|
||||
// Each dithered pixel covers this many layer pixels a side.
|
||||
@@ -44,6 +48,12 @@ struct DitherSettings {
|
||||
double cellSize = 8;
|
||||
// ASCII's line height in layer pixels; letters about 0.6 wide.
|
||||
double textSize = 14;
|
||||
// Scanlines: the lines' distance apart, in layer pixels.
|
||||
double lineSpacing = 4;
|
||||
// Glow and dots 0–100%; wobble sideways in pixels.
|
||||
double glow = 35;
|
||||
double dots = 0;
|
||||
double wobble = 0;
|
||||
// The halftone screen's angle, in degrees.
|
||||
double angle = 45;
|
||||
// Tones per channel; 2 is 1-bit.
|
||||
@@ -76,4 +86,5 @@ struct DitherSettings {
|
||||
|
||||
private:
|
||||
QImage dither(const QImage &image) const;
|
||||
QImage glowing(const QImage &image) const;
|
||||
};
|
||||
@@ -235,14 +235,40 @@ bool ProjectWorkspace::finishTextEditing()
|
||||
return true;
|
||||
}
|
||||
|
||||
void ProjectWorkspace::settlePendingEdits(std::function<void()> done)
|
||||
{
|
||||
// Canvas edits are applied; dialogs cancelled, as their Cancel.
|
||||
const std::shared_ptr<ProjectTab> settled = tab(current().id);
|
||||
settled->session.commitGradient([self = QPointer<ProjectWorkspace>(this), settled, done] {
|
||||
settled->session.finishPixelMove([self, settled, done] {
|
||||
EditorSession &session = settled->session;
|
||||
session.cancelFilter();
|
||||
session.cancelHueSaturation();
|
||||
session.cancelLevels();
|
||||
session.cancelColorRange();
|
||||
session.setSelectionAmountOperation(std::nullopt);
|
||||
session.closeColorPicker(false);
|
||||
if (self)
|
||||
done();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void ProjectWorkspace::confirmQuit(std::function<void(bool)> done)
|
||||
{
|
||||
if (!finishTextEditing() || !canSwitch()) {
|
||||
if (m_isManaging || !finishTextEditing()) {
|
||||
finish(done, false);
|
||||
return;
|
||||
}
|
||||
setManaging(true);
|
||||
askNext(quitOrder(), 0, std::move(done));
|
||||
settlePendingEdits([this, done] {
|
||||
if (!canSwitch()) {
|
||||
qCWarning(lcApp) << "Quit refused: the project is still busy";
|
||||
finish(done, false);
|
||||
return;
|
||||
}
|
||||
setManaging(true);
|
||||
askNext(quitOrder(), 0, done);
|
||||
});
|
||||
}
|
||||
|
||||
void ProjectWorkspace::askNext(std::vector<std::shared_ptr<ProjectTab>> order, size_t index, std::function<void(bool)> done)
|
||||
|
||||
@@ -44,6 +44,8 @@ public:
|
||||
void close(QUuid id, std::function<void()> done = {});
|
||||
void removeTab(QUuid id);
|
||||
std::vector<std::shared_ptr<ProjectTab>> quitOrder() const;
|
||||
// Applies canvas edits and cancels dialogs before a quit.
|
||||
void settlePendingEdits(std::function<void()> done);
|
||||
void confirmQuit(std::function<void(bool)> done);
|
||||
void closeWindow(QWidget *closing);
|
||||
void receive(const QList<QUrl> &urls, std::optional<QUuid> destination = std::nullopt,
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
#include "UI/CameraRawColorControls.h"
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
|
||||
// Swift's curve graph drags, picked once at the press.
|
||||
std::optional<CameraRawCurveControls::Drag> CameraRawCurveControls::parametricDrag(QPointF at, QSizeF size) const
|
||||
{
|
||||
const CameraRawCurveSettings curve = raw().curve;
|
||||
const double tone = at.x() / std::max(size.width(), 1.0) * 100;
|
||||
const std::array<double, 3> splits{curve.shadowSplit, curve.darkSplit, curve.lightSplit};
|
||||
// Along the bottom, the nearest divider, the first of equals.
|
||||
if (at.y() > size.height() - 18) {
|
||||
size_t nearest = 0;
|
||||
for (size_t index = 1; index < splits.size(); ++index)
|
||||
if (std::abs(splits[index] - tone) < std::abs(splits[nearest] - tone))
|
||||
nearest = index;
|
||||
return Drag{.kind = Drag::Kind::divider, .index = nearest, .from = at};
|
||||
}
|
||||
double CameraRawCurveSettings::*key = tone < splits[0] ? &CameraRawCurveSettings::shadows
|
||||
: tone < splits[1] ? &CameraRawCurveSettings::darks
|
||||
: tone < splits[2] ? &CameraRawCurveSettings::lights
|
||||
: &CameraRawCurveSettings::highlights;
|
||||
return Drag{.kind = Drag::Kind::region, .key = key, .start = curve.*key, .from = at};
|
||||
}
|
||||
|
||||
std::optional<CameraRawCurveControls::Drag> CameraRawCurveControls::pointDrag(QPointF at, QSizeF size)
|
||||
{
|
||||
const double x = at.x() / std::max(size.width(), 1.0), y = 1 - at.y() / std::max(size.height(), 1.0);
|
||||
std::vector<CurvePoint> points = currentPoints();
|
||||
// On a point, that point; the first of equals.
|
||||
std::optional<size_t> near;
|
||||
for (size_t index = 0; index < points.size(); ++index)
|
||||
if (!near || std::hypot(points[index].x - x, points[index].y - y) < std::hypot(points[*near].x - x, points[*near].y - y))
|
||||
near = index;
|
||||
if (near && std::hypot(points[*near].x - x, points[*near].y - y) < 0.055) {
|
||||
m_selectedPoint = near;
|
||||
return Drag{.kind = Drag::Kind::point, .index = *near, .from = at};
|
||||
}
|
||||
// Else a new point; the ends bound its x.
|
||||
if (points.size() >= 16 || !std::all_of(points.begin(), points.end(), [x](const CurvePoint &each) { return std::abs(each.x - x) > 0.01; }))
|
||||
return std::nullopt;
|
||||
const CurvePoint point{x, y};
|
||||
points.push_back(point);
|
||||
std::stable_sort(points.begin(), points.end(), [](const CurvePoint &left, const CurvePoint &right) { return left.x < right.x; });
|
||||
store(points);
|
||||
const size_t index = size_t(std::find(points.begin(), points.end(), point) - points.begin());
|
||||
m_selectedPoint = index;
|
||||
return Drag{.kind = Drag::Kind::point, .index = index, .from = at};
|
||||
}
|
||||
|
||||
void CameraRawCurveControls::beginDrag(QPointF at, QSizeF size)
|
||||
{
|
||||
// Qt may lose a release: every press picks afresh.
|
||||
const bool parametric = !m_session.filterEdit() || m_session.filterEdit()->rawPanel.curvePage == CameraRawCurvePage::parametric;
|
||||
m_drag = parametric ? parametricDrag(at, size) : pointDrag(at, size);
|
||||
continueDrag(at, size);
|
||||
}
|
||||
|
||||
void CameraRawCurveControls::continueDrag(QPointF at, QSizeF size)
|
||||
{
|
||||
if (!m_drag)
|
||||
return;
|
||||
const double x = at.x() / std::max(size.width(), 1.0), y = 1 - at.y() / std::max(size.height(), 1.0);
|
||||
const Drag drag = *m_drag;
|
||||
switch (drag.kind) {
|
||||
case Drag::Kind::point: {
|
||||
std::vector<CurvePoint> points = currentPoints();
|
||||
if (drag.index >= points.size())
|
||||
return;
|
||||
points[drag.index].y = y;
|
||||
// Ends stay ends; the rest keep their order.
|
||||
if (drag.index > 0 && drag.index < points.size() - 1)
|
||||
points[drag.index].x = std::min(points[drag.index + 1].x - 0.01, std::max(points[drag.index - 1].x + 0.01, x));
|
||||
store(points);
|
||||
return;
|
||||
}
|
||||
case Drag::Kind::divider: {
|
||||
const double value = x * 100;
|
||||
update([&drag, value](CameraRawSettings &settings) {
|
||||
// Below the next divider; normalizing does the rest.
|
||||
CameraRawCurveSettings &curve = settings.curve;
|
||||
if (drag.index == 0)
|
||||
curve.shadowSplit = std::min(value, curve.darkSplit - 2);
|
||||
else if (drag.index == 1)
|
||||
curve.darkSplit = std::min(curve.lightSplit - 2, value);
|
||||
else
|
||||
curve.lightSplit = value;
|
||||
});
|
||||
return;
|
||||
}
|
||||
case Drag::Kind::region: {
|
||||
const double amount = std::min(100.0, std::max(-100.0, drag.start - (at.y() - drag.from.y()) / std::max(size.height(), 1.0) * 200));
|
||||
update([&drag, amount](CameraRawSettings &settings) { settings.curve.*drag.key = std::round(amount); });
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void CameraRawCurveControls::removePoint(QPointF at, QSizeF size)
|
||||
{
|
||||
std::vector<CurvePoint> points = currentPoints();
|
||||
const double x = at.x() / size.width();
|
||||
// The nearest inner point by x, within 0.04; ends stay.
|
||||
std::optional<size_t> nearest;
|
||||
for (size_t index = 1; index + 1 < points.size(); ++index)
|
||||
if (!nearest || std::abs(points[index].x - x) < std::abs(points[*nearest].x - x))
|
||||
nearest = index;
|
||||
if (!nearest || !(std::abs(points[*nearest].x - x) < 0.04))
|
||||
return;
|
||||
points.erase(points.begin() + std::ptrdiff_t(*nearest));
|
||||
m_selectedPoint.reset();
|
||||
store(points);
|
||||
}
|
||||
@@ -87,103 +87,45 @@ protected:
|
||||
return;
|
||||
}
|
||||
painter.setPen(Qt::NoPen);
|
||||
painter.setBrush(Qt::white);
|
||||
for (const CurvePoint &point : m_owner.currentPoints())
|
||||
painter.drawEllipse(QRectF(point.x * w - 4, (1 - point.y) * h - 4, 8, 8));
|
||||
const std::vector<CurvePoint> points = m_owner.currentPoints();
|
||||
for (size_t index = 0; index < points.size(); ++index) {
|
||||
painter.setBrush(m_owner.m_selectedPoint == index ? palette().color(QPalette::Highlight) : QColor(Qt::white));
|
||||
painter.drawEllipse(QRectF(points[index].x * w - 4, (1 - points[index].y) * h - 4, 8, 8));
|
||||
}
|
||||
}
|
||||
|
||||
// Swift's two drags: still, a press adds; moved, it drags.
|
||||
// Swift's one drag: picked at the press, kept until release.
|
||||
void mousePressEvent(QMouseEvent *event) override
|
||||
{
|
||||
if (event->button() == Qt::LeftButton)
|
||||
m_press = event->position();
|
||||
m_owner.beginDrag(event->position(), size());
|
||||
}
|
||||
|
||||
void mouseMoveEvent(QMouseEvent *event) override
|
||||
{
|
||||
if (!m_press || !event->buttons().testFlag(Qt::LeftButton))
|
||||
return;
|
||||
if (!m_moved && QLineF(*m_press, event->position()).length() < 2)
|
||||
return;
|
||||
m_moved = true;
|
||||
if (parametric())
|
||||
moveDivider(event->position().x());
|
||||
else
|
||||
movePoint(event->position());
|
||||
if (event->buttons().testFlag(Qt::LeftButton))
|
||||
m_owner.continueDrag(event->position(), size());
|
||||
}
|
||||
|
||||
void mouseReleaseEvent(QMouseEvent *event) override
|
||||
{
|
||||
if (event->button() == Qt::LeftButton && m_press && !m_moved && event->position() == *m_press && !parametric())
|
||||
addPoint(event->position());
|
||||
m_press.reset();
|
||||
m_moved = false;
|
||||
if (event->button() == Qt::LeftButton)
|
||||
m_owner.m_drag.reset();
|
||||
}
|
||||
|
||||
// The double click's own release must add nothing back.
|
||||
// The second press drags too; then the point goes.
|
||||
void mouseDoubleClickEvent(QMouseEvent *event) override
|
||||
{
|
||||
if (event->button() == Qt::LeftButton && !parametric())
|
||||
removePoint(event->position());
|
||||
if (event->button() != Qt::LeftButton)
|
||||
return;
|
||||
m_owner.beginDrag(event->position(), size());
|
||||
if (!parametric())
|
||||
m_owner.removePoint(event->position(), size());
|
||||
m_owner.m_drag.reset();
|
||||
}
|
||||
|
||||
private:
|
||||
void moveDivider(double x)
|
||||
{
|
||||
const double value = std::min(98.0, std::max(2.0, x / std::max(double(width()), 1.0) * 100));
|
||||
const CameraRawCurveSettings curve = m_owner.raw().curve;
|
||||
const std::array<double, 3> splits{curve.shadowSplit, curve.darkSplit, curve.lightSplit};
|
||||
size_t nearest = 0;
|
||||
for (size_t index = 1; index < 3; ++index)
|
||||
if (std::abs(splits[index] - value) < std::abs(splits[nearest] - value))
|
||||
nearest = index;
|
||||
m_owner.update([nearest, value](CameraRawSettings &settings) {
|
||||
(nearest == 0 ? settings.curve.shadowSplit : nearest == 1 ? settings.curve.darkSplit : settings.curve.lightSplit) = value;
|
||||
});
|
||||
}
|
||||
|
||||
void addPoint(QPointF at)
|
||||
{
|
||||
std::vector<CurvePoint> points = m_owner.currentPoints();
|
||||
points.push_back({std::min(0.99, std::max(0.01, at.x() / width())), std::min(1.0, std::max(0.0, 1 - at.y() / height()))});
|
||||
m_owner.store(points);
|
||||
}
|
||||
|
||||
// The nearest inner point by x; ends stay.
|
||||
std::optional<size_t> nearestInner(const std::vector<CurvePoint> &points, double x) const
|
||||
{
|
||||
std::optional<size_t> nearest;
|
||||
for (size_t index = 1; index + 1 < points.size(); ++index)
|
||||
if (!nearest || std::abs(points[index].x - x) < std::abs(points[*nearest].x - x))
|
||||
nearest = index;
|
||||
return nearest;
|
||||
}
|
||||
|
||||
void movePoint(QPointF at)
|
||||
{
|
||||
std::vector<CurvePoint> points = m_owner.currentPoints();
|
||||
const double x = at.x() / width();
|
||||
const std::optional<size_t> index = nearestInner(points, x);
|
||||
if (!index)
|
||||
return;
|
||||
points[*index] = {std::min(0.98, std::max(0.02, x)), std::min(1.0, std::max(0.0, 1 - at.y() / height()))};
|
||||
m_owner.store(points);
|
||||
}
|
||||
|
||||
void removePoint(QPointF at)
|
||||
{
|
||||
std::vector<CurvePoint> points = m_owner.currentPoints();
|
||||
const double x = at.x() / width();
|
||||
const std::optional<size_t> index = nearestInner(points, x);
|
||||
if (!index || !(std::abs(points[*index].x - x) < 0.04))
|
||||
return;
|
||||
points.erase(points.begin() + std::ptrdiff_t(*index));
|
||||
m_owner.store(points);
|
||||
}
|
||||
|
||||
CameraRawCurveControls &m_owner;
|
||||
std::optional<QPointF> m_press;
|
||||
bool m_moved = false;
|
||||
};
|
||||
|
||||
namespace {
|
||||
@@ -250,6 +192,9 @@ CameraRawCurveControls::CameraRawCurveControls(EditorSession &session, QWidget *
|
||||
presetRow->addStretch(1);
|
||||
points->addLayout(presetRow);
|
||||
connect(m_preset, &QComboBox::activated, this, [this](int preset) {
|
||||
// A fix beyond Swift: a preset's points drop the choice.
|
||||
if (preset != 0)
|
||||
m_selectedPoint.reset();
|
||||
if (preset == 1)
|
||||
store(CameraRawCurveSettings::linear());
|
||||
else if (preset == 2)
|
||||
@@ -258,7 +203,7 @@ CameraRawCurveControls::CameraRawCurveControls(EditorSession &session, QWidget *
|
||||
store(CameraRawCurveSettings::strongContrast());
|
||||
});
|
||||
m_refine = new CameraRawRow({.name = QStringLiteral("refineSaturation"), .title = QStringLiteral("Refine Saturation"),
|
||||
.help = QStringLiteral("How much the RGB curve also changes color strength. Zero keeps it to brightness."), .titleWidth = 88,
|
||||
.help = QStringLiteral("How much the curve also changes color strength. Zero matches Photoshop; lower keeps it to brightness, higher adds more color."), .titleWidth = 88,
|
||||
.fixedTitle = true, .fieldWidth = 48, .scrub = 1},
|
||||
[this] { return raw().curve.refineSaturation; },
|
||||
[this](double value) { update([value](CameraRawSettings &settings) { settings.curve.refineSaturation = value; }); },
|
||||
@@ -334,15 +279,23 @@ void CameraRawCurveControls::synchronize()
|
||||
m_channelBox->setVisible(point);
|
||||
const CameraRawPointChannel channel = edit ? edit->rawPanel.pointChannel : CameraRawPointChannel::rgb;
|
||||
m_channel->button(int(channel))->setChecked(true);
|
||||
m_graph->setToolTip(point ? QStringLiteral("Drag a point. Click the curve to add one. Double-click a point to remove it.")
|
||||
: QStringLiteral("Drag a divider to change which tones the neighboring sliders affect."));
|
||||
// Swift's onChange: another channel drops the choice and drag.
|
||||
if (channel != m_shownChannel) {
|
||||
m_shownChannel = channel;
|
||||
m_selectedPoint.reset();
|
||||
m_drag.reset();
|
||||
}
|
||||
m_graph->setToolTip(point ? QStringLiteral("Drag a point. Click to add one. Double-click a point to remove it.")
|
||||
: QStringLiteral("Drag up or down to lift or lower those tones. Drag a divider along the bottom to change which tones each region covers."));
|
||||
m_graph->update();
|
||||
m_amounts->setVisible(!point);
|
||||
m_points->setVisible(point);
|
||||
const std::vector<CurvePoint> points = currentPoints();
|
||||
// Swift's selectedPoint: the last inner point, else the last.
|
||||
const CurvePoint selected = points.size() > 2 ? points[points.size() - 2] : points.back();
|
||||
m_selected->setText(QStringLiteral("In %1 Out %2").arg(std::lround(selected.x * 255)).arg(std::lround(selected.y * 255)));
|
||||
// Swift's selectedPoint: the chosen index, while it exists.
|
||||
const bool chosen = m_selectedPoint && *m_selectedPoint < points.size();
|
||||
m_selected->setVisible(chosen);
|
||||
if (chosen)
|
||||
m_selected->setText(QStringLiteral("In %1 Out %2").arg(std::lround(points[*m_selectedPoint].x * 255)).arg(std::lround(points[*m_selectedPoint].y * 255)));
|
||||
const QSignalBlocker quiet(m_preset);
|
||||
m_preset->setCurrentIndex(points == CameraRawCurveSettings::linear() ? 1
|
||||
: points == CameraRawCurveSettings::mediumContrast() ? 2
|
||||
|
||||
@@ -28,9 +28,25 @@ private:
|
||||
void update(const std::function<void(CameraRawSettings &)> &change);
|
||||
void store(const std::vector<CurvePoint> &points);
|
||||
void panel(const std::function<void(CameraRawPanel &)> &change);
|
||||
// Swift's Drag: a point, divider, or region.
|
||||
struct Drag {
|
||||
enum class Kind { point, divider, region } kind;
|
||||
size_t index = 0;
|
||||
double CameraRawCurveSettings::*key = nullptr;
|
||||
double start = 0;
|
||||
QPointF from;
|
||||
};
|
||||
std::optional<Drag> parametricDrag(QPointF at, QSizeF size) const;
|
||||
std::optional<Drag> pointDrag(QPointF at, QSizeF size);
|
||||
void beginDrag(QPointF at, QSizeF size);
|
||||
void continueDrag(QPointF at, QSizeF size);
|
||||
void removePoint(QPointF at, QSizeF size);
|
||||
friend class CameraRawCurveGraph;
|
||||
|
||||
EditorSession &m_session;
|
||||
std::optional<Drag> m_drag;
|
||||
std::optional<size_t> m_selectedPoint;
|
||||
CameraRawPointChannel m_shownChannel = CameraRawPointChannel::rgb;
|
||||
QButtonGroup *m_page;
|
||||
QWidget *m_channelBox;
|
||||
QButtonGroup *m_channel;
|
||||
|
||||
@@ -48,10 +48,25 @@ void FilterSheet::dither()
|
||||
});
|
||||
control(QStringLiteral("Pixel Size"), key(&DitherSettings::pixelSize), DitherSettings::pixelSizeLow, DitherSettings::pixelSizeHigh, QStringLiteral("px"), 0, false);
|
||||
box()->setToolTip(QStringLiteral("Make each dithered pixel this many pixels across, for a chunky old-screen look"));
|
||||
m_shownWhen.emplace_back(box(), [](const FilterSettings &settings) { return settings.dither.style != DitherStyle::ascii; });
|
||||
m_shownWhen.emplace_back(box(), [](const FilterSettings &settings) { return usesPixelSize(settings.dither.style); });
|
||||
control(QStringLiteral("Text Size"), key(&DitherSettings::textSize), DitherSettings::textSizeLow, DitherSettings::textSizeHigh, QStringLiteral("px"), 0, false);
|
||||
box()->setToolTip(QStringLiteral("The height of each line of characters"));
|
||||
m_shownWhen.emplace_back(box(), [](const FilterSettings &settings) { return settings.dither.style == DitherStyle::ascii; });
|
||||
// Scanlines' four rows.
|
||||
const auto scanlines = [](const FilterSettings &settings) { return settings.dither.style == DitherStyle::scanlines; };
|
||||
control(QStringLiteral("Line Spacing"), key(&DitherSettings::lineSpacing), DitherSettings::lineSpacingLow, DitherSettings::lineSpacingHigh, QStringLiteral("px"), 0,
|
||||
false);
|
||||
box()->setToolTip(QStringLiteral("How far apart the screen's lines are"));
|
||||
m_shownWhen.emplace_back(box(), scanlines);
|
||||
control(QStringLiteral("Glow"), key(&DitherSettings::glow), 0, 100, QStringLiteral("%"), 0, false);
|
||||
box()->setToolTip(QStringLiteral("Light blooming around the lines, like a CRT's phosphors"));
|
||||
m_shownWhen.emplace_back(box(), scanlines);
|
||||
control(QStringLiteral("Dots"), key(&DitherSettings::dots), 0, 100, QStringLiteral("%"), 0, false);
|
||||
box()->setToolTip(QStringLiteral("Break the lines into glowing beads"));
|
||||
m_shownWhen.emplace_back(box(), scanlines);
|
||||
control(QStringLiteral("Wobble"), key(&DitherSettings::wobble), DitherSettings::wobbleLow, DitherSettings::wobbleHigh, QStringLiteral("px"), 0, false);
|
||||
box()->setToolTip(QStringLiteral("Make the lines waver sideways down the screen, like a CRT losing sync"));
|
||||
m_shownWhen.emplace_back(box(), scanlines);
|
||||
control(QStringLiteral("Cell Size"), key(&DitherSettings::cellSize), DitherSettings::cellSizeLow, DitherSettings::cellSizeHigh, QStringLiteral("px"), 0, false);
|
||||
m_shownWhen.emplace_back(box(), [](const FilterSettings &settings) { return isHalftone(settings.dither.style); });
|
||||
control(QStringLiteral("Angle"), key(&DitherSettings::angle), -90, 90, QStringLiteral("°"), 0, false);
|
||||
@@ -133,7 +148,7 @@ void FilterSheet::dither()
|
||||
const QSignalBlocker quiet(shape);
|
||||
shape->setCurrentIndex(int(settings.dither.pixelShape));
|
||||
});
|
||||
m_shownWhen.emplace_back(shape->parentWidget(), [](const FilterSettings &settings) { return settings.dither.pixelSize > 1 && settings.dither.style != DitherStyle::ascii; });
|
||||
m_shownWhen.emplace_back(shape->parentWidget(), [](const FilterSettings &settings) { return settings.dither.pixelSize > 1 && usesPixelSize(settings.dither.style); });
|
||||
flag(QStringLiteral("Light on Dark"), QStringLiteral("Draw the marks for the light tones on the dark color, like a glowing screen"),
|
||||
[](FilterSettings &settings) -> bool & { return settings.dither.lightOnDark; });
|
||||
m_shownWhen.emplace_back(m_flags.back().first, [](const FilterSettings &settings) { return drawsMarks(settings.dither.style); });
|
||||
|
||||
@@ -29,7 +29,7 @@ class CameraRawColorTests : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void curvesAreRepairedIntoOrder();
|
||||
void theParametricCurveLiftsItsRegion();
|
||||
void aToneFindsItsRegion();
|
||||
void aNudgeMovesTheFirstNearestPointWithinRange();
|
||||
void splitsAndAmountsStayInTheirRanges();
|
||||
void mixerFamiliesOverlapRoundTheWheel();
|
||||
@@ -56,17 +56,8 @@ void CameraRawColorTests::curvesAreRepairedIntoOrder()
|
||||
QCOMPARE(table[100], float(100 / 255.0));
|
||||
}
|
||||
|
||||
void CameraRawColorTests::theParametricCurveLiftsItsRegion()
|
||||
void CameraRawColorTests::aToneFindsItsRegion()
|
||||
{
|
||||
CameraRawCurveSettings curve;
|
||||
curve.shadows = 100;
|
||||
// The region's middle takes the whole 22%.
|
||||
QVERIFY(std::abs(curve.parametric(0.125) - 0.345) < 1e-12);
|
||||
QCOMPARE(curve.parametric(0.375), 0.375);
|
||||
curve.highlights = -100;
|
||||
curve.lightSplit = 100;
|
||||
// A region of no width still takes a fiftieth's span.
|
||||
QVERIFY(std::abs(curve.parametric(1) - 0.78) < 1e-12);
|
||||
// Region picks the amount a tone moves.
|
||||
CameraRawCurveSettings regions;
|
||||
regions.region(0.1) = 1;
|
||||
|
||||
@@ -0,0 +1,400 @@
|
||||
#include "Document/BrushStroke.h"
|
||||
#include "Document/EditorSession.h"
|
||||
#include "UI/CameraRawColorControls.h"
|
||||
#include <QComboBox>
|
||||
#include <QLabel>
|
||||
#include <QToolButton>
|
||||
#include <QtTest>
|
||||
|
||||
// Swift 1.4.5: the Camera Raw curve graph's one drag.
|
||||
namespace {
|
||||
struct Graph {
|
||||
EditorSession session;
|
||||
std::unique_ptr<CameraRawCurveControls> controls;
|
||||
QWidget *graph = nullptr;
|
||||
explicit Graph(bool point = false)
|
||||
{
|
||||
session.createDocument(8, 8);
|
||||
QImage image = BrushRaster::context(8, 8, false);
|
||||
image.fill(QColor(200, 120, 60));
|
||||
session.insert(ImportedImage(image, image, QStringLiteral("Warm")));
|
||||
session.beginFilter(FilterKind::cameraRaw);
|
||||
controls = std::make_unique<CameraRawCurveControls>(session);
|
||||
controls->resize(374, 600);
|
||||
controls->show();
|
||||
graph = controls->findChild<QWidget *>(QStringLiteral("cameraRawCurveGraph"));
|
||||
if (point)
|
||||
controls->findChild<QToolButton *>(QStringLiteral("curvePage1"))->click();
|
||||
}
|
||||
const CameraRawCurveSettings &curve() const { return session.filterEdit().value().settings.cameraRaw.curve; }
|
||||
void set(const std::function<void(CameraRawCurveSettings &)> &change)
|
||||
{
|
||||
FilterSettings settings = session.filterEdit().value().settings;
|
||||
change(settings.cameraRaw.curve);
|
||||
session.updateFilter(settings, true);
|
||||
}
|
||||
// A curve position in the graph's own points.
|
||||
QPoint at(double x, double y) const { return QPoint(int(std::lround(x * graph->width())), int(std::lround((1 - y) * graph->height()))); }
|
||||
void press(QPoint where, Qt::MouseButton button = Qt::LeftButton) const { QTest::mousePress(graph, button, {}, where); }
|
||||
void move(QPoint where, Qt::MouseButtons buttons = Qt::LeftButton) const
|
||||
{
|
||||
QMouseEvent event(QEvent::MouseMove, where, graph->mapToGlobal(where), Qt::NoButton, buttons, Qt::NoModifier);
|
||||
QApplication::sendEvent(graph, &event);
|
||||
}
|
||||
void release(QPoint where) const { QTest::mouseRelease(graph, Qt::LeftButton, {}, where); }
|
||||
QLabel &readout() const { return *controls->findChild<QLabel *>(QStringLiteral("curveSelectedPoint")); }
|
||||
QColor dot(double x, double y) const { return graph->grab().toImage().pixelColor(at(x, y)); }
|
||||
};
|
||||
}
|
||||
|
||||
class CameraRawCurveGraphTests : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void aRegionDragLiftsOrLowersThePressedRegion();
|
||||
void theRegionIsPickedOnceByItsTone();
|
||||
void dividersMoveInTheBottomStripAndStayOrdered();
|
||||
void aPointIsPickedByDistanceAndKept();
|
||||
void aPressElsewhereAddsAndDragsAPoint();
|
||||
void addingIsRefusedNearAnEdgeAPointOrSixteen();
|
||||
void theChosenPointIsLitAndReadOut();
|
||||
void aNewChannelOrPresetDropsTheChoice();
|
||||
void aDoubleClickRemovesAnInnerPointAndTheChoice();
|
||||
void onlyTheLeftButtonDragsAndAPressStartsAfresh();
|
||||
void theGraphSaysWhatADragDoes();
|
||||
};
|
||||
|
||||
void CameraRawCurveGraphTests::aRegionDragLiftsOrLowersThePressedRegion()
|
||||
{
|
||||
Graph graph;
|
||||
const int h = graph.graph->height();
|
||||
graph.set([](CameraRawCurveSettings &curve) { curve.lights = 10; });
|
||||
// Up lifts: a quarter height adds 50.
|
||||
graph.press(QPoint(int(graph.graph->width() * 0.6), 100));
|
||||
graph.move(QPoint(int(graph.graph->width() * 0.6), 100 - h / 4));
|
||||
QCOMPARE(graph.curve().lights, std::round(10 + double(h / 4) / h * 200));
|
||||
// Down lowers, clamped at −100, rounded to whole.
|
||||
graph.move(QPoint(int(graph.graph->width() * 0.6), 100 + h));
|
||||
QCOMPARE(graph.curve().lights, -100.0);
|
||||
graph.move(QPoint(10, 100 + 3));
|
||||
QCOMPARE(graph.curve().lights, std::round(10 - 3.0 / h * 200));
|
||||
graph.release(QPoint(10, 103));
|
||||
QVERIFY(graph.curve().shadows == 0 && graph.curve().darks == 0 && graph.curve().highlights == 0);
|
||||
// Far up, +100 at most.
|
||||
graph.press(QPoint(int(graph.graph->width() * 0.9), 120));
|
||||
graph.move(QPoint(int(graph.graph->width() * 0.9), 120 - 4 * h));
|
||||
QCOMPARE(graph.curve().highlights, 100.0);
|
||||
}
|
||||
|
||||
void CameraRawCurveGraphTests::theRegionIsPickedOnceByItsTone()
|
||||
{
|
||||
Graph graph;
|
||||
const int w = graph.graph->width();
|
||||
graph.set([](CameraRawCurveSettings &curve) { curve.shadowSplit = 20; });
|
||||
// A tone on a divider joins the region above.
|
||||
const std::pair<double, double CameraRawCurveSettings::*> regions[] = {
|
||||
{0.1, &CameraRawCurveSettings::shadows}, {0.2, &CameraRawCurveSettings::darks}, {0.5, &CameraRawCurveSettings::lights}, {0.75, &CameraRawCurveSettings::highlights}};
|
||||
for (const auto &[tone, key] : regions) {
|
||||
const CameraRawCurveSettings before = graph.curve();
|
||||
graph.press(QPoint(int(std::lround(tone * w)), 60));
|
||||
graph.move(QPoint(int(std::lround(tone * w)), 50));
|
||||
graph.release(QPoint(int(std::lround(tone * w)), 50));
|
||||
CameraRawCurveSettings expected = before;
|
||||
expected.*key = std::round(10.0 / graph.graph->height() * 200);
|
||||
QVERIFY2(graph.curve() == expected, qPrintable(QString::number(tone)));
|
||||
}
|
||||
// At tone 50 exactly, each divider hands it upward.
|
||||
QCOMPARE(w % 2, 0);
|
||||
const std::tuple<std::array<double, 3>, double CameraRawCurveSettings::*> onDividers[] = {
|
||||
{{50, 60, 80}, &CameraRawCurveSettings::darks}, {{20, 50, 80}, &CameraRawCurveSettings::lights}, {{20, 30, 50}, &CameraRawCurveSettings::highlights}};
|
||||
for (const auto &[splits, key] : onDividers) {
|
||||
graph.set([&splits](CameraRawCurveSettings &curve) {
|
||||
curve = CameraRawCurveSettings();
|
||||
curve.shadowSplit = splits[0];
|
||||
curve.darkSplit = splits[1];
|
||||
curve.lightSplit = splits[2];
|
||||
});
|
||||
graph.press(QPoint(w / 2, 60));
|
||||
graph.move(QPoint(w / 2, 50));
|
||||
graph.release(QPoint(w / 2, 50));
|
||||
QVERIFY(graph.curve().*key > 0);
|
||||
}
|
||||
// Kept through the drag: across another region, the same one.
|
||||
graph.set([](CameraRawCurveSettings &curve) { curve.shadows = curve.darks = curve.lights = curve.highlights = 0; });
|
||||
graph.press(QPoint(int(0.1 * w), 60));
|
||||
graph.move(QPoint(int(0.9 * w), 40));
|
||||
QVERIFY(graph.curve().shadows > 0 && graph.curve().highlights == 0);
|
||||
}
|
||||
|
||||
void CameraRawCurveGraphTests::dividersMoveInTheBottomStripAndStayOrdered()
|
||||
{
|
||||
Graph graph;
|
||||
const int w = graph.graph->width(), h = graph.graph->height();
|
||||
// The strip is the bottom 18 points.
|
||||
graph.press(QPoint(int(0.5 * w), h - 18));
|
||||
graph.move(QPoint(int(0.6 * w), h - 30));
|
||||
graph.release(QPoint(int(0.6 * w), h - 30));
|
||||
QCOMPARE(graph.curve().darkSplit, 50.0);
|
||||
QVERIFY(graph.curve().lights > 0);
|
||||
graph.set([](CameraRawCurveSettings &curve) { curve.lights = 0; });
|
||||
// Each divider, the nearest to the press, between its neighbours.
|
||||
const auto dragged = [&](double from, double to) {
|
||||
graph.press(QPoint(int(std::lround(from * w)), h - 17));
|
||||
graph.move(QPoint(int(std::lround(to * w)), 10));
|
||||
graph.release(QPoint(int(std::lround(to * w)), 10));
|
||||
};
|
||||
dragged(0.3, 0.9);
|
||||
QCOMPARE(graph.curve().shadowSplit, 48.0);
|
||||
// Past 2 and 98 the settings' own limits hold.
|
||||
dragged(0.47, 0.0);
|
||||
QCOMPARE(graph.curve().shadowSplit, 5.0);
|
||||
dragged(0.55, 0.9);
|
||||
QCOMPARE(graph.curve().darkSplit, 73.0);
|
||||
dragged(0.73, 0.01);
|
||||
QCOMPARE(graph.curve().darkSplit, 7.0);
|
||||
dragged(0.74, 0.0);
|
||||
QCOMPARE(graph.curve().lightSplit, 9.0);
|
||||
dragged(0.095, 1.2);
|
||||
QCOMPARE(graph.curve().lightSplit, 98.0);
|
||||
QVERIFY(graph.curve().shadows == 0 && graph.curve().darks == 0 && graph.curve().lights == 0 && graph.curve().highlights == 0);
|
||||
// At tone 50 exactly, between 40 and 60: the first.
|
||||
QCOMPARE(w % 2, 0);
|
||||
graph.set([](CameraRawCurveSettings &curve) {
|
||||
curve.shadowSplit = 40;
|
||||
curve.darkSplit = 60;
|
||||
curve.lightSplit = 80;
|
||||
});
|
||||
graph.press(QPoint(w / 2, h - 5));
|
||||
graph.move(QPoint(int(0.45 * w), h - 5));
|
||||
QVERIFY(std::abs(graph.curve().shadowSplit - 45) < 0.5 && graph.curve().darkSplit == 60);
|
||||
}
|
||||
|
||||
void CameraRawCurveGraphTests::aPointIsPickedByDistanceAndKept()
|
||||
{
|
||||
Graph graph(true);
|
||||
graph.set([](CameraRawCurveSettings &curve) { curve.rgb = {{0, 0}, {0.3, 0.3}, {0.36, 0.5}, {1, 1}}; });
|
||||
// Nearer by distance, not by x: the second.
|
||||
graph.press(graph.at(0.33, 0.48));
|
||||
graph.move(graph.at(0.33, 0.6));
|
||||
QCOMPARE(graph.curve().rgb.size(), size_t(4));
|
||||
QVERIFY(std::abs(graph.curve().rgb[2].y - 0.6) < 0.01 && graph.curve().rgb[1] == (CurvePoint{0.3, 0.3}));
|
||||
// Kept as it passes x 0.99, held before the end.
|
||||
graph.move(graph.at(1.2, 0.2));
|
||||
QVERIFY(std::abs(graph.curve().rgb[2].x - 0.99) < 1e-9 && graph.curve().rgb[1] == (CurvePoint{0.3, 0.3}));
|
||||
// Onto its neighbour: a hundredth past, then repaired away.
|
||||
graph.move(graph.at(0.1, 0.2));
|
||||
QCOMPARE(graph.curve().rgb.size(), size_t(3));
|
||||
QVERIFY(graph.curve().rgb[1] == (CurvePoint{0.3, 0.3}));
|
||||
graph.release(graph.at(0.1, 0.2));
|
||||
// Onto the next point: a hundredth short; that one goes.
|
||||
graph.set([](CameraRawCurveSettings &curve) { curve.rgb = {{0, 0}, {0.3, 0.3}, {0.5, 0.5}, {1, 1}}; });
|
||||
graph.press(graph.at(0.3, 0.3));
|
||||
graph.move(graph.at(0.8, 0.3));
|
||||
graph.release(graph.at(0.8, 0.3));
|
||||
QCOMPARE(graph.curve().rgb.size(), size_t(3));
|
||||
QVERIFY(std::abs(graph.curve().rgb[1].x - 0.49) < 1e-9);
|
||||
// 0.05 away takes the point; 0.06 away adds one.
|
||||
graph.set([](CameraRawCurveSettings &curve) { curve.rgb = {{0, 0}, {0.5, 0.5}, {1, 1}}; });
|
||||
graph.press(graph.at(0.53, 0.54));
|
||||
graph.release(graph.at(0.53, 0.54));
|
||||
QCOMPARE(graph.curve().rgb.size(), size_t(3));
|
||||
QVERIFY(std::abs(graph.curve().rgb[1].x - 0.53) < 0.003);
|
||||
graph.set([](CameraRawCurveSettings &curve) { curve.rgb = {{0, 0}, {0.5, 0.5}, {1, 1}}; });
|
||||
graph.press(graph.at(0.54, 0.545));
|
||||
graph.release(graph.at(0.54, 0.545));
|
||||
QCOMPARE(graph.curve().rgb.size(), size_t(4));
|
||||
QVERIFY(graph.curve().rgb[1] == (CurvePoint{0.5, 0.5}));
|
||||
}
|
||||
|
||||
void CameraRawCurveGraphTests::aPressElsewhereAddsAndDragsAPoint()
|
||||
{
|
||||
Graph graph(true);
|
||||
graph.press(graph.at(0.25, 0.6));
|
||||
QCOMPARE(graph.curve().rgb.size(), size_t(3));
|
||||
QVERIFY(std::abs(graph.curve().rgb[1].x - 0.25) < 0.01 && std::abs(graph.curve().rgb[1].y - 0.6) < 0.01);
|
||||
// The same press drags it on, in order.
|
||||
graph.move(graph.at(0.4, 0.7));
|
||||
QVERIFY(std::abs(graph.curve().rgb[1].x - 0.4) < 0.01 && std::abs(graph.curve().rgb[1].y - 0.7) < 0.01);
|
||||
graph.release(graph.at(0.4, 0.7));
|
||||
// Added below, sorted in at its x.
|
||||
graph.press(graph.at(0.8, 0.95));
|
||||
graph.move(graph.at(0.7, 1.4));
|
||||
graph.release(graph.at(0.7, 1.4));
|
||||
QCOMPARE(graph.curve().rgb.size(), size_t(4));
|
||||
QVERIFY(std::abs(graph.curve().rgb[2].x - 0.7) < 0.01 && graph.curve().rgb[2].y == 1);
|
||||
QVERIFY(std::abs(graph.curve().rgb[1].x - 0.4) < 0.01);
|
||||
// The ends move their output alone.
|
||||
graph.press(graph.at(1, 1));
|
||||
graph.move(graph.at(0.6, 0.8));
|
||||
graph.release(graph.at(0.6, 0.8));
|
||||
QVERIFY(graph.curve().rgb.back().x == 1 && std::abs(graph.curve().rgb.back().y - 0.8) < 0.01);
|
||||
graph.press(graph.at(0, 0));
|
||||
graph.move(graph.at(0.3, -0.5));
|
||||
graph.release(graph.at(0.3, -0.5));
|
||||
QVERIFY(graph.curve().rgb.front() == (CurvePoint{0, 0}));
|
||||
}
|
||||
|
||||
void CameraRawCurveGraphTests::addingIsRefusedNearAnEdgeAPointOrSixteen()
|
||||
{
|
||||
Graph graph(true);
|
||||
// Too near an end's x, or a point's.
|
||||
const int w = graph.graph->width();
|
||||
graph.press(QPoint(2, 20));
|
||||
graph.move(QPoint(40, 30));
|
||||
graph.release(QPoint(40, 30));
|
||||
graph.press(QPoint(w - 2, 140));
|
||||
graph.release(QPoint(w - 2, 140));
|
||||
QVERIFY(graph.curve().rgb == CameraRawCurveSettings::linear());
|
||||
graph.set([](CameraRawCurveSettings &curve) { curve.rgb = {{0, 0}, {0.5, 0.2}, {1, 1}}; });
|
||||
graph.press(graph.at(0.505, 0.9));
|
||||
graph.move(graph.at(0.6, 0.9));
|
||||
graph.release(graph.at(0.6, 0.9));
|
||||
QVERIFY(graph.curve().rgb == (std::vector<CurvePoint>{{0, 0}, {0.5, 0.2}, {1, 1}}));
|
||||
QVERIFY(!graph.readout().isVisible());
|
||||
// Sixteen points take no seventeenth.
|
||||
graph.set([](CameraRawCurveSettings &curve) {
|
||||
curve.rgb = {{0, 0}};
|
||||
for (int index = 1; index < 15; ++index)
|
||||
curve.rgb.push_back({index * 0.06, index * 0.06});
|
||||
curve.rgb.push_back({1, 1});
|
||||
});
|
||||
QCOMPARE(graph.curve().rgb.size(), size_t(16));
|
||||
graph.press(graph.at(0.93, 0.2));
|
||||
graph.release(graph.at(0.93, 0.2));
|
||||
QCOMPARE(graph.curve().rgb.size(), size_t(16));
|
||||
// Fifteen take a sixteenth.
|
||||
graph.set([](CameraRawCurveSettings &curve) { curve.rgb.erase(curve.rgb.begin() + 1); });
|
||||
graph.press(graph.at(0.93, 0.2));
|
||||
graph.release(graph.at(0.93, 0.2));
|
||||
QCOMPARE(graph.curve().rgb.size(), size_t(16));
|
||||
}
|
||||
|
||||
void CameraRawCurveGraphTests::theChosenPointIsLitAndReadOut()
|
||||
{
|
||||
Graph graph(true);
|
||||
graph.set([](CameraRawCurveSettings &curve) { curve.rgb = {{0, 0}, {0.3, 0.4}, {0.7, 0.6}, {1, 1}}; });
|
||||
const QColor accent = graph.graph->palette().color(QPalette::Highlight);
|
||||
QCOMPARE(graph.dot(0.3, 0.4), QColor(Qt::white));
|
||||
QVERIFY(!graph.readout().isVisible());
|
||||
const auto shown = [&graph](size_t index) {
|
||||
const CurvePoint point = graph.curve().rgb[index];
|
||||
return QStringLiteral("In %1 Out %2").arg(std::lround(point.x * 255)).arg(std::lround(point.y * 255));
|
||||
};
|
||||
// The press takes the point to the pointer.
|
||||
graph.press(graph.at(0.3, 0.4));
|
||||
QVERIFY(std::abs(graph.curve().rgb[1].x - 0.3) < 0.003 && std::abs(graph.curve().rgb[1].y - 0.4) < 0.007);
|
||||
QCOMPARE(graph.dot(0.3, 0.4), accent);
|
||||
QCOMPARE(graph.dot(0.7, 0.6), QColor(Qt::white));
|
||||
QCOMPARE(graph.readout().text(), shown(1));
|
||||
// The readout follows the drag.
|
||||
graph.move(graph.at(0.4, 0.5));
|
||||
QCOMPARE(graph.readout().text(), shown(1));
|
||||
QVERIFY(graph.readout().text().startsWith("In 102 Out 12"));
|
||||
graph.release(graph.at(0.4, 0.5));
|
||||
// An end point is chosen too.
|
||||
graph.press(graph.at(1, 1));
|
||||
graph.release(graph.at(1, 1));
|
||||
QCOMPARE(graph.readout().text(), QString("In 255 Out 255"));
|
||||
QCOMPARE(graph.graph->grab().toImage().pixelColor(graph.graph->width() - 2, 2), accent);
|
||||
// A new point is chosen as it is made.
|
||||
graph.press(graph.at(0.85, 0.2));
|
||||
graph.release(graph.at(0.85, 0.2));
|
||||
QCOMPARE(graph.readout().text(), shown(3));
|
||||
QVERIFY(std::abs(graph.curve().rgb[3].x - 0.85) < 0.003);
|
||||
QCOMPARE(graph.dot(0.85, 0.2), accent);
|
||||
// A choice past the points changed elsewhere hides.
|
||||
graph.press(graph.at(1, 1));
|
||||
graph.release(graph.at(1, 1));
|
||||
graph.set([](CameraRawCurveSettings &curve) { curve.rgb = CameraRawCurveSettings::linear(); });
|
||||
QVERIFY(!graph.readout().isVisible());
|
||||
}
|
||||
|
||||
void CameraRawCurveGraphTests::aNewChannelOrPresetDropsTheChoice()
|
||||
{
|
||||
Graph graph(true);
|
||||
graph.set([](CameraRawCurveSettings &curve) { curve.rgb = curve.red = {{0, 0}, {0.3, 0.4}, {1, 1}}; });
|
||||
graph.press(graph.at(0.3, 0.4));
|
||||
QVERIFY(graph.readout().isVisible());
|
||||
// Another channel: no choice, and the held drag lets go.
|
||||
graph.controls->findChild<QToolButton *>(QStringLiteral("pointChannel1"))->click();
|
||||
QVERIFY(!graph.readout().isVisible());
|
||||
QCOMPARE(graph.dot(0.3, 0.4), QColor(Qt::white));
|
||||
graph.move(graph.at(0.3, 0.9));
|
||||
QVERIFY(graph.curve().red == (std::vector<CurvePoint>{{0, 0}, {0.3, 0.4}, {1, 1}}));
|
||||
graph.release(graph.at(0.3, 0.9));
|
||||
// A preset's points drop it too; Custom keeps it.
|
||||
graph.press(graph.at(0.3, 0.4));
|
||||
graph.release(graph.at(0.3, 0.4));
|
||||
auto &preset = *graph.controls->findChild<QComboBox *>(QStringLiteral("curvePreset"));
|
||||
emit preset.activated(0);
|
||||
graph.set([](CameraRawCurveSettings &curve) { curve.refineSaturation = 10; });
|
||||
QVERIFY(graph.readout().isVisible());
|
||||
emit preset.activated(2);
|
||||
QVERIFY(graph.curve().red == CameraRawCurveSettings::mediumContrast());
|
||||
QVERIFY(!graph.readout().isVisible());
|
||||
QCOMPARE(graph.dot(0.25, 0.18), QColor(Qt::white));
|
||||
}
|
||||
|
||||
void CameraRawCurveGraphTests::aDoubleClickRemovesAnInnerPointAndTheChoice()
|
||||
{
|
||||
Graph graph(true);
|
||||
graph.set([](CameraRawCurveSettings &curve) { curve.rgb = {{0, 0}, {0.5, 0.5}, {1, 1}}; });
|
||||
QTest::mouseDClick(graph.graph->window()->windowHandle(), Qt::LeftButton, {}, graph.graph->mapTo(graph.graph->window(), graph.at(0.5, 0.5)));
|
||||
QVERIFY(graph.curve().rgb == CameraRawCurveSettings::linear());
|
||||
QVERIFY(!graph.readout().isVisible());
|
||||
// On empty ground: one click adds, two remove.
|
||||
QTest::mouseDClick(graph.graph->window()->windowHandle(), Qt::LeftButton, {}, graph.graph->mapTo(graph.graph->window(), graph.at(0.4, 0.8)));
|
||||
QVERIFY(graph.curve().rgb == CameraRawCurveSettings::linear());
|
||||
// A move after it drags nothing.
|
||||
graph.set([](CameraRawCurveSettings &curve) { curve.rgb = {{0, 0}, {0.3, 0.3}, {0.6, 0.6}, {1, 1}}; });
|
||||
QTest::mouseDClick(graph.graph, Qt::LeftButton, {}, graph.at(0.3, 0.3));
|
||||
graph.move(graph.at(0.5, 0.9));
|
||||
QVERIFY(graph.curve().rgb == (std::vector<CurvePoint>{{0, 0}, {0.6, 0.6}, {1, 1}}));
|
||||
// An end point stays, though chosen.
|
||||
QTest::mouseDClick(graph.graph, Qt::LeftButton, {}, graph.at(1, 1));
|
||||
QCOMPARE(graph.curve().rgb.size(), size_t(3));
|
||||
QCOMPARE(graph.readout().text(), QString("In 255 Out 255"));
|
||||
}
|
||||
|
||||
void CameraRawCurveGraphTests::onlyTheLeftButtonDragsAndAPressStartsAfresh()
|
||||
{
|
||||
Graph graph(true);
|
||||
graph.press(graph.at(0.5, 0.8), Qt::RightButton);
|
||||
graph.move(graph.at(0.5, 0.2), Qt::RightButton);
|
||||
QVERIFY(graph.curve().rgb == CameraRawCurveSettings::linear());
|
||||
graph.set([](CameraRawCurveSettings &curve) { curve.rgb = {{0, 0}, {0.5, 0.5}, {1, 1}}; });
|
||||
QTest::mouseDClick(graph.graph, Qt::RightButton, {}, graph.at(0.5, 0.5));
|
||||
QVERIFY(graph.curve().rgb == (std::vector<CurvePoint>{{0, 0}, {0.5, 0.5}, {1, 1}}));
|
||||
// A move without the left button drags nothing.
|
||||
graph.press(graph.at(0.5, 0.5));
|
||||
graph.move(graph.at(0.5, 0.9), Qt::RightButton);
|
||||
QVERIFY(std::abs(graph.curve().rgb[1].y - 0.5) < 0.01);
|
||||
graph.release(graph.at(0.5, 0.5));
|
||||
// A lost release: the next press picks its own target.
|
||||
graph.set([](CameraRawCurveSettings &curve) { curve.rgb = {{0, 0}, {0.3, 0.3}, {0.7, 0.7}, {1, 1}}; });
|
||||
graph.press(graph.at(0.3, 0.3));
|
||||
graph.press(graph.at(0.7, 0.7));
|
||||
graph.move(graph.at(0.7, 0.9));
|
||||
QVERIFY(std::abs(graph.curve().rgb[1].y - 0.3) < 0.01 && std::abs(graph.curve().rgb[2].y - 0.9) < 0.01);
|
||||
// A release lets go: moves then change nothing.
|
||||
graph.release(graph.at(0.7, 0.9));
|
||||
const std::vector<CurvePoint> held = graph.curve().rgb;
|
||||
graph.move(graph.at(0.5, 0.1));
|
||||
QVERIFY(graph.curve().rgb == held);
|
||||
// Parametric too: a release ends the region.
|
||||
graph.controls->findChild<QToolButton *>(QStringLiteral("curvePage0"))->click();
|
||||
graph.press(QPoint(30, 80));
|
||||
graph.release(QPoint(30, 80));
|
||||
graph.move(QPoint(30, 10));
|
||||
QCOMPARE(graph.curve().shadows, 0.0);
|
||||
}
|
||||
|
||||
void CameraRawCurveGraphTests::theGraphSaysWhatADragDoes()
|
||||
{
|
||||
Graph graph;
|
||||
QCOMPARE(graph.graph->toolTip(), QString("Drag up or down to lift or lower those tones. Drag a divider along the bottom to change which tones each region covers."));
|
||||
graph.controls->findChild<QToolButton *>(QStringLiteral("curvePage1"))->click();
|
||||
QCOMPARE(graph.graph->toolTip(), QString("Drag a point. Click to add one. Double-click a point to remove it."));
|
||||
}
|
||||
|
||||
QTEST_MAIN(CameraRawCurveGraphTests)
|
||||
#include "CameraRawCurveGraphTests.moc"
|
||||
@@ -94,14 +94,20 @@ void CameraRawCurveMixerTests::theParametricPageMovesRegionsAndDividers()
|
||||
QTest::mouseDClick(&slider, Qt::LeftButton, {}, knob(slider));
|
||||
QCOMPARE(group.raw().curve.*member, 0.0);
|
||||
}
|
||||
// A drag moves the nearest divider, within 2 and 98.
|
||||
// Along the bottom: the nearest divider, kept ordered.
|
||||
auto &graph = group.child<QWidget>(QStringLiteral("cameraRawCurveGraph"));
|
||||
press(graph, QPoint(int(graph.width() * 0.3), 75));
|
||||
drag(graph, QPoint(int(graph.width() * 0.35), 75));
|
||||
const int h = graph.height();
|
||||
press(graph, QPoint(int(graph.width() * 0.3), h - 5));
|
||||
drag(graph, QPoint(int(graph.width() * 0.35), h - 5));
|
||||
QVERIFY(std::abs(group.raw().curve.shadowSplit - 35) < 1 && group.raw().curve.darkSplit == 50);
|
||||
drag(graph, QPoint(graph.width() + 40, 75));
|
||||
QVERIFY(group.raw().curve.lightSplit == 98 || group.raw().curve.shadowSplit == 98 || group.raw().curve.darkSplit == 98);
|
||||
QCOMPARE(group.raw().curve.shadowSplit, 48.0);
|
||||
QTest::mouseRelease(&graph, Qt::LeftButton, {}, QPoint(graph.width() + 40, 75));
|
||||
// Above it a drag lifts the region under the press.
|
||||
press(graph, QPoint(int(graph.width() * 0.3), 75));
|
||||
drag(graph, QPoint(int(graph.width() * 0.9), 75 - h / 4));
|
||||
QCOMPARE(group.raw().curve.shadows, std::round(double(h / 4) / h * 200));
|
||||
QTest::mouseRelease(&graph, Qt::LeftButton, {}, QPoint(int(graph.width() * 0.9), 75 - h / 4));
|
||||
// A double click here leaves the point curve's points.
|
||||
group.set([](CameraRawSettings &settings) { settings.curve.rgb = {{0, 0}, {0.5, 0.5}, {1, 1}}; });
|
||||
QTest::mouseDClick(&graph, Qt::LeftButton, {}, QPoint(graph.width() / 2, graph.height() / 2));
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
#include "CameraRawFixtures.h"
|
||||
#include <QtTest>
|
||||
#include <cmath>
|
||||
|
||||
// Swift 1.4.5: Camera Raw's curves as Photoshop's, the model.
|
||||
using namespace CameraRawFixtures;
|
||||
|
||||
namespace {
|
||||
// Swift's bend, written out apart from the code.
|
||||
double bent(double tone, double lower, double low, double upper, double high)
|
||||
{
|
||||
if (tone < lower && lower > 0)
|
||||
return lower * std::pow(tone / lower, std::pow(2.0, -low / 100 * 1.66));
|
||||
if (tone > upper && upper < 1)
|
||||
return 1 - (1 - upper) * std::pow((1 - tone) / (1 - upper), std::pow(2.0, high / 100 * 1.66));
|
||||
return tone;
|
||||
}
|
||||
|
||||
// The kernel's table lookup: linear between entries.
|
||||
double looked(const std::vector<float> &table, double value)
|
||||
{
|
||||
const double at = value * 255;
|
||||
const int below = int(std::floor(at));
|
||||
if (below >= 255)
|
||||
return table[255];
|
||||
return table[size_t(below)] + (table[size_t(below + 1)] - table[size_t(below)]) * (at - below);
|
||||
}
|
||||
|
||||
double luminance(const std::array<int, 4> &pixel)
|
||||
{
|
||||
return 0.2126 * pixel[0] + 0.7152 * pixel[1] + 0.0722 * pixel[2];
|
||||
}
|
||||
}
|
||||
|
||||
class CameraRawCurveTests : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void parametricCurveIsSmooth();
|
||||
void parametricCurveMatchesPhotoshop();
|
||||
void curveDeepensColorLikePhotoshop();
|
||||
void theCurvePassesThroughEachBentAnchor();
|
||||
void eachRegionBendsOnlyItsRange();
|
||||
void theToneTableRunsThroughThePointCurve();
|
||||
void refineSaturationEasesOrStrengthens();
|
||||
};
|
||||
|
||||
void CameraRawCurveTests::parametricCurveIsSmooth()
|
||||
{
|
||||
CameraRawCurveSettings curve;
|
||||
QCOMPARE(curve.parametric(0.3), 0.3);
|
||||
for (const std::array<double, 4> &amounts : {std::array{100.0, 0.0, 0.0, 0.0}, {0, 100, -100, 0}, {-100, 50, 100, -60}}) {
|
||||
curve.shadows = amounts[0];
|
||||
curve.darks = amounts[1];
|
||||
curve.lights = amounts[2];
|
||||
curve.highlights = amounts[3];
|
||||
std::vector<double> samples;
|
||||
for (int index = 0; index <= 200; ++index)
|
||||
samples.push_back(curve.parametric(index / 200.0));
|
||||
QCOMPARE(samples.front(), 0.0);
|
||||
QCOMPARE(samples.back(), 1.0);
|
||||
for (size_t index = 1; index < samples.size(); ++index)
|
||||
QVERIFY(samples[index] >= samples[index - 1] - 1e-9);
|
||||
// Finer sampling halves the slope's jumps; a corner keeps them.
|
||||
const auto jump = [&curve](int steps) {
|
||||
std::vector<double> slopes;
|
||||
double last = curve.parametric(0);
|
||||
for (int index = 1; index <= steps; ++index) {
|
||||
const double value = curve.parametric(double(index) / steps);
|
||||
slopes.push_back((value - last) * steps);
|
||||
last = value;
|
||||
}
|
||||
double worst = 0;
|
||||
for (size_t index = 1; index < slopes.size(); ++index)
|
||||
worst = std::max(worst, std::abs(slopes[index] - slopes[index - 1]));
|
||||
return worst;
|
||||
};
|
||||
QVERIFY(jump(400) < jump(200) * 0.7);
|
||||
}
|
||||
curve = CameraRawCurveSettings();
|
||||
curve.darks = 100;
|
||||
const double before = curve.parametric(0.4);
|
||||
curve.darkSplit = 70;
|
||||
QVERIFY(curve.parametric(0.4) > before);
|
||||
}
|
||||
|
||||
void CameraRawCurveTests::parametricCurveMatchesPhotoshop()
|
||||
{
|
||||
CameraRawCurveSettings curve;
|
||||
curve.darks = -51;
|
||||
curve.lights = 59;
|
||||
const std::pair<double, double> photoshop[] = {{0.093, 0.011}, {0.192, 0.089}, {0.267, 0.174}, {0.367, 0.310}, {0.491, 0.498},
|
||||
{0.616, 0.698}, {0.690, 0.804}, {0.765, 0.886}, {0.840, 0.947}, {0.915, 0.982}};
|
||||
for (const auto &[tone, expected] : photoshop)
|
||||
QVERIFY2(std::abs(curve.parametric(tone) - expected) < 0.035, qPrintable(QString::number(curve.parametric(tone))));
|
||||
}
|
||||
|
||||
void CameraRawCurveTests::curveDeepensColorLikePhotoshop()
|
||||
{
|
||||
const QImage orange = image(2, 2, 0.85, 0.35, 0.1);
|
||||
CameraRawSettings settings;
|
||||
settings.curve.darks = -51;
|
||||
settings.curve.lights = 59;
|
||||
const std::array<int, 4> curved = pixels(settings.apply(orange))[0];
|
||||
QVERIFY2(curved[0] > 230 && curved[1] < 80, qPrintable(QString("%1 %2").arg(curved[0]).arg(curved[1])));
|
||||
settings.curve.refineSaturation = -100;
|
||||
const std::array<int, 4> brightness = pixels(settings.apply(orange))[0];
|
||||
QVERIFY(double(brightness[1]) / double(brightness[0]) > 0.35);
|
||||
}
|
||||
|
||||
void CameraRawCurveTests::theCurvePassesThroughEachBentAnchor()
|
||||
{
|
||||
CameraRawCurveSettings curve;
|
||||
curve.shadows = 40;
|
||||
curve.darks = -51;
|
||||
curve.lights = 59;
|
||||
curve.highlights = -30;
|
||||
curve.shadowSplit = 20;
|
||||
curve.darkSplit = 45;
|
||||
curve.lightSplit = 80;
|
||||
// Shadows and highlights first, then darks and lights.
|
||||
for (int index = 0; index <= 32; ++index) {
|
||||
const double x = index / 32.0;
|
||||
const double expected = bent(bent(x, 0.2, 40, 0.8, -30), 0.45, -51, 0.45, 59);
|
||||
QVERIFY2(std::abs(curve.parametric(x) - expected) < 1e-9, qPrintable(QString::number(x)));
|
||||
}
|
||||
// Between anchors the smooth curve, not a straight line.
|
||||
const double middle = (bent(bent(1 / 32.0, 0.2, 40, 0.8, -30), 0.45, -51, 0.45, 59) + bent(bent(2 / 32.0, 0.2, 40, 0.8, -30), 0.45, -51, 0.45, 59)) / 2;
|
||||
QVERIFY(std::abs(curve.parametric(1.5 / 32) - middle) > 1e-6);
|
||||
}
|
||||
|
||||
void CameraRawCurveTests::eachRegionBendsOnlyItsRange()
|
||||
{
|
||||
// Shadows and highlights bend past their dividers alone.
|
||||
CameraRawCurveSettings shadows;
|
||||
shadows.shadows = 100;
|
||||
QVERIFY(shadows.parametric(4 / 32.0) > 4 / 32.0 + 0.05);
|
||||
QVERIFY(std::abs(shadows.parametric(0.25) - 0.25) < 1e-9);
|
||||
QVERIFY(std::abs(shadows.parametric(0.5) - 0.5) < 1e-9);
|
||||
CameraRawCurveSettings highlights;
|
||||
highlights.highlights = -100;
|
||||
QVERIFY(highlights.parametric(28 / 32.0) < 28 / 32.0 - 0.05);
|
||||
QVERIFY(std::abs(highlights.parametric(0.75) - 0.75) < 1e-9);
|
||||
// Darks cover all below the middle; lights all above it.
|
||||
CameraRawCurveSettings darks;
|
||||
darks.darks = 100;
|
||||
QVERIFY(darks.parametric(4 / 32.0) > 4 / 32.0 + 0.02 && darks.parametric(12 / 32.0) > 12 / 32.0 + 0.05);
|
||||
QVERIFY(std::abs(darks.parametric(0.5) - 0.5) < 1e-9 && std::abs(darks.parametric(0.75) - 0.75) < 1e-9);
|
||||
CameraRawCurveSettings lights;
|
||||
lights.lights = -100;
|
||||
QVERIFY(lights.parametric(20 / 32.0) < 20 / 32.0 - 0.05 && lights.parametric(28 / 32.0) < 28 / 32.0 - 0.02);
|
||||
QVERIFY(std::abs(lights.parametric(0.25) - 0.25) < 1e-9);
|
||||
// A divider at an end bends nothing past it.
|
||||
CameraRawCurveSettings ends;
|
||||
ends.shadows = 100;
|
||||
ends.highlights = 100;
|
||||
ends.shadowSplit = 0;
|
||||
ends.lightSplit = 100;
|
||||
for (int index = 0; index <= 32; ++index)
|
||||
QVERIFY(std::abs(ends.parametric(index / 32.0) - index / 32.0) < 1e-9);
|
||||
}
|
||||
|
||||
void CameraRawCurveTests::theToneTableRunsThroughThePointCurve()
|
||||
{
|
||||
CameraRawCurveSettings curve;
|
||||
curve.lights = 59;
|
||||
curve.rgb = {{0, 0}, {0.5, 0.6}, {1, 1}};
|
||||
const std::vector<float> table = curve.toneTable();
|
||||
QCOMPARE(table.size(), size_t(256));
|
||||
CameraRawCurveSettings point;
|
||||
point.rgb = curve.rgb;
|
||||
// Without amounts, the point curve alone: 0.5 to 0.6.
|
||||
QVERIFY(std::abs(point.toneTable()[128] - 0.6) < 0.005);
|
||||
for (const int index : {0, 64, 128, 200, 255}) {
|
||||
const double expected = point.toneTable()[size_t(std::lround(curve.parametric(index / 255.0) * 255))];
|
||||
QVERIFY2(std::abs(table[size_t(index)] - expected) < 0.01, qPrintable(QString::number(index)));
|
||||
}
|
||||
QCOMPARE(table[255], 1.0f);
|
||||
}
|
||||
|
||||
void CameraRawCurveTests::refineSaturationEasesOrStrengthens()
|
||||
{
|
||||
const QImage orange = image(2, 2, 0.85, 0.35, 0.1);
|
||||
CameraRawSettings settings;
|
||||
settings.curve.darks = -51;
|
||||
settings.curve.lights = 59;
|
||||
// At zero each channel runs through the tone curve alone.
|
||||
const std::vector<float> table = settings.curve.toneTable();
|
||||
const std::array<int, 4> source = pixels(orange)[0], curved = pixels(settings.apply(orange))[0];
|
||||
for (int channel = 0; channel < 3; ++channel)
|
||||
QVERIFY2(std::abs(curved[size_t(channel)] - looked(table, source[size_t(channel)] / 255.0) * 255) <= 1, qPrintable(QString::number(channel)));
|
||||
// −100: the colour scaled to the curved brightness, hue kept.
|
||||
settings.curve.refineSaturation = -100;
|
||||
const std::array<int, 4> brightness = pixels(settings.apply(orange))[0];
|
||||
const double mapped = looked(table, luminance(source) / 255) * 255;
|
||||
QVERIFY(std::abs(luminance(brightness) - mapped) <= 1.5);
|
||||
QVERIFY(std::abs(double(brightness[1]) / brightness[0] - double(source[1]) / source[0]) < 0.02);
|
||||
// −50: halfway between the two.
|
||||
settings.curve.refineSaturation = -50;
|
||||
const std::array<int, 4> half = pixels(settings.apply(orange))[0];
|
||||
for (int channel = 0; channel < 3; ++channel)
|
||||
QVERIFY(std::abs(half[size_t(channel)] - (curved[size_t(channel)] + brightness[size_t(channel)]) / 2.0) <= 1.5);
|
||||
// +50: half again as far from luminance.
|
||||
settings.curve.refineSaturation = 50;
|
||||
const std::array<int, 4> stronger = pixels(settings.apply(orange))[0];
|
||||
const double lum = luminance(curved);
|
||||
for (int channel = 0; channel < 3; ++channel) {
|
||||
const double expected = std::clamp(lum + (curved[size_t(channel)] - lum) * 1.5, 0.0, 255.0);
|
||||
QVERIFY2(std::abs(stronger[size_t(channel)] - expected) <= 1.5, qPrintable(QString("%1 %2").arg(stronger[size_t(channel)]).arg(expected)));
|
||||
}
|
||||
}
|
||||
|
||||
QTEST_MAIN(CameraRawCurveTests)
|
||||
#include "CameraRawCurveTests.moc"
|
||||
@@ -89,7 +89,7 @@ void CameraRawGroupDrawTests::theCurvePagesShowTheirOwnControls()
|
||||
QCOMPARE(preset.toolTip(), QString("Replaces this curve with a straight line or a contrast curve."));
|
||||
QCOMPARE(channel.toolTip(), QString("RGB changes brightness. Red, green, and blue also shift the color."));
|
||||
QCOMPARE(group.child<QWidget>(QStringLiteral("cameraRawCurveGraph")).toolTip(),
|
||||
QString("Drag a point. Click the curve to add one. Double-click a point to remove it."));
|
||||
QString("Drag a point. Click to add one. Double-click a point to remove it."));
|
||||
QCOMPARE(group.child<QWidget>(QStringLiteral("curveSelectedPoint")).toolTip(), QString("Input and output of the selected curve point."));
|
||||
QCOMPARE(group.child<QAbstractButton>(QStringLiteral("curveTargeted")).toolTip(),
|
||||
QString("Drag on the picture to move the curve for the tone under the pointer."));
|
||||
@@ -126,20 +126,20 @@ void CameraRawGroupDrawTests::endPointsStayAndARealDoubleClickRemoves()
|
||||
group.arm([](CameraRawPanel &panel) { panel.curvePage = CameraRawCurvePage::point; });
|
||||
auto &graph = group.child<QWidget>(QStringLiteral("cameraRawCurveGraph"));
|
||||
const int w = graph.width(), h = graph.height();
|
||||
// With no inner point a drag moves nothing.
|
||||
// An end point keeps its x; its output follows.
|
||||
QTest::mousePress(&graph, Qt::LeftButton, {}, QPoint(2, h - 2));
|
||||
QMouseEvent move(QEvent::MouseMove, QPointF(40, 40), graph.mapToGlobal(QPointF(40, 40)), Qt::NoButton, Qt::LeftButton, Qt::NoModifier);
|
||||
QApplication::sendEvent(&graph, &move);
|
||||
QTest::mouseRelease(&graph, Qt::LeftButton, {}, QPoint(40, 40));
|
||||
QVERIFY(group.raw().curve.rgb == CameraRawCurveSettings::linear());
|
||||
// An inner point is held within 0.02 and 0.98.
|
||||
QCOMPARE(group.raw().curve.rgb.size(), size_t(2));
|
||||
QVERIFY(group.raw().curve.rgb[0].x == 0 && std::abs(group.raw().curve.rgb[0].y - (1 - 40.0 / h)) < 1e-9 && group.raw().curve.rgb[1] == (CurvePoint{1, 1}));
|
||||
// Past the start: a hundredth in, then repaired away.
|
||||
group.set([](CameraRawSettings &settings) { settings.curve.rgb = {{0, 0}, {0.5, 0.5}, {1, 1}}; });
|
||||
QTest::mousePress(&graph, Qt::LeftButton, {}, QPoint(w / 2, h / 2));
|
||||
QMouseEvent far(QEvent::MouseMove, QPointF(-50, h / 2), graph.mapToGlobal(QPointF(-50, h / 2)), Qt::NoButton, Qt::LeftButton, Qt::NoModifier);
|
||||
QApplication::sendEvent(&graph, &far);
|
||||
QTest::mouseRelease(&graph, Qt::LeftButton, {}, QPoint(-50, h / 2));
|
||||
QCOMPARE(group.raw().curve.rgb.size(), size_t(3));
|
||||
QVERIFY(std::abs(group.raw().curve.rgb[1].x - 0.02) < 1e-9 && group.raw().curve.rgb.front().x == 0 && group.raw().curve.rgb.back().x == 1);
|
||||
QVERIFY(group.raw().curve.rgb == CameraRawCurveSettings::linear());
|
||||
// A real double click: press, release, double click, release.
|
||||
group.set([](CameraRawSettings &settings) { settings.curve.rgb = {{0, 0}, {0.5, 0.5}, {1, 1}}; });
|
||||
doubleClick(graph, QPoint(w / 2, h / 2));
|
||||
|
||||
@@ -83,7 +83,7 @@ void CameraRawGroupRowsTests::curveRows()
|
||||
checkRow(*group.controls, group.session, row);
|
||||
group.arm([](CameraRawPanel &panel) { panel.curvePage = CameraRawCurvePage::point; });
|
||||
checkRow(*group.controls, group.session,
|
||||
{"refineSaturation", "Refine Saturation", "How much the RGB curve also changes color strength. Zero keeps it to brightness.", -100, 100, 0, false,
|
||||
{"refineSaturation", "Refine Saturation", "How much the curve also changes color strength. Zero matches Photoshop; lower keeps it to brightness, higher adds more color.", -100, 100, 0, false,
|
||||
48, 88, true, false, curve(&CameraRawCurveSettings::refineSaturation)});
|
||||
}
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@ void CameraRawGroupWordsTests::everyViewCarriesSwiftsWords()
|
||||
curve.show();
|
||||
holds(*curve.controls, {"Parametric", "Point", "RGB", "Red", "Green", "Blue", "Parametric lifts tonal regions. Point places anchors on the curve.",
|
||||
"RGB changes brightness. Red, green, and blue also shift the color.",
|
||||
"Drag a divider to change which tones the neighboring sliders affect.", "Targeted Adjustment", "Preset"});
|
||||
"Drag up or down to lift or lower those tones. Drag a divider along the bottom to change which tones each region covers.", "Targeted Adjustment", "Preset"});
|
||||
Group<CameraRawMixerControls> mixer;
|
||||
mixer.set([](CameraRawSettings &settings) { settings.mixer.points = {CameraRawPointColor{.hue = 30, .saturation = 1, .luminance = 0.5}}; });
|
||||
mixer.show();
|
||||
@@ -228,7 +228,7 @@ void CameraRawGroupWordsTests::theCurveShowsItsLineItsSelectedPointAndRepaints()
|
||||
Group<CameraRawCurveControls> curve;
|
||||
curve.show();
|
||||
auto &graph = curve.child<QWidget>(QStringLiteral("cameraRawCurveGraph"));
|
||||
// Lights at 100 lift their region's middle by 0.22.
|
||||
// The drawn curve is the model's parametric curve.
|
||||
curve.set([](CameraRawSettings &settings) { settings.curve.lights = 100; });
|
||||
QImage drawn = graph.grab().toImage();
|
||||
const int w = drawn.width(), h = drawn.height();
|
||||
@@ -237,7 +237,7 @@ void CameraRawGroupWordsTests::theCurveShowsItsLineItsSelectedPointAndRepaints()
|
||||
for (int y = 0; y < h; ++y)
|
||||
if (qGray(drawn.pixel(int(0.625 * w), y)) > qGray(drawn.pixel(int(0.625 * w), brightest)))
|
||||
brightest = y;
|
||||
QVERIFY2(std::abs(brightest - (1 - 0.845) * h) < 3, qPrintable(QString::number(brightest)));
|
||||
QVERIFY2(std::abs(brightest - (1 - curve.raw().curve.parametric(0.625)) * h) < 3, qPrintable(QString::number(brightest)));
|
||||
// A write repaints the graph without being asked.
|
||||
struct Paints : QObject {
|
||||
int count = 0;
|
||||
@@ -253,10 +253,14 @@ void CameraRawGroupWordsTests::theCurveShowsItsLineItsSelectedPointAndRepaints()
|
||||
paints.count = 0;
|
||||
curve.set([](CameraRawSettings &settings) { settings.curve.darkSplit = 60; });
|
||||
QTRY_VERIFY(paints.count > 0);
|
||||
// The selected point is the last inner one.
|
||||
// No point is chosen until one is pressed.
|
||||
curve.arm([](CameraRawPanel &panel) { panel.curvePage = CameraRawCurvePage::point; });
|
||||
curve.set([](CameraRawSettings &settings) { settings.curve.rgb = {{0, 0}, {0.2, 0.3}, {0.6, 0.7}, {1, 1}}; });
|
||||
QCOMPARE(curve.child<QLabel>(QStringLiteral("curveSelectedPoint")).text(), QString("In 153 Out 179"));
|
||||
auto &readout = curve.child<QLabel>(QStringLiteral("curveSelectedPoint"));
|
||||
QVERIFY(!readout.isVisible());
|
||||
QTest::mouseClick(&graph, Qt::LeftButton, {}, QPoint(int(0.6 * graph.width()), int(0.3 * graph.height())));
|
||||
QVERIFY(readout.isVisible());
|
||||
QCOMPARE(readout.text(), QString("In 153 Out 179"));
|
||||
}
|
||||
|
||||
void CameraRawGroupWordsTests::widgetsFollowTheModelAndGuardsHold()
|
||||
|
||||
@@ -238,7 +238,7 @@ void CameraRawWiringTests::curveMixerAndGradingReachTheKernelInOrder()
|
||||
settings.mixer.points = {{200, 0.6, 0.4, 25, -35, 15, 40, 0.5, 0.3, false}, {40, 0.8, 0.6, -10, 20, -45, 60, 0.3, 0.6, false}};
|
||||
settings.grading = {{210, 40, -20}, {100, 25, 10}, {30, 55, 15}, {300, 10, -5}, 70, -30};
|
||||
const CameraRawCurveSettings shape = curve.normalized();
|
||||
const std::vector<float> luma = shape.lumaTable(), red = shape.channelTable(shape.red), green = shape.channelTable(shape.green),
|
||||
const std::vector<float> tone = shape.toneTable(), red = shape.channelTable(shape.red), green = shape.channelTable(shape.green),
|
||||
blue = shape.channelTable(shape.blue);
|
||||
std::vector<float> mixer;
|
||||
for (size_t index = 0; index < 8; ++index)
|
||||
@@ -253,7 +253,7 @@ void CameraRawWiringTests::curveMixerAndGradingReachTheKernelInOrder()
|
||||
float(30 / 360.0), 0.55f, 0.15f, float(300 / 360.0), 0.1f, -0.05f};
|
||||
for (const int visualize : {-1, 1}) {
|
||||
const QImage direct = kernel(picture, [&](uchar *bytes, size_t w, size_t h, size_t stride) {
|
||||
adjust_camera_raw_curve_color(bytes, w, h, stride, luma.data(), red.data(), green.data(), blue.data(), 0.4, mixer.data(), 2, points.data(),
|
||||
adjust_camera_raw_curve_color(bytes, w, h, stride, tone.data(), red.data(), green.data(), blue.data(), 0.4, mixer.data(), 2, points.data(),
|
||||
grade.data(), 0.7, -0.3, visualize);
|
||||
});
|
||||
QVERIFY(pixels(settings.apply(picture, std::nullopt, 1, 0, visualize)) == pixels(direct));
|
||||
|
||||
@@ -179,13 +179,13 @@ void DitherSheetTests::theControlsFollowTheStyle()
|
||||
dither.colors = DitherColors::twoColors;
|
||||
});
|
||||
QCOMPARE(shown.shown(), sorted(QStringList{"Style", "Pixel Size", "Density", "Contrast", "Colors", "Light on Dark", "Swatches"}));
|
||||
// Style's menu: ten looks in four groups.
|
||||
// Style's menu: eleven looks in four groups.
|
||||
QComboBox &style = shown.child<QComboBox>("ditherStyle");
|
||||
QStringList items;
|
||||
for (int index = 0; index < style.count(); ++index)
|
||||
items << (style.itemText(index).isEmpty() ? QStringLiteral("—") : style.itemText(index));
|
||||
QCOMPARE(items, (QStringList{"Atkinson (Classic Mac)", "Floyd–Steinberg", "—", "Bayer 2 × 2", "Bayer 4 × 4", "Bayer 8 × 8", "—", "Halftone Dots",
|
||||
"Halftone Lines", "Halftone Diamonds", "—", "Mac Patterns", "ASCII"}));
|
||||
"Halftone Lines", "Halftone Diamonds", "—", "Mac Patterns", "ASCII", "Scanlines (CRT)"}));
|
||||
QCOMPARE(style.currentText(), QString("Mac Patterns"));
|
||||
// Its quick preview never shows the busy line.
|
||||
QVERIFY(!shown.child<QProgressBar>("filterSpinner").isVisibleTo(shown.sheet.get()));
|
||||
|
||||
@@ -67,11 +67,11 @@ void DitherTests::stylesKeepSwiftsNamesOrderAndGroups()
|
||||
for (const DitherStyle each : allDitherStyles)
|
||||
names << rawValue(each);
|
||||
QCOMPARE(names, (QStringList{"Atkinson (Classic Mac)", "Floyd–Steinberg", "Bayer 2 × 2", "Bayer 4 × 4", "Bayer 8 × 8", "Halftone Dots",
|
||||
"Halftone Lines", "Halftone Diamonds", "Mac Patterns", "ASCII"}));
|
||||
"Halftone Lines", "Halftone Diamonds", "Mac Patterns", "ASCII", "Scanlines (CRT)"}));
|
||||
QString groups;
|
||||
for (const DitherStyle each : allDitherStyles)
|
||||
groups += QString::number(ditherGroup(each));
|
||||
QCOMPARE(groups, QString("0011122233"));
|
||||
QCOMPARE(groups, QString("00111222333"));
|
||||
QVERIFY(diffuses(DitherStyle::floydSteinberg) && !diffuses(DitherStyle::bayer2));
|
||||
QVERIFY(hasTones(DitherStyle::bayer8) && !hasTones(DitherStyle::dots));
|
||||
QVERIFY(isHalftone(DitherStyle::diamonds) && !isHalftone(DitherStyle::patterns));
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
#include "DialogDesk.h"
|
||||
#include "Document/ProjectWorkspace.h"
|
||||
#include "SelectionFixtures.h"
|
||||
#include "UI/ProjectWorkspaceView.h"
|
||||
#include <QtTest>
|
||||
|
||||
// Swift 1.4.4: quit applies canvas edits and cancels dialogs.
|
||||
namespace {
|
||||
ImportedImage filled(const QColor &colour)
|
||||
{
|
||||
QImage pixels(100, 20, QImage::Format_RGBA8888_Premultiplied);
|
||||
pixels.fill(colour);
|
||||
return ImportedImage(pixels, pixels, QStringLiteral("Fixture"));
|
||||
}
|
||||
|
||||
// Swift's await: true once the call's callback has run.
|
||||
bool awaited(const std::function<void(std::function<void()>)> &call)
|
||||
{
|
||||
bool done = false;
|
||||
call([&done] { done = true; });
|
||||
return QTest::qWaitFor([&done] { return done; }, 10'000);
|
||||
}
|
||||
|
||||
std::optional<bool> answer(ProjectWorkspace &workspace)
|
||||
{
|
||||
std::optional<bool> result;
|
||||
workspace.confirmQuit([&](bool value) { result = value; });
|
||||
return QTest::qWaitFor([&] { return result.has_value(); }, 10'000) ? result : std::nullopt;
|
||||
}
|
||||
|
||||
// Swift's test: a gradient waiting for Apply.
|
||||
void pendGradient(EditorSession &session)
|
||||
{
|
||||
session.createDocument(100, 20);
|
||||
session.addBlankLayer();
|
||||
session.selectTool(NavigationTool::gradient);
|
||||
session.beginGradient(QPointF(0, 10));
|
||||
session.moveGradient(std::nullopt, QPointF(100, 10));
|
||||
session.endGradientDrag();
|
||||
}
|
||||
|
||||
ImageIdentity identity(const EditorSession &session)
|
||||
{
|
||||
return session.activeLayer().value().asset.value().identity();
|
||||
}
|
||||
}
|
||||
|
||||
class QuitPendingEditsTests : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void quittingAppliesAPendingGradient();
|
||||
void quittingAppliesAPixelMove();
|
||||
void quittingCancelsAnOpenDialog();
|
||||
void quittingCancelsTheOtherDialogs();
|
||||
void quittingCancelsAnAdjustmentsEditor();
|
||||
void aWorkspaceGoneMidSettleCallsNothing();
|
||||
void closingTheWindowAppliesTheGradientThenAsks();
|
||||
void aBusyProjectHoldsTheQuit();
|
||||
void aQuitWhileManagingIsRefusedAtOnce();
|
||||
};
|
||||
|
||||
void QuitPendingEditsTests::quittingAppliesAPendingGradient()
|
||||
{
|
||||
ProjectWorkspace workspace;
|
||||
EditorSession &session = workspace.current().session;
|
||||
pendGradient(session);
|
||||
QVERIFY(!workspace.canSwitch());
|
||||
const int count = session.history.undoCount();
|
||||
QVERIFY(awaited([&](std::function<void()> done) { workspace.settlePendingEdits(std::move(done)); }));
|
||||
QVERIFY(!session.gradientEdit());
|
||||
QCOMPARE(session.history.undoCount(), count + 1);
|
||||
QCOMPARE(session.history.undoName(), QString("Gradient"));
|
||||
QVERIFY(workspace.canSwitch());
|
||||
}
|
||||
|
||||
void QuitPendingEditsTests::quittingAppliesAPixelMove()
|
||||
{
|
||||
ProjectWorkspace workspace;
|
||||
EditorSession &session = workspace.current().session;
|
||||
session.createDocument(100, 20);
|
||||
session.insert(filled(QColor(200, 100, 50)));
|
||||
session.applySelection(rectPath(QRectF(10, 5, 10, 10)), SelectionMode::replace, "Select");
|
||||
QVERIFY(session.beginPixelMove());
|
||||
session.movePixels(QSizeF(30, 0));
|
||||
QVERIFY(!workspace.canSwitch());
|
||||
const int count = session.history.undoCount();
|
||||
QVERIFY(awaited([&](std::function<void()> done) { workspace.settlePendingEdits(std::move(done)); }));
|
||||
QVERIFY(!session.pixelMove());
|
||||
QCOMPARE(session.history.undoCount(), count + 1);
|
||||
QCOMPARE(session.history.undoName(), QString("Move Pixels"));
|
||||
QCOMPARE(session.selection().value().path.boundingRect(), QRectF(40, 5, 10, 10));
|
||||
QVERIFY(workspace.canSwitch());
|
||||
}
|
||||
|
||||
void QuitPendingEditsTests::quittingCancelsAnOpenDialog()
|
||||
{
|
||||
ProjectWorkspace workspace;
|
||||
EditorSession &session = workspace.current().session;
|
||||
session.createDocument(100, 20);
|
||||
session.insert(filled(QColor(200, 100, 50)));
|
||||
const ImageIdentity original = identity(session);
|
||||
const int count = session.history.undoCount();
|
||||
const std::vector<std::function<void()>> opens{[&] { session.beginFilter(FilterKind::gaussianBlur); },
|
||||
[&] { session.beginHueSaturation(); }, [&] { session.beginLevels(); }};
|
||||
for (const std::function<void()> &open : opens) {
|
||||
open();
|
||||
QVERIFY(!workspace.canSwitch());
|
||||
QVERIFY(awaited([&](std::function<void()> done) { workspace.settlePendingEdits(std::move(done)); }));
|
||||
QVERIFY(workspace.canSwitch());
|
||||
QVERIFY(identity(session) == original);
|
||||
}
|
||||
QVERIFY(!session.filterEdit() && !session.hueSaturation() && !session.levels());
|
||||
QCOMPARE(session.history.undoCount(), count);
|
||||
}
|
||||
|
||||
void QuitPendingEditsTests::quittingCancelsTheOtherDialogs()
|
||||
{
|
||||
ProjectWorkspace workspace;
|
||||
EditorSession &session = workspace.current().session;
|
||||
session.createDocument(100, 20);
|
||||
session.insert(filled(QColor(200, 100, 50)));
|
||||
const QPainterPath before = rectPath(QRectF(10, 5, 10, 10));
|
||||
session.applySelection(before, SelectionMode::replace, "Select");
|
||||
const PaletteColor foreground = session.foregroundColor();
|
||||
const auto settle = [&] { return awaited([&](std::function<void()> done) { workspace.settlePendingEdits(std::move(done)); }); };
|
||||
// Color Range: the selection goes back as it was.
|
||||
session.beginColorRange();
|
||||
QVERIFY(session.colorRange() && !workspace.canSwitch());
|
||||
QVERIFY(settle());
|
||||
QVERIFY(!session.colorRange());
|
||||
QCOMPARE(session.selection().value().path.boundingRect(), before.boundingRect());
|
||||
// Expand, Contract or Feather: its panel goes.
|
||||
session.setSelectionAmountOperation(SelectionAmountOperation::feather);
|
||||
QVERIFY(!workspace.canSwitch());
|
||||
QVERIFY(settle());
|
||||
QVERIFY(!session.selectionAmountOperation());
|
||||
// The colour picker: closed, its colour not taken.
|
||||
session.openColorPicker(false);
|
||||
session.setColorPickerHSB(PickerHSB(120, 1, 1));
|
||||
QVERIFY(!workspace.canSwitch());
|
||||
QVERIFY(settle());
|
||||
QVERIFY(!session.colorPicker());
|
||||
QVERIFY(session.foregroundColor() == foreground);
|
||||
QVERIFY(workspace.canSwitch());
|
||||
}
|
||||
|
||||
void QuitPendingEditsTests::quittingCancelsAnAdjustmentsEditor()
|
||||
{
|
||||
ProjectWorkspace workspace;
|
||||
EditorSession &session = workspace.current().session;
|
||||
session.createDocument(100, 20);
|
||||
session.insert(filled(Qt::white));
|
||||
session.addAdjustment(AdjustmentKind::exposure);
|
||||
const QUuid id = session.adjustmentEditingID().value();
|
||||
const LayerAdjustment made = session.activeLayer().value().adjustment.value();
|
||||
QVERIFY(awaited([&](std::function<void()> done) { session.beginAdjustmentEditing(id, std::move(done)); }));
|
||||
FilterSettings settings = session.filterEdit().value().settings;
|
||||
settings.exposure.exposure = 2;
|
||||
session.updateFilter(settings, true);
|
||||
QVERIFY(!(session.activeLayer().value().adjustment.value() == made));
|
||||
QVERIFY(!workspace.canSwitch());
|
||||
QVERIFY(awaited([&](std::function<void()> done) { workspace.settlePendingEdits(std::move(done)); }));
|
||||
QVERIFY(!session.adjustmentEditingID() && !session.adjustmentOriginal() && !session.filterEdit());
|
||||
QVERIFY(session.activeLayer().value().adjustment.value() == made);
|
||||
QVERIFY(workspace.canSwitch());
|
||||
}
|
||||
|
||||
void QuitPendingEditsTests::aWorkspaceGoneMidSettleCallsNothing()
|
||||
{
|
||||
auto workspace = std::make_unique<ProjectWorkspace>();
|
||||
const std::shared_ptr<ProjectTab> tab = workspace->tabs().front();
|
||||
pendGradient(tab->session);
|
||||
bool called = false;
|
||||
workspace->settlePendingEdits([&called] { called = true; });
|
||||
workspace.reset();
|
||||
// The tab lives on and its gradient lands; no answer.
|
||||
QTRY_VERIFY(!tab->session.gradientEdit());
|
||||
QTest::qWait(100);
|
||||
QVERIFY(!called);
|
||||
QCOMPARE(tab->session.history.undoName(), QString("Gradient"));
|
||||
}
|
||||
|
||||
void QuitPendingEditsTests::closingTheWindowAppliesTheGradientThenAsks()
|
||||
{
|
||||
ProjectWorkspace workspace;
|
||||
ProjectWorkspaceView view(workspace);
|
||||
view.show();
|
||||
EditorSession &session = workspace.current().session;
|
||||
pendGradient(session);
|
||||
session.history.markSaved();
|
||||
DialogDesk desk;
|
||||
desk.replies = {"Cancel"};
|
||||
// The gradient is a step by the time it asks.
|
||||
desk.note = [&] { return QStringLiteral("pending=%1 step=%2").arg(session.gradientEdit().has_value()).arg(session.history.undoName()); };
|
||||
view.close();
|
||||
QTRY_COMPARE(desk.seen.size(), 1);
|
||||
QVERIFY2(desk.seen[0].startsWith("alert|2|Save changes to Untitled?") && desk.seen[0].endsWith("|pending=0 step=Gradient"),
|
||||
qPrintable(desk.seen[0]));
|
||||
QTRY_VERIFY(!workspace.isManaging());
|
||||
QVERIFY(view.isVisible());
|
||||
}
|
||||
|
||||
void QuitPendingEditsTests::aBusyProjectHoldsTheQuit()
|
||||
{
|
||||
ProjectWorkspace workspace;
|
||||
EditorSession &session = workspace.current().session;
|
||||
pendGradient(session);
|
||||
session.setIsProjectBusy(true);
|
||||
DialogDesk desk;
|
||||
QTest::ignoreMessage(QtWarningMsg, "Quit refused: the project is still busy");
|
||||
QCOMPARE(answer(workspace), std::optional(false));
|
||||
// Nothing asked, nothing applied: the gradient waits.
|
||||
QVERIFY(desk.seen.isEmpty());
|
||||
QVERIFY(session.gradientEdit());
|
||||
QVERIFY(!workspace.isManaging());
|
||||
session.setIsProjectBusy(false);
|
||||
}
|
||||
|
||||
void QuitPendingEditsTests::aQuitWhileManagingIsRefusedAtOnce()
|
||||
{
|
||||
ProjectWorkspace workspace;
|
||||
EditorSession &session = workspace.current().session;
|
||||
session.createDocument(100, 20);
|
||||
DialogDesk desk;
|
||||
desk.replies = {"Cancel"};
|
||||
std::optional<bool> second;
|
||||
bool settled = false;
|
||||
bool answered = false;
|
||||
// While the first quit asks, a second is refused unasked.
|
||||
desk.note = [&] {
|
||||
session.setSelectionAmountOperation(SelectionAmountOperation::feather);
|
||||
workspace.confirmQuit([&](bool value) { second = value; });
|
||||
answered = QTest::qWaitFor([&] { return second.has_value(); }, 5000);
|
||||
settled = !session.selectionAmountOperation();
|
||||
session.setSelectionAmountOperation(std::nullopt);
|
||||
return QString();
|
||||
};
|
||||
QCOMPARE(answer(workspace), std::optional(false));
|
||||
QCOMPARE(desk.seen.size(), 1);
|
||||
QVERIFY(answered);
|
||||
QCOMPARE(second, std::optional(false));
|
||||
QVERIFY(!settled);
|
||||
}
|
||||
|
||||
QTEST_MAIN(QuitPendingEditsTests)
|
||||
#include "QuitPendingEditsTests.moc"
|
||||
@@ -0,0 +1,377 @@
|
||||
#include "Document/BrushStroke.h"
|
||||
#include "Document/Dither.h"
|
||||
#include "Document/EditorSession.h"
|
||||
#include "Document/PixelAdjust.h"
|
||||
#include "UI/FilterSheet.h"
|
||||
#include <QCheckBox>
|
||||
#include <QLabel>
|
||||
#include <QLayout>
|
||||
#include <QLineEdit>
|
||||
#include <QPainter>
|
||||
#include <QSlider>
|
||||
#include <QtTest>
|
||||
#include <numeric>
|
||||
|
||||
// Swift 1.4.4's Dither › Scanlines (CRT), on every core.
|
||||
namespace {
|
||||
QImage filled(int width, int height, QColor colour)
|
||||
{
|
||||
QImage image = BrushRaster::context(width, height, false);
|
||||
image.fill(colour);
|
||||
return image;
|
||||
}
|
||||
|
||||
DitherSettings scanlines(double spacing, double glow = 0)
|
||||
{
|
||||
DitherSettings settings;
|
||||
settings.style = DitherStyle::scanlines;
|
||||
settings.lineSpacing = spacing;
|
||||
settings.glow = glow;
|
||||
return settings;
|
||||
}
|
||||
|
||||
// One channel of one column, top to bottom.
|
||||
std::vector<int> column(const QImage &image, int x, int channel = 0)
|
||||
{
|
||||
std::vector<int> values;
|
||||
for (int y = 0; y < image.height(); ++y)
|
||||
values.push_back(image.constScanLine(y)[x * 4 + channel]);
|
||||
return values;
|
||||
}
|
||||
|
||||
std::vector<int> row(const QImage &image, int y, int channel = 0)
|
||||
{
|
||||
std::vector<int> values;
|
||||
for (int x = 0; x < image.width(); ++x)
|
||||
values.push_back(image.constScanLine(y)[x * 4 + channel]);
|
||||
return values;
|
||||
}
|
||||
|
||||
// Swift's test: one column of a flat gray's render.
|
||||
std::vector<int> gray(int level, double spacing, double glow = 0)
|
||||
{
|
||||
return column(scanlines(spacing, glow).apply(filled(16, 32, QColor(level, level, level))), 5);
|
||||
}
|
||||
|
||||
std::vector<int> repeated(const std::vector<int> &band, int times)
|
||||
{
|
||||
std::vector<int> all;
|
||||
for (int each = 0; each < times; ++each)
|
||||
all.insert(all.end(), band.begin(), band.end());
|
||||
return all;
|
||||
}
|
||||
|
||||
struct Shown {
|
||||
EditorSession session;
|
||||
std::unique_ptr<FilterSheet> sheet;
|
||||
Shown()
|
||||
{
|
||||
session.createDocument(40, 30);
|
||||
const QImage image = filled(40, 30, QColor(200, 120, 40));
|
||||
session.insert(ImportedImage(image, image, QStringLiteral("Colour")));
|
||||
session.beginFilter(FilterKind::dither);
|
||||
FilterSettings settings = session.filterEdit().value().settings;
|
||||
settings.dither.style = DitherStyle::scanlines;
|
||||
session.updateFilter(settings, true);
|
||||
sheet = std::make_unique<FilterSheet>(session);
|
||||
sheet->show();
|
||||
}
|
||||
const DitherSettings &dither() const { return session.filterEdit().value().settings.dither; }
|
||||
// The shown rows in the column's order, with their help.
|
||||
QStringList rows() const
|
||||
{
|
||||
QStringList titles;
|
||||
QLayout *column = sheet->layout();
|
||||
for (int index = 0; index < column->count(); ++index) {
|
||||
QWidget *each = column->itemAt(index)->widget();
|
||||
if (!each || each->isHidden())
|
||||
continue;
|
||||
if (auto *box = qobject_cast<QCheckBox *>(each); box && box->text() != QLatin1String("Preview"))
|
||||
titles << box->text();
|
||||
for (QLabel *label : each->findChildren<QLabel *>(QString(), Qt::FindDirectChildrenOnly)) {
|
||||
if (label->buddy())
|
||||
titles << label->text() + u'|' + each->toolTip();
|
||||
}
|
||||
}
|
||||
return titles;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
class ScanlinesTests : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void scanlinesAreLinesOfLightThatBloomWithBrightness();
|
||||
void glowLightsBetweenTheLines();
|
||||
void dotsBreakTheLinesIntoBeads();
|
||||
void wobbleMovesLinesSideways();
|
||||
void theStyleDrawsAtFullResolutionWithoutMarks();
|
||||
void normalizingClampsTheFourSettings();
|
||||
void eachLineIsTheAverageOfItsRows();
|
||||
void theScreenIsTheDarkColourOrBlack();
|
||||
void clearPixelsStayClear();
|
||||
void everyBandOfATallImageIsDrawn();
|
||||
void theGlowIsTheBlurredLightAtItsAmount();
|
||||
void theGlowNeverPassesAlpha();
|
||||
void theSheetShowsScanlinesRows();
|
||||
void theRowsWriteTheirSettings();
|
||||
};
|
||||
|
||||
void ScanlinesTests::scanlinesAreLinesOfLightThatBloomWithBrightness()
|
||||
{
|
||||
const std::vector<int> white = gray(255, 8), dim = gray(89, 8), black = gray(0, 8);
|
||||
for (int band = 0; band < 4; ++band) {
|
||||
const std::vector<int> rows(white.begin() + band * 8, white.begin() + band * 8 + 8);
|
||||
QVERIFY(rows[3] == 255 && rows[4] == 255);
|
||||
QVERIFY(std::any_of(rows.begin(), rows.end(), [](int value) { return value < 40; }));
|
||||
}
|
||||
QVERIFY(std::accumulate(dim.begin(), dim.end(), 0) * 2 < std::accumulate(white.begin(), white.end(), 0));
|
||||
QVERIFY(std::all_of(black.begin(), black.end(), [](int value) { return value == 0; }));
|
||||
// The beam's half height, 2.8 white, about 1.98 at 0.35.
|
||||
QCOMPARE(white, repeated({0, 255, 255, 255, 255, 255, 255, 0}, 4));
|
||||
QCOMPARE(dim, repeated({0, 0, 118, 120, 120, 118, 0, 0}, 4));
|
||||
}
|
||||
|
||||
void ScanlinesTests::glowLightsBetweenTheLines()
|
||||
{
|
||||
const std::vector<int> plain = gray(255, 8), glowing = gray(255, 8, 100);
|
||||
QVERIFY2(glowing[0] > plain[0] + 40, qPrintable(QString::number(glowing[0])));
|
||||
}
|
||||
|
||||
void ScanlinesTests::dotsBreakTheLinesIntoBeads()
|
||||
{
|
||||
const QImage white = filled(64, 16, Qt::white);
|
||||
DitherSettings settings = scanlines(8);
|
||||
QCOMPARE(row(settings.apply(white), 4), std::vector<int>(64, 255));
|
||||
settings.dots = 100;
|
||||
// Each bead lit at its middle, dark at its ends.
|
||||
const std::vector<int> lit = row(settings.apply(white), 4);
|
||||
QCOMPARE(std::vector<int>(lit.begin(), lit.begin() + 8), (std::vector<int>{0, 255, 255, 255, 255, 255, 255, 0}));
|
||||
QCOMPARE(std::vector<int>(lit.begin() + 8, lit.begin() + 16), (std::vector<int>{0, 255, 255, 255, 255, 255, 255, 0}));
|
||||
// Half dots: the bead ends dim off the middle.
|
||||
settings.dots = 50;
|
||||
QCOMPARE(row(settings.apply(white), 1)[0], 85);
|
||||
}
|
||||
|
||||
void ScanlinesTests::wobbleMovesLinesSideways()
|
||||
{
|
||||
QImage edge = filled(64, 64, Qt::black);
|
||||
QPainter(&edge).fillRect(QRect(32, 0, 32, 64), Qt::white);
|
||||
const auto edges = [&edge](double wobble) {
|
||||
DitherSettings settings = scanlines(8);
|
||||
settings.wobble = wobble;
|
||||
const QImage result = settings.apply(edge);
|
||||
std::vector<int> found;
|
||||
for (int line = 0; line < 8; ++line) {
|
||||
const std::vector<int> middle = row(result, line * 8 + 4);
|
||||
found.push_back(int(std::find_if(middle.begin(), middle.end(), [](int value) { return value > 128; }) - middle.begin()));
|
||||
}
|
||||
return found;
|
||||
};
|
||||
QCOMPARE(edges(0), std::vector<int>(8, 32));
|
||||
// Each line's shift: 12 times the two waves, rounded.
|
||||
QCOMPARE(edges(12), (std::vector<int>{35, 36, 35, 41, 44, 37, 32, 34}));
|
||||
}
|
||||
|
||||
void ScanlinesTests::theStyleDrawsAtFullResolutionWithoutMarks()
|
||||
{
|
||||
QVERIFY(!usesPixelSize(DitherStyle::scanlines) && !usesPixelSize(DitherStyle::ascii) && usesPixelSize(DitherStyle::patterns));
|
||||
QVERIFY(!drawsMarks(DitherStyle::scanlines) && drawsMarks(DitherStyle::ascii));
|
||||
QVERIFY(!hasTones(DitherStyle::scanlines) && !isHalftone(DitherStyle::scanlines) && ditherGroup(DitherStyle::scanlines) == 3);
|
||||
// A pixel size changes nothing: no chunks, no dots.
|
||||
const QImage image = filled(20, 24, QColor(180, 90, 30));
|
||||
DitherSettings settings = scanlines(4);
|
||||
const QImage plain = settings.apply(image);
|
||||
settings.pixelSize = 5;
|
||||
settings.pixelShape = DitherPixelShape::dot;
|
||||
QCOMPARE(settings.apply(image), plain);
|
||||
// The cell is the line spacing, not the cell size.
|
||||
settings.cellSize = 32;
|
||||
QCOMPARE(settings.apply(image), plain);
|
||||
}
|
||||
|
||||
void ScanlinesTests::normalizingClampsTheFourSettings()
|
||||
{
|
||||
DitherSettings settings;
|
||||
QCOMPARE(settings.lineSpacing, 4.0);
|
||||
QCOMPARE(settings.glow, 35.0);
|
||||
QCOMPARE(settings.dots, 0.0);
|
||||
QCOMPARE(settings.wobble, 0.0);
|
||||
settings.lineSpacing = 1.4;
|
||||
settings.glow = 150;
|
||||
settings.dots = -5;
|
||||
settings.wobble = 3.5;
|
||||
DitherSettings result = settings.normalized();
|
||||
QCOMPARE(result.lineSpacing, 2.0);
|
||||
QCOMPARE(result.glow, 100.0);
|
||||
QCOMPARE(result.dots, 0.0);
|
||||
QCOMPARE(result.wobble, 3.5);
|
||||
settings.lineSpacing = 40;
|
||||
settings.glow = -1;
|
||||
settings.dots = 120;
|
||||
settings.wobble = 70;
|
||||
result = settings.normalized();
|
||||
QCOMPARE(result.lineSpacing, 32.0);
|
||||
QCOMPARE(result.glow, 0.0);
|
||||
QCOMPARE(result.dots, 100.0);
|
||||
QCOMPARE(result.wobble, 64.0);
|
||||
// Rounded within; not a number takes the default.
|
||||
settings.lineSpacing = 5.5;
|
||||
settings.glow = std::nan("");
|
||||
settings.dots = std::nan("");
|
||||
settings.wobble = std::nan("");
|
||||
result = settings.normalized();
|
||||
QCOMPARE(result.lineSpacing, 6.0);
|
||||
QCOMPARE(result.glow, 35.0);
|
||||
QCOMPARE(result.dots, 0.0);
|
||||
QCOMPARE(result.wobble, 0.0);
|
||||
settings.lineSpacing = std::nan("");
|
||||
QCOMPARE(settings.normalized().lineSpacing, 4.0);
|
||||
// Glow and dots keep their fractions.
|
||||
settings.glow = 12.5;
|
||||
settings.dots = 33.3;
|
||||
QCOMPARE(settings.normalized().glow, 12.5);
|
||||
QCOMPARE(settings.normalized().dots, 33.3);
|
||||
}
|
||||
|
||||
void ScanlinesTests::eachLineIsTheAverageOfItsRows()
|
||||
{
|
||||
// Rows of white and black: each line lights at half.
|
||||
QImage stripes = filled(8, 16, Qt::black);
|
||||
for (int y = 0; y < 16; y += 2)
|
||||
QPainter(&stripes).fillRect(QRect(0, y, 8, 1), Qt::white);
|
||||
QCOMPARE(column(scanlines(8).apply(stripes), 3), repeated({0, 37, 172, 172, 172, 172, 37, 0}, 2));
|
||||
}
|
||||
|
||||
void ScanlinesTests::theScreenIsTheDarkColourOrBlack()
|
||||
{
|
||||
const QImage image = filled(8, 8, QColor(255, 128, 0));
|
||||
DitherSettings settings = scanlines(8);
|
||||
settings.colors = DitherColors::twoColors;
|
||||
settings.dark = AdjustmentColor(0, 0, 1);
|
||||
settings.light = AdjustmentColor(1, 1, 0);
|
||||
const QImage two = settings.apply(image);
|
||||
// Off the line the dark colour; on it, the mix.
|
||||
QCOMPARE(two.pixelColor(2, 0), QColor(0, 0, 255));
|
||||
QCOMPARE(two.pixelColor(2, 4), QColor(197, 197, 147));
|
||||
settings.colors = DitherColors::original;
|
||||
const QImage original = settings.apply(image);
|
||||
QCOMPARE(original.pixelColor(2, 0), QColor(0, 0, 0));
|
||||
QCOMPARE(original.pixelColor(2, 4), QColor(255, 173, 0));
|
||||
}
|
||||
|
||||
void ScanlinesTests::clearPixelsStayClear()
|
||||
{
|
||||
QImage image = filled(8, 16, Qt::white);
|
||||
QPainter painter(&image);
|
||||
painter.setCompositionMode(QPainter::CompositionMode_Source);
|
||||
painter.fillRect(QRect(0, 0, 8, 8), Qt::transparent);
|
||||
painter.end();
|
||||
const QImage result = scanlines(8, 100).apply(image);
|
||||
QCOMPARE(column(result, 2, 3), (std::vector<int>{0, 0, 0, 0, 0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255}));
|
||||
QCOMPARE(column(result, 2, 0)[4], 0);
|
||||
}
|
||||
|
||||
void ScanlinesTests::everyBandOfATallImageIsDrawn()
|
||||
{
|
||||
// 150 two-row lines: 32 bands of 5, two empty.
|
||||
const QImage image = filled(3, 300, Qt::white);
|
||||
QCOMPARE(column(scanlines(2).apply(image), 1), repeated({241, 241}, 150));
|
||||
// 70 lines of 8: bands of 3, the last one.
|
||||
const std::vector<int> tall = column(scanlines(8).apply(filled(3, 560, Qt::white)), 1);
|
||||
QCOMPARE(tall, repeated({0, 255, 255, 255, 255, 255, 255, 0}, 70));
|
||||
}
|
||||
|
||||
void ScanlinesTests::theGlowIsTheBlurredLightAtItsAmount()
|
||||
{
|
||||
QImage image = filled(64, 160, Qt::black);
|
||||
QPainter(&image).fillRect(QRect(0, 0, 32, 40), Qt::white);
|
||||
const QImage lines = scanlines(8).apply(image);
|
||||
// Swift's first glow: the lines blurred whole, sigma 27.
|
||||
const QImage blurred = PixelAdjust::gaussianBlur(lines, 8 * 3 + 3, true);
|
||||
for (const double glow : {20.0, 100.0}) {
|
||||
const QImage result = scanlines(8, glow).apply(image);
|
||||
const float amount = float(glow / 100 * 2.5);
|
||||
int worst = 0;
|
||||
for (int y = 0; y < 160; ++y) {
|
||||
for (int x = 0; x < 64; ++x) {
|
||||
const int plain = lines.constScanLine(y)[x * 4], light = blurred.constScanLine(y)[x * 4];
|
||||
const int expected = int(std::lround(std::min(255.0f, float(plain) + float(light) * amount)));
|
||||
worst = std::max(worst, std::abs(result.constScanLine(y)[x * 4] - expected));
|
||||
}
|
||||
}
|
||||
// Three levels of the shrink's blur, times 2.5.
|
||||
QVERIFY2(worst <= 8, qPrintable(QString::number(worst)));
|
||||
}
|
||||
// Below the light, at amount 0.5, it fades.
|
||||
const std::vector<int> fading = column(scanlines(8, 20).apply(image), 16);
|
||||
QVERIFY(fading[48] > 20 && fading[48] < fading[40] && fading[120] < 3);
|
||||
}
|
||||
|
||||
void ScanlinesTests::theGlowNeverPassesAlpha()
|
||||
{
|
||||
QImage image = BrushRaster::context(16, 32, false);
|
||||
image.fill(QColor(255, 255, 255, 128));
|
||||
const QImage result = scanlines(4, 100).apply(image);
|
||||
for (int y = 0; y < 32; ++y) {
|
||||
for (int x = 0; x < 16; ++x) {
|
||||
const uchar *at = result.constScanLine(y) + x * 4;
|
||||
QCOMPARE(int(at[3]), 128);
|
||||
QVERIFY(at[0] <= 128 && at[1] <= 128 && at[2] <= 128);
|
||||
}
|
||||
}
|
||||
QCOMPARE(int(result.constScanLine(0)[0]), 128);
|
||||
}
|
||||
|
||||
void ScanlinesTests::theSheetShowsScanlinesRows()
|
||||
{
|
||||
Shown shown;
|
||||
QCOMPARE(shown.rows(), (QStringList{"Style|", "Line Spacing|How far apart the screen's lines are",
|
||||
"Glow|Light blooming around the lines, like a CRT's phosphors", "Dots|Break the lines into glowing beads",
|
||||
"Wobble|Make the lines waver sideways down the screen, like a CRT losing sync",
|
||||
"Density|More ink (darker) or less before dithering", "Contrast|", "Colors|"}));
|
||||
// Each slider's travel reaches its range's ends.
|
||||
const std::tuple<const char *, double DitherSettings::*, double, double> sliders[] = {
|
||||
{"lineSpacingSlider", &DitherSettings::lineSpacing, 2, 32}, {"glowSlider", &DitherSettings::glow, 0, 100},
|
||||
{"dotsSlider", &DitherSettings::dots, 0, 100}, {"wobbleSlider", &DitherSettings::wobble, 0, 64}};
|
||||
for (const auto &[name, setting, low, high] : sliders) {
|
||||
QSlider *slider = shown.sheet->findChild<QSlider *>(QString::fromLatin1(name));
|
||||
QVERIFY2(slider, name);
|
||||
slider->setValue(slider->maximum());
|
||||
QCOMPARE(shown.dither().*setting, high);
|
||||
slider->setValue(slider->minimum());
|
||||
QCOMPARE(shown.dither().*setting, low);
|
||||
}
|
||||
// Pixel Size and Pixel Shape stay away under Scanlines.
|
||||
FilterSettings settings = shown.session.filterEdit().value().settings;
|
||||
settings.dither.pixelSize = 4;
|
||||
shown.session.updateFilter(settings, true);
|
||||
QVERIFY(!shown.rows().join(',').contains("Pixel"));
|
||||
settings.dither.style = DitherStyle::bayer4;
|
||||
shown.session.updateFilter(settings, true);
|
||||
QVERIFY(shown.rows().contains("Pixel Shape|Draw each chunky pixel as a solid square, or as a round dot like a dot-matrix screen"));
|
||||
QVERIFY(!shown.rows().join(',').contains("Glow"));
|
||||
}
|
||||
|
||||
void ScanlinesTests::theRowsWriteTheirSettings()
|
||||
{
|
||||
Shown shown;
|
||||
const std::tuple<const char *, double DitherSettings::*, const char *, double> rows[] = {
|
||||
{"lineSpacingField", &DitherSettings::lineSpacing, "12", 12}, {"glowField", &DitherSettings::glow, "60", 60},
|
||||
{"dotsField", &DitherSettings::dots, "25", 25}, {"wobbleField", &DitherSettings::wobble, "9", 9}};
|
||||
for (const auto &[name, setting, typed, expected] : rows) {
|
||||
const DitherSettings before = shown.dither();
|
||||
QLineEdit *field = shown.sheet->findChild<QLineEdit *>(QString::fromLatin1(name));
|
||||
QVERIFY2(field, name);
|
||||
field->setFocus();
|
||||
field->selectAll();
|
||||
QTest::keyClicks(field, QString::fromLatin1(typed));
|
||||
QTest::keyClick(field, Qt::Key_Return);
|
||||
DitherSettings wanted = before;
|
||||
wanted.*setting = expected;
|
||||
QVERIFY2(shown.dither() == wanted, name);
|
||||
}
|
||||
}
|
||||
|
||||
QTEST_MAIN(ScanlinesTests)
|
||||
#include "ScanlinesTests.moc"
|
||||
@@ -168,6 +168,10 @@ compositor_test(ProjectReceiveTests)
|
||||
compositor_test(ProjectReplaceTests)
|
||||
compositor_test(ProjectWorkspaceFlowTests)
|
||||
compositor_test(QuitWhileEditingTextTests)
|
||||
compositor_test(QuitPendingEditsTests)
|
||||
compositor_test(ScanlinesTests)
|
||||
compositor_test(CameraRawCurveTests)
|
||||
compositor_test(CameraRawCurveGraphTests)
|
||||
compositor_test(OpenRecentTests)
|
||||
compositor_test(ColoredSliderTests)
|
||||
compositor_test(SheetTeardownTests)
|
||||
|
||||
Reference in new issue
Block a user