feat: Allow clipping to shapes with path effects (#13356) 0c254772d2
A ClippingShape built its clip from the source shape's raw composed geometry, so any path effect on the source's fill — trim, dash, or a scripted effect — was ignored. Clipping to a shape whose fill had been visibly modified used the unmodified path.

The clip now uses the aggregate of what the source shape's fills draw: each fill's post-effect path where it has an effect chain, the raw composed path otherwise, both where they're mixed.

Runtime. ClippingShape::addFillPaths reads each fill's lastEffectPath — the result the paint already resolved in its own update, so nothing is evaluated twice. buildDependencies adds an edge from effected fills to the clip so it sorts after them, and ShapePaint::invalidateRendering recurses so the clip rebuilds when an effect invalidates.

Editor. Effect resolution moves out of ShapePaint.render into a cached resolveEffectPath, which the clip reads during update and render reads after. This also fixes the chain re-running — and re-entering Lua — on every draw of every paint. Dirt is pushed to the composer's dependents explicitly: a Dart ShapePaint has no update pass to clear its own dirt, so addDirt's recursion stops propagating after the first call.

Strokes don't participate — their effect output is a centerline, meaningless filled.
An effect resolving to empty (trim at 0) clips to nothing rather than falling back to the raw path.
Fill visibility and opacity don't gate the contribution, matching how clipping already ignores paints.

Co-authored-by: Philip Chung <philterdesign@gmail.com>
diff --git a/.rive_head b/.rive_head
index 8d01dc5..37b556f 100644
--- a/.rive_head
+++ b/.rive_head
@@ -1 +1 @@
-5df57079b56a7e7a844ff1628e19f2ed3238b164
+0c254772d2eedb180ca5d0d740d58dfb52942ad8
diff --git a/include/rive/shapes/clipping_shape.hpp b/include/rive/shapes/clipping_shape.hpp
index cab469d..ba96a17 100644
--- a/include/rive/shapes/clipping_shape.hpp
+++ b/include/rive/shapes/clipping_shape.hpp
@@ -131,6 +131,9 @@
     ClippingShapeEnd clipEnd;
 
 private:
+    // Folds a shape's fills into m_path, preferring post-effect paths. False
+    // when it contributed nothing.
+    bool addFillPaths(Shape* shape);
     ShapePaintPath m_path;
     ShapePaintPath* m_clipPath = nullptr;
 };
diff --git a/include/rive/shapes/paint/effects_container.hpp b/include/rive/shapes/paint/effects_container.hpp
index 07c29cb..1ff4fae 100644
--- a/include/rive/shapes/paint/effects_container.hpp
+++ b/include/rive/shapes/paint/effects_container.hpp
@@ -20,6 +20,7 @@
     virtual void invalidateEffects(StrokeEffect* invalidatingEffect);
     virtual void invalidateEffects();
     ShapePaintPath* lastEffectPath(PathProvider*);
+    bool hasEffects() const { return !m_effects.empty(); }
 #ifdef TESTING
     StrokeEffect* effect()
     {
diff --git a/include/rive/shapes/shape_paint_container.hpp b/include/rive/shapes/shape_paint_container.hpp
index 6867bdf..8174b34 100644
--- a/include/rive/shapes/shape_paint_container.hpp
+++ b/include/rive/shapes/shape_paint_container.hpp
@@ -44,12 +44,10 @@
 
     virtual const Mat2D& shapeWorldTransform() const = 0;
 
-#ifdef TESTING
     const std::vector<ShapePaint*>& shapePaints() const
     {
         return m_ShapePaints;
     }
-#endif
 
     virtual ShapePaintPath* worldPath() { return nullptr; }
     virtual ShapePaintPath* localPath() { return nullptr; }
diff --git a/src/shapes/clipping_shape.cpp b/src/shapes/clipping_shape.cpp
index 413cb7c..4b4e383 100644
--- a/src/shapes/clipping_shape.cpp
+++ b/src/shapes/clipping_shape.cpp
@@ -4,6 +4,7 @@
 #include "rive/factory.hpp"
 #include "rive/node.hpp"
 #include "rive/renderer.hpp"
+#include "rive/shapes/paint/fill.hpp"
 #include "rive/shapes/path_composer.hpp"
 #include "rive/shapes/shape.hpp"
 
@@ -142,12 +143,61 @@
     for (auto shape : m_Shapes)
     {
         shape->pathComposer()->addDependent(this);
+        // We read what a fill resolves in its own update, so we sort after it.
+        for (auto paint : shape->shapePaints())
+        {
+            if (paint->is<Fill>() && paint->hasEffects())
+            {
+                paint->addDependent(this);
+            }
+        }
     }
     clipStart.clippingShape(this);
     clipEnd.clippingShape(this);
 }
 
 static Mat2D identity;
+
+// Strokes are skipped: their effect output is a centerline, meaningless filled.
+bool ClippingShape::addFillPaths(Shape* shape)
+{
+    bool addedEffected = false;
+    bool needsRawPath = false;
+    for (auto paint : shape->shapePaints())
+    {
+        if (!paint->is<Fill>())
+        {
+            continue;
+        }
+        auto effected =
+            paint->hasEffects() ? paint->lastEffectPath(paint) : nullptr;
+        if (effected == nullptr)
+        {
+            // No effect: this fill draws the whole shape.
+            needsRawPath = true;
+            continue;
+        }
+        if (!effected->empty())
+        {
+            const Mat2D& world = shape->worldTransform();
+            m_path.addPath(effected, effected->isLocal() ? &world : &identity);
+        }
+        addedEffected = true;
+    }
+
+    if (addedEffected && !needsRawPath)
+    {
+        return true;
+    }
+    auto path = shape->pathComposer()->worldPath();
+    if (path == nullptr)
+    {
+        return addedEffected;
+    }
+    m_path.addPath(path, &identity);
+    return true;
+}
+
 void ClippingShape::update(ComponentDirt value)
 {
     if (hasDirt(value,
@@ -158,14 +208,8 @@
         m_clipPath = nullptr;
         for (auto shape : m_Shapes)
         {
-            if (!shape->isEmpty())
+            if (!shape->isEmpty() && addFillPaths(shape))
             {
-                auto path = shape->pathComposer()->worldPath();
-                if (path == nullptr)
-                {
-                    continue;
-                }
-                m_path.addPath(path, &identity);
                 m_clipPath = &m_path;
             }
         }
diff --git a/src/shapes/paint/shape_paint.cpp b/src/shapes/paint/shape_paint.cpp
index 6975025..42b8e13 100644
--- a/src/shapes/paint/shape_paint.cpp
+++ b/src/shapes/paint/shape_paint.cpp
@@ -202,7 +202,7 @@
 
 void ShapePaint::invalidateEffects() { invalidateEffects(nullptr); }
 
-void ShapePaint::invalidateRendering() { addDirt(ComponentDirt::Path); }
+void ShapePaint::invalidateRendering() { addDirt(ComponentDirt::Path, true); }
 
 void ShapePaint::addStrokeEffect(StrokeEffect* effect)
 {
diff --git a/tests/unit_tests/assets/scripted_path_effect_clip.riv b/tests/unit_tests/assets/scripted_path_effect_clip.riv
new file mode 100644
index 0000000..1d2971d
--- /dev/null
+++ b/tests/unit_tests/assets/scripted_path_effect_clip.riv
Binary files differ
diff --git a/tests/unit_tests/runtime/scripting/scripting_path_effect_test.cpp b/tests/unit_tests/runtime/scripting/scripting_path_effect_test.cpp
index 2cbd68d..8e1e1fb 100644
--- a/tests/unit_tests/runtime/scripting/scripting_path_effect_test.cpp
+++ b/tests/unit_tests/runtime/scripting/scripting_path_effect_test.cpp
@@ -29,3 +29,31 @@
 
     CHECK(silver.matches("reuse_path_in_effect"));
 }
+
+TEST_CASE("A clip follows the path effect on its source's fill", "[silver]")
+{
+    SerializingFactory silver;
+    auto file = ReadRiveFile("assets/scripted_path_effect_clip.riv", &silver);
+
+    auto artboard = file->artboardDefault();
+    silver.frameSize(artboard->width(), artboard->height());
+
+    auto stateMachine = artboard->stateMachineAt(0);
+    auto vmi = file->createDefaultViewModelInstance(artboard.get());
+
+    stateMachine->bindViewModelInstance(vmi);
+    auto renderer = silver.makeRenderer();
+    stateMachine->advanceAndApply(0.0f);
+    artboard->draw(renderer.get());
+
+    // Multiple frames: a single one passes even if the clip resolves the
+    // effect once and then freezes, which is how this broke in the editor.
+    for (int i = 0; i < 60; i++)
+    {
+        silver.addFrame();
+        stateMachine->advanceAndApply(1.0f / 60.0f);
+        artboard->draw(renderer.get());
+    }
+
+    CHECK(silver.matches("scripted_path_effect_clip"));
+}
diff --git a/tests/unit_tests/silvers/scripted_path_effect_clip.sriv b/tests/unit_tests/silvers/scripted_path_effect_clip.sriv
new file mode 100644
index 0000000..fde9e62
--- /dev/null
+++ b/tests/unit_tests/silvers/scripted_path_effect_clip.sriv
Binary files differ