fix(runtime): skinned winding follow ups for the bind pose (#14465) f848d2174f
* fix(runtime): bind pose winding in the Dart composer, reuse the bind path, rename to bindTransform

* fix(editor): measure the bind winding in bind space, as the runtime does

* fix(runtime): exact local bind winding on both sides, editor fallback parity, bindTransform in Dart

Co-authored-by: Luigi Rosso <luigi-rosso@users.noreply.github.com>
diff --git a/.rive_head b/.rive_head
index 1ad80e7..e0b2166 100644
--- a/.rive_head
+++ b/.rive_head
@@ -1 +1 @@
-e3c4939942893dab8d7b4f76bf9d2b736367e11c
+f848d2174f3418b750a40c9cb0d14d672ad04ac0
diff --git a/include/rive/bones/skin.hpp b/include/rive/bones/skin.hpp
index ff06b49..3294c38 100644
--- a/include/rive/bones/skin.hpp
+++ b/include/rive/bones/skin.hpp
@@ -34,7 +34,7 @@
     void deform(Span<Vertex*> vertices);
     // The skinnable's world transform when it was bound. With every bone at
     // its bind transform, deform maps a vertex through this alone.
-    const Mat2D& worldTransform() const { return m_WorldTransform; }
+    const Mat2D& bindTransform() const { return m_WorldTransform; }
     void onDirty(ComponentDirt dirt) override;
     void update(ComponentDirt value) override;
 #ifdef WITH_RIVE_EDITOR
@@ -61,6 +61,9 @@
     // 1 when no bone mirrors, -1 when every bone mirrors, 0 when they
     // disagree or none carries an orientation.
     int windingSign() const;
+    // 1 when a transform keeps orientation, -1 when it mirrors, 0 when it
+    // collapses.
+    static int orientation(float xx, float xy, float yx, float yy);
 
 #ifdef TESTING
     std::vector<Tendon*>& tendons() { return m_Tendons; }
diff --git a/src/bones/skin.cpp b/src/bones/skin.cpp
index bde292b..58e8897 100644
--- a/src/bones/skin.cpp
+++ b/src/bones/skin.cpp
@@ -94,27 +94,27 @@
 #else
         (void)tendon;
 #endif
-        float xxyy = transform[0] * transform[3];
-        float xyyx = transform[1] * transform[2];
-        float determinant = xxyy - xyyx;
-        // A collapsed bone carries no orientation, and its determinant is
-        // only zero up to the rounding of the two products.
-        if (std::abs(determinant) <= 1e-6f * (std::abs(xxyy) + std::abs(xyyx)))
-        {
-            continue;
-        }
-        if (determinant < 0)
-        {
-            anyMirrored = true;
-        }
-        else
-        {
-            anyUpright = true;
-        }
+        int sign =
+            orientation(transform[0], transform[1], transform[2], transform[3]);
+        anyMirrored |= sign < 0;
+        anyUpright |= sign > 0;
     }
     return anyMirrored == anyUpright ? 0 : (anyMirrored ? -1 : 1);
 }
 
+int Skin::orientation(float xx, float xy, float yx, float yy)
+{
+    float xxyy = xx * yy;
+    float xyyx = xy * yx;
+    float determinant = xxyy - xyyx;
+    // The determinant is only zero up to the rounding of the two products.
+    if (std::abs(determinant) <= 1e-6f * (std::abs(xxyy) + std::abs(xyyx)))
+    {
+        return 0;
+    }
+    return determinant < 0 ? -1 : 1;
+}
+
 void Skin::buildDependencies()
 {
     // depend on bones from tendons
diff --git a/src/shapes/points_path.cpp b/src/shapes/points_path.cpp
index fb14862..31ae0fa 100644
--- a/src/shapes/points_path.cpp
+++ b/src/shapes/points_path.cpp
@@ -76,42 +76,57 @@
 }
 } // namespace
 
-// With every bone at its bind transform the tendons' inverse binds cancel them,
-// so deform leaves each point at the skin's world transform applied to it.
-// Corner radii are left out, they round a corner without turning it around.
+// 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()
 {
     const auto& points = m_Vertices;
     size_t count = points.size();
-    if (count < 2)
+    const Mat2D& bind = skin()->bindTransform();
+    int orientation =
+        Skin::orientation(bind.xx(), bind.xy(), bind.yx(), bind.yy());
+    if (count < 2 || orientation == 0)
     {
         return 0;
     }
-    const Mat2D& world = skin()->worldTransform();
-    RawPath bound;
-    bound.move(world * Vec2D(points[0]->x(), points[0]->y()));
+    // Measured from the first point, so an open path closes on it for free.
+    Vec2D origin(points[0]->x(), points[0]->y());
+    Vec2D p0(0, 0);
+    float area = 0, minX = 0, minY = 0, maxX = 0, maxY = 0;
     size_t segments = isPathClosed() ? count : count - 1;
     for (size_t i = 0; i < segments; i++)
     {
         auto from = points[i];
         auto to = points[(i + 1) % count];
-        Vec2D end = world * Vec2D(to->x(), to->y());
-        if (from->is<CubicVertex>() || to->is<CubicVertex>())
+        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;
+        // 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) +
+                3 * Vec2D::cross(p1, p3) + 6 * Vec2D::cross(p2, p3);
+        for (Vec2D p : {p1, p2, p3})
         {
-            Vec2D out = from->is<CubicVertex>()
-                            ? from->as<CubicVertex>()->outPoint()
-                            : Vec2D(from->x(), from->y());
-            Vec2D in = to->is<CubicVertex>() ? to->as<CubicVertex>()->inPoint()
-                                             : Vec2D(to->x(), to->y());
-            bound.cubic(world * out, world * in, end);
+            minX = std::min(minX, p.x);
+            minY = std::min(minY, p.y);
+            maxX = std::max(maxX, p.x);
+            maxY = std::max(maxY, p.y);
         }
-        else
-        {
-            bound.line(end);
-        }
+        p0 = p3;
     }
-    bound.close();
-    return measureWinding(bound);
+    area /= 20;
+    float extent = std::max(maxX - minX, maxY - minY);
+    // Same tolerance as measureWinding.
+    if (std::abs(area) <= 1e-5f * extent * extent)
+    {
+        return 0;
+    }
+    return (area < 0 ? -1 : 1) * orientation;
 }
 
 // Taken from the path as bound, then follows the bones' mirroring. Bones that
diff --git a/tests/unit_tests/runtime/skinned_winding_test.cpp b/tests/unit_tests/runtime/skinned_winding_test.cpp
index cdb224f..9b6aa02 100644
--- a/tests/unit_tests/runtime/skinned_winding_test.cpp
+++ b/tests/unit_tests/runtime/skinned_winding_test.cpp
@@ -1,10 +1,13 @@
 #include <rive/artboard.hpp>
 #include <rive/bones/bone.hpp>
+#include <rive/bones/cubic_weight.hpp>
 #include <rive/bones/root_bone.hpp>
 #include <rive/bones/skin.hpp>
 #include <rive/bones/tendon.hpp>
 #include <rive/bones/weight.hpp>
 #include <rive/file.hpp>
+#include <rive/math/math_types.hpp>
+#include <rive/shapes/cubic_detached_vertex.hpp>
 #include <rive/shapes/paint/fill.hpp>
 #include <rive/shapes/paint/solid_color.hpp>
 #include <rive/shapes/points_path.hpp>
@@ -18,8 +21,10 @@
 
 namespace
 {
-// A 100x100 quad with a clockwise fill, its top edge bound to one root bone
-// and its bottom edge to another.
+// A [size] wide square quad with a clockwise fill, its top edge bound to one
+// root bone and its bottom edge to another. A lens spans the same box with two
+// curved points on the bottom bone, so only its handles, one pair per bone,
+// give it area.
 struct QuadRig
 {
     rive::NoOpFactory factory;
@@ -29,11 +34,14 @@
     rive::Shape* shape = new rive::Shape();
     rive::PointsPath* path = new rive::PointsPath();
     rive::Skin* skin = new rive::Skin();
-    std::vector<rive::StraightVertex*> vertices;
+    std::vector<rive::PathVertex*> vertices;
 
     // The top bone is bound at y = 0 but starts at [topStartY], so a value
     // past the bottom bone loads the path folded inside out.
-    QuadRig(bool clockwise, float topStartY = 0.0f)
+    QuadRig(bool clockwise,
+            float topStartY = 0.0f,
+            bool lens = false,
+            float size = 100.0f)
     {
         artboard.addObject(&artboard);
         auto add = [&](rive::Component* component, rive::Core* parent) {
@@ -41,7 +49,7 @@
             component->parentId(artboard.idOf(parent));
         };
         add(top, &artboard);
-        bottom->y(100.0f);
+        bottom->y(size);
         add(bottom, &artboard);
         add(shape, &artboard);
         auto fill = new rive::Fill();
@@ -55,13 +63,36 @@
         }
         add(path, shape);
 
+        // The first point's out handle and the second's in handle sit on the
+        // top edge, the other two on the bottom edge.
+        for (int i = 0; lens && i < 2; i++)
+        {
+            auto vertex = new rive::CubicDetachedVertex();
+            vertex->x((i == 0) == clockwise ? 0.0f : size);
+            vertex->y(size / 2);
+            vertex->outRotation((i == 0 ? -0.5f : 0.5f) * rive::math::PI);
+            vertex->inRotation((i == 0 ? 0.5f : -0.5f) * rive::math::PI);
+            vertex->outDistance(size / 2);
+            vertex->inDistance(size / 2);
+            add(vertex, path);
+            auto weight = new rive::CubicWeight();
+            weight->values(255);
+            weight->indices(2);
+            weight->outValues(255);
+            weight->outIndices(i == 0 ? 1 : 2);
+            weight->inValues(255);
+            weight->inIndices(i == 0 ? 2 : 1);
+            add(weight, vertex);
+            vertices.push_back(vertex);
+        }
+
         float corners[4][2] = {{0, 0}, {100, 0}, {100, 100}, {0, 100}};
-        for (int i = 0; i < 4; i++)
+        for (int i = 0; !lens && i < 4; i++)
         {
             auto corner = corners[clockwise ? i : 3 - i];
             auto vertex = new rive::StraightVertex();
-            vertex->x(corner[0]);
-            vertex->y(corner[1]);
+            vertex->x(corner[0] * size / 100);
+            vertex->y(corner[1] * size / 100);
             add(vertex, path);
             auto weight = new rive::Weight();
             weight->values(255);
@@ -254,9 +285,8 @@
 }
 
 // Pins a known limitation so that changing it is deliberate: bones that fold
-// the path inside out without mirroring keep the winding measured before.
-TEST_CASE("a fold without mirroring keeps the measured winding",
-          "[skinwinding]")
+// the path inside out without mirroring keep the winding it was bound with.
+TEST_CASE("a fold without mirroring keeps the bound winding", "[skinwinding]")
 {
     QuadRig rig(true);
     CHECK(rig.composedArea() > 0);
@@ -288,6 +318,43 @@
     }
 }
 
+// Its straight edges would enclose nothing, so a bind pose that dropped the
+// handles would fall back to caching the folded pose.
+TEST_CASE("a curved path takes its bound winding from its handles",
+          "[skinwinding]")
+{
+    for (bool clockwise : {true, false})
+    {
+        QuadRig rig(clockwise, 200.0f, true);
+        CHECK(rig.composedArea() < 0);
+        CHECK((rig.deformedArea() < 0) == clockwise);
+
+        rig.top->y(0.0f);
+        CHECK(rig.composedArea() > 0);
+        CHECK((rig.deformedArea() > 0) == clockwise);
+    }
+}
+
+// Small enough that a measure linearizing curves by pixel tolerance would only
+// see the chords, which enclose nothing.
+TEST_CASE("a small curved path follows mirroring from its handles",
+          "[skinwinding]")
+{
+    for (bool clockwise : {true, false})
+    {
+        QuadRig rig(clockwise, 0.0f, true, 8.0f);
+        int authored = clockwise ? 1 : -1;
+        rig.artboard.advance(0.0f);
+        CHECK(rig.path->winding() == authored);
+
+        rig.scale(rig.top, -1.0f, 1.0f);
+        rig.scale(rig.bottom, -1.0f, 1.0f);
+        rig.artboard.advance(0.0f);
+        CHECK(rig.skin->windingSign() == -1);
+        CHECK(rig.path->winding() == -authored);
+    }
+}
+
 TEST_CASE("a real rig stays clockwise when its bones mirror", "[skinwinding]")
 {
     auto file = ReadRiveFile("assets/zombie_skins.riv");