diff --git a/CMakeLists.txt b/CMakeLists.txt index 5917d39..ef9eb09 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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() diff --git a/docs/modernization/build-inventory.md b/docs/modernization/build-inventory.md index fa692d4..f9d13e5 100644 --- a/docs/modernization/build-inventory.md +++ b/docs/modernization/build-inventory.md @@ -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: diff --git a/docs/modernization/roadmap.md b/docs/modernization/roadmap.md index 3c2b555..84ff58f 100644 --- a/docs/modernization/roadmap.md +++ b/docs/modernization/roadmap.md @@ -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. diff --git a/scripts/automation/platform-build.ps1 b/scripts/automation/platform-build.ps1 index 450ce66..6b3bfe6 100644 --- a/scripts/automation/platform-build.ps1 +++ b/scripts/automation/platform-build.ps1 @@ -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" diff --git a/scripts/automation/platform-build.sh b/scripts/automation/platform-build.sh index 5f4597a..9f9b842 100644 --- a/scripts/automation/platform-build.sh +++ b/scripts/automation/platform-build.sh @@ -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" diff --git a/src/paint/blend.cpp b/src/paint/blend.cpp new file mode 100644 index 0000000..3d3810e --- /dev/null +++ b/src/paint/blend.cpp @@ -0,0 +1,109 @@ +#include "paint/blend.h" + +#include +#include + +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"; +} + +} diff --git a/src/paint/blend.h b/src/paint/blend.h new file mode 100644 index 0000000..6aea086 --- /dev/null +++ b/src/paint/blend.h @@ -0,0 +1,25 @@ +#pragma once + +#include + +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; + +} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 1318bff..bcf27b4 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -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) diff --git a/tests/paint/blend_tests.cpp b/tests/paint/blend_tests.cpp new file mode 100644 index 0000000..b8ad8f4 --- /dev/null +++ b/tests/paint/blend_tests.cpp @@ -0,0 +1,105 @@ +#include "paint/blend.h" +#include "test_harness.h" + +#include +#include + +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(); +}