Add stroke script document automation

This commit is contained in:
2026-06-02 11:31:08 +02:00
parent 0eded78c4c
commit 2b50c2157f
6 changed files with 394 additions and 5 deletions

View File

@@ -153,6 +153,11 @@ Known local toolchain state:
automation and is covered by `pano_cli_simulate_stroke_smoke`. automation and is covered by `pano_cli_simulate_stroke_smoke`.
- `pano_cli simulate-stroke-script` loads a text stroke script fixture and is - `pano_cli simulate-stroke-script` loads a text stroke script fixture and is
covered by `pano_cli_simulate_stroke_script_smoke`. covered by `pano_cli_simulate_stroke_script_smoke`.
- `pano_cli apply-stroke-script` parses a text stroke script fixture, samples
every stroke through `pp_paint`, maps the samples into a bounded
`pp_document` RGBA8 face payload, writes a PPI file, and is covered by
`pano_cli_apply_stroke_script_roundtrip_smoke`, which inspects the dirty-face
box and loads the generated file back as decoded document pixel data.
- `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.
@@ -311,6 +316,9 @@ Known local toolchain state:
through JSON automation for agent-driven checks. through JSON automation for agent-driven checks.
- `pano_cli save-document-project` exposes file-writing document export - `pano_cli save-document-project` exposes file-writing document export
automation for inspect/load round trips. automation for inspect/load round trips.
- `pano_cli apply-stroke-script` exposes file-driven stroke-script application
to a pure document face payload and writes a PPI artifact for inspect/load
round-trip automation.
- `pp_ui_core` consumes vcpkg tinyxml2 only when `PP_USE_VCPKG_TINYXML2=ON` - `pp_ui_core` consumes vcpkg tinyxml2 only when `PP_USE_VCPKG_TINYXML2=ON`
through the vcpkg preset; default and Android validation still use the through the vcpkg preset; default and Android validation still use the
retained vendored fallback tracked by DEBT-0012. retained vendored fallback tracked by DEBT-0012.

View File

@@ -28,10 +28,10 @@ agent or engineer to remove them without reconstructing context from chat.
| DEBT-0007 | Open | Modernization | `vcpkg.json` and `windows-msvc-vcpkg-headless` are validated for the headless Windows component matrix, but app targets still use vendored libraries and Android/Apple triplets are not proven | Dependency migration must stay incremental while SDK/patched/vendor dependencies remain in use | `$env:VCPKG_ROOT="C:\Program Files\Microsoft Visual Studio\2022\Community\VC\vcpkg"; cmake --preset windows-msvc-vcpkg-headless`; `ctest --preset desktop-fast-vcpkg --build-config Debug` | Component targets consume vcpkg packages where reliable and desktop app, Android, and Apple triplets are validated or explicitly documented as permanent vendor exceptions | | DEBT-0007 | Open | Modernization | `vcpkg.json` and `windows-msvc-vcpkg-headless` are validated for the headless Windows component matrix, but app targets still use vendored libraries and Android/Apple triplets are not proven | Dependency migration must stay incremental while SDK/patched/vendor dependencies remain in use | `$env:VCPKG_ROOT="C:\Program Files\Microsoft Visual Studio\2022\Community\VC\vcpkg"; cmake --preset windows-msvc-vcpkg-headless`; `ctest --preset desktop-fast-vcpkg --build-config Debug` | Component targets consume vcpkg packages where reliable and desktop app, Android, and Apple triplets are validated or explicitly documented as permanent vendor exceptions |
| DEBT-0008 | Open | Modernization | `windows-msvc-default` preset is used for local validation because the VS 2026 generator is not installed here | The target VS 2026 preset must remain, but this machine configures with Visual Studio 17 2022 | `cmake --preset windows-msvc-default`; `ctest --preset desktop-fast --build-config Debug` | Validate `windows-vs2026-x64` on a machine with Visual Studio 2026 installed and make it the default Windows validation preset | | DEBT-0008 | Open | Modernization | `windows-msvc-default` preset is used for local validation because the VS 2026 generator is not installed here | The target VS 2026 preset must remain, but this machine configures with Visual Studio 17 2022 | `cmake --preset windows-msvc-default`; `ctest --preset desktop-fast --build-config Debug` | Validate `windows-vs2026-x64` on a machine with Visual Studio 2026 installed and make it the default Windows validation preset |
| DEBT-0009 | Open | Modernization | Android root CMake validation currently builds headless targets only, not APK/package variants | Platform app entrypoints still live in legacy Gradle/CMake projects and need Phase 6 alignment | `powershell -ExecutionPolicy Bypass -File scripts\automation\platform-build.ps1 -Presets android-arm64` | Android standard, Quest, and Focus/Wave package targets consume shared component targets and have package smoke commands | | DEBT-0009 | Open | Modernization | Android root CMake validation currently builds headless targets only, not APK/package variants | Platform app entrypoints still live in legacy Gradle/CMake projects and need Phase 6 alignment | `powershell -ExecutionPolicy Bypass -File scripts\automation\platform-build.ps1 -Presets android-arm64` | Android standard, Quest, and Focus/Wave package targets consume shared component targets and have package smoke commands |
| DEBT-0010 | Open | Modernization | `pp_document` is a pure layer/frame/document/undo-history model with alpha-lock metadata, snapshot construction, per-layer frame metadata, renderer-free RGBA8 face payload storage, snapshot-embedded face-payload validation, renderer-free alpha8 selection-mask storage, and PPI import/export helpers, but it is not yet wired to legacy `Canvas`, legacy save, or legacy action commands | Keep extraction incremental while preserving app behavior | `ctest --preset desktop-fast --build-config Debug`; `pano_cli create-document --width 64 --height 32 --layers 2`; `pano_cli load-project --path tests\data\projects\minimal-project.ppi`; `pp_document_tests`; `pp_document_ppi_import_tests`; `pp_document_ppi_export_tests`; `pano_cli_simulate_document_edits_smoke`; `pano_cli_simulate_document_export_smoke`; `pano_cli_save_document_project_roundtrip_smoke` | Legacy document behavior is represented by `pp_document` tests and the app consumes it through a boundary/facade | | DEBT-0010 | Open | Modernization | `pp_document` is a pure layer/frame/document/undo-history model with alpha-lock metadata, snapshot construction, per-layer frame metadata, renderer-free RGBA8 face payload storage, snapshot-embedded face-payload validation, renderer-free alpha8 selection-mask storage, PPI import/export helpers, and stroke-script-to-face-payload CLI automation, but it is not yet wired to legacy `Canvas`, legacy save, or legacy action commands | Keep extraction incremental while preserving app behavior | `ctest --preset desktop-fast --build-config Debug`; `pano_cli create-document --width 64 --height 32 --layers 2`; `pano_cli load-project --path tests\data\projects\minimal-project.ppi`; `pp_document_tests`; `pp_document_ppi_import_tests`; `pp_document_ppi_export_tests`; `pano_cli_simulate_document_edits_smoke`; `pano_cli_simulate_document_export_smoke`; `pano_cli_save_document_project_roundtrip_smoke`; `pano_cli_apply_stroke_script_roundtrip_smoke` | Legacy document behavior is represented by `pp_document` tests and the app consumes it through a boundary/facade |
| DEBT-0011 | Open | Modernization | `package-smoke` validates the Windows CMake app artifact only, not AppX/APK/Apple/WebGL package outputs | Platform package targets are not migrated to root CMake yet | `powershell -ExecutionPolicy Bypass -File scripts\automation\package-smoke.ps1 -Preset windows-msvc-default -Configuration Debug` | Package-smoke covers Windows AppX, Android APK variants, Apple bundles, and WebGL output where local toolchains are present | | DEBT-0011 | Open | Modernization | `package-smoke` validates the Windows CMake app artifact only, not AppX/APK/Apple/WebGL package outputs | Platform package targets are not migrated to root CMake yet | `powershell -ExecutionPolicy Bypass -File scripts\automation\package-smoke.ps1 -Preset windows-msvc-default -Configuration Debug` | Package-smoke covers Windows AppX, Android APK variants, Apple bundles, and WebGL output where local toolchains are present |
| DEBT-0012 | Open | Modernization | `pp_ui_core` uses vcpkg tinyxml2 on `windows-msvc-vcpkg-headless`, but retains `pp_vendor_tinyxml2` for default and unproven platform presets | Mobile/AppX/Apple triplets and app packaging still need validation before removing the vendored fallback | `ctest --preset desktop-fast-vcpkg --build-config Debug`; `ctest --preset desktop-fast --build-config Debug`; `powershell -ExecutionPolicy Bypass -File scripts\automation\platform-build.ps1 -Presets android-arm64` | All supported presets consume vcpkg tinyxml2 or document a permanent vendored exception | | DEBT-0012 | Open | Modernization | `pp_ui_core` uses vcpkg tinyxml2 on `windows-msvc-vcpkg-headless`, but retains `pp_vendor_tinyxml2` for default and unproven platform presets | Mobile/AppX/Apple triplets and app packaging still need validation before removing the vendored fallback | `ctest --preset desktop-fast-vcpkg --build-config Debug`; `ctest --preset desktop-fast --build-config Debug`; `powershell -ExecutionPolicy Bypass -File scripts\automation\platform-build.ps1 -Presets android-arm64` | All supported presets consume vcpkg tinyxml2 or document a permanent vendored exception |
| DEBT-0013 | Open | Modernization | `pp_assets`, `pp_document`, `pano_cli inspect-project`, `pano_cli load-project`, and `pano_cli save-project` validate the fixed PPI header, thumbnail/body byte layout, generated multi-layer/multi-frame PPI writing with explicit layer opacity/blend/alpha-lock/visibility metadata, per-layer frame durations, metadata-only and targeted dirty-face-payload save/load round-trips, layer/frame index, dirty-face descriptors, dirty-face PNG payload metadata, asset-level RGBA PNG payload decoding, pure document-to-PPI export, CLI document export automation, file-writing document export automation, and decoded pixel attachment to `pp_document`, but full legacy PPI round-trip parity is not yet extracted | Full PPI save parity requires staged extraction of legacy `Canvas` serialization and image/layer payload handling | `ctest --preset desktop-fast --build-config Debug`; `pp_assets_image_pixels_tests`; `pp_assets_ppi_header_tests`; `pp_document_ppi_import_tests`; `pp_document_ppi_export_tests`; `pano_cli_inspect_project_layout_smoke`; `pano_cli_load_project_metadata_smoke`; `pano_cli_save_project_roundtrip_smoke`; `pano_cli_save_project_payload_roundtrip_smoke`; `pano_cli_simulate_document_export_smoke`; `pano_cli_save_document_project_roundtrip_smoke` | Full PPI load/save fixtures cover thumbnails, decoded layer face payloads attached to documents, frames, corrupt payloads, dirty-face payload saving, arbitrary legacy canvas payload/layout combinations, and legacy app round-trip compatibility | | DEBT-0013 | Open | Modernization | `pp_assets`, `pp_document`, `pano_cli inspect-project`, `pano_cli load-project`, and `pano_cli save-project` validate the fixed PPI header, thumbnail/body byte layout, generated multi-layer/multi-frame PPI writing with explicit layer opacity/blend/alpha-lock/visibility metadata, per-layer frame durations, metadata-only and targeted dirty-face-payload save/load round-trips, layer/frame index, dirty-face descriptors, dirty-face PNG payload metadata, asset-level RGBA PNG payload decoding, pure document-to-PPI export, CLI document export automation, file-writing document export automation, stroke-script-generated document payload export, and decoded pixel attachment to `pp_document`, but full legacy PPI round-trip parity is not yet extracted | Full PPI save parity requires staged extraction of legacy `Canvas` serialization and image/layer payload handling | `ctest --preset desktop-fast --build-config Debug`; `pp_assets_image_pixels_tests`; `pp_assets_ppi_header_tests`; `pp_document_ppi_import_tests`; `pp_document_ppi_export_tests`; `pano_cli_inspect_project_layout_smoke`; `pano_cli_load_project_metadata_smoke`; `pano_cli_save_project_roundtrip_smoke`; `pano_cli_save_project_payload_roundtrip_smoke`; `pano_cli_simulate_document_export_smoke`; `pano_cli_save_document_project_roundtrip_smoke`; `pano_cli_apply_stroke_script_roundtrip_smoke` | Full PPI load/save fixtures cover thumbnails, decoded layer face payloads attached to documents, frames, corrupt payloads, dirty-face payload saving, arbitrary legacy canvas payload/layout combinations, and legacy app round-trip compatibility |
| DEBT-0014 | Open | Modernization | `windows-clangcl-asan` now configures as a headless Ninja/clang-cl preset and uses the release MSVC runtime required by ASan, but local builds still fail because installed clang-cl 18.1.8 is paired with VS 2026-preview STL headers that require Clang 20 or newer | Sanitizer validation should be local and repeatable, but this machine's compiler/header pairing is incompatible | `cmake --fresh --preset windows-clangcl-asan`; `cmake --build --preset windows-clangcl-asan --target pp_foundation` | Install/use Clang 20+ with the VS 2026 STL, or point the preset at a compatible VS 2022 toolchain, then make `platform-build.ps1 -Presets windows-clangcl-asan` pass for the headless matrix | | DEBT-0014 | Open | Modernization | `windows-clangcl-asan` now configures as a headless Ninja/clang-cl preset and uses the release MSVC runtime required by ASan, but local builds still fail because installed clang-cl 18.1.8 is paired with VS 2026-preview STL headers that require Clang 20 or newer | Sanitizer validation should be local and repeatable, but this machine's compiler/header pairing is incompatible | `cmake --fresh --preset windows-clangcl-asan`; `cmake --build --preset windows-clangcl-asan --target pp_foundation` | Install/use Clang 20+ with the VS 2026 STL, or point the preset at a compatible VS 2022 toolchain, then make `platform-build.ps1 -Presets windows-clangcl-asan` pass for the headless matrix |
## Closed Debt ## Closed Debt

View File

@@ -371,9 +371,12 @@ PPI and verifies it through inspect/load smoke coverage.
`pano_cli simulate-stroke` exercises the pure stroke sampler for `pano_cli simulate-stroke` exercises the pure stroke sampler for
scripted-stroke automation. `pano_cli simulate-stroke-script` scripted-stroke automation. `pano_cli simulate-stroke-script`
loads stroke script fixtures, parses them through `pp_paint`, and samples every loads stroke script fixtures, parses them through `pp_paint`, and samples every
stroke. `pano_cli parse-layout` exercises the XML layout path. Continue stroke. `pano_cli apply-stroke-script` maps sampled script points into a
expanding document behavior toward legacy Canvas parity and then port OpenGL bounded `pp_document` RGBA8 face payload, writes a PPI file, and verifies that
classes behind the renderer boundary. the applied stroke payload survives inspect/load round-trip automation.
`pano_cli parse-layout` exercises the XML layout path. Continue expanding
document behavior toward legacy Canvas parity and then port OpenGL classes
behind the renderer boundary.
Implementation tasks: Implementation tasks:
@@ -753,6 +756,10 @@ Results:
- `pano_cli_save_document_project_roundtrip_smoke` passed and proves a pure - `pano_cli_save_document_project_roundtrip_smoke` passed and proves a pure
`pp_document` export can be written to a PPI file, inspected for layer/frame `pp_document` export can be written to a PPI file, inspected for layer/frame
dirty-face descriptors, and loaded back through the PPI import path. dirty-face descriptors, and loaded back through the PPI import path.
- `pano_cli_apply_stroke_script_roundtrip_smoke` passed and proves a checked-in
stroke script can be parsed, sampled, applied to a pure `pp_document` face
payload, written to PPI, inspected for the expected dirty-face box, and loaded
back as decoded document pixel data.
- `pano_cli_parse_layout_smoke` passed. - `pano_cli_parse_layout_smoke` passed.
- `pano_cli_simulate_stroke_smoke` passed and reports deterministic stroke - `pano_cli_simulate_stroke_smoke` passed and reports deterministic stroke
sample counts/distances. sample counts/distances.
@@ -833,6 +840,9 @@ Results:
automation for agents. automation for agents.
- `pano_cli save-document-project` exposes file-writing document export - `pano_cli save-document-project` exposes file-writing document export
automation for inspect/load round trips. automation for inspect/load round trips.
- `pano_cli apply-stroke-script` exposes file-driven stroke-script application
to a pure document face payload and writes a PPI artifact for inspect/load
round-trip automation.
- Snapshot creation now rejects invalid embedded RGBA8 face payloads before - Snapshot creation now rejects invalid embedded RGBA8 face payloads before
document export or history can persist malformed state. document export or history can persist malformed state.
- PowerShell package-smoke wrapper validates the Windows CMake app executable - PowerShell package-smoke wrapper validates the Windows CMake app executable

View File

@@ -337,6 +337,15 @@ if(TARGET pano_cli)
set_tests_properties(pano_cli_save_document_project_roundtrip_smoke PROPERTIES set_tests_properties(pano_cli_save_document_project_roundtrip_smoke PROPERTIES
LABELS "assets;document;integration;desktop-fast") LABELS "assets;document;integration;desktop-fast")
add_test(NAME pano_cli_apply_stroke_script_roundtrip_smoke
COMMAND "${CMAKE_COMMAND}"
-DPANO_CLI=$<TARGET_FILE:pano_cli>
-DSTROKE_SCRIPT=${CMAKE_CURRENT_SOURCE_DIR}/data/strokes/two-strokes.ppstroke
-DOUTPUT_PATH=${CMAKE_CURRENT_BINARY_DIR}/data/generated/applied-stroke-script.ppi
-P "${CMAKE_CURRENT_SOURCE_DIR}/cmake/pano_cli_apply_stroke_script_roundtrip.cmake")
set_tests_properties(pano_cli_apply_stroke_script_roundtrip_smoke PROPERTIES
LABELS "assets;document;paint;integration;desktop-fast")
add_test(NAME pano_cli_parse_layout_smoke add_test(NAME pano_cli_parse_layout_smoke
COMMAND pano_cli parse-layout --path "${CMAKE_CURRENT_SOURCE_DIR}/data/layouts/simple-layout.xml") COMMAND pano_cli parse-layout --path "${CMAKE_CURRENT_SOURCE_DIR}/data/layouts/simple-layout.xml")
set_tests_properties(pano_cli_parse_layout_smoke PROPERTIES set_tests_properties(pano_cli_parse_layout_smoke PROPERTIES

View File

@@ -0,0 +1,100 @@
if(NOT DEFINED PANO_CLI)
message(FATAL_ERROR "PANO_CLI must be set")
endif()
if(NOT DEFINED STROKE_SCRIPT)
message(FATAL_ERROR "STROKE_SCRIPT must be set")
endif()
if(NOT DEFINED OUTPUT_PATH)
message(FATAL_ERROR "OUTPUT_PATH must be set")
endif()
get_filename_component(output_dir "${OUTPUT_PATH}" DIRECTORY)
file(MAKE_DIRECTORY "${output_dir}")
file(REMOVE "${OUTPUT_PATH}")
execute_process(
COMMAND "${PANO_CLI}" apply-stroke-script
--path "${STROKE_SCRIPT}"
--output "${OUTPUT_PATH}"
--width 64
--height 32
RESULT_VARIABLE apply_result
OUTPUT_VARIABLE apply_output
ERROR_VARIABLE apply_error)
if(NOT apply_result EQUAL 0)
message(FATAL_ERROR "apply-stroke-script failed: ${apply_output}${apply_error}")
endif()
string(FIND "${apply_output}" "\"command\":\"apply-stroke-script\"" apply_command_index)
string(FIND "${apply_output}" "\"strokes\":2" apply_strokes_index)
string(FIND "${apply_output}" "\"samples\":9" apply_samples_index)
string(FIND "${apply_output}" "\"samplesPerStroke\":[6,3]" apply_samples_per_stroke_index)
string(FIND "${apply_output}" "\"document\":{\"width\":64,\"height\":32,\"layers\":1,\"frames\":1,\"facePayloads\":1}" apply_document_index)
string(FIND "${apply_output}" "\"payload\":{\"face\":0,\"box\":[0,0,11,11],\"width\":11,\"height\":11,\"bytes\":484}" apply_payload_index)
string(FIND "${apply_output}" "\"export\":{\"bytes\":" apply_export_index)
if(apply_command_index LESS 0
OR apply_strokes_index LESS 0
OR apply_samples_index LESS 0
OR apply_samples_per_stroke_index LESS 0
OR apply_document_index LESS 0
OR apply_payload_index LESS 0
OR apply_export_index LESS 0)
message(FATAL_ERROR "apply-stroke-script output did not contain expected summary: ${apply_output}")
endif()
if(NOT EXISTS "${OUTPUT_PATH}")
message(FATAL_ERROR "apply-stroke-script did not create ${OUTPUT_PATH}")
endif()
execute_process(
COMMAND "${PANO_CLI}" inspect-project --path "${OUTPUT_PATH}"
RESULT_VARIABLE inspect_result
OUTPUT_VARIABLE inspect_output
ERROR_VARIABLE inspect_error)
if(NOT inspect_result EQUAL 0)
message(FATAL_ERROR "inspect-project failed after apply-stroke-script: ${inspect_output}${inspect_error}")
endif()
string(FIND "${inspect_output}" "\"dirtyFaces\":1" inspect_dirty_index)
string(FIND "${inspect_output}" "\"rgbaFacePayloads\":1" inspect_payload_index)
string(FIND "${inspect_output}" "\"name\":\"Stroke Script\"" inspect_layer_index)
string(REGEX MATCH "\"dirtyFaces\":\\[\\{\"face\":0,\"box\":\\[0,0,11,11\\]" inspect_face_box "${inspect_output}")
if(inspect_dirty_index LESS 0
OR inspect_payload_index LESS 0
OR inspect_layer_index LESS 0
OR NOT inspect_face_box)
message(FATAL_ERROR "inspect-project output did not contain expected applied stroke layout: ${inspect_output}")
endif()
execute_process(
COMMAND "${PANO_CLI}" load-project --path "${OUTPUT_PATH}"
RESULT_VARIABLE load_result
OUTPUT_VARIABLE load_output
ERROR_VARIABLE load_error)
if(NOT load_result EQUAL 0)
message(FATAL_ERROR "load-project failed after apply-stroke-script: ${load_output}${load_error}")
endif()
string(FIND "${load_output}" "\"command\":\"load-project\"" load_command_index)
string(FIND "${load_output}" "\"pixelDataLoaded\":true" load_pixels_index)
string(FIND "${load_output}" "\"facePayloads\":1" load_payload_index)
string(FIND "${load_output}" "\"layers\":1" load_layers_index)
string(FIND "${load_output}" "\"frames\":1" load_frames_index)
string(FIND "${load_output}" "\"layerNames\":[\"Stroke Script\"]" load_names_index)
string(FIND "${load_output}" "\"layerFrameCounts\":[1]" load_frame_counts_index)
string(FIND "${load_output}" "\"layerDurationsMs\":[100]" load_durations_index)
if(load_command_index LESS 0
OR load_pixels_index LESS 0
OR load_payload_index LESS 0
OR load_layers_index LESS 0
OR load_frames_index LESS 0
OR load_names_index LESS 0
OR load_frame_counts_index LESS 0
OR load_durations_index LESS 0)
message(FATAL_ERROR "load-project output did not contain expected applied stroke summary: ${load_output}")
endif()

View File

@@ -12,8 +12,10 @@
#include "renderer_api/recording_renderer.h" #include "renderer_api/recording_renderer.h"
#include "ui_core/layout_xml.h" #include "ui_core/layout_xml.h"
#include <algorithm>
#include <array> #include <array>
#include <charconv> #include <charconv>
#include <cmath>
#include <cstdint> #include <cstdint>
#include <fstream> #include <fstream>
#include <iostream> #include <iostream>
@@ -96,6 +98,13 @@ struct SimulateStrokeScriptArgs {
std::string path; std::string path;
}; };
struct ApplyStrokeScriptArgs {
std::string path;
std::string output_path;
std::uint32_t width = 64;
std::uint32_t height = 32;
};
struct SimulateDocumentEditsArgs { struct SimulateDocumentEditsArgs {
std::uint32_t width = 128; std::uint32_t width = 128;
std::uint32_t height = 64; std::uint32_t height = 64;
@@ -212,6 +221,7 @@ void print_help()
{ {
std::cout std::cout
<< "pano_cli commands:\n" << "pano_cli commands:\n"
<< " apply-stroke-script --path FILE --output FILE [--width N] [--height N]\n"
<< " create-document --width N --height N [--layers N] [--frames N] [--frame-duration-ms N]\n" << " create-document --width N --height N [--layers N] [--frames N] [--frame-duration-ms N]\n"
<< " export-image --path FILE [--width N] [--height N]\n" << " export-image --path FILE [--width N] [--height N]\n"
<< " inspect-image --path FILE\n" << " inspect-image --path FILE\n"
@@ -1350,6 +1360,254 @@ int simulate_stroke_script(int argc, char** argv)
return 0; return 0;
} }
pp::foundation::Status parse_apply_stroke_script_args(int argc, char** argv, ApplyStrokeScriptArgs& args)
{
for (int i = 2; i < argc; ++i) {
const std::string_view key(argv[i]);
if (key == "--path" || key == "--output") {
if (i + 1 >= argc) {
return pp::foundation::Status::invalid_argument("missing value for option");
}
if (key == "--path") {
args.path = argv[++i];
} else {
args.output_path = argv[++i];
}
} else if (key == "--width" || key == "--height") {
if (i + 1 >= argc) {
return pp::foundation::Status::invalid_argument("missing value for option");
}
const auto value = pp::foundation::parse_u32(argv[++i]);
if (!value) {
return value.status();
}
if (key == "--width") {
args.width = value.value();
} else {
args.height = value.value();
}
} else {
return pp::foundation::Status::invalid_argument("unknown option");
}
}
if (args.path.empty() || args.output_path.empty()) {
return pp::foundation::Status::invalid_argument("path and output must not be empty");
}
if (args.width < 8 || args.height < 8 || args.width > 4096 || args.height > 4096) {
return pp::foundation::Status::out_of_range("width and height must be between 8 and 4096");
}
return pp::foundation::Status::success();
}
std::uint8_t pressure_to_alpha(float pressure) noexcept
{
const auto normalized = std::clamp(pressure, 0.0F, 1.0F);
const auto alpha = static_cast<int>(std::lround(normalized * 255.0F));
return static_cast<std::uint8_t>(std::clamp(alpha, 1, 255));
}
std::uint32_t clamp_sample_coordinate(float value, std::uint32_t limit) noexcept
{
if (limit == 0U) {
return 0;
}
const auto rounded = static_cast<int>(std::lround(value));
const auto clamped = std::clamp(rounded, 0, static_cast<int>(limit - 1U));
return static_cast<std::uint32_t>(clamped);
}
int apply_stroke_script(int argc, char** argv)
{
ApplyStrokeScriptArgs args;
const auto status = parse_apply_stroke_script_args(argc, argv, args);
if (!status.ok()) {
print_error("apply-stroke-script", status.message);
return 2;
}
std::ifstream script_stream(args.path, std::ios::binary);
if (!script_stream) {
print_error("apply-stroke-script", "stroke script file could not be opened");
return 2;
}
const std::string text {
std::istreambuf_iterator<char>(script_stream),
std::istreambuf_iterator<char>()
};
const auto script = pp::paint::parse_stroke_script(text);
if (!script) {
print_error("apply-stroke-script", script.status().message);
return 2;
}
struct AppliedPoint {
std::uint32_t x = 0;
std::uint32_t y = 0;
std::uint32_t stroke_index = 0;
float pressure = 1.0F;
};
std::vector<AppliedPoint> applied_points;
std::vector<std::size_t> samples_per_stroke;
std::size_t total_samples = 0;
float total_distance = 0.0F;
std::uint32_t x0 = args.width;
std::uint32_t y0 = args.height;
std::uint32_t x1 = 0;
std::uint32_t y1 = 0;
for (std::size_t stroke_index = 0; stroke_index < script.value().strokes.size(); ++stroke_index) {
const auto& stroke = script.value().strokes[stroke_index];
const pp::paint::StrokePoint points[] { stroke.start, stroke.end };
const auto samples = pp::paint::sample_stroke(
points,
pp::paint::StrokeSamplingConfig {
.spacing = stroke.spacing,
});
if (!samples) {
print_error("apply-stroke-script", samples.status().message);
return 2;
}
samples_per_stroke.push_back(samples.value().size());
total_samples += samples.value().size();
total_distance += samples.value().back().distance;
for (const auto& sample : samples.value()) {
const auto x = clamp_sample_coordinate(sample.x, args.width);
const auto y = clamp_sample_coordinate(sample.y, args.height);
x0 = std::min(x0, x);
y0 = std::min(y0, y);
x1 = std::max(x1, x);
y1 = std::max(y1, y);
applied_points.push_back(AppliedPoint {
.x = x,
.y = y,
.stroke_index = static_cast<std::uint32_t>(stroke_index),
.pressure = sample.pressure,
});
}
}
if (applied_points.empty()) {
print_error("apply-stroke-script", "stroke script produced no samples");
return 2;
}
const auto payload_width = x1 - x0 + 1U;
const auto payload_height = y1 - y0 + 1U;
std::vector<std::uint8_t> rgba8(
static_cast<std::size_t>(payload_width) * payload_height * pp::document::rgba8_components,
0);
for (std::size_t point_index = 0; point_index < applied_points.size(); ++point_index) {
const auto& point = applied_points[point_index];
const auto local_x = point.x - x0;
const auto local_y = point.y - y0;
const auto pixel_index = (static_cast<std::size_t>(local_y) * payload_width + local_x)
* pp::document::rgba8_components;
rgba8[pixel_index + 0U] = static_cast<std::uint8_t>(64U + ((point.stroke_index * 53U) % 192U));
rgba8[pixel_index + 1U] = static_cast<std::uint8_t>((point_index * 31U) % 256U);
rgba8[pixel_index + 2U] = static_cast<std::uint8_t>(255U - ((point.stroke_index * 29U) % 192U));
rgba8[pixel_index + 3U] = pressure_to_alpha(point.pressure);
}
const auto document_result = pp::document::CanvasDocument::create(
pp::document::DocumentConfig {
.width = args.width,
.height = args.height,
.layer_count = 1,
});
if (!document_result) {
print_error("apply-stroke-script", document_result.status().message);
return 2;
}
auto document = document_result.value();
auto edit_status = document.rename_layer(0, "Stroke Script");
if (!edit_status.ok()) {
print_error("apply-stroke-script", edit_status.message);
return 2;
}
edit_status = document.set_layer_frame_face_pixels(
0,
0,
pp::document::LayerFacePixels {
.face_index = 0,
.x = x0,
.y = y0,
.width = payload_width,
.height = payload_height,
.rgba8 = std::move(rgba8),
});
if (!edit_status.ok()) {
print_error("apply-stroke-script", edit_status.message);
return 2;
}
const auto exported = pp::document::export_ppi_project_document(document);
if (!exported) {
print_error("apply-stroke-script", exported.status().message);
return 2;
}
std::ofstream output_stream(args.output_path, std::ios::binary);
if (!output_stream) {
print_error("apply-stroke-script", "project file could not be opened for writing");
return 2;
}
const auto& bytes = exported.value();
output_stream.write(reinterpret_cast<const char*>(bytes.data()), static_cast<std::streamsize>(bytes.size()));
if (!output_stream) {
print_error("apply-stroke-script", "project file could not be written");
return 2;
}
const auto decoded = pp::assets::decode_ppi_project_images(bytes);
if (!decoded) {
print_error("apply-stroke-script", decoded.status().message);
return 2;
}
std::cout << "{\"ok\":true,\"command\":\"apply-stroke-script\""
<< ",\"path\":\"" << json_escape(args.path) << "\""
<< ",\"output\":\"" << json_escape(args.output_path) << "\""
<< ",\"strokes\":" << script.value().strokes.size()
<< ",\"samples\":" << total_samples
<< ",\"distance\":" << total_distance
<< ",\"samplesPerStroke\":[";
for (std::size_t stroke_index = 0; stroke_index < samples_per_stroke.size(); ++stroke_index) {
if (stroke_index != 0U) {
std::cout << ",";
}
std::cout << samples_per_stroke[stroke_index];
}
std::cout << "],\"document\":{\"width\":" << document.width()
<< ",\"height\":" << document.height()
<< ",\"layers\":" << document.layers().size()
<< ",\"frames\":" << document.frames().size()
<< ",\"facePayloads\":" << document.face_pixel_payload_count()
<< "},\"payload\":{\"face\":0"
<< ",\"box\":[" << x0 << "," << y0 << "," << (x1 + 1U) << "," << (y1 + 1U) << "]"
<< ",\"width\":" << payload_width
<< ",\"height\":" << payload_height
<< ",\"bytes\":" << document.layers()[0].frames[0].face_pixels[0].rgba8.size()
<< "},\"export\":{\"bytes\":" << bytes.size()
<< ",\"dirtyFaces\":" << decoded.value().project.body.summary.dirty_face_count
<< ",\"rgbaFacePayloads\":" << decoded.value().project.body.summary.rgba_face_payload_count
<< ",\"compressedBytes\":" << decoded.value().project.body.summary.compressed_face_bytes
<< "}}\n";
return 0;
}
pp::foundation::Status parse_simulate_document_edits_args( pp::foundation::Status parse_simulate_document_edits_args(
int argc, int argc,
char** argv, char** argv,
@@ -2119,6 +2377,10 @@ int main(int argc, char** argv)
return simulate_stroke_script(argc, argv); return simulate_stroke_script(argc, argv);
} }
if (command == "apply-stroke-script") {
return apply_stroke_script(argc, argv);
}
if (command == "simulate-document-edits") { if (command == "simulate-document-edits") {
return simulate_document_edits(argc, argv); return simulate_document_edits(argc, argv);
} }