| /* | |
| * Copyright 2026 Rive | |
| * | |
| * The one definition of how far past its geometry a paint reaches. Two | |
| * independent consumers depend on this table agreeing: | |
| * | |
| * - RiveRenderPath::calculateBoundsOutset, which culls draws; | |
| * - offscreen raster sizing, which decides how big a layer-mask surface is. | |
| * | |
| * and a third, the editor's Dart mirror, is pinned against the same numbers. | |
| * If a case here changes, the Dart table has to change with it or the editor | |
| * preview will crop where playback does not. | |
| */ | |
| #include <rive/artboard.hpp> | |
| #include <rive/file.hpp> | |
| #include <rive/shapes/paint/paint_outset.hpp> | |
| #include <rive/shapes/paint/stroke.hpp> | |
| #include "rive_file_reader.hpp" | |
| #include <catch.hpp> | |
| using namespace rive; | |
| TEST_CASE("no stroke and no feather reaches nowhere", "[paint-outset]") | |
| { | |
| CHECK(paintBoundsOutset({}, 0.0f) == 0.0f); | |
| } | |
| TEST_CASE("a stroke reaches half its thickness", "[paint-outset]") | |
| { | |
| StrokeParams s; | |
| s.thickness = 10.0f; | |
| s.join = StrokeJoin::round; | |
| s.cap = StrokeCap::butt; | |
| CHECK(paintBoundsOutset(s, 0.0f) == Approx(5.0f)); | |
| // Zero thickness is a real authored state and must not be special-cased | |
| // into something non-zero. | |
| s.thickness = 0.0f; | |
| CHECK(paintBoundsOutset(s, 0.0f) == 0.0f); | |
| } | |
| TEST_CASE("a miter join overshoots the corner by the miter limit", | |
| "[paint-outset]") | |
| { | |
| StrokeParams s; | |
| s.thickness = 10.0f; | |
| s.join = StrokeJoin::miter; | |
| s.cap = StrokeCap::butt; | |
| CHECK(paintBoundsOutset(s, 0.0f) == Approx(5.0f * kMiterLimit)); | |
| CHECK(paintBoundsOutset(s, 0.0f) == Approx(20.0f)); | |
| } | |
| TEST_CASE("a square cap overshoots the end by its diagonal", "[paint-outset]") | |
| { | |
| StrokeParams s; | |
| s.thickness = 10.0f; | |
| s.join = StrokeJoin::round; | |
| s.cap = StrokeCap::square; | |
| CHECK(paintBoundsOutset(s, 0.0f) == Approx(5.0f * math::SQRT2)); | |
| // Bevel joins do not overshoot, so the cap still decides. | |
| s.join = StrokeJoin::bevel; | |
| CHECK(paintBoundsOutset(s, 0.0f) == Approx(5.0f * math::SQRT2)); | |
| } | |
| TEST_CASE("a miter join outranks a square cap", "[paint-outset]") | |
| { | |
| // Not additive, and the miter branch wins: it is the larger of the two and | |
| // the two overshoots cannot both happen at the same vertex. | |
| StrokeParams s; | |
| s.thickness = 10.0f; | |
| s.join = StrokeJoin::miter; | |
| s.cap = StrokeCap::square; | |
| CHECK(paintBoundsOutset(s, 0.0f) == Approx(5.0f * kMiterLimit)); | |
| } | |
| TEST_CASE("an outside stroke reaches its full thickness", "[paint-outset]") | |
| { | |
| // Drawn as a centered stroke twice as thick, clipped to the outside. | |
| StrokeParams s; | |
| s.thickness = 10.0f; | |
| s.join = StrokeJoin::round; | |
| s.position = StrokePosition::outside; | |
| CHECK(paintBoundsOutset(s, 0.0f) == Approx(10.0f)); | |
| s.join = StrokeJoin::miter; | |
| CHECK(paintBoundsOutset(s, 0.0f) == Approx(10.0f * kMiterLimit)); | |
| CHECK(paintBoundsOutset(s, 0.0f) == Approx(40.0f)); | |
| s.join = StrokeJoin::bevel; | |
| s.cap = StrokeCap::square; | |
| CHECK(paintBoundsOutset(s, 0.0f) == Approx(10.0f * math::SQRT2)); | |
| // Clipped to the path, an inside stroke reaches nothing past it. Half its | |
| // thickness, like a centered one, is kept as a safe over-estimate. | |
| s.position = StrokePosition::inside; | |
| s.join = StrokeJoin::round; | |
| s.cap = StrokeCap::butt; | |
| CHECK(paintBoundsOutset(s, 0.0f) == Approx(5.0f)); | |
| } | |
| TEST_CASE("a feather reaches 1.5x its authored strength", "[paint-outset]") | |
| { | |
| // Design tools quote a blur as the width of two standard deviations, so an | |
| // authored 10 bleeds 15 in every direction. | |
| CHECK(featherRadiusFromFeather(10.0f) == Approx(15.0f)); | |
| CHECK(paintBoundsOutset({}, 10.0f) == Approx(15.0f)); | |
| CHECK(paintBoundsOutset({}, 0.0f) == 0.0f); | |
| } | |
| TEST_CASE("a feather adds to a stroke rather than replacing it", | |
| "[paint-outset]") | |
| { | |
| // These two DO compound: the stroke widens the shape and the blur spreads | |
| // the widened edge. | |
| StrokeParams s; | |
| s.thickness = 10.0f; | |
| s.join = StrokeJoin::round; | |
| s.cap = StrokeCap::butt; | |
| CHECK(paintBoundsOutset(s, 10.0f) == Approx(5.0f + 15.0f)); | |
| s.join = StrokeJoin::miter; | |
| CHECK(paintBoundsOutset(s, 10.0f) == Approx(20.0f + 15.0f)); | |
| } | |
| TEST_CASE("the outset never shrinks the box", "[paint-outset]") | |
| { | |
| // Whatever combination is authored, the result is an over-estimate or exact | |
| // -- never negative, which would crop. | |
| for (float thickness : {0.0f, 0.5f, 1.0f, 40.0f}) | |
| { | |
| for (float feather : {0.0f, 0.25f, 30.0f}) | |
| { | |
| for (auto join : | |
| {StrokeJoin::miter, StrokeJoin::round, StrokeJoin::bevel}) | |
| { | |
| for (auto cap : | |
| {StrokeCap::butt, StrokeCap::round, StrokeCap::square}) | |
| { | |
| StrokeParams s; | |
| s.thickness = thickness; | |
| s.join = join; | |
| s.cap = cap; | |
| const float outset = paintBoundsOutset(s, feather); | |
| CHECK(outset >= 0.0f); | |
| // Must cover the naive half-thickness plus the blur. | |
| CHECK(outset >= Approx(thickness * 0.5f + | |
| featherRadiusFromFeather(feather)) | |
| .margin(1e-4f)); | |
| } | |
| } | |
| } | |
| } | |
| } | |
| TEST_CASE("shapePaintOutset reads a stroke's position", "[paint-outset]") | |
| { | |
| auto file = ReadRiveFile("assets/stroke_name_test.riv"); | |
| auto artboard = file->artboardDefault(); | |
| REQUIRE(artboard != nullptr); | |
| artboard->advance(0.0f); | |
| auto stroke = artboard->find<Stroke>("white_stroke"); | |
| REQUIRE(stroke != nullptr); | |
| REQUIRE(stroke->shouldDraw()); | |
| StrokeParams params; | |
| params.thickness = stroke->thickness(); | |
| params.join = static_cast<StrokeJoin>(stroke->join()); | |
| params.cap = static_cast<StrokeCap>(stroke->cap()); | |
| const float centeredStroke = paintBoundsOutset(params, 0.0f); | |
| params.position = StrokePosition::outside; | |
| const float outsideStroke = paintBoundsOutset(params, 0.0f); | |
| REQUIRE(outsideStroke > centeredStroke); | |
| // Compared as a difference so any feather on the stroke cancels out. | |
| const float centered = shapePaintOutset(stroke).outset; | |
| stroke->position((uint8_t)StrokePosition::outside); | |
| CHECK(shapePaintOutset(stroke).outset - centered == | |
| Approx(outsideStroke - centeredStroke)); | |
| } |