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)
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")

View File

@@ -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.

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
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.

View File

@@ -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"

View File

@@ -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"

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
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

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();
}