fix(runtime): measure a skinned path's pose winding exactly (#14480) 0b5fd6293b
fix(runtime): measure a skinned path's pose winding exactly, like the editor

feat(tests): choose the present mode, and report Artemis refresh (#14380) 1570bcffb4
* feat(tests): a --present mode option, and the Artemis window reports its present modes and measured refresh

* query the present modes safely, log the display refresh on its own, and say only Artemis reads the present mode

Co-authored-by: Luigi Rosso <luigi-rosso@users.noreply.github.com>
diff --git a/.rive_head b/.rive_head
index b699c18..53b26e1 100644
--- a/.rive_head
+++ b/.rive_head
@@ -1 +1 @@
-961a2accfaed7de1c9b486518baf81c36748462d
+0b5fd6293b7655e31839cbdb4cf6319fe898bdd8
diff --git a/include/rive/shapes/points_path.hpp b/include/rive/shapes/points_path.hpp
index 33b0ac2..278c261 100644
--- a/include/rive/shapes/points_path.hpp
+++ b/include/rive/shapes/points_path.hpp
@@ -21,8 +21,9 @@
 private:
     // Winding with the bones' mirroring divided out, 0 when unknown.
     int m_windingReference = 0;
-    // Winding of the path as bound, 0 when it has no area there.
-    int bindWinding();
+    // 1 when the vertices wind clockwise, -1 the other way, 0 when they enclose
+    // too little area to tell. Reads where the bones put them when [deformed].
+    int measureWinding(bool deformed);
 };
 } // namespace rive
 
diff --git a/src/shapes/points_path.cpp b/src/shapes/points_path.cpp
index 31ae0fa..795955f 100644
--- a/src/shapes/points_path.cpp
+++ b/src/shapes/points_path.cpp
@@ -57,41 +57,20 @@
 
 void PointsPath::markSkinDirty() { Super::markPathDirty(); }
 
-namespace
-{
-// 1 when [path] winds clockwise, -1 when it winds the other way, 0 when it has
-// too little area to tell.
-int measureWinding(const RawPath& path)
-{
-    AABB bounds = path.bounds();
-    float area = path.computeCoarseArea(bounds.center());
-    float extent = std::max(bounds.width(), bounds.height());
-    // The area sums products of the path's extent, so that is the scale of
-    // its rounding. A collapsed path has no winding worth caching.
-    if (std::abs(area) <= 1e-5f * extent * extent)
-    {
-        return 0;
-    }
-    return area < 0 ? -1 : 1;
-}
-} // namespace
-
-// With every bone at its bind transform the tendons' inverse binds cancel,
-// leaving each point at the bind transform applied to it. That map is affine,
-// so it scales the local area by its determinant.
-int PointsPath::bindWinding()
+int PointsPath::measureWinding(bool deformed)
 {
     const auto& points = m_Vertices;
     size_t count = points.size();
-    const Mat2D& bind = skin()->bindTransform();
-    int orientation =
-        Skin::orientation(bind.xx(), bind.xy(), bind.yx(), bind.yy());
-    if (count < 2 || orientation == 0)
+    if (count < 2)
     {
         return 0;
     }
+    auto at = [deformed](PathVertex* vertex) {
+        return deformed ? vertex->renderTranslation()
+                        : Vec2D(vertex->x(), vertex->y());
+    };
     // Measured from the first point, so an open path closes on it for free.
-    Vec2D origin(points[0]->x(), points[0]->y());
+    Vec2D origin = at(points[0]);
     Vec2D p0(0, 0);
     float area = 0, minX = 0, minY = 0, maxX = 0, maxY = 0;
     size_t segments = isPathClosed() ? count : count - 1;
@@ -99,13 +78,18 @@
     {
         auto from = points[i];
         auto to = points[(i + 1) % count];
-        Vec2D p3 = Vec2D(to->x(), to->y()) - origin;
-        Vec2D p1 = from->is<CubicVertex>()
-                       ? from->as<CubicVertex>()->outPoint() - origin
-                       : p0;
-        Vec2D p2 = to->is<CubicVertex>()
-                       ? to->as<CubicVertex>()->inPoint() - origin
-                       : p3;
+        Vec2D p3 = at(to) - origin;
+        Vec2D p1 = p0, p2 = p3;
+        if (from->is<CubicVertex>())
+        {
+            auto cubic = from->as<CubicVertex>();
+            p1 = (deformed ? cubic->renderOut() : cubic->outPoint()) - origin;
+        }
+        if (to->is<CubicVertex>())
+        {
+            auto cubic = to->as<CubicVertex>();
+            p2 = (deformed ? cubic->renderIn() : cubic->inPoint()) - origin;
+        }
         // Exact area of a cubic, a line being one with its handles on its ends.
         area += 6 * Vec2D::cross(p0, p1) + 3 * Vec2D::cross(p0, p2) +
                 Vec2D::cross(p0, p3) + 3 * Vec2D::cross(p1, p2) +
@@ -121,12 +105,13 @@
     }
     area /= 20;
     float extent = std::max(maxX - minX, maxY - minY);
-    // Same tolerance as measureWinding.
+    // The area sums products of the path's extent, so that is the scale of
+    // its rounding. A collapsed path has no winding worth caching.
     if (std::abs(area) <= 1e-5f * extent * extent)
     {
         return 0;
     }
-    return (area < 0 ? -1 : 1) * orientation;
+    return area < 0 ? -1 : 1;
 }
 
 // Taken from the path as bound, then follows the bones' mirroring. Bones that
@@ -141,7 +126,13 @@
     }
     if (m_windingReference == 0)
     {
-        m_windingReference = bindWinding();
+        // With every bone at its bind transform the inverse binds cancel,
+        // leaving the local points under an affine map that only flips their
+        // winding when it mirrors.
+        const Mat2D& bind = skin()->bindTransform();
+        m_windingReference =
+            measureWinding(false) *
+            Skin::orientation(bind.xx(), bind.xy(), bind.yx(), bind.yy());
     }
     int sign = skin()->windingSign();
     if (sign != 0 && m_windingReference != 0)
@@ -150,7 +141,7 @@
     }
     // Mixed or collapsed bones leave no mirroring to follow, and a path bound
     // with no area has no reference: measure the pose itself.
-    int measured = measureWinding(rawPath());
+    int measured = measureWinding(true);
     if (measured == 0)
     {
         return authored;
diff --git a/tests/common/testing_window.hpp b/tests/common/testing_window.hpp
index fd0387b..3810eff 100644
--- a/tests/common/testing_window.hpp
+++ b/tests/common/testing_window.hpp
@@ -138,6 +138,19 @@
             ANGLERenderer::vk;
 #endif
         std::string gpuNameFilter;
+
+        // How a window presents: fifo waits for vblank, relaxed tears when
+        // late, mailbox and immediate run uncapped. Only the Artemis window
+        // reads this today, and it falls back to fifo when the surface lacks
+        // the mode.
+        enum class PresentMode
+        {
+            fifo,
+            fifoRelaxed,
+            mailbox,
+            immediate,
+        };
+        PresentMode presentMode = PresentMode::fifo;
     };
 
     enum class Visibility
diff --git a/tests/player/player.cpp b/tests/player/player.cpp
index 65f5243..bab6b9a 100644
--- a/tests/player/player.cpp
+++ b/tests/player/player.cpp
@@ -7,6 +7,7 @@
 
 #include "player.hpp"
 
+#include <optional>
 #include <sstream>
 #include "common/test_harness.hpp"
 #include "common/testing_window.hpp"
@@ -279,6 +280,8 @@
                        FrameRunner::LaunchOptions& options)
 {
     bool onlyUbershaders = false;
+    // Applied after parsing, since --backend resets the backend params.
+    std::optional<TestingWindow::BackendParams::PresentMode> presentMode;
 
     for (int i = 1; i < argc; ++i)
     {
@@ -331,6 +334,35 @@
                     : strcmp(fit, "fill") == 0  ? rive::Fit::fill
                                                 : rive::Fit::contain;
         }
+        else if (strcmp(argv[i], "--present") == 0 && i + 1 < argc)
+        {
+            using PM = TestingWindow::BackendParams::PresentMode;
+            const char* mode = argv[++i];
+            if (strcmp(mode, "fifo") == 0)
+            {
+                presentMode = PM::fifo;
+            }
+            else if (strcmp(mode, "relaxed") == 0)
+            {
+                presentMode = PM::fifoRelaxed;
+            }
+            else if (strcmp(mode, "mailbox") == 0)
+            {
+                presentMode = PM::mailbox;
+            }
+            else if (strcmp(mode, "immediate") == 0)
+            {
+                presentMode = PM::immediate;
+            }
+            else
+            {
+                fprintf(stderr,
+                        "--present must be fifo, relaxed, mailbox or "
+                        "immediate, not %s\n",
+                        mode);
+                abort();
+            }
+        }
         else if (strcmp(argv[i], "--threaded") == 0)
         {
             m_threaded = true;
@@ -370,6 +402,10 @@
         }
     }
 
+    if (presentMode)
+    {
+        options.backendParams.presentMode = *presentMode;
+    }
     if (onlyUbershaders)
     {
         options.backendParams.shaderCompilationMode =
diff --git a/tests/unit_tests/runtime/skinned_winding_test.cpp b/tests/unit_tests/runtime/skinned_winding_test.cpp
index 9b6aa02..ba7baca 100644
--- a/tests/unit_tests/runtime/skinned_winding_test.cpp
+++ b/tests/unit_tests/runtime/skinned_winding_test.cpp
@@ -355,6 +355,21 @@
     }
 }
 
+// Folding the lens and mirroring only its top bone leaves the pose to be
+// measured, at a size where a pixel tolerance would only see the chords.
+TEST_CASE("a small curved path measures its pose from its handles",
+          "[skinwinding]")
+{
+    for (bool clockwise : {true, false})
+    {
+        QuadRig rig(clockwise, 8.0f, true, 4.0f);
+        rig.scale(rig.top, 1.0f, -1.0f);
+        rig.artboard.advance(0.0f);
+        CHECK(rig.skin->windingSign() == 0);
+        CHECK(rig.path->winding() == (clockwise ? -1 : 1));
+    }
+}
+
 TEST_CASE("a real rig stays clockwise when its bones mirror", "[skinwinding]")
 {
     auto file = ReadRiveFile("assets/zombie_skins.riv");