Add paint blend reference tests

This commit is contained in:
2026-05-31 23:58:47 +02:00
parent 99eda95cee
commit 8014345b99
9 changed files with 270 additions and 6 deletions

View File

@@ -77,6 +77,18 @@ target_link_libraries(pp_assets
PRIVATE
pp_project_warnings)
add_library(pp_paint STATIC
src/paint/blend.cpp)
target_include_directories(pp_paint
PUBLIC
"${CMAKE_CURRENT_SOURCE_DIR}/src")
target_link_libraries(pp_paint
PUBLIC
pp_foundation
pp_project_options
PRIVATE
pp_project_warnings)
if(PP_BUILD_TOOLS)
add_subdirectory(tools/pano_cli)
endif()

View File

@@ -75,7 +75,7 @@ Known local toolchain state:
- Android NDK: `C:\Users\omara\AppData\Local\Android\Sdk\ndk\29.0.14206865`
- Android arm64 headless configure/build passes through root CMake and the
`platform-build` automation wrapper for `pp_foundation`, `pp_assets`,
`pano_cli`, and their current headless test binaries.
`pp_paint`, `pano_cli`, and their current headless test binaries.
- `vcpkg` is not on PATH yet; see DEBT-0007.
Known warnings after the current CMake app build:

View File

@@ -303,8 +303,10 @@ boundary/overread tests. It also owns strict decimal `uint32` parsing used by
`pano_cli`, with rejection tests for empty, signed, mixed, and overflowing
input. A deterministic `TraceRecorder` now records component/name/thread/frame
and stroke timing spans with invalid-end tests. `pp_assets` has started with
PNG/JPEG signature detection and corrupt/truncated/unsupported tests. Continue
extracting legacy-safe utilities before moving paint or document behavior.
PNG/JPEG signature detection and corrupt/truncated/unsupported tests.
`pp_paint` has started with CPU reference math for the five current shader
blend modes. Continue extracting legacy-safe utilities before moving document
behavior.
Implementation tasks:
@@ -504,7 +506,7 @@ Last verified on 2026-05-31:
```powershell
cmake --preset windows-msvc-default
cmake --build --preset windows-msvc-default --config Debug --target pp_foundation_binary_stream_tests pp_foundation_parse_tests pp_foundation_trace_tests pp_assets_image_format_tests pano_cli PanoPainter
cmake --build --preset windows-msvc-default --config Debug --target pp_foundation_binary_stream_tests pp_foundation_parse_tests pp_foundation_trace_tests pp_assets_image_format_tests pp_paint_blend_tests pano_cli PanoPainter
ctest --preset desktop-fast --build-config Debug
powershell -ExecutionPolicy Bypass -File scripts\automation\test.ps1 -Preset desktop-fast -Configuration Debug
powershell -ExecutionPolicy Bypass -File scripts\automation\build.ps1 -Preset windows-msvc-default -Configuration Debug -Target pano_cli
@@ -518,6 +520,7 @@ Results:
- `pp_foundation_parse_tests` passed.
- `pp_foundation_trace_tests` passed.
- `pp_assets_image_format_tests` passed.
- `pp_paint_blend_tests` passed.
- `pano_cli_create_document_smoke` passed.
- `pano_cli_inspect_image_rejects_unsupported` passed as an expected failure
test.

View File

@@ -1,7 +1,7 @@
[CmdletBinding()]
param(
[string[]]$Presets = @("android-arm64"),
[string[]]$Targets = @("pp_foundation", "pp_assets", "pano_cli", "pp_foundation_binary_stream_tests", "pp_foundation_parse_tests", "pp_foundation_trace_tests", "pp_assets_image_format_tests")
[string[]]$Targets = @("pp_foundation", "pp_assets", "pp_paint", "pano_cli", "pp_foundation_binary_stream_tests", "pp_foundation_parse_tests", "pp_foundation_trace_tests", "pp_assets_image_format_tests", "pp_paint_blend_tests")
)
$ErrorActionPreference = "Stop"

View File

@@ -3,7 +3,7 @@ set -u
preset="${1:-android-arm64}"
shift || true
targets="${*:-pp_foundation pp_assets pano_cli pp_foundation_binary_stream_tests pp_foundation_parse_tests pp_foundation_trace_tests pp_assets_image_format_tests}"
targets="${*:-pp_foundation pp_assets pp_paint pano_cli pp_foundation_binary_stream_tests pp_foundation_parse_tests pp_foundation_trace_tests pp_assets_image_format_tests pp_paint_blend_tests}"
start="$(date +%s)"
cmake --preset "$preset"

109
src/paint/blend.cpp Normal file
View File

@@ -0,0 +1,109 @@
#include "paint/blend.h"
#include <algorithm>
#include <cmath>
namespace pp::paint {
namespace {
[[nodiscard]] float saturate(float value) noexcept
{
if (!std::isfinite(value)) {
return value < 0.0F ? 0.0F : 1.0F;
}
return std::clamp(value, 0.0F, 1.0F);
}
[[nodiscard]] float mix(float a, float b, float t) noexcept
{
return a * (1.0F - t) + b * t;
}
[[nodiscard]] float blend_channel(float base, float stroke, BlendMode mode) noexcept
{
switch (mode) {
case BlendMode::normal:
return stroke;
case BlendMode::multiply:
return base * stroke;
case BlendMode::screen:
return 1.0F - (1.0F - base) * (1.0F - stroke);
case BlendMode::color_dodge:
if (stroke >= 1.0F) {
return 1.0F;
}
return saturate(base / (1.0F - stroke));
case BlendMode::overlay:
return base < 0.5F
? 2.0F * base * stroke
: 1.0F - 2.0F * (1.0F - base) * (1.0F - stroke);
}
return stroke;
}
[[nodiscard]] float blend_rgb(float base, float stroke, float base_alpha, float stroke_alpha, float alpha_total, BlendMode mode) noexcept
{
if (alpha_total <= 0.0F) {
return 0.0F;
}
const auto stroke_weight = stroke_alpha / alpha_total;
const auto base_weight = base_alpha / alpha_total;
if (mode == BlendMode::normal) {
return saturate(mix(base, stroke, stroke_weight));
}
const auto mode_value = blend_channel(base, stroke, mode);
return saturate(mix(stroke, mix(base, mode_value, stroke_weight), base_weight));
}
}
Rgba blend_pixels(Rgba base, Rgba stroke, BlendMode mode) noexcept
{
base.r = saturate(base.r);
base.g = saturate(base.g);
base.b = saturate(base.b);
base.a = saturate(base.a);
stroke.r = saturate(stroke.r);
stroke.g = saturate(stroke.g);
stroke.b = saturate(stroke.b);
stroke.a = saturate(stroke.a);
if (stroke.a == 0.0F) {
return base;
}
const auto contribution = (1.0F - base.a) * stroke.a;
const auto alpha_total = saturate(base.a + contribution);
return {
blend_rgb(base.r, stroke.r, base.a, stroke.a, alpha_total, mode),
blend_rgb(base.g, stroke.g, base.a, stroke.a, alpha_total, mode),
blend_rgb(base.b, stroke.b, base.a, stroke.a, alpha_total, mode),
alpha_total,
};
}
const char* blend_mode_name(BlendMode mode) noexcept
{
switch (mode) {
case BlendMode::normal:
return "normal";
case BlendMode::multiply:
return "multiply";
case BlendMode::screen:
return "screen";
case BlendMode::color_dodge:
return "color_dodge";
case BlendMode::overlay:
return "overlay";
}
return "unknown";
}
}

25
src/paint/blend.h Normal file
View File

@@ -0,0 +1,25 @@
#pragma once
#include <cstdint>
namespace pp::paint {
enum class BlendMode : std::uint8_t {
normal,
multiply,
screen,
color_dodge,
overlay,
};
struct Rgba {
float r = 0.0F;
float g = 0.0F;
float b = 0.0F;
float a = 0.0F;
};
[[nodiscard]] Rgba blend_pixels(Rgba base, Rgba stroke, BlendMode mode) noexcept;
[[nodiscard]] const char* blend_mode_name(BlendMode mode) noexcept;
}

View File

@@ -46,6 +46,16 @@ add_test(NAME pp_assets_image_format_tests COMMAND pp_assets_image_format_tests)
set_tests_properties(pp_assets_image_format_tests PROPERTIES
LABELS "assets;desktop-fast")
add_executable(pp_paint_blend_tests
paint/blend_tests.cpp)
target_link_libraries(pp_paint_blend_tests PRIVATE
pp_paint
pp_test_harness)
add_test(NAME pp_paint_blend_tests COMMAND pp_paint_blend_tests)
set_tests_properties(pp_paint_blend_tests PROPERTIES
LABELS "paint;desktop-fast")
if(TARGET pano_cli)
add_test(NAME pano_cli_create_document_smoke
COMMAND pano_cli create-document --width 64 --height 32 --layers 2)

105
tests/paint/blend_tests.cpp Normal file
View File

@@ -0,0 +1,105 @@
#include "paint/blend.h"
#include "test_harness.h"
#include <cmath>
#include <string_view>
using pp::paint::BlendMode;
using pp::paint::Rgba;
using pp::paint::blend_mode_name;
using pp::paint::blend_pixels;
namespace {
bool near(float a, float b)
{
return std::fabs(a - b) < 0.0001F;
}
void normal_blend_matches_source_over_alpha(pp::tests::Harness& h)
{
const auto result = blend_pixels(
Rgba { .r = 0.2F, .g = 0.4F, .b = 0.6F, .a = 0.5F },
Rgba { .r = 0.8F, .g = 0.2F, .b = 0.1F, .a = 0.25F },
BlendMode::normal);
PP_EXPECT(h, near(result.a, 0.625F));
PP_EXPECT(h, near(result.r, 0.44F));
PP_EXPECT(h, near(result.g, 0.32F));
PP_EXPECT(h, near(result.b, 0.4F));
}
void zero_alpha_stroke_leaves_base_unchanged(pp::tests::Harness& h)
{
const Rgba base { .r = 0.2F, .g = 0.3F, .b = 0.4F, .a = 0.5F };
const auto result = blend_pixels(
base,
Rgba { .r = 1.0F, .g = 1.0F, .b = 1.0F, .a = 0.0F },
BlendMode::screen);
PP_EXPECT(h, near(result.r, base.r));
PP_EXPECT(h, near(result.g, base.g));
PP_EXPECT(h, near(result.b, base.b));
PP_EXPECT(h, near(result.a, base.a));
}
void multiply_and_screen_are_bounded(pp::tests::Harness& h)
{
const Rgba base { .r = 0.25F, .g = 0.5F, .b = 0.75F, .a = 1.0F };
const Rgba stroke { .r = 0.5F, .g = 0.5F, .b = 0.5F, .a = 1.0F };
const auto multiply = blend_pixels(base, stroke, BlendMode::multiply);
const auto screen = blend_pixels(base, stroke, BlendMode::screen);
PP_EXPECT(h, near(multiply.r, 0.125F));
PP_EXPECT(h, near(multiply.g, 0.25F));
PP_EXPECT(h, near(multiply.b, 0.375F));
PP_EXPECT(h, near(screen.r, 0.625F));
PP_EXPECT(h, near(screen.g, 0.75F));
PP_EXPECT(h, near(screen.b, 0.875F));
}
void color_dodge_and_overlay_handle_extremes(pp::tests::Harness& h)
{
const auto dodge = blend_pixels(
Rgba { .r = 0.4F, .g = 0.5F, .b = 0.6F, .a = 1.0F },
Rgba { .r = 1.0F, .g = 0.5F, .b = 0.0F, .a = 1.0F },
BlendMode::color_dodge);
const auto overlay = blend_pixels(
Rgba { .r = 0.25F, .g = 0.5F, .b = 0.75F, .a = 1.0F },
Rgba { .r = 0.5F, .g = 0.5F, .b = 0.5F, .a = 1.0F },
BlendMode::overlay);
PP_EXPECT(h, near(dodge.r, 1.0F));
PP_EXPECT(h, near(dodge.g, 1.0F));
PP_EXPECT(h, near(dodge.b, 0.6F));
PP_EXPECT(h, near(overlay.r, 0.25F));
PP_EXPECT(h, near(overlay.g, 0.5F));
PP_EXPECT(h, near(overlay.b, 0.75F));
}
void clamps_inputs_and_names_modes(pp::tests::Harness& h)
{
const auto result = blend_pixels(
Rgba { .r = -1.0F, .g = 2.0F, .b = 0.5F, .a = 2.0F },
Rgba { .r = 2.0F, .g = -1.0F, .b = 0.5F, .a = 2.0F },
BlendMode::normal);
PP_EXPECT(h, near(result.r, 1.0F));
PP_EXPECT(h, near(result.g, 0.0F));
PP_EXPECT(h, near(result.b, 0.5F));
PP_EXPECT(h, near(result.a, 1.0F));
PP_EXPECT(h, blend_mode_name(BlendMode::overlay) == std::string_view("overlay"));
}
}
int main()
{
pp::tests::Harness harness;
harness.run("normal_blend_matches_source_over_alpha", normal_blend_matches_source_over_alpha);
harness.run("zero_alpha_stroke_leaves_base_unchanged", zero_alpha_stroke_leaves_base_unchanged);
harness.run("multiply_and_screen_are_bounded", multiply_and_screen_are_bounded);
harness.run("color_dodge_and_overlay_handle_extremes", color_dodge_and_overlay_handle_extremes);
harness.run("clamps_inputs_and_names_modes", clamps_inputs_and_names_modes);
return harness.finish();
}