Add paint stroke sampling tests
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
162
src/paint/stroke.cpp
Normal 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
39
src/paint/stroke.h
Normal 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;
|
||||
|
||||
}
|
||||
@@ -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
|
||||
|
||||
128
tests/paint/stroke_tests.cpp
Normal file
128
tests/paint/stroke_tests.cpp
Normal 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();
|
||||
}
|
||||
Reference in New Issue
Block a user