use world bounds for coarse grained collision test

fixes #7286
I'm creating this PR mostly to align the way both runtimes behave, although the performance gain is a plus probably.
Duo and others have reported that this difference of two pixels between runtime and editor are critical to how they build their projects.

Diffs=
405b8ef90 use world bounds for coarse grained collision test (#7287)

Co-authored-by: hernan <hernan@rive.app>
diff --git a/.rive_head b/.rive_head
index 036d288..618f380 100644
--- a/.rive_head
+++ b/.rive_head
@@ -1 +1 @@
-3734d9bac071052acbcfdbec576ba9532bc84e3b
+405b8ef907d29cf480422b94d4717fdcdfad0824
diff --git a/include/rive/drawable_flag.hpp b/include/rive/drawable_flag.hpp
index 9f79a15..c21604d 100644
--- a/include/rive/drawable_flag.hpp
+++ b/include/rive/drawable_flag.hpp
@@ -20,6 +20,10 @@
 
     /// Whether this Component lets hit events pass through to components behind it
     Opaque = 1 << 3,
+
+    /// Whether the computed world bounds for a shape need to be recalculated
+    /// Using Clean instead of dirty so it doesn't need to be initialized to 1
+    WorldBoundsClean = 1 << 4,
 };
 RIVE_MAKE_ENUM_BITSET(DrawableFlag)
 } // namespace rive
diff --git a/include/rive/math/aabb.hpp b/include/rive/math/aabb.hpp
index 2eabd8c..6dda2ab 100644
--- a/include/rive/math/aabb.hpp
+++ b/include/rive/math/aabb.hpp
@@ -121,6 +121,8 @@
         return Vec2D(width() == 0.0f ? 0.0f : (point.x - left()) * 2.0f / width() - 1.0f,
                      (height() == 0.0f ? 0.0f : point.y - top()) * 2.0f / height() - 1.0f);
     }
+
+    bool contains(Vec2D position) const;
 };
 
 } // namespace rive
diff --git a/include/rive/shapes/shape.hpp b/include/rive/shapes/shape.hpp
index 3f2c686..830e877 100644
--- a/include/rive/shapes/shape.hpp
+++ b/include/rive/shapes/shape.hpp
@@ -5,6 +5,7 @@
 #include "rive/generated/shapes/shape_base.hpp"
 #include "rive/shapes/path_composer.hpp"
 #include "rive/shapes/shape_paint_container.hpp"
+#include "rive/drawable_flag.hpp"
 #include <vector>
 
 namespace rive
@@ -17,6 +18,7 @@
 private:
     PathComposer m_PathComposer;
     std::vector<Path*> m_Paths;
+    AABB m_WorldBounds;
 
     bool m_WantDifferencePath = false;
 
@@ -47,6 +49,18 @@
     bool isEmpty();
     void pathCollapseChanged();
 
+    AABB worldBounds()
+    {
+        if ((static_cast<DrawableFlag>(drawableFlags()) & DrawableFlag::WorldBoundsClean) !=
+            DrawableFlag::WorldBoundsClean)
+        {
+            drawableFlags(drawableFlags() |
+                          static_cast<unsigned short>(DrawableFlag::WorldBoundsClean));
+            m_WorldBounds = computeWorldBounds();
+        }
+        return m_WorldBounds;
+    }
+
     AABB computeWorldBounds(const Mat2D* xform = nullptr) const;
     AABB computeLocalBounds() const;
 };
diff --git a/src/animation/state_machine_instance.cpp b/src/animation/state_machine_instance.cpp
index 2400ced..dd3f785 100644
--- a/src/animation/state_machine_instance.cpp
+++ b/src/animation/state_machine_instance.cpp
@@ -418,15 +418,28 @@
     float hitRadius = 2;
     Vec2D previousPosition;
     std::vector<const StateMachineListener*> listeners;
-    HitResult processEvent(Vec2D position, ListenerType hitType, bool canHit) override
+
+    bool hitTest(Vec2D position) const
     {
+
         auto shape = m_component->as<Shape>();
+        auto worldBounds = shape->worldBounds();
+        if (!worldBounds.contains(position))
+        {
+            return false;
+        }
         auto hitArea = AABB(position.x - hitRadius,
                             position.y - hitRadius,
                             position.x + hitRadius,
                             position.y + hitRadius)
                            .round();
-        bool isOver = canHit ? shape->hitTest(hitArea) : false;
+        return shape->hitTest(hitArea);
+    }
+
+    HitResult processEvent(Vec2D position, ListenerType hitType, bool canHit) override
+    {
+        auto shape = m_component->as<Shape>();
+        bool isOver = canHit ? hitTest(position) : false;
         bool hoverChange = isHovered != isOver;
         isHovered = isOver;
         if (hoverChange && isHovered)
diff --git a/src/math/aabb.cpp b/src/math/aabb.cpp
index 23688fc..c6fa11a 100644
--- a/src/math/aabb.cpp
+++ b/src/math/aabb.cpp
@@ -80,3 +80,8 @@
     out.maxX = std::max(a.maxX, b.maxX);
     out.maxY = std::max(a.maxY, b.maxY);
 }
+
+bool AABB::contains(Vec2D point) const
+{
+    return point.x >= left() && point.x <= right() && point.y >= top() && point.y <= bottom();
+}
diff --git a/src/shapes/shape.cpp b/src/shapes/shape.cpp
index e37b474..157e378 100644
--- a/src/shapes/shape.cpp
+++ b/src/shapes/shape.cpp
@@ -70,6 +70,7 @@
 
 void Shape::pathChanged()
 {
+    drawableFlags(drawableFlags() & ~static_cast<unsigned short>(DrawableFlag::WorldBoundsClean));
     m_PathComposer.addDirt(ComponentDirt::Path, true);
     for (auto constraint : constraints())
     {
diff --git a/test/aabb_test.cpp b/test/aabb_test.cpp
index 0f06abe..2e380b3 100644
--- a/test/aabb_test.cpp
+++ b/test/aabb_test.cpp
@@ -49,4 +49,19 @@
     CHECK(AABB{nan, nan, nan, 10}.isEmptyOrNaN());
     CHECK(AABB{nan, nan, nan, nan}.isEmptyOrNaN());
 }
+
+TEST_CASE("AABB contains", "[AABB]")
+{
+    CHECK(AABB{0, 0, 100, 100}.contains(Vec2D(20, 20)));
+    CHECK(AABB{0, 0, 100, 100}.contains(Vec2D(0, 0)));
+    CHECK(AABB{0, 0, 100, 100}.contains(Vec2D(100, 100)));
+    CHECK(!AABB{0, 0, 100, 100}.contains(Vec2D(200, 200)));
+    CHECK(!AABB{0, 0, 100, 100}.contains(Vec2D(-200, -200)));
+    auto leftBoundary = 0.f;
+    auto rightBoundary = 100.f;
+    CHECK(!AABB{leftBoundary, 0, rightBoundary, 100.0}.contains(
+        Vec2D(leftBoundary - std::numeric_limits<float>::epsilon(), 50)));
+    CHECK(!AABB{leftBoundary, 0, rightBoundary, 100.0}.contains(
+        Vec2D(rightBoundary + rightBoundary * std::numeric_limits<float>::epsilon(), 50)));
+}
 } // namespace rive
diff --git a/test/hittest_test.cpp b/test/hittest_test.cpp
index cb4f8f8..87b1429 100644
--- a/test/hittest_test.cpp
+++ b/test/hittest_test.cpp
@@ -161,6 +161,11 @@
     // toggle changes value because it is not under an opaque nested artboard
     REQUIRE(secondGrayToggle->value() == true);
 
+    stateMachineInstance->pointerDown(rive::Vec2D(301.0f, 50.0f));
+    // toggle does not change because it is beyond the area of the square by 1 pixel
+    // And the 2px padding is unly used after the coarse grained test passes
+    REQUIRE(secondGrayToggle->value() == true);
+
     stateMachineInstance->pointerDown(rive::Vec2D(100.0f, 50.0f));
     // toggle does not change because it is under an opaque nested artboard
     REQUIRE(secondGrayToggle->value() == true);