From 2ac2c45b118bb53c3bf6f57a08862394aad62eb9 Mon Sep 17 00:00:00 2001 From: omigamedev Date: Wed, 3 Jun 2026 18:52:37 +0200 Subject: [PATCH] Plan canvas stroke feedback copies --- docs/modernization/capability-map.md | 2 +- docs/modernization/debt.md | 2 +- docs/modernization/roadmap.md | 10 ++++ src/canvas.cpp | 47 +++++++++++++---- src/canvas.h | 2 +- src/paint_renderer/compositor.cpp | 50 +++++++++++++++++- src/paint_renderer/compositor.h | 13 +++++ tests/paint_renderer/compositor_tests.cpp | 62 +++++++++++++++++++++++ 8 files changed, 174 insertions(+), 14 deletions(-) diff --git a/docs/modernization/capability-map.md b/docs/modernization/capability-map.md index 0ff4350..72536e7 100644 --- a/docs/modernization/capability-map.md +++ b/docs/modernization/capability-map.md @@ -37,7 +37,7 @@ and validation command. | PPBR import/export | brush panel/dialog | `pp_assets`, `pp_panopainter_ui` | Round-trip fixture | | Stroke sampling | `Stroke`, `Canvas` | `pp_paint` | Property tests for spacing, pressure, jitter | | Dual brush/pattern behavior | `Brush`, shaders | `pp_paint`, `pp_paint_renderer` | Stroke-alpha CPU reference, dual/pattern feedback planning, GPU golden | -| Blend modes | GLSL include files, layer rendering | `pp_paint`, `pp_paint_renderer` | Final RGBA and stroke-alpha CPU reference vectors, fixed-function/framebuffer-fetch/ping-pong stroke composite planning, live `Canvas`/`NodeCanvas` blend-gate and destination-copy coverage, and GPU parity | +| Blend modes | GLSL include files, layer rendering | `pp_paint`, `pp_paint_renderer` | Final RGBA and stroke-alpha CPU reference vectors, fixed-function/framebuffer-fetch/ping-pong stroke composite planning, live `Canvas`/`NodeCanvas` blend-gate coverage, live canvas stroke-feedback destination-copy coverage, and GPU parity | | Erase/flood fill/masks | `Canvas`, modes, shaders | `pp_document`, `pp_paint_renderer` | Edge masks, alpha lock, dirty rects | ## Layers And Animation diff --git a/docs/modernization/debt.md b/docs/modernization/debt.md index 3225d46..2cb3268 100644 --- a/docs/modernization/debt.md +++ b/docs/modernization/debt.md @@ -53,7 +53,7 @@ agent or engineer to remove them without reconstructing context from chat. | DEBT-0033 | Open | Modernization | Tools menu planning and direct command execution dispatch now consume pure `pp_app_core` through `App::init_menu_tools`, `pano_cli plan-tools-menu`, `pano_cli plan-tools-panel`, and the `ToolsMenuServices` boundary, but live adapters still construct legacy `NodePanelFloating` panels, mutate legacy panel nodes, clear `CanvasModeGrid`, reset `NodeCanvas` camera state, open legacy shortcuts UI, and call the iOS SonarPen bridge directly | Preserve current Tools menu behavior while UI shell actions move toward app/UI/platform services | `pp_app_core_tools_menu_tests`; `pano_cli plan-tools-menu --command shortcuts`; `pano_cli plan-tools-panel --panel layers`; `pano_cli plan-tools-panel --panel animation --already-visible`; `ctest --preset desktop-fast --build-config Debug` | Tools panel creation, submenu routing, grid clear, camera reset, shortcuts dialog, and SonarPen dispatch are owned by injected app/UI/platform services with `App::init_menu_tools` acting only as a UI adapter and no legacy Tools adapter | | DEBT-0034 | Open | Modernization | About menu command planning and execution dispatch now consume pure `pp_app_core` through `App::init_menu_about`, `pano_cli plan-about-menu`, and the `AboutMenuServices` boundary, but the live adapter still opens legacy About/manual/what's-new dialogs, invokes the injected crash hook, and runs the legacy Canvas stroke performance test directly | Preserve About menu behavior while dialogs and diagnostics move toward app/UI/platform services | `pp_app_core_about_menu_tests`; `pano_cli plan-about-menu --command news --version-major 2 --version-minor 5 --version-fix 7`; `pano_cli plan-about-menu --command performance --no-canvas`; `ctest --preset desktop-fast --build-config Debug` | About/manual/what's-new dialog dispatch, crash-test dispatch, and performance-test execution are owned by injected app/UI/platform services with `App::init_menu_about` acting only as a UI adapter and no legacy About adapter | | DEBT-0035 | Open | Modernization | Main toolbar/status command planning and execution dispatch now consume pure `pp_app_core` through `App::init_toolbar_main`, `pano_cli plan-main-toolbar`, and the `MainToolbarServices` boundary, and history/canvas commands now hand off through `HistoryUiServices` and `DocumentCanvasClearServices`, but the live adapter still opens legacy open/save/settings/message-box dialogs and delegates to legacy history/canvas adapters | Preserve reachable toolbar/status behavior while app shell commands move toward app/document/UI services | `pp_app_core_main_toolbar_tests`; `pano_cli plan-main-toolbar --command undo --undo-count 2`; `pano_cli plan-main-toolbar --command clear-canvas --no-canvas`; `ctest --preset desktop-fast --build-config Debug` | Open/save/settings/message-box routing, undo/redo/clear-history execution, and canvas-clear execution are owned by injected app/document/UI services with `App::init_toolbar_main` acting only as a UI adapter and no legacy toolbar adapter | -| DEBT-0036 | Open | Modernization | `pp_renderer_api`, `pp_paint_renderer`, `pano_cli plan-paint-feedback`, and `pano_cli plan-stroke-composite` can choose backend-neutral complex paint feedback strategies for fixed-function blending, framebuffer-fetch-capable renderers, or ping-pong render targets. OpenGL extension detection now stores `pp::renderer::RenderDeviceFeatures` through `ShaderManager`, using `pp_renderer_gl::render_device_features` as the backend conversion point. `pp_paint_renderer::plan_canvas_blend_gate` owns the compatibility mapping from persisted layer/brush blend indices to the extracted stroke-composite planner, and live `Canvas::draw_merge` plus `NodeCanvas` panorama rendering both call it with the stored renderer-neutral feature set for their existing shader-blend gates and destination-copy versus framebuffer-fetch decisions. Actual live stroke compositing, dual-brush feedback, and pattern feedback still use the legacy OpenGL canvas path | Preserve current painting behavior while the renderer boundary matures for OpenGL parity and later Vulkan/Metal experiments | `pp_renderer_api_tests`; `pp_renderer_gl_capabilities_tests`; `pp_paint_renderer_compositor_tests`; `pano_cli plan-paint-feedback --framebuffer-fetch --explicit-transitions --render-only`; `pano_cli plan-paint-feedback --texture-copy`; `pano_cli plan-stroke-composite --stroke-blend 10 --framebuffer-fetch --explicit-transitions --render-only`; `pano_cli plan-stroke-composite --layer-blend 4 --dual-blend --texture-copy`; `ctest --preset desktop-fast --build-config Debug`; `cmake --build --preset windows-msvc-default --config Debug --target PanoPainter` | Live stroke/layer compositing chooses its feedback path through `pp_paint_renderer` and renderer services, with OpenGL golden parity and Vulkan/Metal lab tests covering framebuffer-fetch and ping-pong behavior | +| DEBT-0036 | Open | Modernization | `pp_renderer_api`, `pp_paint_renderer`, `pano_cli plan-paint-feedback`, and `pano_cli plan-stroke-composite` can choose backend-neutral complex paint feedback strategies for fixed-function blending, framebuffer-fetch-capable renderers, or ping-pong render targets. OpenGL extension detection now stores `pp::renderer::RenderDeviceFeatures` through `ShaderManager`, using `pp_renderer_gl::render_device_features` as the backend conversion point. `pp_paint_renderer::plan_canvas_blend_gate` owns the compatibility mapping from persisted layer/brush blend indices to the extracted stroke-composite planner, and live `Canvas::draw_merge` plus `NodeCanvas` panorama rendering both call it with the stored renderer-neutral feature set for their existing shader-blend gates and destination-copy versus framebuffer-fetch decisions. `pp_paint_renderer::plan_canvas_stroke_feedback` also owns the current stroke shader feedback decision, and live `Canvas::stroke_draw` uses it for main-brush, dual-brush, and stroke-pad destination-copy decisions. Actual live stroke rasterization, dual-brush compositing, pattern feedback math, and thumbnail layer blending still use legacy OpenGL canvas execution | Preserve current painting behavior while the renderer boundary matures for OpenGL parity and later Vulkan/Metal experiments | `pp_renderer_api_tests`; `pp_renderer_gl_capabilities_tests`; `pp_paint_renderer_compositor_tests`; `pano_cli plan-paint-feedback --framebuffer-fetch --explicit-transitions --render-only`; `pano_cli plan-paint-feedback --texture-copy`; `pano_cli plan-stroke-composite --stroke-blend 10 --framebuffer-fetch --explicit-transitions --render-only`; `pano_cli plan-stroke-composite --layer-blend 4 --dual-blend --texture-copy`; `ctest --preset desktop-fast --build-config Debug`; `cmake --build --preset windows-msvc-default --config Debug --target PanoPainter` | Live stroke/layer compositing chooses its feedback path through `pp_paint_renderer` and renderer services, with OpenGL golden parity and Vulkan/Metal lab tests covering framebuffer-fetch and ping-pong behavior | ## Closed Debt diff --git a/docs/modernization/roadmap.md b/docs/modernization/roadmap.md index 33e1c8f..f77f2ce 100644 --- a/docs/modernization/roadmap.md +++ b/docs/modernization/roadmap.md @@ -888,6 +888,12 @@ The OpenGL shader initialization path now stores a renderer-neutral `RenderDeviceFeatures` snapshot converted by `pp_renderer_gl`, and those live canvas gates consume that snapshot instead of rebuilding feature flags from individual `ShaderManager` extension booleans. +`pp_paint_renderer::plan_canvas_stroke_feedback` now models the current stroke +shader's required destination feedback without changing the legacy shader math. +Live `Canvas::stroke_draw` consumes that plan for main-brush, dual-brush, and +stroke-pad destination-copy versus framebuffer-fetch decisions; thumbnail layer +blending is the remaining direct canvas feedback branch before a fuller live +paint-renderer execution boundary can take over. The existing renderer classes are not yet fully behind the renderer interfaces. @@ -1623,6 +1629,10 @@ Results: shared canvas blend-gate plan to decide whether they can read destination color through framebuffer fetch or must copy the destination texture before the legacy OpenGL blend draw. +- Canvas main-brush, dual-brush, and stroke-pad draw paths now use the tested + `pp_paint_renderer` stroke-feedback plan to decide whether framebuffer fetch + supplies destination color or the legacy OpenGL path must copy the target + texture before drawing. - Canvas equirectangular import drawing and depth export rendering now route depth/blend state and active texture units through the renderer GL backend mapping. diff --git a/src/canvas.cpp b/src/canvas.cpp index 9089297..c9a9030 100644 --- a/src/canvas.cpp +++ b/src/canvas.cpp @@ -49,6 +49,27 @@ pp::renderer::RenderDeviceFeatures canvas_stroke_composite_features() noexcept return ShaderManager::render_device_features(); } +pp::paint_renderer::CanvasStrokeFeedbackPlan canvas_stroke_feedback_plan( + int width, + int height) noexcept +{ + const auto plan = pp::paint_renderer::plan_canvas_stroke_feedback( + canvas_stroke_composite_features(), + pp::renderer::Extent2D { + .width = static_cast(std::max(width, 0)), + .height = static_cast(std::max(height, 0)), + }); + if (plan) { + return plan.value(); + } + + pp::paint_renderer::CanvasStrokeFeedbackPlan fallback; + fallback.compatibility_fallback = true; + fallback.path = pp::paint_renderer::StrokeCompositePath::ping_pong_textures; + fallback.requires_auxiliary_texture = true; + return fallback; +} + pp::paint_renderer::CanvasBlendGatePlan draw_merge_blend_gate_plan( int width, int height, @@ -464,9 +485,12 @@ std::array, 6> Canvas::stroke_draw_project(std::array& P) +glm::vec4 Canvas::stroke_draw_samples( + int i, + std::vector& P, + bool copy_stroke_destination) { - if (!ShaderManager::ext_framebuffer_fetch) + if (copy_stroke_destination) { set_active_texture_unit(1); m_tex[i].bind(); // bg, copy of framebuffer (copied before drawing) @@ -484,7 +508,7 @@ glm::vec4 Canvas::stroke_draw_samples(int i, std::vector& P) glm::vec2 pad(1); glm::ivec2 tex_pos = glm::clamp(glm::floor(bb_min) - pad, { 0, 0 }, { m_width, m_height }); glm::ivec2 tex_sz = glm::clamp(glm::ceil(bb_sz) + pad * 2.f, { 0, 0 }, (glm::vec2)(glm::ivec2(m_width, m_height) - tex_pos)); - if (!ShaderManager::ext_framebuffer_fetch) + if (copy_stroke_destination) { glCopyTexSubImage2D(texture_2d_target(), 0, tex_pos.x, tex_pos.y, tex_pos.x, tex_pos.y, tex_sz.x, tex_sz.y); @@ -512,7 +536,7 @@ glm::vec4 Canvas::stroke_draw_samples(int i, std::vector& P) } m_brush_shape.draw_fill(); - if (!ShaderManager::ext_framebuffer_fetch) + if (copy_stroke_destination) { set_active_texture_unit(1); m_tex[i].unbind(); @@ -630,10 +654,13 @@ void Canvas::stroke_draw() if (brush->m_pattern_flipx) patt_scale.x *= -1.f; if (brush->m_pattern_flipy) patt_scale.y *= -1.f; + const auto stroke_feedback = canvas_stroke_feedback_plan(m_width, m_height); + const bool copy_stroke_destination = !stroke_feedback.reads_destination_color; + glDisable(blend_state()); ShaderManager::use(kShader::Stroke); ShaderManager::u_int(kShaderUniform::Tex, 0); // brush - if (!ShaderManager::ext_framebuffer_fetch) + if (copy_stroke_destination) ShaderManager::u_int(kShaderUniform::TexBG, 1); // bg ShaderManager::u_int(kShaderUniform::TexPattern, 2); // pattern ShaderManager::u_int(kShaderUniform::TexMix, 3); // mixer @@ -691,7 +718,7 @@ void Canvas::stroke_draw() ShaderManager::u_vec4(kShaderUniform::Col, f.col); ShaderManager::u_float(kShaderUniform::Alpha, f.flow); ShaderManager::u_float(kShaderUniform::Opacity, f.opacity); - auto box_sample = stroke_draw_samples(i, P); + auto box_sample = stroke_draw_samples(i, P, copy_stroke_destination); m_tmp[i].unbindFramebuffer(); @@ -718,7 +745,7 @@ void Canvas::stroke_draw() // work on documents that doesn't have the padding, so on document loading. ShaderManager::use(kShader::StrokePad); ShaderManager::u_vec4(kShaderUniform::Col, pad_color); - if (!ShaderManager::ext_framebuffer_fetch) + if (copy_stroke_destination) { set_active_texture_unit(1); ShaderManager::u_int(kShaderUniform::TexBG, 1); @@ -748,7 +775,7 @@ void Canvas::stroke_draw() m_brush_shape.update_vertices(pad_quad.data(), pad_quad.size()); m_tmp[i].bindFramebuffer(); - if (!ShaderManager::ext_framebuffer_fetch) + if (copy_stroke_destination) { glm::vec2 o = glm::max({0, 0}, xy(b) - pad); glm::vec2 sz = glm::min(m_size, zw(b) + pad) - o; @@ -759,7 +786,7 @@ void Canvas::stroke_draw() m_brush_shape.draw_fill(); m_tmp[i].unbindFramebuffer(); } - if (!ShaderManager::ext_framebuffer_fetch) + if (copy_stroke_destination) { unbind_texture_2d(); } @@ -790,7 +817,7 @@ void Canvas::stroke_draw() if (P.size() < 3) continue; m_tmp_dual[i].bindFramebuffer(); - auto box_sample = stroke_draw_samples(i, P); + auto box_sample = stroke_draw_samples(i, P, copy_stroke_destination); m_tmp_dual[i].unbindFramebuffer(); // this mode overflows the main brush boundries diff --git a/src/canvas.h b/src/canvas.h index a79d350..652acbb 100644 --- a/src/canvas.h +++ b/src/canvas.h @@ -205,7 +205,7 @@ public: void stroke_draw_mix(const glm::vec2& bb_min, const glm::vec2& bb_sz); std::array, 6> stroke_draw_project(std::array& B, bool project_3d = false, glm::mat4 mv = glm::mat4(1)) const; // return rect {origin, size} - glm::vec4 stroke_draw_samples(int i, std::vector& P); + glm::vec4 stroke_draw_samples(int i, std::vector& P, bool copy_stroke_destination); std::vector stroke_draw_compute(Stroke& stroke) const; void stroke_draw(); void stroke_end(); diff --git a/src/paint_renderer/compositor.cpp b/src/paint_renderer/compositor.cpp index 5140c19..ded4a91 100644 --- a/src/paint_renderer/compositor.cpp +++ b/src/paint_renderer/compositor.cpp @@ -110,12 +110,21 @@ namespace { void apply_stroke_plan(CanvasBlendGatePlan& gate, const StrokeCompositePlan& stroke) noexcept { gate.path = stroke.path; - gate.reads_destination_color = stroke.reads_destination_color; + gate.reads_destination_color = stroke.path == StrokeCompositePath::framebuffer_fetch; gate.requires_auxiliary_texture = stroke.requires_auxiliary_texture; gate.requires_texture_copy = stroke.requires_texture_copy; gate.requires_render_target_blit = stroke.requires_render_target_blit; } +void apply_feedback_plan(CanvasStrokeFeedbackPlan& plan, const pp::renderer::PaintFeedbackPlan& feedback) noexcept +{ + plan.path = composite_path_from_feedback(feedback.path); + plan.reads_destination_color = plan.path == StrokeCompositePath::framebuffer_fetch; + plan.requires_auxiliary_texture = feedback.requires_auxiliary_texture; + plan.requires_texture_copy = feedback.requires_texture_copy; + plan.requires_render_target_blit = feedback.requires_render_target_blit; +} + void mark_shader_blend_fallback( CanvasBlendGatePlan& gate, pp::renderer::RenderDeviceFeatures features) noexcept @@ -303,6 +312,45 @@ pp::foundation::Result plan_canvas_blend_gate( return pp::foundation::Result::success(gate); } +pp::foundation::Result plan_canvas_stroke_feedback( + pp::renderer::RenderDeviceFeatures features, + pp::renderer::Extent2D extent) noexcept +{ + const auto extent_status = pp::renderer::validate_extent(extent); + if (!extent_status.ok()) { + return pp::foundation::Result::failure(extent_status); + } + + const pp::renderer::TextureDesc target_desc { + .extent = extent, + .format = pp::renderer::TextureFormat::rgba8, + .usage = pp::renderer::TextureUsage::render_target + | pp::renderer::TextureUsage::sampled + | pp::renderer::TextureUsage::copy_source + | pp::renderer::TextureUsage::copy_destination, + .debug_name = "canvas-stroke-feedback-target", + }; + const auto feedback = pp::renderer::plan_paint_feedback(features, target_desc, true); + if (feedback) { + CanvasStrokeFeedbackPlan plan; + apply_feedback_plan(plan, feedback.value()); + return pp::foundation::Result::success(plan); + } + + CanvasStrokeFeedbackPlan fallback; + fallback.compatibility_fallback = true; + if (features.framebuffer_fetch) { + fallback.path = StrokeCompositePath::framebuffer_fetch; + fallback.reads_destination_color = true; + } else { + fallback.path = StrokeCompositePath::ping_pong_textures; + fallback.requires_auxiliary_texture = true; + fallback.requires_texture_copy = features.texture_copy; + fallback.requires_render_target_blit = !features.texture_copy && features.render_target_blit; + } + return pp::foundation::Result::success(fallback); +} + const char* stroke_composite_path_name(StrokeCompositePath path) noexcept { switch (path) { diff --git a/src/paint_renderer/compositor.h b/src/paint_renderer/compositor.h index 8546b91..ad5d73d 100644 --- a/src/paint_renderer/compositor.h +++ b/src/paint_renderer/compositor.h @@ -74,6 +74,15 @@ struct CanvasBlendGatePlan { bool requires_render_target_blit = false; }; +struct CanvasStrokeFeedbackPlan { + StrokeCompositePath path = StrokeCompositePath::fixed_function_blend; + bool reads_destination_color = false; + bool requires_auxiliary_texture = false; + bool requires_texture_copy = false; + bool requires_render_target_blit = false; + bool compatibility_fallback = false; +}; + [[nodiscard]] pp::foundation::Status composite_layer( std::span destination, pp::renderer::Extent2D extent, @@ -93,6 +102,10 @@ struct CanvasBlendGatePlan { pp::renderer::RenderDeviceFeatures features, CanvasBlendGateRequest request) noexcept; +[[nodiscard]] pp::foundation::Result plan_canvas_stroke_feedback( + pp::renderer::RenderDeviceFeatures features, + pp::renderer::Extent2D extent) noexcept; + [[nodiscard]] const char* stroke_composite_path_name(StrokeCompositePath path) noexcept; } diff --git a/tests/paint_renderer/compositor_tests.cpp b/tests/paint_renderer/compositor_tests.cpp index 1de520c..7f87e20 100644 --- a/tests/paint_renderer/compositor_tests.cpp +++ b/tests/paint_renderer/compositor_tests.cpp @@ -15,6 +15,7 @@ using pp::paint_renderer::StrokeCompositePath; using pp::paint_renderer::StrokeCompositeRequest; using pp::paint_renderer::composite_layer; using pp::paint_renderer::plan_canvas_blend_gate; +using pp::paint_renderer::plan_canvas_stroke_feedback; using pp::paint_renderer::plan_stroke_composite; using pp::paint_renderer::stroke_composite_path_name; using pp::paint_renderer::stroke_composite_requires_feedback; @@ -309,6 +310,7 @@ void plans_canvas_blend_gate_from_persisted_indices(pp::tests::Harness& h) PP_EXPECT(h, stroke.value().stroke_complex); PP_EXPECT(h, stroke.value().first_complex_layer_index == -1); PP_EXPECT(h, stroke.value().path == StrokeCompositePath::ping_pong_textures); + PP_EXPECT(h, !stroke.value().reads_destination_color); PP_EXPECT(h, stroke.value().requires_texture_copy); } } @@ -381,6 +383,64 @@ void canvas_blend_gate_preserves_legacy_fallbacks(pp::tests::Harness& h) } } +void plans_canvas_stroke_feedback_paths(pp::tests::Harness& h) +{ + const Extent2D extent { .width = 32, .height = 16 }; + const auto fetch = plan_canvas_stroke_feedback( + RenderDeviceFeatures { .framebuffer_fetch = true }, + extent); + PP_EXPECT(h, fetch); + if (fetch) { + PP_EXPECT(h, fetch.value().path == StrokeCompositePath::framebuffer_fetch); + PP_EXPECT(h, fetch.value().reads_destination_color); + PP_EXPECT(h, !fetch.value().requires_auxiliary_texture); + PP_EXPECT(h, !fetch.value().compatibility_fallback); + } + + const auto copy = plan_canvas_stroke_feedback( + RenderDeviceFeatures { .texture_copy = true }, + extent); + PP_EXPECT(h, copy); + if (copy) { + PP_EXPECT(h, copy.value().path == StrokeCompositePath::ping_pong_textures); + PP_EXPECT(h, !copy.value().reads_destination_color); + PP_EXPECT(h, copy.value().requires_auxiliary_texture); + PP_EXPECT(h, copy.value().requires_texture_copy); + PP_EXPECT(h, !copy.value().requires_render_target_blit); + } + + const auto blit = plan_canvas_stroke_feedback( + RenderDeviceFeatures { .render_target_blit = true }, + extent); + PP_EXPECT(h, blit); + if (blit) { + PP_EXPECT(h, blit.value().path == StrokeCompositePath::ping_pong_textures); + PP_EXPECT(h, blit.value().requires_auxiliary_texture); + PP_EXPECT(h, !blit.value().requires_texture_copy); + PP_EXPECT(h, blit.value().requires_render_target_blit); + } +} + +void canvas_stroke_feedback_preserves_legacy_fallback(pp::tests::Harness& h) +{ + const auto fallback = plan_canvas_stroke_feedback( + RenderDeviceFeatures {}, + Extent2D { .width = 32, .height = 16 }); + PP_EXPECT(h, fallback); + if (fallback) { + PP_EXPECT(h, fallback.value().path == StrokeCompositePath::ping_pong_textures); + PP_EXPECT(h, fallback.value().requires_auxiliary_texture); + PP_EXPECT(h, !fallback.value().requires_texture_copy); + PP_EXPECT(h, fallback.value().compatibility_fallback); + } + + const auto invalid = plan_canvas_stroke_feedback( + RenderDeviceFeatures { .texture_copy = true }, + Extent2D { .width = 0, .height = 16 }); + PP_EXPECT(h, !invalid.ok()); + PP_EXPECT(h, invalid.status().code == StatusCode::invalid_argument); +} + } int main() @@ -394,5 +454,7 @@ int main() harness.run("rejects_bad_stroke_composite_plans", rejects_bad_stroke_composite_plans); harness.run("plans_canvas_blend_gate_from_persisted_indices", plans_canvas_blend_gate_from_persisted_indices); harness.run("canvas_blend_gate_preserves_legacy_fallbacks", canvas_blend_gate_preserves_legacy_fallbacks); + harness.run("plans_canvas_stroke_feedback_paths", plans_canvas_stroke_feedback_paths); + harness.run("canvas_stroke_feedback_preserves_legacy_fallback", canvas_stroke_feedback_preserves_legacy_fallback); return harness.finish(); }