Add paint blend reference tests
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
109
src/paint/blend.cpp
Normal 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
25
src/paint/blend.h
Normal 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;
|
||||
|
||||
}
|
||||
@@ -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
105
tests/paint/blend_tests.cpp
Normal 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();
|
||||
}
|
||||
Reference in New Issue
Block a user