Add paint stroke sampling tests

This commit is contained in:
2026-06-01 08:08:27 +02:00
parent f9e4bcaeea
commit 93d8aaaffd
9 changed files with 350 additions and 8 deletions

View File

@@ -94,7 +94,8 @@ target_link_libraries(pp_assets
pp_project_warnings) pp_project_warnings)
add_library(pp_paint STATIC add_library(pp_paint STATIC
src/paint/blend.cpp) src/paint/blend.cpp
src/paint/stroke.cpp)
target_include_directories(pp_paint target_include_directories(pp_paint
PUBLIC PUBLIC
"${CMAKE_CURRENT_SOURCE_DIR}/src") "${CMAKE_CURRENT_SOURCE_DIR}/src")

View File

@@ -80,7 +80,7 @@ Known local toolchain state:
`platform-build` automation wrapper for `pp_foundation`, `pp_assets`, `platform-build` automation wrapper for `pp_foundation`, `pp_assets`,
`pp_paint`, `pp_document`, `pp_renderer_api`, `pp_paint_renderer`, `pp_paint`, `pp_document`, `pp_renderer_api`, `pp_paint_renderer`,
`pp_ui_core`, `pano_cli`, and their current headless test binaries, `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 - `panopainter_validate_shaders` validates the current combined GLSL shader
files for one vertex stage marker, one fragment stage marker, valid marker files for one vertex stage marker, one fragment stage marker, valid marker
order, and existing relative includes. order, and existing relative includes.

View File

@@ -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 PNG/JPEG signature detection plus PPI header recognition, with
corrupt/truncated/unsupported tests. corrupt/truncated/unsupported tests.
`pp_paint` has started with CPU reference math for the five current shader `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 blend modes plus deterministic stroke spacing/interpolation. `pp_document` has
and layer/frame/undo-redo history invariant tests. `pp_renderer_api` has started started with a pure canvas/layer/frame model and layer/frame/undo-redo history
with renderer-neutral texture/readback descriptors and validation tests. `pp_paint_renderer` has 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 started with deterministic CPU layer compositing over renderer extents using
the paint blend reference. `pp_ui_core` has started with XML-layout-facing the paint blend reference. `pp_ui_core` has started with XML-layout-facing
length parsing, tinyxml-backed layout XML parsing, and invalid input tests. length parsing, tinyxml-backed layout XML parsing, and invalid input tests.
@@ -521,7 +522,7 @@ Last verified on 2026-06-01:
```powershell ```powershell
cmake --preset windows-msvc-default 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 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\test.ps1 -Preset desktop-fast -Configuration Debug
powershell -ExecutionPolicy Bypass -File scripts\automation\build.ps1 -Preset windows-msvc-default -Configuration Debug -Target pano_cli 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_image_format_tests` passed.
- `pp_assets_ppi_header_tests` passed. - `pp_assets_ppi_header_tests` passed.
- `pp_paint_blend_tests` passed. - `pp_paint_blend_tests` passed.
- `pp_paint_stroke_tests` passed.
- `pp_document_tests` passed. - `pp_document_tests` passed.
- `pp_renderer_api_tests` passed. - `pp_renderer_api_tests` passed.
- `pp_paint_renderer_compositor_tests` passed. - `pp_paint_renderer_compositor_tests` passed.

View File

@@ -1,7 +1,7 @@
[CmdletBinding()] [CmdletBinding()]
param( param(
[string[]]$Presets = @("android-arm64"), [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" $ErrorActionPreference = "Stop"

View File

@@ -3,7 +3,7 @@ set -u
preset="${1:-android-arm64}" preset="${1:-android-arm64}"
shift || true 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)" start="$(date +%s)"
cmake --preset "$preset" cmake --preset "$preset"

162
src/paint/stroke.cpp Normal file
View File

@@ -0,0 +1,162 @@
#include "paint/stroke.h"
#include <algorithm>
#include <cmath>
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<const StrokePoint> 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<StrokeSample>& 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<std::vector<StrokeSample>> sample_stroke(
std::span<const StrokePoint> points,
StrokeSamplingConfig config) noexcept
{
const auto input_status = validate_input(points, config);
if (!input_status.ok()) {
return pp::foundation::Result<std::vector<StrokeSample>>::failure(input_status);
}
std::vector<StrokeSample> samples;
samples.reserve(std::min<std::size_t>(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<std::vector<StrokeSample>>::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<std::vector<StrokeSample>>::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<std::vector<StrokeSample>>::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<std::vector<StrokeSample>>::failure(status);
}
}
return pp::foundation::Result<std::vector<StrokeSample>>::success(samples);
}
}

39
src/paint/stroke.h Normal file
View File

@@ -0,0 +1,39 @@
#pragma once
#include "foundation/result.h"
#include <cstddef>
#include <cstdint>
#include <span>
#include <vector>
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<std::vector<StrokeSample>> sample_stroke(
std::span<const StrokePoint> points,
StrokeSamplingConfig config) noexcept;
}

View File

@@ -66,6 +66,16 @@ add_test(NAME pp_paint_blend_tests COMMAND pp_paint_blend_tests)
set_tests_properties(pp_paint_blend_tests PROPERTIES set_tests_properties(pp_paint_blend_tests PROPERTIES
LABELS "paint;desktop-fast") 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 add_executable(pp_document_tests
document/document_tests.cpp) document/document_tests.cpp)
target_link_libraries(pp_document_tests PRIVATE target_link_libraries(pp_document_tests PRIVATE

View File

@@ -0,0 +1,128 @@
#include "paint/stroke.h"
#include "test_harness.h"
#include <array>
#include <cmath>
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();
}