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");