skia2/samplecode/SampleFitCubicToCircle.cpp
Chris Dalton 392fa03a1b Add an interactive sample that fits cubics to circular arcs
Fits a cubic to an arc on the unit circle using the following
constraints:

  1) The endpoints and tangent directions at the endpoints must match
     the arc.
  2) The cubic must be symmetric (length(p1 - p0) == length(p3 - p2)).
  3) The height of the cubic must match the height of the arc.

Using the above constraints, we arrive at the following formula for the
control length (c) (i.e., lengths of (p1 - p0) and (p3 - p2)):

  d = x0*x1 + y0*y1
  c = (sqrt(1 + d) * kM + kA) * rsqrt(1 - d)

(Where x0,y0,x1,y1 are the endpoints, kM = -4/3, and kA = 4/3 * sqrt(2).)

The sample also comes with a root finder that finds the max error value
of the curve, which ends up serving as a semi-formal proof that:

  1) Max error always occurs at T=0.21132486540519, regardless of
     endpoints.
  2) Error is reduced by exactly 64x each time we halve the arc angle

Change-Id: I29ad60dda06743d79f71275c38ffdd97261159ac
Reviewed-on: https://skia-review.googlesource.com/c/skia/+/295303
Commit-Queue: Chris Dalton <csmartdalton@google.com>
Reviewed-by: Mike Reed <reed@google.com>
2020-06-09 23:04:03 +00:00

258 lines
9.6 KiB
C++

/*
* Copyright 2020 Google Inc.
*
* Use of this source code is governed by a BSD-style license that can be
* found in the LICENSE file.
*/
#include "samplecode/Sample.h"
#include "include/core/SkCanvas.h"
#include "include/core/SkFont.h"
#include "include/core/SkPaint.h"
#include "include/core/SkPath.h"
#include <tuple>
// Math constants are not always defined.
#ifndef M_PI
#define M_PI 3.14159265358979323846264338327950288
#endif
#ifndef M_SQRT2
#define M_SQRT2 1.41421356237309504880168872420969808
#endif
constexpr static int kCenterX = 300;
constexpr static int kCenterY = 325;
constexpr static int kRadius = 250;
// This sample fits a cubic to the arc between two interactive points on a circle. It also finds the
// T-coordinate of max error, and outputs it and its value in pixels. (It turns out that max error
// always occurs at T=0.21132486540519.)
//
// Press 'E' to iteratively cut the arc in half and report the improvement in max error after each
// halving. (It turns out that max error improves by exactly 64x on every halving.)
class SampleFitCubicToCircle : public Sample {
SkString name() override { return SkString("FitCubicToCircle"); }
void onOnceBeforeDraw() override { this->fitCubic(); }
void fitCubic();
void onDrawContent(SkCanvas*) override;
Sample::Click* onFindClickHandler(SkScalar x, SkScalar y, skui::ModifierKey) override;
bool onClick(Sample::Click*) override;
bool onChar(SkUnichar) override;
// Coordinates of two points on the unit circle. These are the two endpoints of the arc we fit.
double fEndptsX[2] = {0, 1};
double fEndptsY[2] = {-1, 0};
// Fitted cubic and info, set by fitCubic().
double fControlLength; // Length of (p1 - p0) and/or (p3 - p2) in unit circle space.
double fMaxErrorT; // T value where the cubic diverges most from the true arc.
std::array<double, 4> fCubicX; // Screen space cubic control points.
std::array<double, 4> fCubicY;
double fMaxError; // Max error (in pixels) between the cubic and the screen-space arc.
double fTheta; // Angle of the arc. This is only used for informational purposes.
SkTArray<SkString> fInfoStrings;
class Click;
};
// Fits a cubic to an arc on the unit circle with endpoints (x0, y0) and (x1, y1). Using the
// following 3 constraints, we arrive at the formula used in the method:
//
// 1) The endpoints and tangent directions at the endpoints must match the arc.
// 2) The cubic must be symmetric (i.e., length(p1 - p0) == length(p3 - p2)).
// 3) The height of the cubic must match the height of the arc.
//
// Returns the "control length", or length of (p1 - p0) and/or (p3 - p2).
static float fit_cubic_to_unit_circle(double x0, double y0, double x1, double y1,
std::array<double, 4>* X, std::array<double, 4>* Y) {
constexpr static double kM = -4.0/3;
constexpr static double kA = 4*M_SQRT2/3;
double d = x0*x1 + y0*y1;
double c = (std::sqrt(1 + d) * kM + kA) / std::sqrt(1 - d);
*X = {x0, x0 - y0*c, x1 + y1*c, x1};
*Y = {y0, y0 + x0*c, y1 - x1*c, y1};
return c;
}
static double lerp(double x, double y, double T) {
return x + T*(y - x);
}
// Evaluates the cubic and 1st and 2nd derivatives at T.
static std::tuple<double, double, double> eval_cubic(double x[], double T) {
// Use De Casteljau's algorithm for better accuracy and stability.
double ab = lerp(x[0], x[1], T);
double bc = lerp(x[1], x[2], T);
double cd = lerp(x[2], x[3], T);
double abc = lerp(ab, bc, T);
double bcd = lerp(bc, cd, T);
double abcd = lerp(abc, bcd, T);
return {abcd, 3 * (bcd - abc) /*1st derivative.*/, 6 * (cd - 2*bc + ab) /*2nd derivative.*/};
}
// Uses newton-raphson convergence to find the point where the provided cubic diverges most from the
// unit circle. i.e., the point where the derivative of error == 0. For error we use:
//
// error = x^2 + y^2 - 1
// error' = 2xx' + 2yy'
// error'' = 2xx'' + 2yy'' + 2x'^2 + 2y'^2
//
double find_max_error_T(double cubicX[4], double cubicY[4]) {
constexpr static double kInitialT = .25;
double T = kInitialT;
for (int i = 0; i < 64; ++i) {
auto [x, dx, ddx] = eval_cubic(cubicX, T);
auto [y, dy, ddy] = eval_cubic(cubicY, T);
double dError = 2*(x*dx + y*dy);
double ddError = 2*(x*ddx + y*ddy + dx*dx + dy*dy);
T -= dError / ddError;
}
return T;
}
void SampleFitCubicToCircle::fitCubic() {
fInfoStrings.reset();
std::array<double, 4> X, Y;
// "Control length" is the length of (p1 - p0) and/or (p3 - p2) in unit circle space.
fControlLength = fit_cubic_to_unit_circle(fEndptsX[0], fEndptsY[0], fEndptsX[1], fEndptsY[1],
&X, &Y);
fInfoStrings.push_back().printf("control length=%0.14f", fControlLength);
fMaxErrorT = find_max_error_T(X.data(), Y.data());
fInfoStrings.push_back().printf("max error T=%0.14f", fMaxErrorT);
for (int i = 0; i < 4; ++i) {
fCubicX[i] = X[i] * kRadius + kCenterX;
fCubicY[i] = Y[i] * kRadius + kCenterY;
}
double errX = std::get<0>(eval_cubic(fCubicX.data(), fMaxErrorT)) - kCenterX;
double errY = std::get<0>(eval_cubic(fCubicY.data(), fMaxErrorT)) - kCenterY;
fMaxError = std::sqrt(errX*errX + errY*errY) - kRadius;
fInfoStrings.push_back().printf("max error=%.5gpx", fMaxError);
fTheta = std::atan2(fEndptsY[1], fEndptsX[1]) - std::atan2(fEndptsY[0], fEndptsX[0]);
fTheta = std::abs(fTheta * 180/M_PI);
if (fTheta > 180) {
fTheta = 360 - fTheta;
}
fInfoStrings.push_back().printf("(theta=%.2f)", fTheta);
SkDebugf("\n");
for (const SkString& infoString : fInfoStrings) {
SkDebugf("%s\n", infoString.c_str());
}
}
void SampleFitCubicToCircle::onDrawContent(SkCanvas* canvas) {
canvas->clear(SK_ColorBLACK);
SkPaint circlePaint;
circlePaint.setColor(0x80ffffff);
circlePaint.setStyle(SkPaint::kStroke_Style);
circlePaint.setStrokeWidth(0);
circlePaint.setAntiAlias(true);
canvas->drawArc(SkRect::MakeXYWH(kCenterX - kRadius, kCenterY - kRadius, kRadius * 2,
kRadius * 2), 0, 360, false, circlePaint);
SkPaint cubicPaint;
cubicPaint.setColor(SK_ColorGREEN);
cubicPaint.setStyle(SkPaint::kStroke_Style);
cubicPaint.setStrokeWidth(10);
cubicPaint.setAntiAlias(true);
SkPath cubicPath;
cubicPath.moveTo(fCubicX[0], fCubicY[0]);
cubicPath.cubicTo(fCubicX[1], fCubicY[1], fCubicX[2], fCubicY[2], fCubicX[3], fCubicY[3]);
canvas->drawPath(cubicPath, cubicPaint);
SkPaint endpointsPaint;
endpointsPaint.setColor(SK_ColorBLUE);
endpointsPaint.setStrokeWidth(8);
endpointsPaint.setAntiAlias(true);
SkPoint points[2] = {{(float)fCubicX[0], (float)fCubicY[0]},
{(float)fCubicX[3], (float)fCubicY[3]}};
canvas->drawPoints(SkCanvas::kPoints_PointMode, 2, points, endpointsPaint);
SkPaint textPaint;
textPaint.setColor(SK_ColorWHITE);
constexpr static float kInfoTextSize = 16;
SkFont font(nullptr, kInfoTextSize);
int infoY = 10 + kInfoTextSize;
for (const SkString& infoString : fInfoStrings) {
canvas->drawString(infoString.c_str(), 10, infoY, font, textPaint);
infoY += kInfoTextSize * 3/2;
}
}
class SampleFitCubicToCircle::Click : public Sample::Click {
public:
Click(int ptIdx) : fPtIdx(ptIdx) {}
void doClick(SampleFitCubicToCircle* that) {
double dx = fCurr.fX - kCenterX;
double dy = fCurr.fY - kCenterY;
double l = std::sqrt(dx*dx + dy*dy);
that->fEndptsX[fPtIdx] = dx/l;
that->fEndptsY[fPtIdx] = dy/l;
if (that->fEndptsX[0] * that->fEndptsY[1] - that->fEndptsY[0] * that->fEndptsX[1] < 0) {
std::swap(that->fEndptsX[0], that->fEndptsX[1]);
std::swap(that->fEndptsY[0], that->fEndptsY[1]);
fPtIdx = 1 - fPtIdx;
}
that->fitCubic();
}
private:
int fPtIdx;
};
Sample::Click* SampleFitCubicToCircle::onFindClickHandler(SkScalar x, SkScalar y,
skui::ModifierKey) {
double dx0 = x - fCubicX[0];
double dy0 = y - fCubicY[0];
double dx3 = x - fCubicX[3];
double dy3 = y - fCubicY[3];
if (dx0*dx0 + dy0*dy0 < dx3*dx3 + dy3*dy3) {
return new Click(0);
} else {
return new Click(1);
}
}
bool SampleFitCubicToCircle::onClick(Sample::Click* click) {
Click* myClick = (Click*)click;
myClick->doClick(this);
return true;
}
bool SampleFitCubicToCircle::onChar(SkUnichar unichar) {
if (unichar == 'E') {
constexpr static double kMaxErrorT = 0.21132486540519; // Always the same.
// Split the arc in half until error =~0, and report the improvement after each halving.
double lastError = -1;
for (double theta = fTheta; lastError != 0; theta /= 2) {
double rads = theta * M_PI/180;
std::array<double, 4> X, Y;
fit_cubic_to_unit_circle(1, 0, std::cos(rads), std::sin(rads), &X, &Y);
auto [x, dx, ddx] = eval_cubic(X.data(), kMaxErrorT);
auto [y, dy, ddy] = eval_cubic(Y.data(), kMaxErrorT);
double error = std::sqrt(x*x + y*y) * kRadius - kRadius;
if ((float)error <= 0) {
error = 0;
}
SkDebugf("%6.2f degrees: error= %10.5gpx", theta, error);
if (lastError > 0) {
SkDebugf(" (%17.14fx improvement)", lastError / error);
}
SkDebugf("\n");
lastError = error;
}
return true;
}
return false;
}
DEF_SAMPLE(return new SampleFitCubicToCircle;)