diff --git a/docs/modernization/debt.md b/docs/modernization/debt.md index b738888..443f506 100644 --- a/docs/modernization/debt.md +++ b/docs/modernization/debt.md @@ -18,6 +18,14 @@ agent or engineer to remove them without reconstructing context from chat. ## Recent Reductions +- 2026-06-13: DEBT-0036 was narrowed again. `NodeStrokePreview::draw_stroke_immediate()` + now routes retained preview stroke max-size fallback, dual-preview max-size + derivation, pattern-scale flips, and Bezier preview-point generation through + `plan_legacy_node_stroke_preview_stroke_setup(...)`; compositor coverage now + also locks the retained stroke-setup curve/pressure intent and + pass-sequence validation short-circuit behavior. Brush object mutation, + camera wiring, retained `Stroke` population, and all live GL execution + remain in the legacy preview node. - 2026-06-13: DEBT-0036 was narrowed again. `Canvas::draw_merge()` per-layer blend composite now routes merge-RTT unbind, shader setup, optional destination-copy feedback, draw, and cleanup ordering through diff --git a/docs/modernization/roadmap.md b/docs/modernization/roadmap.md index 0111b9f..52acc29 100644 --- a/docs/modernization/roadmap.md +++ b/docs/modernization/roadmap.md @@ -3155,6 +3155,12 @@ Results: dual-pass/background/main-pass/final-composite/copy-back ordering, while the remaining local preview ownership is concentrated around `stroke_draw_mix()` setup/execution. +- `NodeStrokePreview::draw_stroke_immediate()` now routes preview stroke + max-size fallback, dual-preview max-size derivation, pattern-scale flips, + and Bezier preview-point generation through + `plan_legacy_node_stroke_preview_stroke_setup(...)`, while brush mutation, + camera wiring, retained `Stroke` population, and live GL execution remain in + the preview node. - `NodeStrokePreview::stroke_draw_mix()` now shares one local helper for mixer framebuffer bind/unbind, viewport/scissor/blend state, texture-slot binding, and final plane draw, while material planning and shader uniform diff --git a/docs/modernization/tasks.md b/docs/modernization/tasks.md index c41bb4a..2008393 100644 --- a/docs/modernization/tasks.md +++ b/docs/modernization/tasks.md @@ -509,6 +509,16 @@ Done Checks: Progress Notes: +- 2026-06-13: `NodeStrokePreview::draw_stroke_immediate()` now routes preview + stroke max-size fallback, dual-preview max-size derivation, pattern-scale + flips, and Bezier preview-point generation through + `plan_legacy_node_stroke_preview_stroke_setup(...)`; compositor coverage now + also locks the retained stroke-setup curve/pressure intent and + pass-sequence request-validation short-circuit behavior. The preview node + still owns brush object mutation, camera wiring, retained `Stroke` + population, and live GL execution. Next slice should target the remaining + higher-level preview pass orchestration seam without reopening landed + sample, mix, pass-sequence, or final-composite helpers. - 2026-06-13: `Canvas::draw_merge()` per-layer blend composite now routes merge-RTT unbind, shader setup, optional destination-copy feedback, draw, and cleanup ordering through diff --git a/src/legacy_node_stroke_preview_execution_services.h b/src/legacy_node_stroke_preview_execution_services.h index 447dfd7..8a72443 100644 --- a/src/legacy_node_stroke_preview_execution_services.h +++ b/src/legacy_node_stroke_preview_execution_services.h @@ -7,7 +7,9 @@ #include "paint_renderer/compositor.h" #include +#include #include +#include namespace pp::panopainter { @@ -222,4 +224,98 @@ struct LegacyNodeStrokePreviewPassSequenceRequest { return true; } +struct LegacyNodeStrokePreviewStrokePoint { + glm::vec3 position {}; + float pressure = 0.0f; +}; + +struct LegacyNodeStrokePreviewStrokeSetupPlan { + float stroke_max_size = 0.0f; + float dual_stroke_max_size = 0.0f; + bool dual_enabled = false; + glm::vec2 pattern_scale {}; + std::vector points; +}; + +struct LegacyNodeStrokePreviewStrokeSetupRequest { + glm::vec2 preview_size {}; + float zoom = 1.0f; + float brush_tip_size = 0.0f; + float stroke_max_size_override = 0.0f; + float pad_override = NAN; + bool tip_size_pressure = false; + bool dual_enabled = false; + float dual_size = 1.0f; + float pattern_scale = 0.0f; + bool pattern_flipx = false; + bool pattern_flipy = false; + int preview_point_count = 100; +}; + +[[nodiscard]] inline glm::vec2 evaluate_legacy_node_stroke_preview_bezier( + std::vector control_points, + float t) noexcept +{ + if (control_points.empty()) { + return {}; + } + + for (std::size_t remaining = control_points.size(); remaining > 1; --remaining) { + for (std::size_t i = 0; i + 1 < remaining; ++i) { + control_points[i] = glm::mix(control_points[i], control_points[i + 1], t); + } + } + + return control_points.front(); +} + +[[nodiscard]] inline LegacyNodeStrokePreviewStrokeSetupPlan plan_legacy_node_stroke_preview_stroke_setup( + const LegacyNodeStrokePreviewStrokeSetupRequest& request) noexcept +{ + LegacyNodeStrokePreviewStrokeSetupPlan plan; + plan.stroke_max_size = request.stroke_max_size_override > 0.0f ? + request.stroke_max_size_override : + request.preview_size.y * 0.75f; + plan.dual_enabled = request.dual_enabled; + plan.dual_stroke_max_size = plan.stroke_max_size * request.dual_size; + plan.pattern_scale = glm::vec2(request.pattern_scale); + if (request.pattern_flipx) { + plan.pattern_scale.x *= -1.0f; + } + if (request.pattern_flipy) { + plan.pattern_scale.y *= -1.0f; + } + + const float min_pad = request.preview_size.x * 0.05f; + float pad = (5.0f + glm::max(glm::min(plan.stroke_max_size, request.brush_tip_size) / 2.0f, min_pad)) * request.zoom; + if (request.tip_size_pressure) { + pad = min_pad * request.zoom; + } + if (!std::isnan(request.pad_override)) { + pad = request.pad_override; + } + + const float width = request.preview_size.x * request.zoom; + const float height = request.preview_size.y * request.zoom; + const std::vector keypoints { + { pad, height / 2.0f }, + { width / 2.0f, 0.0f }, + { width / 2.0f, height }, + { width - pad, height / 2.0f }, + }; + + const int point_count = std::max(request.preview_point_count, 0); + plan.points.reserve(static_cast(point_count)); + for (int i = 0; i < point_count; ++i) { + const float t = static_cast(i) / static_cast(point_count); + const float pressure = glm::clamp((1.0f - glm::abs(t * 2.0f - 1.0f)) * 1.1f, 0.0f, 1.0f); + plan.points.push_back(LegacyNodeStrokePreviewStrokePoint { + .position = glm::vec3(evaluate_legacy_node_stroke_preview_bezier(keypoints, t), 0.0f), + .pressure = pressure, + }); + } + + return plan; +} + } // namespace pp::panopainter diff --git a/src/node_stroke_preview.cpp b/src/node_stroke_preview.cpp index d25f1bd..a83794a 100644 --- a/src/node_stroke_preview.cpp +++ b/src/node_stroke_preview.cpp @@ -687,6 +687,20 @@ void NodeStrokePreview::draw_stroke_immediate() float zoom = root()->m_zoom; glm::vec2 size = { m_rtt.getWidth(), m_rtt.getHeight() }; + const auto stroke_setup = pp::panopainter::plan_legacy_node_stroke_preview_stroke_setup( + pp::panopainter::LegacyNodeStrokePreviewStrokeSetupRequest { + .preview_size = m_size, + .zoom = zoom, + .brush_tip_size = m_brush->m_tip_size, + .stroke_max_size_override = m_max_size, + .pad_override = m_pad_override, + .tip_size_pressure = m_brush->m_tip_size_pressure, + .dual_enabled = m_brush->m_dual_enabled, + .dual_size = m_brush->m_dual_size, + .pattern_scale = m_brush->m_pattern_scale, + .pattern_flipx = m_brush->m_pattern_flipx, + .pattern_flipy = m_brush->m_pattern_flipy, + }); glm::mat4 ortho_proj = glm::ortho(0, size.x, 0, size.y, -1, 1); apply_stroke_preview_viewport(0, 0, m_rtt.getWidth(), m_rtt.getHeight()); m_rtt.bindFramebuffer(); @@ -702,7 +716,7 @@ void NodeStrokePreview::draw_stroke_immediate() Stroke m_dual_stroke; m_stroke.m_filter_points = false; - m_stroke.m_max_size = m_max_size > 0 ? m_max_size : m_size.y * .75f; + m_stroke.m_max_size = stroke_setup.stroke_max_size; m_stroke.m_camera.fov = Canvas::I->m_cam_fov; m_stroke.m_camera.rot = Canvas::I->m_cam_rot; m_stroke.reset(true); @@ -727,44 +741,24 @@ void NodeStrokePreview::draw_stroke_immediate() dual_brush->m_tip_texture = b->m_dual_texture; dual_brush->m_tip_aspect = b->m_dual_aspect; - if (b->m_dual_enabled) + if (stroke_setup.dual_enabled) { m_dual_stroke.m_filter_points = false; - m_dual_stroke.m_max_size = m_stroke.m_max_size * b->m_dual_size; + m_dual_stroke.m_max_size = stroke_setup.dual_stroke_max_size; m_dual_stroke.m_camera.fov = Canvas::I->m_cam_fov; m_dual_stroke.m_camera.rot = Canvas::I->m_cam_rot; m_dual_stroke.reset(true); m_dual_stroke.start(dual_brush); } + for (const auto& point : stroke_setup.points) { - float min_pad = size.x * 0.05f; - float pad = (5.f + glm::max(glm::min(m_stroke.m_max_size, m_brush->m_tip_size) / 2.f, min_pad)) * zoom; - if (b->m_tip_size_pressure) - pad = min_pad * zoom; - if (!glm::isnan(m_pad_override)) - pad = m_pad_override; - float w = m_size.x * zoom; - float h = m_size.y * zoom; - std::vector kp = { - { pad, h / 2.f }, - { w / 2.f, 0 }, - { w / 2.f, h }, - { w - pad, h / 2.f }, - }; - for (int i = 0; i < 100; i++) - { - float t = (float)i / 100.f; - float p = glm::clamp((1.f - glm::abs(t * 2.f - 1.f)) * 1.1f, 0.f, 1.f); - m_stroke.add_point(glm::vec3(BezierCurve::Bezier2D(kp, t), 0), p); - if (b->m_dual_enabled) - m_dual_stroke.add_point(glm::vec3(BezierCurve::Bezier2D(kp, t), 0), p); - } + m_stroke.add_point(point.position, point.pressure); + if (stroke_setup.dual_enabled) + m_dual_stroke.add_point(point.position, point.pressure); } - glm::vec2 patt_scale = glm::vec2(b->m_pattern_scale); - if (b->m_pattern_flipx) patt_scale.x *= -1.f; - if (b->m_pattern_flipy) patt_scale.y *= -1.f; + const glm::vec2 patt_scale = stroke_setup.pattern_scale; apply_stroke_preview_capability(pp::renderer::gl::blend_state(), false); const auto stroke_feedback = stroke_preview_destination_feedback_plan(m_rtt.getWidth(), m_rtt.getHeight()); diff --git a/tests/paint_renderer/compositor_tests.cpp b/tests/paint_renderer/compositor_tests.cpp index 7c41c35..4257196 100644 --- a/tests/paint_renderer/compositor_tests.cpp +++ b/tests/paint_renderer/compositor_tests.cpp @@ -2421,12 +2421,99 @@ void legacy_node_stroke_preview_pass_sequence_preserves_dual_main_and_composite_ PP_EXPECT(h, !missing_dual_prepare); PP_EXPECT(h, steps.empty()); + steps.clear(); + const bool missing_main_prepare = + pp::panopainter::execute_legacy_node_stroke_preview_pass_sequence( + pp::panopainter::LegacyNodeStrokePreviewPassSequenceRequest { + .dual_pass_enabled = true, + .prepare_dual_pass = [&] { + steps.emplace_back("prepare_dual"); + }, + .execute_dual_pass = [&] { + steps.emplace_back("execute_dual"); + }, + .capture_background = [&] { + steps.emplace_back("capture_background"); + }, + .prepare_main_pass = {}, + .execute_main_pass = [&] { + steps.emplace_back("execute_main"); + }, + .finish_main_pass = [&] { + steps.emplace_back("finish_main"); + }, + .execute_final_composite = [&] { + steps.emplace_back("execute_composite"); + }, + .copy_preview_result = [&] { + steps.emplace_back("copy_preview"); + }, + }); + PP_EXPECT(h, !missing_main_prepare); + PP_EXPECT(h, steps.empty()); + const bool missing_required = pp::panopainter::execute_legacy_node_stroke_preview_pass_sequence( pp::panopainter::LegacyNodeStrokePreviewPassSequenceRequest {}); PP_EXPECT(h, !missing_required); } +void legacy_node_stroke_preview_stroke_setup_plan_preserves_curve_and_dual_inputs(pp::tests::Harness& h) +{ + const auto plan = pp::panopainter::plan_legacy_node_stroke_preview_stroke_setup( + pp::panopainter::LegacyNodeStrokePreviewStrokeSetupRequest { + .preview_size = glm::vec2(120.0F, 80.0F), + .zoom = 2.0F, + .brush_tip_size = 30.0F, + .stroke_max_size_override = 0.0F, + .pad_override = 17.0F, + .tip_size_pressure = false, + .dual_enabled = true, + .dual_size = 0.25F, + .pattern_scale = 0.5F, + .pattern_flipx = true, + .pattern_flipy = false, + .preview_point_count = 4, + }); + + PP_EXPECT(h, near(plan.stroke_max_size, 60.0F)); + PP_EXPECT(h, near(plan.dual_stroke_max_size, 15.0F)); + PP_EXPECT(h, plan.dual_enabled); + PP_EXPECT(h, near(plan.pattern_scale, glm::vec2(-0.5F, 0.5F))); + PP_EXPECT(h, plan.points.size() == 4U); + + PP_EXPECT(h, near(plan.points[0].position.x, 17.0F)); + PP_EXPECT(h, near(plan.points[0].position.y, 80.0F)); + PP_EXPECT(h, near(plan.points[0].position.z, 0.0F)); + PP_EXPECT(h, near(plan.points[0].pressure, 0.0F)); + + PP_EXPECT(h, near(plan.points[1].pressure, 0.55F)); + PP_EXPECT(h, near(plan.points[2].pressure, 1.0F)); + PP_EXPECT(h, near(plan.points[3].pressure, 0.55F)); + + const auto pressure_fallback = pp::panopainter::plan_legacy_node_stroke_preview_stroke_setup( + pp::panopainter::LegacyNodeStrokePreviewStrokeSetupRequest { + .preview_size = glm::vec2(120.0F, 80.0F), + .zoom = 2.0F, + .brush_tip_size = 30.0F, + .stroke_max_size_override = 20.0F, + .pad_override = NAN, + .tip_size_pressure = true, + .dual_enabled = false, + .dual_size = 2.0F, + .pattern_scale = 0.25F, + .pattern_flipx = false, + .pattern_flipy = true, + .preview_point_count = 0, + }); + + PP_EXPECT(h, near(pressure_fallback.stroke_max_size, 20.0F)); + PP_EXPECT(h, near(pressure_fallback.dual_stroke_max_size, 40.0F)); + PP_EXPECT(h, !pressure_fallback.dual_enabled); + PP_EXPECT(h, near(pressure_fallback.pattern_scale, glm::vec2(0.25F, -0.25F))); + PP_EXPECT(h, pressure_fallback.points.empty()); +} + void plans_canvas_blend_gate_from_persisted_indices(pp::tests::Harness& h) { const std::vector normal_layers { 0, 0, 0 }; @@ -2887,6 +2974,9 @@ int main() harness.run( "legacy_node_stroke_preview_pass_sequence_preserves_dual_main_and_composite_order", legacy_node_stroke_preview_pass_sequence_preserves_dual_main_and_composite_order); + harness.run( + "legacy_node_stroke_preview_stroke_setup_plan_preserves_curve_and_dual_inputs", + legacy_node_stroke_preview_stroke_setup_plan_preserves_curve_and_dual_inputs); 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);