From 93d8aaaffdcb75018da308c4397d867a854e121b Mon Sep 17 00:00:00 2001 From: omigamedev Date: Mon, 1 Jun 2026 08:08:27 +0200 Subject: [PATCH] Add paint stroke sampling tests --- CMakeLists.txt | 3 +- docs/modernization/build-inventory.md | 2 +- docs/modernization/roadmap.md | 10 +- scripts/automation/platform-build.ps1 | 2 +- scripts/automation/platform-build.sh | 2 +- src/paint/stroke.cpp | 162 ++++++++++++++++++++++++++ src/paint/stroke.h | 39 +++++++ tests/CMakeLists.txt | 10 ++ tests/paint/stroke_tests.cpp | 128 ++++++++++++++++++++ 9 files changed, 350 insertions(+), 8 deletions(-) create mode 100644 src/paint/stroke.cpp create mode 100644 src/paint/stroke.h create mode 100644 tests/paint/stroke_tests.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 8dde4af..132b408 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -94,7 +94,8 @@ target_link_libraries(pp_assets pp_project_warnings) add_library(pp_paint STATIC - src/paint/blend.cpp) + src/paint/blend.cpp + src/paint/stroke.cpp) target_include_directories(pp_paint PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/src") diff --git a/docs/modernization/build-inventory.md b/docs/modernization/build-inventory.md index 74347f5..a5a3f4e 100644 --- a/docs/modernization/build-inventory.md +++ b/docs/modernization/build-inventory.md @@ -80,7 +80,7 @@ Known local toolchain state: `platform-build` automation wrapper for `pp_foundation`, `pp_assets`, `pp_paint`, `pp_document`, `pp_renderer_api`, `pp_paint_renderer`, `pp_ui_core`, `pano_cli`, and their current headless test binaries, - including PPI header and layout XML parse coverage. + including PPI header, paint stroke sampling, and layout XML parse coverage. - `panopainter_validate_shaders` validates the current combined GLSL shader files for one vertex stage marker, one fragment stage marker, valid marker order, and existing relative includes. diff --git a/docs/modernization/roadmap.md b/docs/modernization/roadmap.md index b3a0f77..998df6a 100644 --- a/docs/modernization/roadmap.md +++ b/docs/modernization/roadmap.md @@ -309,9 +309,10 @@ and stroke timing spans with invalid-end tests. `pp_assets` has started with PNG/JPEG signature detection plus PPI header recognition, with corrupt/truncated/unsupported tests. `pp_paint` has started with CPU reference math for the five current shader -blend modes. `pp_document` has started with a pure canvas/layer/frame model -and layer/frame/undo-redo history invariant tests. `pp_renderer_api` has started -with renderer-neutral texture/readback descriptors and validation tests. `pp_paint_renderer` has +blend modes plus deterministic stroke spacing/interpolation. `pp_document` has +started with a pure canvas/layer/frame model and layer/frame/undo-redo history +invariant tests. `pp_renderer_api` has started with renderer-neutral +texture/readback descriptors and validation tests. `pp_paint_renderer` has started with deterministic CPU layer compositing over renderer extents using the paint blend reference. `pp_ui_core` has started with XML-layout-facing length parsing, tinyxml-backed layout XML parsing, and invalid input tests. @@ -521,7 +522,7 @@ Last verified on 2026-06-01: ```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 pp_assets_ppi_header_tests pp_paint_blend_tests pp_document_tests pp_renderer_api_tests pp_paint_renderer_compositor_tests pp_ui_core_layout_value_tests pp_ui_core_layout_xml_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_assets_ppi_header_tests pp_paint_blend_tests pp_paint_stroke_tests pp_document_tests pp_renderer_api_tests pp_paint_renderer_compositor_tests pp_ui_core_layout_value_tests pp_ui_core_layout_xml_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 @@ -540,6 +541,7 @@ Results: - `pp_assets_image_format_tests` passed. - `pp_assets_ppi_header_tests` passed. - `pp_paint_blend_tests` passed. +- `pp_paint_stroke_tests` passed. - `pp_document_tests` passed. - `pp_renderer_api_tests` passed. - `pp_paint_renderer_compositor_tests` passed. diff --git a/scripts/automation/platform-build.ps1 b/scripts/automation/platform-build.ps1 index 84eacec..7b34425 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", "pp_paint", "pp_document", "pp_renderer_api", "pp_paint_renderer", "pp_ui_core", "pano_cli", "pp_foundation_binary_stream_tests", "pp_foundation_parse_tests", "pp_foundation_trace_tests", "pp_assets_image_format_tests", "pp_assets_ppi_header_tests", "pp_paint_blend_tests", "pp_document_tests", "pp_renderer_api_tests", "pp_paint_renderer_compositor_tests", "pp_ui_core_layout_value_tests", "pp_ui_core_layout_xml_tests") + [string[]]$Targets = @("pp_foundation", "pp_assets", "pp_paint", "pp_document", "pp_renderer_api", "pp_paint_renderer", "pp_ui_core", "pano_cli", "pp_foundation_binary_stream_tests", "pp_foundation_parse_tests", "pp_foundation_trace_tests", "pp_assets_image_format_tests", "pp_assets_ppi_header_tests", "pp_paint_blend_tests", "pp_paint_stroke_tests", "pp_document_tests", "pp_renderer_api_tests", "pp_paint_renderer_compositor_tests", "pp_ui_core_layout_value_tests", "pp_ui_core_layout_xml_tests") ) $ErrorActionPreference = "Stop" diff --git a/scripts/automation/platform-build.sh b/scripts/automation/platform-build.sh index 032d55a..4519970 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 pp_paint pp_document pp_renderer_api pp_paint_renderer pp_ui_core pano_cli pp_foundation_binary_stream_tests pp_foundation_parse_tests pp_foundation_trace_tests pp_assets_image_format_tests pp_assets_ppi_header_tests pp_paint_blend_tests pp_document_tests pp_renderer_api_tests pp_paint_renderer_compositor_tests pp_ui_core_layout_value_tests pp_ui_core_layout_xml_tests}" +targets="${*:-pp_foundation pp_assets pp_paint pp_document pp_renderer_api pp_paint_renderer pp_ui_core pano_cli pp_foundation_binary_stream_tests pp_foundation_parse_tests pp_foundation_trace_tests pp_assets_image_format_tests pp_assets_ppi_header_tests pp_paint_blend_tests pp_paint_stroke_tests pp_document_tests pp_renderer_api_tests pp_paint_renderer_compositor_tests pp_ui_core_layout_value_tests pp_ui_core_layout_xml_tests}" start="$(date +%s)" cmake --preset "$preset" diff --git a/src/paint/stroke.cpp b/src/paint/stroke.cpp new file mode 100644 index 0000000..f23a050 --- /dev/null +++ b/src/paint/stroke.cpp @@ -0,0 +1,162 @@ +#include "paint/stroke.h" + +#include +#include + +namespace pp::paint { + +namespace { + +[[nodiscard]] bool is_finite_point(const StrokePoint& point) noexcept +{ + return std::isfinite(point.x) && std::isfinite(point.y) && std::isfinite(point.pressure); +} + +[[nodiscard]] float clamp_pressure(float pressure) noexcept +{ + return std::clamp(pressure, 0.0F, 1.0F); +} + +[[nodiscard]] float distance_between(const StrokePoint& a, const StrokePoint& b) noexcept +{ + const auto dx = b.x - a.x; + const auto dy = b.y - a.y; + return std::sqrt(dx * dx + dy * dy); +} + +[[nodiscard]] StrokeSample interpolate_sample( + const StrokePoint& a, + const StrokePoint& b, + float t, + float distance) noexcept +{ + return StrokeSample { + .x = a.x + ((b.x - a.x) * t), + .y = a.y + ((b.y - a.y) * t), + .pressure = clamp_pressure(a.pressure + ((b.pressure - a.pressure) * t)), + .distance = distance, + }; +} + +[[nodiscard]] pp::foundation::Status validate_input( + std::span points, + StrokeSamplingConfig config) noexcept +{ + if (points.size() < 2U) { + return pp::foundation::Status::invalid_argument("stroke sampling requires at least two points"); + } + + if (points.size() > max_stroke_points) { + return pp::foundation::Status::out_of_range("stroke point count exceeds the configured limit"); + } + + if (!std::isfinite(config.spacing) || config.spacing <= 0.0F) { + return pp::foundation::Status::invalid_argument("stroke spacing must be finite and greater than zero"); + } + + if (config.max_samples == 0U || config.max_samples > max_stroke_samples) { + return pp::foundation::Status::out_of_range("stroke sample count limit is outside the configured range"); + } + + for (const auto& point : points) { + if (!is_finite_point(point)) { + return pp::foundation::Status::invalid_argument("stroke points must contain finite coordinates and pressure"); + } + } + + return pp::foundation::Status::success(); +} + +[[nodiscard]] pp::foundation::Status append_sample( + std::vector& samples, + StrokeSample sample, + std::size_t max_samples) +{ + if (samples.size() >= max_samples) { + return pp::foundation::Status::out_of_range("stroke sampling exceeded the configured sample limit"); + } + + samples.push_back(sample); + return pp::foundation::Status::success(); +} + +} + +pp::foundation::Result> sample_stroke( + std::span points, + StrokeSamplingConfig config) noexcept +{ + const auto input_status = validate_input(points, config); + if (!input_status.ok()) { + return pp::foundation::Result>::failure(input_status); + } + + std::vector samples; + samples.reserve(std::min(points.size(), config.max_samples)); + + auto status = append_sample( + samples, + StrokeSample { + .x = points.front().x, + .y = points.front().y, + .pressure = clamp_pressure(points.front().pressure), + .distance = 0.0F, + }, + config.max_samples); + if (!status.ok()) { + return pp::foundation::Result>::failure(status); + } + + float segment_start_distance = 0.0F; + float next_sample_distance = config.spacing; + float total_distance = 0.0F; + + for (std::size_t i = 1; i < points.size(); ++i) { + const auto& a = points[i - 1U]; + const auto& b = points[i]; + const auto segment_length = distance_between(a, b); + if (segment_length <= 0.0F) { + continue; + } + + const auto segment_end_distance = segment_start_distance + segment_length; + while (next_sample_distance <= segment_end_distance) { + const auto t = (next_sample_distance - segment_start_distance) / segment_length; + status = append_sample( + samples, + interpolate_sample(a, b, t, next_sample_distance), + config.max_samples); + if (!status.ok()) { + return pp::foundation::Result>::failure(status); + } + next_sample_distance += config.spacing; + } + + segment_start_distance = segment_end_distance; + total_distance = segment_end_distance; + } + + if (total_distance <= 0.0F) { + return pp::foundation::Result>::failure( + pp::foundation::Status::invalid_argument("stroke path must have nonzero length")); + } + + if (config.include_endpoint && samples.back().distance < total_distance) { + status = append_sample( + samples, + StrokeSample { + .x = points.back().x, + .y = points.back().y, + .pressure = clamp_pressure(points.back().pressure), + .distance = total_distance, + }, + config.max_samples); + if (!status.ok()) { + return pp::foundation::Result>::failure(status); + } + } + + return pp::foundation::Result>::success(samples); +} + +} diff --git a/src/paint/stroke.h b/src/paint/stroke.h new file mode 100644 index 0000000..b23d329 --- /dev/null +++ b/src/paint/stroke.h @@ -0,0 +1,39 @@ +#pragma once + +#include "foundation/result.h" + +#include +#include +#include +#include + +namespace pp::paint { + +constexpr std::size_t max_stroke_points = 1000000; +constexpr std::size_t max_stroke_samples = 1000000; + +struct StrokePoint { + float x = 0.0F; + float y = 0.0F; + float pressure = 1.0F; + std::uint32_t time_ms = 0; +}; + +struct StrokeSample { + float x = 0.0F; + float y = 0.0F; + float pressure = 1.0F; + float distance = 0.0F; +}; + +struct StrokeSamplingConfig { + float spacing = 1.0F; + bool include_endpoint = true; + std::size_t max_samples = max_stroke_samples; +}; + +[[nodiscard]] pp::foundation::Result> sample_stroke( + std::span points, + StrokeSamplingConfig config) noexcept; + +} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index b04bb8b..54c1857 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -66,6 +66,16 @@ add_test(NAME pp_paint_blend_tests COMMAND pp_paint_blend_tests) set_tests_properties(pp_paint_blend_tests PROPERTIES LABELS "paint;desktop-fast") +add_executable(pp_paint_stroke_tests + paint/stroke_tests.cpp) +target_link_libraries(pp_paint_stroke_tests PRIVATE + pp_paint + pp_test_harness) + +add_test(NAME pp_paint_stroke_tests COMMAND pp_paint_stroke_tests) +set_tests_properties(pp_paint_stroke_tests PROPERTIES + LABELS "paint;desktop-fast") + add_executable(pp_document_tests document/document_tests.cpp) target_link_libraries(pp_document_tests PRIVATE diff --git a/tests/paint/stroke_tests.cpp b/tests/paint/stroke_tests.cpp new file mode 100644 index 0000000..a23cd40 --- /dev/null +++ b/tests/paint/stroke_tests.cpp @@ -0,0 +1,128 @@ +#include "paint/stroke.h" +#include "test_harness.h" + +#include +#include + +using pp::foundation::StatusCode; +using pp::paint::StrokePoint; +using pp::paint::StrokeSamplingConfig; +using pp::paint::max_stroke_samples; +using pp::paint::sample_stroke; + +namespace { + +bool near(float a, float b) +{ + return std::fabs(a - b) < 0.0001F; +} + +void samples_straight_line_at_fixed_spacing(pp::tests::Harness& h) +{ + const std::array points { + StrokePoint { .x = 0.0F, .y = 0.0F, .pressure = 0.25F }, + StrokePoint { .x = 10.0F, .y = 0.0F, .pressure = 0.75F }, + }; + + const auto result = sample_stroke(points, StrokeSamplingConfig { .spacing = 2.5F }); + PP_EXPECT(h, result.ok()); + PP_EXPECT(h, result.value().size() == 5U); + PP_EXPECT(h, near(result.value()[0].x, 0.0F)); + PP_EXPECT(h, near(result.value()[1].x, 2.5F)); + PP_EXPECT(h, near(result.value()[2].x, 5.0F)); + PP_EXPECT(h, near(result.value()[3].x, 7.5F)); + PP_EXPECT(h, near(result.value()[4].x, 10.0F)); + PP_EXPECT(h, near(result.value()[2].pressure, 0.5F)); + PP_EXPECT(h, near(result.value()[4].distance, 10.0F)); +} + +void carries_spacing_across_segments(pp::tests::Harness& h) +{ + const std::array points { + StrokePoint { .x = 0.0F, .y = 0.0F, .pressure = 1.0F }, + StrokePoint { .x = 3.0F, .y = 0.0F, .pressure = 1.0F }, + StrokePoint { .x = 3.0F, .y = 4.0F, .pressure = 0.0F }, + }; + + const auto result = sample_stroke(points, StrokeSamplingConfig { .spacing = 2.0F }); + PP_EXPECT(h, result.ok()); + PP_EXPECT(h, result.value().size() == 5U); + PP_EXPECT(h, near(result.value()[1].x, 2.0F)); + PP_EXPECT(h, near(result.value()[1].y, 0.0F)); + PP_EXPECT(h, near(result.value()[2].x, 3.0F)); + PP_EXPECT(h, near(result.value()[2].y, 1.0F)); + PP_EXPECT(h, near(result.value()[3].x, 3.0F)); + PP_EXPECT(h, near(result.value()[3].y, 3.0F)); + PP_EXPECT(h, near(result.value()[4].distance, 7.0F)); +} + +void can_skip_endpoint_and_clamps_pressure(pp::tests::Harness& h) +{ + const std::array points { + StrokePoint { .x = 0.0F, .y = 0.0F, .pressure = -1.0F }, + StrokePoint { .x = 5.0F, .y = 0.0F, .pressure = 2.0F }, + }; + + const auto result = sample_stroke( + points, + StrokeSamplingConfig { + .spacing = 2.0F, + .include_endpoint = false, + }); + PP_EXPECT(h, result.ok()); + PP_EXPECT(h, result.value().size() == 3U); + PP_EXPECT(h, near(result.value()[0].pressure, 0.0F)); + PP_EXPECT(h, near(result.value()[2].pressure, 1.0F)); + PP_EXPECT(h, near(result.value()[2].distance, 4.0F)); +} + +void rejects_invalid_sampling_inputs(pp::tests::Harness& h) +{ + const std::array one_point { + StrokePoint { .x = 0.0F, .y = 0.0F }, + }; + const std::array zero_length { + StrokePoint { .x = 1.0F, .y = 1.0F }, + StrokePoint { .x = 1.0F, .y = 1.0F }, + }; + const std::array non_finite { + StrokePoint { .x = 0.0F, .y = 0.0F }, + StrokePoint { .x = std::nanf(""), .y = 1.0F }, + }; + const std::array valid { + StrokePoint { .x = 0.0F, .y = 0.0F }, + StrokePoint { .x = 10.0F, .y = 0.0F }, + }; + + const auto missing_points = sample_stroke(one_point, StrokeSamplingConfig {}); + const auto bad_spacing = sample_stroke(valid, StrokeSamplingConfig { .spacing = 0.0F }); + const auto bad_limit = sample_stroke(valid, StrokeSamplingConfig { .max_samples = max_stroke_samples + 1U }); + const auto no_distance = sample_stroke(zero_length, StrokeSamplingConfig {}); + const auto bad_point = sample_stroke(non_finite, StrokeSamplingConfig {}); + const auto too_many = sample_stroke(valid, StrokeSamplingConfig { .spacing = 1.0F, .max_samples = 2U }); + + PP_EXPECT(h, !missing_points.ok()); + PP_EXPECT(h, missing_points.status().code == StatusCode::invalid_argument); + PP_EXPECT(h, !bad_spacing.ok()); + PP_EXPECT(h, bad_spacing.status().code == StatusCode::invalid_argument); + PP_EXPECT(h, !bad_limit.ok()); + PP_EXPECT(h, bad_limit.status().code == StatusCode::out_of_range); + PP_EXPECT(h, !no_distance.ok()); + PP_EXPECT(h, no_distance.status().code == StatusCode::invalid_argument); + PP_EXPECT(h, !bad_point.ok()); + PP_EXPECT(h, bad_point.status().code == StatusCode::invalid_argument); + PP_EXPECT(h, !too_many.ok()); + PP_EXPECT(h, too_many.status().code == StatusCode::out_of_range); +} + +} + +int main() +{ + pp::tests::Harness harness; + harness.run("samples_straight_line_at_fixed_spacing", samples_straight_line_at_fixed_spacing); + harness.run("carries_spacing_across_segments", carries_spacing_across_segments); + harness.run("can_skip_endpoint_and_clamps_pressure", can_skip_endpoint_and_clamps_pressure); + harness.run("rejects_invalid_sampling_inputs", rejects_invalid_sampling_inputs); + return harness.finish(); +}