fix(runtime): keep coincident control points exact when trimming paths (#14474) dcd60ea7f5

Co-authored-by: Philip Chung <philterdesign@gmail.com>
diff --git a/.rive_head b/.rive_head
index 53b26e1..4e00c48 100644
--- a/.rive_head
+++ b/.rive_head
@@ -1 +1 @@
-0b5fd6293b7655e31839cbdb4cf6319fe898bdd8
+dcd60ea7f53e84aafd1b10001667d8946a2b8c85
diff --git a/include/rive/math/raw_path_utils.hpp b/include/rive/math/raw_path_utils.hpp
index 56aab47..f838786 100644
--- a/include/rive/math/raw_path_utils.hpp
+++ b/include/rive/math/raw_path_utils.hpp
@@ -46,9 +46,6 @@
 
 // Extract a subcurve from the curve (given start and end t-values)
 
-extern void quad_subdivide(const Vec2D src[3], float t, Vec2D dst[5]);
-extern void cubic_subdivide(const Vec2D src[4], float t, Vec2D dst[7]);
-
 extern void line_extract(const Vec2D src[2],
                          float startT,
                          float endT,
diff --git a/src/math/raw_path_utils.cpp b/src/math/raw_path_utils.cpp
index 3d11908..b36158c 100644
--- a/src/math/raw_path_utils.cpp
+++ b/src/math/raw_path_utils.cpp
@@ -2,41 +2,28 @@
  * Copyright 2022 Rive
  */
 
-#include "rive/math/math_types.hpp"
 #include "rive/math/raw_path_utils.hpp"
-#include <cmath>
+#include "rive/math/bezier_utils.hpp"
+#include <algorithm>
 
-// Extract subsets
-
-void rive::quad_subdivide(const rive::Vec2D src[3], float t, rive::Vec2D dst[5])
+// Exact at t=0, t=1 and when a == b, so coincident points survive a chop.
+static rive::Vec2D exact_lerp(rive::Vec2D a, rive::Vec2D b, float t)
 {
-    assert(t >= 0 && t <= 1);
-    auto ab = lerp(src[0], src[1], t);
-    auto bc = lerp(src[1], src[2], t);
-    dst[0] = src[0];
-    dst[1] = ab;
-    dst[2] = lerp(ab, bc, t);
-    dst[3] = bc;
-    dst[4] = src[2];
+    return t < 0.5f ? a + (b - a) * t : b - (b - a) * (1 - t);
 }
 
-void rive::cubic_subdivide(const rive::Vec2D src[4],
+static void quad_subdivide(const rive::Vec2D src[3],
                            float t,
-                           rive::Vec2D dst[7])
+                           rive::Vec2D dst[5])
 {
     assert(t >= 0 && t <= 1);
-    auto ab = lerp(src[0], src[1], t);
-    auto bc = lerp(src[1], src[2], t);
-    auto cd = lerp(src[2], src[3], t);
-    auto abc = lerp(ab, bc, t);
-    auto bcd = lerp(bc, cd, t);
+    auto ab = exact_lerp(src[0], src[1], t);
+    auto bc = exact_lerp(src[1], src[2], t);
     dst[0] = src[0];
     dst[1] = ab;
-    dst[2] = abc;
-    dst[3] = lerp(abc, bcd, t);
-    dst[4] = bcd;
-    dst[5] = cd;
-    dst[6] = src[3];
+    dst[2] = exact_lerp(ab, bc, t);
+    dst[3] = bc;
+    dst[4] = src[2];
 }
 
 void rive::line_extract(const rive::Vec2D src[2],
@@ -47,8 +34,8 @@
     assert(startT <= endT);
     assert(startT >= 0 && endT <= 1);
 
-    dst[0] = lerp(src[0], src[1], startT);
-    dst[1] = lerp(src[0], src[1], endT);
+    dst[0] = exact_lerp(src[0], src[1], startT);
+    dst[1] = exact_lerp(src[0], src[1], endT);
 }
 
 void rive::quad_extract(const rive::Vec2D src[3],
@@ -66,20 +53,20 @@
     }
     else if (startT == 0)
     {
-        rive::quad_subdivide(src, endT, tmp);
+        quad_subdivide(src, endT, tmp);
         std::copy(tmp, tmp + 3, dst);
     }
     else if (endT == 1)
     {
-        rive::quad_subdivide(src, startT, tmp);
+        quad_subdivide(src, startT, tmp);
         std::copy(tmp + 2, tmp + 5, dst);
     }
     else
     {
         assert(endT > 0);
-        rive::quad_subdivide(src, endT, tmp);
+        quad_subdivide(src, endT, tmp);
         rive::Vec2D tmp2[5];
-        rive::quad_subdivide(tmp, startT / endT, tmp2);
+        quad_subdivide(tmp, startT / endT, tmp2);
         std::copy(tmp2 + 2, tmp2 + 5, dst);
     }
 }
@@ -89,30 +76,7 @@
                          float endT,
                          rive::Vec2D dst[4])
 {
-    assert(startT <= endT);
-    assert(startT >= 0 && endT <= 1);
-
-    rive::Vec2D tmp[7];
-    if (startT == 0 && endT == 1)
-    {
-        std::copy(src, src + 4, dst);
-    }
-    else if (startT == 0)
-    {
-        rive::cubic_subdivide(src, endT, tmp);
-        std::copy(tmp, tmp + 4, dst);
-    }
-    else if (endT == 1)
-    {
-        rive::cubic_subdivide(src, startT, tmp);
-        std::copy(tmp + 3, tmp + 7, dst);
-    }
-    else
-    {
-        assert(endT > 0);
-        rive::cubic_subdivide(src, endT, tmp);
-        rive::Vec2D tmp2[7];
-        rive::cubic_subdivide(tmp, startT / endT, tmp2);
-        std::copy(tmp2 + 3, tmp2 + 7, dst);
-    }
+    rive::Vec2D tmp[10];
+    rive::math::chop_cubic_at(src, tmp, startT, endT);
+    std::copy(tmp + 3, tmp + 7, dst);
 }
diff --git a/tests/unit_tests/runtime/contour_measure_test.cpp b/tests/unit_tests/runtime/contour_measure_test.cpp
index 7788b25..9c2c7a2 100644
--- a/tests/unit_tests/runtime/contour_measure_test.cpp
+++ b/tests/unit_tests/runtime/contour_measure_test.cpp
@@ -199,3 +199,40 @@
     contour->getSegment(.0f, 168.389008f, &result, true);
     CHECK(math::nearly_equal(contour->length(), 168.389008f));
 }
+
+// A trim chop must keep P1 == P0 exact, or the stroker reads the fp32 residue
+// as the start tangent (fan + detached cap on a straight-vertex shoulder).
+TEST_CASE("trim keeps coincident control points", "[contourmeasure]")
+{
+    const Vec2D p0 = {244.19449f, 186.5583f};
+    const Vec2D p2 = {160.f, 90.f};
+    const Vec2D p3 = {330.f, 280.f};
+    for (int i = 1; i < 1000; ++i)
+    {
+        RawPath path;
+        path.move(p0);
+        path.cubic(p0, p2, p3);
+        ContourMeasureIter iter(&path);
+        auto cm = iter.next();
+        REQUIRE(cm != nullptr);
+
+        const float d = cm->length() * i / 1000.f;
+        RawPath head;
+        cm->getSegment(0, d, &head, true);
+        const Vec2D* hp = head.points().data();
+        REQUIRE(head.points().size() == 4);
+        CHECK(hp[1] == hp[0]);
+
+        // Mirror case: coincident end control points on the tail.
+        RawPath rpath;
+        rpath.move(p3);
+        rpath.cubic(p2, p0, p0);
+        ContourMeasureIter riter(&rpath);
+        auto rcm = riter.next();
+        RawPath tail;
+        rcm->getSegment(rcm->length() - d, rcm->length(), &tail, true);
+        const Vec2D* tp = tail.points().data();
+        REQUIRE(tail.points().size() == 4);
+        CHECK(tp[2] == tp[3]);
+    }
+}