RawPath
diff --git a/include/rive/math/raw_path.hpp b/include/rive/math/raw_path.hpp
new file mode 100644
index 0000000..2ac9709
--- /dev/null
+++ b/include/rive/math/raw_path.hpp
@@ -0,0 +1,83 @@
+/*
+ * Copyright 2022 Rive
+ */
+
+#ifndef _RIVE_RAW_PATH_HPP_
+#define _RIVE_RAW_PATH_HPP_
+
+#include "rive/span.hpp"
+#include "rive/math/aabb.hpp"
+#include "rive/math/mat2d.hpp"
+#include "rive/math/vec2d.hpp"
+
+#include <cmath>
+#include <stdio.h>
+#include <cstdint>
+#include <vector>
+
+namespace rive {
+
+enum class PathDirection {
+    cw,
+    ccw,
+    // aliases
+    clockwise = cw,
+    counterclockwise = ccw,
+};
+
+enum class PathVerb : uint8_t {
+    move,
+    line,
+    quad,
+    conic_unused,   // so we match skia's order
+    cubic,
+    close,
+};
+
+class RawPath {
+public:
+    std::vector<Vec2D> m_Points;
+    std::vector<PathVerb> m_Verbs;
+    
+    RawPath() {}
+    ~RawPath() {}
+
+    bool empty() const { return m_Points.empty(); }
+    AABB bounds() const;
+    
+    void move(Vec2D);
+    void line(Vec2D);
+    void quad(Vec2D, Vec2D);
+    void cubic(Vec2D, Vec2D, Vec2D);
+    void close();
+    
+    RawPath transform(const Mat2D&) const;
+    void transformInPlace(const Mat2D&);
+    
+    Span<const Vec2D> points() const { return toSpan(m_Points); }
+    Span<Vec2D> points() { return toSpan(m_Points); }
+
+    Span<const PathVerb> verbs() const { return toSpan(m_Verbs); }
+    Span<PathVerb> verbs() { return toSpan(m_Verbs); }
+
+    // Syntactic sugar for x,y -vs- vec2d
+
+    void moveTo(float x, float y) { move({x, y}); }
+    void lineTo(float x, float y) { line({x, y}); }
+    void quadTo(float x, float y, float x1, float y1) {
+        quad({x, y}, {x1, y1});
+    }
+    void cubicTo(float x, float y, float x1, float y1, float x2, float y2) {
+        cubic({x, y}, {x1, y1}, {x2, y2});
+    }
+
+    // Helpers for adding new contours
+
+    void addRect(const AABB&, PathDirection = PathDirection::cw);
+    void addOval(const AABB&, PathDirection = PathDirection::cw);
+    void addPoly(Span<const Vec2D>, bool isClosed);
+};
+
+} // namespace rive
+
+#endif
diff --git a/include/rive/renderer.hpp b/include/rive/renderer.hpp
index adeff88..8c9a3f6 100644
--- a/include/rive/renderer.hpp
+++ b/include/rive/renderer.hpp
@@ -8,6 +8,7 @@
 #include "rive/span.hpp"
 #include "rive/math/aabb.hpp"
 #include "rive/math/mat2d.hpp"
+#include "rive/math/raw_path.hpp"
 #include "rive/shapes/paint/blend_mode.hpp"
 #include "rive/shapes/paint/stroke_cap.hpp"
 #include "rive/shapes/paint/stroke_join.hpp"
@@ -144,6 +145,11 @@
                    const AABB& content);
     };
 
+    // Returns a full-formed RenderPath -- can be treated as immutable
+    extern RenderPath* makeRenderPath(Span<const Vec2D> points,
+                                      Span<const uint8_t> verbs,
+                                      FillRule);
+
     extern RenderPath* makeRenderPath();
     extern RenderPaint* makeRenderPaint();
     extern RenderImage* makeRenderImage();
diff --git a/skia/renderer/include/skia_renderer.hpp b/skia/renderer/include/skia_renderer.hpp
index d7af5f7..0f63a40 100644
--- a/skia/renderer/include/skia_renderer.hpp
+++ b/skia/renderer/include/skia_renderer.hpp
@@ -14,6 +14,9 @@
         SkPath m_Path;
 
     public:
+        SkiaRenderPath() {}
+        SkiaRenderPath(SkPath&& path) : m_Path(std::move(path)) {}
+
         const SkPath& path() const { return m_Path; }
         void reset() override;
         void addRenderPath(RenderPath* path, const Mat2D& transform) override;
diff --git a/skia/renderer/include/to_skia.hpp b/skia/renderer/include/to_skia.hpp
index 6420106..28f3aab 100644
--- a/skia/renderer/include/to_skia.hpp
+++ b/skia/renderer/include/to_skia.hpp
@@ -32,6 +32,15 @@
             return SkTileMode::kClamp;
         }
 
+        static SkPathFillType convert(FillRule value) {
+            switch (value) {
+                case FillRule::evenOdd: return SkPathFillType::kEvenOdd;
+                case FillRule::nonZero: return SkPathFillType::kWinding;
+            }
+            assert(false);
+            return SkPathFillType::kWinding;
+        }
+
         static SkPaint::Cap convert(rive::StrokeCap cap) {
             switch (cap) {
                 case rive::StrokeCap::butt:
diff --git a/skia/renderer/src/skia_renderer.cpp b/skia/renderer/src/skia_renderer.cpp
index 4a8572f..b635126 100644
--- a/skia/renderer/src/skia_renderer.cpp
+++ b/skia/renderer/src/skia_renderer.cpp
@@ -54,14 +54,7 @@
 };
 
 void SkiaRenderPath::fillRule(FillRule value) {
-    switch (value) {
-        case FillRule::evenOdd:
-            m_Path.setFillType(SkPathFillType::kEvenOdd);
-            break;
-        case FillRule::nonZero:
-            m_Path.setFillType(SkPathFillType::kWinding);
-            break;
-    }
+    m_Path.setFillType(ToSkia::convert(value));
 }
 
 void SkiaRenderPath::reset() { m_Path.reset(); }
@@ -237,6 +230,14 @@
         return make_buffer(data);
     }
 
+    RenderPath* makeRenderPath(Span<const Vec2D> points, Span<const uint8_t> verbs,
+                               FillRule fillrule) {
+        return new SkiaRenderPath(SkPath::Make((const SkPoint*)points.data(), points.size(),
+                                               verbs.data(), verbs.size(),
+                                               nullptr, 0,  // conics
+                                               ToSkia::convert(fillrule), false));
+    }
+
     RenderPath* makeRenderPath() { return new SkiaRenderPath(); }
     RenderPaint* makeRenderPaint() { return new SkiaRenderPaint(); }
     RenderImage* makeRenderImage() { return new SkiaRenderImage(); }
diff --git a/src/math/raw_path.cpp b/src/math/raw_path.cpp
new file mode 100644
index 0000000..775b94b
--- /dev/null
+++ b/src/math/raw_path.cpp
@@ -0,0 +1,169 @@
+/*
+ * Copyright 2022 Rive
+ */
+
+#include "rive/math/raw_path.hpp"
+#include <cmath>
+
+using namespace rive;
+
+AABB RawPath::bounds() const {
+    if (this->empty()) {
+        return {0, 0, 0, 0};
+    }
+    
+    float l, t, r, b;
+    l = r = m_Points[0].x();
+    t = b = m_Points[0].y();
+    for (size_t i = 1; i < m_Points.size(); ++i) {
+        const float x = m_Points[i].x();
+        const float y = m_Points[i].y();
+        l = std::min(l, x);
+        r = std::max(r, x);
+        t = std::min(t, y);
+        b = std::max(b, y);
+    }
+    return {l, t, r, b};
+}
+
+void RawPath::move(Vec2D a) {
+    const auto n = m_Verbs.size();
+    if (n > 0 && m_Verbs[n-1] == PathVerb::move) {
+        m_Points[n-1] = a;  // replace previous move position
+    } else {
+        m_Points.push_back(a);
+        m_Verbs.push_back(PathVerb::move);
+    }
+}
+
+void RawPath::line(Vec2D a) {
+    m_Points.push_back(a);
+    m_Verbs.push_back(PathVerb::line);
+}
+
+void RawPath::quad(Vec2D a, Vec2D b) {
+    m_Points.push_back(a);
+    m_Points.push_back(b);
+    m_Verbs.push_back(PathVerb::quad);
+}
+
+void RawPath::cubic(Vec2D a, Vec2D b, Vec2D c) {
+    m_Points.push_back(a);
+    m_Points.push_back(b);
+    m_Points.push_back(c);
+    m_Verbs.push_back(PathVerb::cubic);
+}
+
+void RawPath::close() {
+    const auto n = m_Verbs.size();
+    if (n > 0 && m_Verbs[n-1] != PathVerb::close) {
+        m_Verbs.push_back(PathVerb::close);
+    }
+}
+
+RawPath RawPath::transform(const Mat2D& m) const {
+    RawPath path;
+
+    path.m_Verbs = m_Verbs;
+
+    path.m_Points.resize(m_Points.size());
+    for (size_t i = 0; i < m_Points.size(); ++i) {
+        const float x = m_Points[i].x();
+        const float y = m_Points[i].y();
+        path.m_Points[i] = {
+            m[0] * x + m[2] * y + m[4],
+            m[1] * x + m[3] * y + m[5],
+        };
+    }
+    return path;
+}
+
+void RawPath::transformInPlace(const Mat2D& m) {
+    for (auto& p : m_Points) {
+        const float x = p.x();
+        const float y = p.y();
+        p = {
+            m[0] * x + m[2] * y + m[4],
+            m[1] * x + m[3] * y + m[5],
+        };
+    }
+}
+
+void RawPath::addRect(const AABB& r, PathDirection dir) {
+    // We manually close the rectangle, in case we want to stroke
+    // this path. We also call close() so we get proper joins
+    // (and not caps).
+
+    m_Points.reserve(5);
+    m_Verbs.reserve(6);
+
+    moveTo(r.left(), r.top());
+    if (dir == PathDirection::clockwise) {
+        lineTo(r.right(), r.top());
+        lineTo(r.right(), r.bottom());
+        lineTo(r.left(), r.bottom());
+    } else {
+        lineTo(r.left(), r.bottom());
+        lineTo(r.right(), r.bottom());
+        lineTo(r.right(), r.top());
+    }
+    close();
+}
+
+void RawPath::addOval(const AABB& r, PathDirection dir) {
+    // see https://spencermortensen.com/articles/bezier-circle/
+    constexpr float C = 0.5519150244935105707435627f;
+    // precompute clockwise unit circle, starting and ending at {1, 0}
+    constexpr rive::Vec2D unit[] = {
+        { 1,  0}, { 1,  C}, { C,  1}, // quadrant 1 ( 4:30)
+        { 0,  1}, {-C,  1}, {-1,  C}, // quadrant 2 ( 7:30)
+        {-1,  0}, {-1, -C}, {-C, -1}, // quadrant 3 (10:30)
+        { 0, -1}, { C, -1}, { 1, -C}, // quadrant 4 ( 1:30)
+        { 1,  0},
+    };
+
+    const auto center = r.center();
+    const float dx = center.x();
+    const float dy = center.y();
+    const float sx = r.width() * 0.5f;
+    const float sy = r.height() * 0.5f;
+
+    auto map = [dx, dy, sx, sy](rive::Vec2D p) {
+        return rive::Vec2D(p.x() * sx + dx, p.y() * sy + dy);
+    };
+
+    m_Points.reserve(13);
+    m_Verbs.reserve(6);
+
+    if (dir == PathDirection::clockwise) {
+        move(map(unit[0]));
+        for (int i = 1; i <= 12; i += 3) {
+            cubic(map(unit[i+0]), map(unit[i+1]), map(unit[i+2]));
+        }
+    } else {
+        move(map(unit[12]));
+        for (int i = 11; i >= 0; i -= 3) {
+            cubic(map(unit[i-0]), map(unit[i-1]), map(unit[i-2]));
+        }
+    }
+    close();
+}
+
+void RawPath::addPoly(Span<const Vec2D> span, bool isClosed) {
+    if (span.size() == 0) {
+        return;
+    }
+    
+    // should we permit must moveTo() or just moveTo()/close() ?
+
+    m_Points.reserve(span.size() + isClosed);
+    m_Verbs.reserve(span.size() + isClosed);
+
+    move(span[0]);
+    for (size_t i = 1; i < span.size(); ++i) {
+        line(span[i]);
+    }
+    if (isClosed) {
+        close();
+    }
+}
diff --git a/test/raw_path_test.cpp b/test/raw_path_test.cpp
new file mode 100644
index 0000000..dc3fa85
--- /dev/null
+++ b/test/raw_path_test.cpp
@@ -0,0 +1,74 @@
+/*
+ * Copyright 2022 Rive
+ */
+
+#include <rive/math/aabb.hpp>
+#include <rive/math/raw_path.hpp>
+#include "no_op_renderer.hpp"
+
+#include <catch.hpp>
+#include <cstdio>
+
+using namespace rive;
+
+TEST_CASE("rawpath-basics", "[rawpath]") {
+    RawPath path;
+    
+    REQUIRE(path.empty());
+    REQUIRE(path.bounds() == AABB{0, 0, 0, 0});
+    
+    path.move({1, 2});
+    REQUIRE(!path.empty());
+    REQUIRE(path.bounds() == AABB{1, 2, 1, 2});
+    
+    path = RawPath();
+    REQUIRE(path.empty());
+    REQUIRE(path.bounds() == AABB{0, 0, 0, 0});
+
+    path.move({1, -2});
+    path.line({3, 4});
+    path.line({-1, 5});
+    REQUIRE(!path.empty());
+    REQUIRE(path.bounds() == AABB{-1, -2, 3, 5});
+}
+
+TEST_CASE("rawpath-add-helpers", "[rawpath]") {
+    RawPath path;
+    
+    path.addRect({1, 1, 5, 6});
+    REQUIRE(!path.empty());
+    REQUIRE(path.bounds() == AABB{1, 1, 5, 6});
+    REQUIRE(path.points().size() == 4);
+    REQUIRE(path.verbs().size() == 5);  // move, line, line, line, close
+
+    path = RawPath();
+    path.addOval({0, 0, 3, 6});
+    REQUIRE(!path.empty());
+    REQUIRE(path.bounds() == AABB{0, 0, 3, 6});
+    REQUIRE(path.points().size() == 13);
+    REQUIRE(path.verbs().size() == 6);  // move, cubic, cubic, cubic, cubic, close
+
+    const Vec2D pts[] = {
+        {1, 2}, {4, 5}, {3, 2}, {100, -100},
+    };
+    constexpr auto size = sizeof(pts) / sizeof(pts[0]);
+
+    for (auto isClosed : {false, true}) {
+        path = RawPath();
+        path.addPoly({pts, size}, isClosed);
+        REQUIRE(path.bounds() == AABB{1, -100, 100, 5});
+        REQUIRE(path.points().size() == size);
+        REQUIRE(path.verbs().size() == size + isClosed);
+        
+        for (size_t i = 0; i < size; ++i) {
+            REQUIRE(path.points()[i] == pts[i]);
+        }
+        REQUIRE(path.verbs()[0] == PathVerb::move);
+        for (size_t i = 1; i < size; ++i) {
+            REQUIRE(path.verbs()[i] == PathVerb::line);
+        }
+        if (isClosed) {
+            REQUIRE(path.verbs()[size] == PathVerb::close);
+        }
+    }
+}