diff --git a/docs/modernization/build-inventory.md b/docs/modernization/build-inventory.md index d043fa0..e31b917 100644 --- a/docs/modernization/build-inventory.md +++ b/docs/modernization/build-inventory.md @@ -91,7 +91,7 @@ Known local toolchain state: `pp_paint`, `pp_document`, `pp_renderer_api`, `pp_paint_renderer`, `pp_ui_core`, `pano_cli`, and their current headless test binaries, including foundation event/logging/task queue coverage, PNG metadata, PPI - header/layout, settings document, document frame move/duration coverage, + header/layout, settings document, document snapshot/frame move/duration coverage, paint brush/stroke/stroke-script coverage, renderer shader descriptor coverage, UI color parsing, and layout XML parse coverage. - `pano_cli inspect-image` reports PNG IHDR metadata as JSON and is covered by @@ -100,6 +100,9 @@ Known local toolchain state: body summary fields, layer/frame descriptors, and dirty-face PNG payload metadata, and is covered by `pano_cli_inspect_project_layout_smoke` with a minimal PPI fixture. +- `pano_cli load-project` creates a metadata-only `pp_document` projection for + representable PPI timelines and is covered by + `pano_cli_load_project_metadata_smoke`. - `pano_cli create-document` supports `--frames` and `--frame-duration-ms` and is covered by `pano_cli_create_animation_document_smoke`. - `pano_cli simulate-stroke` exposes the pure stroke sampler for scripted diff --git a/docs/modernization/debt.md b/docs/modernization/debt.md index d6f45dd..9272b81 100644 --- a/docs/modernization/debt.md +++ b/docs/modernization/debt.md @@ -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-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-0010 | Open | Modernization | `pp_document` is a pure layer/frame/document/undo-history model but is not yet wired to legacy `Canvas`, PPI load/save, selection masks, 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` | 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 and snapshot construction, but it is not yet wired to legacy `Canvas`, PPI pixel load/save, selection masks, 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` | 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-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` and `pano_cli inspect-project` validate the fixed PPI header, thumbnail/body byte layout, layer/frame index, dirty-face descriptors, and dirty-face PNG payload metadata, but do not yet deserialize layer face PNG pixels into document/layer/frame data | Full PPI parsing requires staged extraction of legacy `Canvas` serialization and image/layer payload handling | `ctest --preset desktop-fast --build-config Debug`; `pp_assets_ppi_header_tests`; `pano_cli_inspect_project_layout_smoke` | Full PPI load/save fixtures cover thumbnail, decoded layer face payloads, frames, metadata, corrupt payloads, and round-trip compatibility | +| DEBT-0013 | Open | Modernization | `pp_assets`, `pano_cli inspect-project`, and metadata-only `pano_cli load-project` validate the fixed PPI header, thumbnail/body byte layout, layer/frame index, dirty-face descriptors, and dirty-face PNG payload metadata, but do not yet deserialize layer face PNG pixels into document/layer/frame data | Full PPI parsing requires staged extraction of legacy `Canvas` serialization and image/layer payload handling | `ctest --preset desktop-fast --build-config Debug`; `pp_assets_ppi_header_tests`; `pano_cli_inspect_project_layout_smoke`; `pano_cli_load_project_metadata_smoke` | Full PPI load/save fixtures cover thumbnail, decoded layer face payloads, frames, metadata, corrupt payloads, and 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 | ## Closed Debt diff --git a/docs/modernization/roadmap.md b/docs/modernization/roadmap.md index e00de01..8e41f17 100644 --- a/docs/modernization/roadmap.md +++ b/docs/modernization/roadmap.md @@ -249,10 +249,10 @@ Goal: make each component reachable by automated tools and future agents. Status: in progress. `tests/` exists, `desktop-fast` runs headlessly, and PowerShell/bash wrappers exist for configure/build/test/analyze/platform-build/package-smoke. `pano_cli` exists -with JSON automation commands for creating a `pp_document` model and -inspecting image signatures, PPI headers, and layout XML; full document/app -integration is debt-tracked as DEBT-0010 and full PPI body parsing is -debt-tracked as DEBT-0013. +with JSON automation commands for creating a `pp_document` model, metadata-only +PPI project loading, and inspecting image signatures, PPI headers, and layout +XML; full document/app integration is debt-tracked as DEBT-0010 and full PPI +body parsing is debt-tracked as DEBT-0013. Implementation tasks: @@ -320,8 +320,9 @@ corrupt/truncated/unsupported, extreme-dimension, and key/value limit tests. CPU reference math for the five current shader blend modes, and deterministic stroke spacing/interpolation plus a pure text stroke-script parser. `pp_document` has -started with a pure canvas/layer/frame model, layer metadata operations, frame -move/duration queries, and layer/frame/undo-redo history invariant tests. +started with a pure canvas/layer/frame model, alpha-lock metadata, snapshot +construction, layer metadata operations, frame move/duration queries, 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 @@ -331,6 +332,8 @@ input tests. `pano_cli inspect-image` exposes PNG IHDR metadata as JSON, `pano_cli inspect-project` reports validated PPI thumbnail/body byte layout, body summary, layer/frame descriptors, and dirty-face PNG payload metadata, and +`pano_cli load-project` creates a metadata-only `pp_document` projection for +representable PPI timelines. `pano_cli create-document` can create simple animation documents with explicit frame count/duration, and `pano_cli simulate-stroke` exercises the pure stroke sampler for scripted-stroke automation. `pano_cli simulate-stroke-script` @@ -577,8 +580,8 @@ Results: - `pp_paint_blend_tests` passed. - `pp_paint_stroke_tests` passed. - `pp_paint_stroke_script_tests` passed. -- `pp_document_tests` passed, including frame move, duration, and history - invariants. +- `pp_document_tests` passed, including snapshot construction, alpha-lock + metadata, frame move, duration, and history invariants. - `pp_renderer_api_tests` passed, including shader descriptor validation. - `pp_paint_renderer_compositor_tests` passed. - `pp_ui_core_color_tests` passed. @@ -594,6 +597,8 @@ Results: - `pano_cli_inspect_project_layout_smoke` passed and reports PPI thumbnail/body byte layout, body summary, layer/frame descriptors, and dirty-face PNG payload metadata JSON. +- `pano_cli_load_project_metadata_smoke` passed and reports a metadata-only + `pp_document` projection for the minimal PPI fixture. - `pano_cli_parse_layout_smoke` passed. - `pano_cli_simulate_stroke_smoke` passed and reports deterministic stroke sample counts/distances. diff --git a/src/document/document.cpp b/src/document/document.cpp index e009bad..eebe474 100644 --- a/src/document/document.cpp +++ b/src/document/document.cpp @@ -46,6 +46,38 @@ namespace { return pp::foundation::Status::success(); } +[[nodiscard]] pp::foundation::Status validate_layer_opacity(float opacity) noexcept +{ + if (!std::isfinite(opacity) || opacity < 0.0F || opacity > 1.0F) { + return pp::foundation::Status::out_of_range("layer opacity must be finite and within 0..1"); + } + + return pp::foundation::Status::success(); +} + +[[nodiscard]] pp::foundation::Status validate_blend_mode(pp::paint::BlendMode blend_mode) noexcept +{ + switch (blend_mode) { + case pp::paint::BlendMode::normal: + case pp::paint::BlendMode::multiply: + case pp::paint::BlendMode::screen: + case pp::paint::BlendMode::color_dodge: + case pp::paint::BlendMode::overlay: + return pp::foundation::Status::success(); + } + + return pp::foundation::Status::invalid_argument("layer blend mode is not supported"); +} + +[[nodiscard]] pp::foundation::Status validate_frame_duration(std::uint32_t duration_ms) noexcept +{ + if (duration_ms < min_frame_duration_ms) { + return pp::foundation::Status::invalid_argument("frame duration must be greater than zero"); + } + + return pp::foundation::Status::success(); +} + [[nodiscard]] pp::foundation::Status validate_layer_index(std::size_t index, std::size_t layer_count) noexcept { if (index >= layer_count) { @@ -85,6 +117,69 @@ pp::foundation::Result CanvasDocument::create(DocumentConfig con return pp::foundation::Result::success(document); } +pp::foundation::Result CanvasDocument::create_from_snapshot(DocumentSnapshotConfig config) +{ + const auto status = validate_config(DocumentConfig { + .width = config.width, + .height = config.height, + .layer_count = static_cast(config.layers.size()), + }); + if (!status.ok()) { + return pp::foundation::Result::failure(status); + } + + if (config.frames.empty()) { + return pp::foundation::Result::failure( + pp::foundation::Status::invalid_argument("document must contain at least one frame")); + } + + if (config.frames.size() > max_frame_count) { + return pp::foundation::Result::failure( + pp::foundation::Status::out_of_range("document frame count exceeds the configured limit")); + } + + CanvasDocument document; + document.width_ = config.width; + document.height_ = config.height; + document.layers_.reserve(config.layers.size()); + for (const auto& layer_config : config.layers) { + const auto name_status = validate_layer_name(layer_config.name); + if (!name_status.ok()) { + return pp::foundation::Result::failure(name_status); + } + + const auto opacity_status = validate_layer_opacity(layer_config.opacity); + if (!opacity_status.ok()) { + return pp::foundation::Result::failure(opacity_status); + } + + const auto blend_status = validate_blend_mode(layer_config.blend_mode); + if (!blend_status.ok()) { + return pp::foundation::Result::failure(blend_status); + } + + document.layers_.push_back(Layer { + .name = std::string(layer_config.name), + .visible = layer_config.visible, + .alpha_locked = layer_config.alpha_locked, + .opacity = layer_config.opacity, + .blend_mode = layer_config.blend_mode, + }); + } + + document.frames_.reserve(config.frames.size()); + for (const auto& frame_config : config.frames) { + const auto duration_status = validate_frame_duration(frame_config.duration_ms); + if (!duration_status.ok()) { + return pp::foundation::Result::failure(duration_status); + } + + document.frames_.push_back(frame_config); + } + + return pp::foundation::Result::success(document); +} + std::uint32_t CanvasDocument::width() const noexcept { return width_; @@ -229,6 +324,17 @@ pp::foundation::Status CanvasDocument::set_layer_visible(std::size_t index, bool return pp::foundation::Status::success(); } +pp::foundation::Status CanvasDocument::set_layer_alpha_locked(std::size_t index, bool alpha_locked) noexcept +{ + const auto index_status = validate_layer_index(index, layers_.size()); + if (!index_status.ok()) { + return index_status; + } + + layers_[index].alpha_locked = alpha_locked; + return pp::foundation::Status::success(); +} + pp::foundation::Status CanvasDocument::set_layer_opacity(std::size_t index, float opacity) noexcept { const auto index_status = validate_layer_index(index, layers_.size()); @@ -236,8 +342,9 @@ pp::foundation::Status CanvasDocument::set_layer_opacity(std::size_t index, floa return index_status; } - if (!std::isfinite(opacity) || opacity < 0.0F || opacity > 1.0F) { - return pp::foundation::Status::out_of_range("layer opacity must be finite and within 0..1"); + const auto opacity_status = validate_layer_opacity(opacity); + if (!opacity_status.ok()) { + return opacity_status; } layers_[index].opacity = opacity; @@ -251,17 +358,13 @@ pp::foundation::Status CanvasDocument::set_layer_blend_mode(std::size_t index, p return index_status; } - switch (blend_mode) { - case pp::paint::BlendMode::normal: - case pp::paint::BlendMode::multiply: - case pp::paint::BlendMode::screen: - case pp::paint::BlendMode::color_dodge: - case pp::paint::BlendMode::overlay: - layers_[index].blend_mode = blend_mode; - return pp::foundation::Status::success(); + const auto blend_status = validate_blend_mode(blend_mode); + if (!blend_status.ok()) { + return blend_status; } - return pp::foundation::Status::invalid_argument("layer blend mode is not supported"); + layers_[index].blend_mode = blend_mode; + return pp::foundation::Status::success(); } pp::foundation::Result CanvasDocument::add_frame(std::uint32_t duration_ms) @@ -271,9 +374,10 @@ pp::foundation::Result CanvasDocument::add_frame(std::uint32_t dura pp::foundation::Status::out_of_range("document frame count exceeds the configured limit")); } - if (duration_ms < min_frame_duration_ms) { + const auto duration_status = validate_frame_duration(duration_ms); + if (!duration_status.ok()) { return pp::foundation::Result::failure( - pp::foundation::Status::invalid_argument("frame duration must be greater than zero")); + duration_status); } frames_.push_back(AnimationFrame { .duration_ms = duration_ms }); @@ -353,8 +457,9 @@ pp::foundation::Status CanvasDocument::set_frame_duration(std::size_t index, std return index_status; } - if (duration_ms < min_frame_duration_ms) { - return pp::foundation::Status::invalid_argument("frame duration must be greater than zero"); + const auto duration_status = validate_frame_duration(duration_ms); + if (!duration_status.ok()) { + return duration_status; } frames_[index].duration_ms = duration_ms; diff --git a/src/document/document.h b/src/document/document.h index 5960d8b..49434dd 100644 --- a/src/document/document.h +++ b/src/document/document.h @@ -28,6 +28,7 @@ struct DocumentConfig { struct Layer { std::string name; bool visible = true; + bool alpha_locked = false; float opacity = 1.0F; pp::paint::BlendMode blend_mode = pp::paint::BlendMode::normal; }; @@ -36,9 +37,25 @@ struct AnimationFrame { std::uint32_t duration_ms = 100; }; +struct DocumentLayerConfig { + std::string_view name; + bool visible = true; + bool alpha_locked = false; + float opacity = 1.0F; + pp::paint::BlendMode blend_mode = pp::paint::BlendMode::normal; +}; + +struct DocumentSnapshotConfig { + std::uint32_t width = 0; + std::uint32_t height = 0; + std::span layers; + std::span frames; +}; + class CanvasDocument { public: [[nodiscard]] static pp::foundation::Result create(DocumentConfig config); + [[nodiscard]] static pp::foundation::Result create_from_snapshot(DocumentSnapshotConfig config); [[nodiscard]] std::uint32_t width() const noexcept; [[nodiscard]] std::uint32_t height() const noexcept; @@ -54,6 +71,7 @@ public: [[nodiscard]] pp::foundation::Status set_active_layer(std::size_t index) noexcept; [[nodiscard]] pp::foundation::Status rename_layer(std::size_t index, std::string_view name); [[nodiscard]] pp::foundation::Status set_layer_visible(std::size_t index, bool visible) noexcept; + [[nodiscard]] pp::foundation::Status set_layer_alpha_locked(std::size_t index, bool alpha_locked) noexcept; [[nodiscard]] pp::foundation::Status set_layer_opacity(std::size_t index, float opacity) noexcept; [[nodiscard]] pp::foundation::Status set_layer_blend_mode(std::size_t index, pp::paint::BlendMode blend_mode) noexcept; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 173491c..41f76a1 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -236,6 +236,12 @@ if(TARGET pano_cli) LABELS "assets;integration;desktop-fast" PASS_REGULAR_EXPRESSION "\"thumbnail\":\\{\"width\":128,\"height\":128,\"components\":4,\"bytes\":65536\\}.*\"body\":\\{\"offset\":65576,\"bytes\":73,\"width\":64,\"height\":32,\"layers\":1,\"frames\":1,\"dirtyFaces\":0,\"rgbaFacePayloads\":0,\"compressedBytes\":0,\"infoBytes\":0\\}.*\"layers\":\\[\\{\"index\":0,\"storedOrder\":0,\"name\":\"Ink\"") + add_test(NAME pano_cli_load_project_metadata_smoke + COMMAND pano_cli load-project --path "${CMAKE_CURRENT_SOURCE_DIR}/data/projects/minimal-project.ppi") + set_tests_properties(pano_cli_load_project_metadata_smoke PROPERTIES + LABELS "assets;document;integration;desktop-fast" + PASS_REGULAR_EXPRESSION "\"command\":\"load-project\".*\"pixelDataLoaded\":false.*\"document\":\\{\"width\":64,\"height\":32,\"layers\":1,\"frames\":1,\"animationDurationMs\":100,\"layerNames\":\\[\"Ink\"\\]") + add_test(NAME pano_cli_parse_layout_smoke COMMAND pano_cli parse-layout --path "${CMAKE_CURRENT_SOURCE_DIR}/data/layouts/simple-layout.xml") set_tests_properties(pano_cli_parse_layout_smoke PROPERTIES diff --git a/tests/document/document_tests.cpp b/tests/document/document_tests.cpp index fa66d5b..a278876 100644 --- a/tests/document/document_tests.cpp +++ b/tests/document/document_tests.cpp @@ -8,6 +8,9 @@ using pp::paint::BlendMode; using pp::document::CanvasDocument; using pp::document::DocumentHistory; using pp::document::DocumentConfig; +using pp::document::DocumentLayerConfig; +using pp::document::DocumentSnapshotConfig; +using pp::document::AnimationFrame; using pp::document::max_document_history_entries; using pp::document::max_canvas_dimension; using pp::document::max_frame_count; @@ -106,11 +109,13 @@ void updates_layer_metadata(pp::tests::Harness& h) PP_EXPECT(h, document.rename_layer(1, "Ink").ok()); PP_EXPECT(h, document.set_layer_visible(1, false).ok()); + PP_EXPECT(h, document.set_layer_alpha_locked(1, true).ok()); PP_EXPECT(h, document.set_layer_opacity(1, 0.25F).ok()); PP_EXPECT(h, document.set_layer_blend_mode(1, BlendMode::multiply).ok()); PP_EXPECT(h, document.layers()[1].name == std::string_view("Ink")); PP_EXPECT(h, !document.layers()[1].visible); + PP_EXPECT(h, document.layers()[1].alpha_locked); PP_EXPECT(h, std::fabs(document.layers()[1].opacity - 0.25F) < 0.0001F); PP_EXPECT(h, document.layers()[1].blend_mode == BlendMode::multiply); } @@ -129,6 +134,7 @@ void rejects_invalid_layer_metadata(pp::tests::Harness& h) const auto bad_opacity_high = document.set_layer_opacity(0, 1.1F); const auto bad_opacity_nan = document.set_layer_opacity(0, std::nanf("")); const auto missing_visible = document.set_layer_visible(2, true); + const auto missing_alpha_lock = document.set_layer_alpha_locked(2, true); const auto missing_blend = document.set_layer_blend_mode(2, BlendMode::normal); const auto bad_blend = document.set_layer_blend_mode(0, static_cast(255)); const auto bad_add_layer = document.add_layer(std::string(max_layer_name_length + 1U, 'x')); @@ -147,6 +153,8 @@ void rejects_invalid_layer_metadata(pp::tests::Harness& h) PP_EXPECT(h, bad_opacity_nan.code == StatusCode::out_of_range); PP_EXPECT(h, !missing_visible.ok()); PP_EXPECT(h, missing_visible.code == StatusCode::out_of_range); + PP_EXPECT(h, !missing_alpha_lock.ok()); + PP_EXPECT(h, missing_alpha_lock.code == StatusCode::out_of_range); PP_EXPECT(h, !missing_blend.ok()); PP_EXPECT(h, missing_blend.code == StatusCode::out_of_range); PP_EXPECT(h, !bad_blend.ok()); @@ -155,6 +163,88 @@ void rejects_invalid_layer_metadata(pp::tests::Harness& h) PP_EXPECT(h, bad_add_layer.status().code == StatusCode::out_of_range); } +void creates_document_from_snapshot_metadata(pp::tests::Harness& h) +{ + const DocumentLayerConfig layers[] { + { + .name = "Ink", + .visible = false, + .alpha_locked = true, + .opacity = 0.5F, + .blend_mode = BlendMode::screen, + }, + { + .name = "Glaze", + .visible = true, + .alpha_locked = false, + .opacity = 0.75F, + .blend_mode = BlendMode::overlay, + }, + }; + const AnimationFrame frames[] { + { .duration_ms = 100 }, + { .duration_ms = 250 }, + }; + + const auto document_result = CanvasDocument::create_from_snapshot(DocumentSnapshotConfig { + .width = 128, + .height = 64, + .layers = layers, + .frames = frames, + }); + + PP_EXPECT(h, document_result.ok()); + PP_EXPECT(h, document_result.value().layers().size() == 2U); + PP_EXPECT(h, document_result.value().layers()[0].name == std::string_view("Ink")); + PP_EXPECT(h, !document_result.value().layers()[0].visible); + PP_EXPECT(h, document_result.value().layers()[0].alpha_locked); + PP_EXPECT(h, document_result.value().layers()[0].blend_mode == BlendMode::screen); + PP_EXPECT(h, document_result.value().frames().size() == 2U); + PP_EXPECT(h, document_result.value().animation_duration_ms() == 350U); +} + +void rejects_invalid_snapshot_metadata(pp::tests::Harness& h) +{ + const DocumentLayerConfig layers[] { { .name = "Ink" } }; + const AnimationFrame frames[] { { .duration_ms = 100 } }; + const AnimationFrame bad_frames[] { { .duration_ms = 0 } }; + const DocumentLayerConfig bad_layers[] { { .name = "" } }; + + const auto no_layers = CanvasDocument::create_from_snapshot(DocumentSnapshotConfig { + .width = 64, + .height = 64, + .layers = {}, + .frames = frames, + }); + const auto no_frames = CanvasDocument::create_from_snapshot(DocumentSnapshotConfig { + .width = 64, + .height = 64, + .layers = layers, + .frames = {}, + }); + const auto bad_frame = CanvasDocument::create_from_snapshot(DocumentSnapshotConfig { + .width = 64, + .height = 64, + .layers = layers, + .frames = bad_frames, + }); + const auto bad_layer = CanvasDocument::create_from_snapshot(DocumentSnapshotConfig { + .width = 64, + .height = 64, + .layers = bad_layers, + .frames = frames, + }); + + PP_EXPECT(h, !no_layers.ok()); + PP_EXPECT(h, no_layers.status().code == StatusCode::invalid_argument); + PP_EXPECT(h, !no_frames.ok()); + PP_EXPECT(h, no_frames.status().code == StatusCode::invalid_argument); + PP_EXPECT(h, !bad_frame.ok()); + PP_EXPECT(h, bad_frame.status().code == StatusCode::invalid_argument); + PP_EXPECT(h, !bad_layer.ok()); + PP_EXPECT(h, bad_layer.status().code == StatusCode::invalid_argument); +} + void manages_animation_frames_and_duration(pp::tests::Harness& h) { auto document_result = CanvasDocument::create( @@ -366,6 +456,8 @@ int main() harness.run("moves_layers_and_preserves_active_layer_identity", moves_layers_and_preserves_active_layer_identity); harness.run("updates_layer_metadata", updates_layer_metadata); harness.run("rejects_invalid_layer_metadata", rejects_invalid_layer_metadata); + harness.run("creates_document_from_snapshot_metadata", creates_document_from_snapshot_metadata); + harness.run("rejects_invalid_snapshot_metadata", rejects_invalid_snapshot_metadata); harness.run("manages_animation_frames_and_duration", manages_animation_frames_and_duration); harness.run("moves_frames_and_preserves_active_frame_identity", moves_frames_and_preserves_active_frame_identity); harness.run("rejects_invalid_animation_frame_operations", rejects_invalid_animation_frame_operations); diff --git a/tools/pano_cli/main.cpp b/tools/pano_cli/main.cpp index 1dc2daf..1e1420c 100644 --- a/tools/pano_cli/main.cpp +++ b/tools/pano_cli/main.cpp @@ -106,6 +106,7 @@ void print_help() << " create-document --width N --height N [--layers N] [--frames N] [--frame-duration-ms N]\n" << " inspect-image --path FILE\n" << " inspect-project --path FILE\n" + << " load-project --path FILE\n" << " parse-layout --path FILE\n" << " simulate-stroke --x1 N --y1 N --x2 N --y2 N [--spacing N]\n" << " simulate-stroke-script --path FILE\n" @@ -404,6 +405,134 @@ int inspect_project(int argc, char** argv) return 0; } +pp::foundation::Result ppi_layer_blend_mode(std::uint32_t blend_mode) noexcept +{ + switch (blend_mode) { + case 0: + return pp::foundation::Result::success(pp::paint::BlendMode::normal); + case 1: + return pp::foundation::Result::success(pp::paint::BlendMode::multiply); + case 2: + return pp::foundation::Result::success(pp::paint::BlendMode::screen); + case 3: + return pp::foundation::Result::success(pp::paint::BlendMode::color_dodge); + case 4: + return pp::foundation::Result::success(pp::paint::BlendMode::overlay); + default: + return pp::foundation::Result::failure( + pp::foundation::Status::invalid_argument("PPI layer blend mode is not supported by pp_document")); + } +} + +pp::foundation::Result document_from_ppi_index( + const pp::assets::PpiProjectIndex& project) +{ + if (project.body.layers.empty()) { + return pp::foundation::Result::failure( + pp::foundation::Status::invalid_argument("PPI project has no layers")); + } + + const auto& reference_frames = project.body.layers.front().frames; + if (reference_frames.empty()) { + return pp::foundation::Result::failure( + pp::foundation::Status::invalid_argument("PPI project has no frames")); + } + + std::vector frames; + frames.reserve(reference_frames.size()); + for (const auto& frame : reference_frames) { + frames.push_back(pp::document::AnimationFrame { .duration_ms = frame.duration_ms }); + } + + std::vector layers; + layers.reserve(project.body.layers.size()); + for (const auto& layer : project.body.layers) { + if (layer.frames.size() != reference_frames.size()) { + return pp::foundation::Result::failure( + pp::foundation::Status::invalid_argument("PPI per-layer frame counts are not representable yet")); + } + + for (std::size_t frame_index = 0; frame_index < layer.frames.size(); ++frame_index) { + if (layer.frames[frame_index].duration_ms != reference_frames[frame_index].duration_ms) { + return pp::foundation::Result::failure( + pp::foundation::Status::invalid_argument("PPI per-layer frame durations are not representable yet")); + } + } + + const auto blend_mode = ppi_layer_blend_mode(layer.blend_mode); + if (!blend_mode) { + return pp::foundation::Result::failure(blend_mode.status()); + } + + layers.push_back(pp::document::DocumentLayerConfig { + .name = layer.name, + .visible = layer.visible, + .alpha_locked = layer.alpha_locked, + .opacity = layer.opacity, + .blend_mode = blend_mode.value(), + }); + } + + return pp::document::CanvasDocument::create_from_snapshot(pp::document::DocumentSnapshotConfig { + .width = project.body.summary.width, + .height = project.body.summary.height, + .layers = layers, + .frames = frames, + }); +} + +int load_project(int argc, char** argv) +{ + InspectProjectArgs args; + const auto status = parse_inspect_project_args(argc, argv, args); + if (!status.ok()) { + print_error("load-project", status.message); + return 2; + } + + std::ifstream stream(args.path, std::ios::binary); + if (!stream) { + print_error("load-project", "project file could not be opened"); + return 2; + } + + const std::string chars { + std::istreambuf_iterator(stream), + std::istreambuf_iterator() + }; + const auto* data = reinterpret_cast(chars.data()); + const auto project = pp::assets::parse_ppi_project_index(std::span(data, chars.size())); + if (!project) { + print_error("load-project", project.status().message); + return 2; + } + + const auto document_result = document_from_ppi_index(project.value()); + if (!document_result) { + print_error("load-project", document_result.status().message); + return 2; + } + + const auto& document = document_result.value(); + std::cout << "{\"ok\":true,\"command\":\"load-project\"" + << ",\"source\":\"ppi\"" + << ",\"pixelDataLoaded\":false" + << ",\"document\":{\"width\":" << document.width() + << ",\"height\":" << document.height() + << ",\"layers\":" << document.layers().size() + << ",\"frames\":" << document.frames().size() + << ",\"animationDurationMs\":" << document.animation_duration_ms() + << ",\"layerNames\":["; + for (std::size_t layer_index = 0; layer_index < document.layers().size(); ++layer_index) { + if (layer_index != 0U) { + std::cout << ","; + } + std::cout << "\"" << json_escape(document.layers()[layer_index].name) << "\""; + } + std::cout << "]}}\n"; + return 0; +} + pp::foundation::Status parse_simulate_stroke_args(int argc, char** argv, SimulateStrokeArgs& args) { for (int i = 2; i < argc; ++i) { @@ -635,6 +764,10 @@ int main(int argc, char** argv) return inspect_project(argc, argv); } + if (command == "load-project") { + return load_project(argc, argv); + } + if (command == "simulate-stroke") { return simulate_stroke(argc, argv); }