diff --git a/docs/modernization/build-inventory.md b/docs/modernization/build-inventory.md index edbef02..37ab759 100644 --- a/docs/modernization/build-inventory.md +++ b/docs/modernization/build-inventory.md @@ -246,9 +246,11 @@ Known local toolchain state: - `src/legacy_document_animation_services.*` is the current UI-shell bridge for animation frame commands, timeline/selected-frame execution, playback ticks, onion-size updates, and play-mode toggles. It keeps those live paths on the - `pp_app_core` contracts while legacy `Canvas`/`Layer` frame state, canvas - mode, animation-panel timeline/playback fields, and the temporary - `NodePanelAnimation` friend adapter remain tracked by `DEBT-0022`. + `pp_app_core` contracts. `NodeCanvas` onion-skin panorama drawing now also + consumes the tested `pp_app_core` onion frame range and alpha falloff helper, + while legacy `Canvas`/`Layer` frame state, canvas mode, animation-panel + timeline/playback fields, and the temporary `NodePanelAnimation` friend + adapter remain tracked by `DEBT-0022`. - `src/legacy_brush_ui_services.*` is the current UI-shell bridge for brush color, texture, preset, stroke-refresh, brush texture-list, and stroke-control execution. `NodePanelBrushPreset` now consumes `pp_app_core` preset-list diff --git a/docs/modernization/debt.md b/docs/modernization/debt.md index 7236774..10bb0ca 100644 --- a/docs/modernization/debt.md +++ b/docs/modernization/debt.md @@ -35,6 +35,11 @@ agent or engineer to remove them without reconstructing context from chat. blocker matrix without requiring an app build first, and `panopainter_package_smoke_readiness_self_test` guards package-kind parity across both wrappers. Package target migration remains open. +- 2026-06-05: DEBT-0022 was narrowed. `pp_app_core` now owns tested + onion-skin frame range and alpha falloff planning, and live `NodeCanvas` + panorama drawing consumes that helper instead of open-coding frame clamping + and opacity falloff in the render loop. Legacy canvas/layer/UI execution + remains open under DEBT-0022. - 2026-06-04: DEBT-0036 was narrowed again. Canvas stroke commit, thumbnail, and object-draw history paths now query saved blend state through tested `pp_renderer_gl` capability-state dispatch; CanvasLayer equirect diff --git a/docs/modernization/roadmap.md b/docs/modernization/roadmap.md index f7f9ad9..23aa77b 100644 --- a/docs/modernization/roadmap.md +++ b/docs/modernization/roadmap.md @@ -527,6 +527,8 @@ before the legacy layer-panel merge adapter continues. frame add, duplicate, remove, duration adjustment, timeline moves, timeline goto/next/previous, onion-size updates, frame selection, no-reload playback stepping, and play-mode toggles used by the live animation panel. +`pp_app_core` also owns onion-skin frame range and alpha falloff planning now +consumed by live `NodeCanvas` panorama drawing. `pano_cli plan-animation-panel-action` exposes the higher-level animation panel state/action planner for goto, next, previous, playback-step, and play-toggle automation without requiring the legacy UI or canvas. @@ -1694,8 +1696,9 @@ Results: goto/next/previous wrapping, onion-size rejection, service dispatch ordering, frame-click selection planning, no-reload playback step planning, playback toggle start/stop planning, animation panel action planning, - invalid panel timeline state rejection, non-mutating duration no-ops, and - malformed execution payload rejection. + invalid panel timeline state rejection, non-mutating duration no-ops, tested + onion-skin frame range/alpha falloff planning consumed by live `NodeCanvas` + panorama drawing, and malformed execution payload rejection. - `pano_cli_plan_animation_operation_add_smoke`, `pano_cli_plan_animation_operation_duration_floor_smoke`, `pano_cli_plan_animation_operation_next_wrap_smoke`, diff --git a/src/app_core/document_animation.h b/src/app_core/document_animation.h index d73829a..96e42f1 100644 --- a/src/app_core/document_animation.h +++ b/src/app_core/document_animation.h @@ -62,6 +62,14 @@ struct DocumentAnimationOperationPlan { bool resets_playback_timer = false; }; +struct DocumentAnimationOnionFrameRange { + int frame_count = 1; + int current_frame = 0; + int onion_size = 0; + int first_frame = 0; + int last_frame = 0; +}; + class DocumentAnimationServices { public: virtual ~DocumentAnimationServices() = default; @@ -122,6 +130,56 @@ public: return pp::foundation::Status::success(); } +[[nodiscard]] inline pp::foundation::Result plan_animation_onion_frame_range( + int frame_count, + int current_frame, + int onion_size) +{ + const auto index_status = validate_animation_frame_index(frame_count, current_frame); + if (!index_status.ok()) { + return pp::foundation::Result::failure(index_status); + } + + if (onion_size < 0) { + return pp::foundation::Result::failure( + pp::foundation::Status::invalid_argument("animation onion size must not be negative")); + } + + const auto first = std::max( + static_cast(current_frame) - static_cast(onion_size), + 0); + const auto last = std::min( + static_cast(current_frame) + static_cast(onion_size), + static_cast(frame_count) - 1); + + return pp::foundation::Result::success( + DocumentAnimationOnionFrameRange { + .frame_count = frame_count, + .current_frame = current_frame, + .onion_size = onion_size, + .first_frame = static_cast(first), + .last_frame = static_cast(last), + }); +} + +[[nodiscard]] inline float animation_onion_frame_alpha( + const DocumentAnimationOnionFrameRange& range, + int frame) noexcept +{ + if (frame < range.first_frame || frame > range.last_frame) { + return 0.0f; + } + + const int distance = frame >= range.current_frame + ? frame - range.current_frame + : range.current_frame - frame; + if (distance > range.onion_size) { + return 0.0f; + } + + return 1.0f - static_cast(distance) / static_cast(range.onion_size + 1); +} + [[nodiscard]] inline pp::foundation::Result plan_animation_add_frame( int frame_count, int current_frame) diff --git a/src/node_canvas.cpp b/src/node_canvas.cpp index ef73c7a..970b013 100644 --- a/src/node_canvas.cpp +++ b/src/node_canvas.cpp @@ -8,6 +8,7 @@ #include "app_core/canvas_hotkey.h" #include "app_core/canvas_tool_ui.h" +#include "app_core/document_animation.h" #include "app.h" #include "legacy_canvas_tool_services.h" #include "legacy_history_services.h" @@ -514,12 +515,17 @@ void NodeCanvas::draw() auto layer_index = i; for (int plane_index = 0; plane_index < 6; plane_index++) { - int onion_size = App::I->animation->get_onion_size(); - int frame_current = m_canvas->m_layers[layer_index]->m_frame_index; - int frame_start = glm::max(frame_current - onion_size, 0); - int frame_end = glm::min(frame_current + onion_size, m_canvas->m_layers[layer_index]->frames_count() - 1); + const auto onion_range_result = pp::app::plan_animation_onion_frame_range( + m_canvas->m_layers[layer_index]->frames_count(), + m_canvas->m_layers[layer_index]->m_frame_index, + App::I->animation->get_onion_size()); + if (!onion_range_result) { + LOG("NodeCanvas onion frame range failed: %s", onion_range_result.status().message); + continue; + } + const auto onion_range = onion_range_result.value(); bool faces = false; - for (int frame = frame_start; frame <= frame_end; frame++) + for (int frame = onion_range.first_frame; frame <= onion_range.last_frame; frame++) faces |= m_canvas->m_layers[layer_index]->face(plane_index, frame); if (!(m_canvas->m_show_tmp && m_canvas->m_current_layer_idx == layer_index) && (!m_canvas->m_layers[layer_index]->m_visible || @@ -557,9 +563,9 @@ void NodeCanvas::draw() m_canvas->m_tmp[plane_index].bindTexture(); set_active_texture_unit(2); m_canvas->m_smask.rtt(plane_index).bindTexture(); - for (int frame = frame_start; frame <= frame_end; frame++) + for (int frame = onion_range.first_frame; frame <= onion_range.last_frame; frame++) { - float onion_alpha = 1.f - (float)glm::abs(frame - frame_current) / (float)(onion_size + 1); + const float onion_alpha = pp::app::animation_onion_frame_alpha(onion_range, frame); ShaderManager::u_float(kShaderUniform::Alpha, m_canvas->m_layers[layer_index]->m_opacity* onion_alpha); set_active_texture_unit(0); m_canvas->m_layers[layer_index]->rtt(plane_index, frame).bindTexture(); @@ -619,9 +625,9 @@ void NodeCanvas::draw() b->m_pattern_texture ? b->m_pattern_texture->bind() : unbind_texture_2d(); - for (int frame = frame_start; frame <= frame_end; frame++) + for (int frame = onion_range.first_frame; frame <= onion_range.last_frame; frame++) { - float onion_alpha = 1.f - (float)glm::abs(frame - frame_current) / (float)(onion_size + 1); + const float onion_alpha = pp::app::animation_onion_frame_alpha(onion_range, frame); ShaderManager::u_float(kShaderUniform::Alpha, m_canvas->m_layers[layer_index]->m_opacity * onion_alpha); set_active_texture_unit(0); m_canvas->m_layers[layer_index]->rtt(plane_index, frame).bindTexture(); @@ -645,9 +651,9 @@ void NodeCanvas::draw() ShaderManager::u_int(kShaderUniform::Highlight, m_canvas->m_layers[layer_index]->m_hightlight); ShaderManager::u_mat4(kShaderUniform::MVP, plane_mvp_z); - for (int frame = frame_start; frame <= frame_end; frame++) + for (int frame = onion_range.first_frame; frame <= onion_range.last_frame; frame++) { - float onion_alpha = 1.f - (float)glm::abs(frame - frame_current) / (float)(onion_size + 1); + const float onion_alpha = pp::app::animation_onion_frame_alpha(onion_range, frame); ShaderManager::u_float(kShaderUniform::Alpha, m_canvas->m_layers[layer_index]->m_opacity * onion_alpha); set_active_texture_unit(0); m_canvas->m_layers[layer_index]->rtt(plane_index, frame).bindTexture(); diff --git a/tests/app_core/document_animation_tests.cpp b/tests/app_core/document_animation_tests.cpp index 2ecf51c..4d88255 100644 --- a/tests/app_core/document_animation_tests.cpp +++ b/tests/app_core/document_animation_tests.cpp @@ -426,6 +426,46 @@ void onion_size_updates_canvas_without_document_mutation(pp::tests::Harness& har PP_EXPECT(harness, !pp::app::plan_animation_onion_size(-1)); } +void onion_frame_ranges_clamp_edges_and_alpha(pp::tests::Harness& harness) +{ + const auto center = pp::app::plan_animation_onion_frame_range(5, 2, 1); + PP_REQUIRE(harness, center); + PP_EXPECT(harness, center.value().first_frame == 1); + PP_EXPECT(harness, center.value().last_frame == 3); + PP_EXPECT(harness, pp::app::animation_onion_frame_alpha(center.value(), 2) == 1.0f); + PP_EXPECT(harness, pp::app::animation_onion_frame_alpha(center.value(), 1) == 0.5f); + PP_EXPECT(harness, pp::app::animation_onion_frame_alpha(center.value(), 3) == 0.5f); + PP_EXPECT(harness, pp::app::animation_onion_frame_alpha(center.value(), 0) == 0.0f); + + const auto left = pp::app::plan_animation_onion_frame_range(5, 0, 3); + PP_REQUIRE(harness, left); + PP_EXPECT(harness, left.value().first_frame == 0); + PP_EXPECT(harness, left.value().last_frame == 3); + PP_EXPECT(harness, pp::app::animation_onion_frame_alpha(left.value(), 0) == 1.0f); + PP_EXPECT(harness, pp::app::animation_onion_frame_alpha(left.value(), 3) == 0.25f); + + const auto right = pp::app::plan_animation_onion_frame_range(5, 4, 3); + PP_REQUIRE(harness, right); + PP_EXPECT(harness, right.value().first_frame == 1); + PP_EXPECT(harness, right.value().last_frame == 4); + PP_EXPECT(harness, pp::app::animation_onion_frame_alpha(right.value(), 1) == 0.25f); + PP_EXPECT(harness, pp::app::animation_onion_frame_alpha(right.value(), 4) == 1.0f); + + const auto single = pp::app::plan_animation_onion_frame_range(1, 0, 8); + PP_REQUIRE(harness, single); + PP_EXPECT(harness, single.value().first_frame == 0); + PP_EXPECT(harness, single.value().last_frame == 0); + PP_EXPECT(harness, pp::app::animation_onion_frame_alpha(single.value(), 0) == 1.0f); +} + +void onion_frame_ranges_reject_invalid_inputs(pp::tests::Harness& harness) +{ + PP_EXPECT(harness, !pp::app::plan_animation_onion_frame_range(0, 0, 1)); + PP_EXPECT(harness, !pp::app::plan_animation_onion_frame_range(3, -1, 1)); + PP_EXPECT(harness, !pp::app::plan_animation_onion_frame_range(3, 3, 1)); + PP_EXPECT(harness, !pp::app::plan_animation_onion_frame_range(3, 1, -1)); +} + void executor_dispatches_mutating_frame_operations(pp::tests::Harness& harness) { FakeDocumentAnimationServices services; @@ -590,6 +630,8 @@ int main() harness.run("panel actions plan timeline and playback intent", panel_actions_plan_timeline_and_playback_intent); harness.run("panel actions reject invalid timeline state", panel_actions_reject_invalid_timeline_state); harness.run("onion size updates canvas without document mutation", onion_size_updates_canvas_without_document_mutation); + harness.run("onion frame ranges clamp edges and alpha", onion_frame_ranges_clamp_edges_and_alpha); + harness.run("onion frame ranges reject invalid inputs", onion_frame_ranges_reject_invalid_inputs); harness.run("executor dispatches mutating frame operations", executor_dispatches_mutating_frame_operations); harness.run("executor dispatches timeline and parameter operations", executor_dispatches_timeline_and_parameter_operations); harness.run("executor rejects malformed animation plans", executor_rejects_malformed_animation_plans);