InkCanvasForClass/InkCanvasForClassX/Libraries/PerfectFreehand.cs

641 lines
27 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Input;
using InkCanvasForClassX.Libraries;
using InkCanvasForClassX.Libraries.Stroke;
namespace InkCanvasForClassX.Libraries
{
/// <summary>
/// 提供對JS庫<c>steveruizok/perfect-freehand</c>的C#包裝
/// </summary>
public class PerfectFreehand {
private static double Average(double a, double b) {
return (a + b) / 2;
}
public static string ConvertVectorsToSVGPath(Vector[] points, bool closed = true)
{
int len = points.Length;
if (len < 4)
{
return string.Empty;
}
Vector a = points[0];
Vector b = points[1];
Vector c = points[2];
StringBuilder result = new StringBuilder();
result.AppendFormat("M{0:F2},{1:F2} Q{2:F2},{3:F2} {4:F2},{5:F2} T",
a.X, a.Y, b.X, b.Y, Average(b.X, c.X), Average(b.Y, c.Y));
for (int i = 2, max = len - 1; i < max; i++)
{
a = points[i];
b = points[i + 1];
result.AppendFormat("{0:F2},{1:F2} ", Average(a.X, b.X), Average(a.Y, b.Y));
}
if (closed)
{
result.Append("Z");
}
return result.ToString();
}
/// <summary>
/// Get an array of points as objects with an adjusted point, pressure, vector, distance, and runningLength.
/// </summary>
/// <param name="points">原注釋: An array of points (as `[x, y, pressure]` or `{x, y, pressure}`). Pressure is optional in both cases. 請使用<c>StylusPointCollection</c></param>
/// <param name="options">An object with options.</param>
/// <returns></returns>
public static StrokePoint[] GetStrokePoints(StylusPointCollection points,
StrokeOptions options) {
var streamline = options.Streamline ?? 0.5;
var size = options.Size ?? 16;
var isComplete = options.Last ?? false;
// If we don't have any points, return an empty array.
if (points.Count == 0) return Array.Empty<StrokePoint>();
// Find the interpolation level between points.
double t = 0.15 + (1 - streamline) * 0.85;
// Purify the StylusPointCollection
var pts = new StylusPointCollection();
foreach (var stylusPoint in points) {
pts.Add(new StylusPoint(stylusPoint.X, stylusPoint.Y, stylusPoint.PressureFactor));
}
// Add extra points between the two, to help avoid "dash" lines
// for strokes with tapered start and ends. Don't mutate the
// input array!
if (points.Count == 2) {
var last = pts[1];
pts.RemoveAt(pts.Count - 1);
for (var i = 1; i < 5; i++) {
var _vec = Vector.InterpolateVectors(
new Vector(pts[0].X, pts[0].Y),
new Vector(last.X, last.Y),
i / 4
);
pts.Add(new StylusPoint(_vec.X, _vec.Y));
}
}
// If there's only one point, add another point at a 1pt offset.
// Don't mutate the input array!
if (pts.Count == 1) {
var onePt = new Vector(pts[0].X + 1, pts[0].Y + 1);
pts.Add(new StylusPoint(onePt.X, onePt.Y, pts[0].PressureFactor));
}
// The strokePoints array will hold the points for the stroke.
// Start it out with the first point, which needs no adjustment.
var strokePoints = new List<StrokePoint>() {
new StrokePoint() {
Point = new Vector(pts[0].X, pts[0].Y),
Pressure = pts[0].PressureFactor >= 0 ? pts[0].PressureFactor : 0.25,
Vector = new Vector(1, 1),
Distance = 0,
RunningLength = 0,
}
};
// A flag to see whether we've already reached out minimum length
var hasReachedMinimumLength = false;
// We use the runningLength to keep track of the total distance
double runningLength = 0;
// We're set this to the latest point, so we can use it to calculate
// the distance and vector of the next point.
var prev = strokePoints[0];
// const max = pts.length - 1
var max = pts.Count - 1;
// Iterate through all of the points, creating StrokePoints.
for (var i = 1; i < pts.Count; i++) {
var point = isComplete && i == max
? // If we're at the last point, and `options.last` is true,
// then add the actual input point.
new Vector(pts[i].X, pts[i].Y)
: // Otherwise, using the t calculated from the streamline
// option, interpolate a new point between the previous
// point the current point.
Vector.InterpolateVectors(prev.Point, new Vector(pts[i].X, pts[i].Y), t);
// If the new point is the same as the previous point, skip ahead.
if (prev.Point.IsEqual(point)) continue;
// How far is the new point from the previous point?
var distance = Vector.DistLengthVectors(point, prev.Point);
// Add this distance to the total "running length" of the line.
runningLength += distance;
// At the start of the line, we wait until the new point is a
// certain distance away from the original point, to avoid noise
if (i < max && !hasReachedMinimumLength) {
if (runningLength < size) continue;
hasReachedMinimumLength = true;
// TODO: Backfill the missing points so that tapering works correctly.
}
// Create a new strokepoint (it will be the new "previous" one).
prev = new StrokePoint() {
// The adjusted point
Point = point,
// The input pressure (or .5 if not specified)
Pressure = pts[i].PressureFactor >= 0 ? pts[i].PressureFactor : 0.5,
// The vector from the current point to the previous point
Vector = Vector.UnitVector(Vector.SubtractVectors(prev.Point, point)),
// The distance between the current point and the previous point
Distance = distance,
// The total distance so far
RunningLength = runningLength,
};
// Push it to the strokePoints array.
strokePoints.Add(prev);
}
// Set the vector of the first point to be the same as the second point.
strokePoints[0].Vector = strokePoints[1]?.Vector ?? new Vector(0, 0);
return strokePoints.ToArray();
}
/// <summary>
/// Compute a radius based on the pressure.
/// </summary>
public static double GetStrokeRadius(
double size,
double thinning,
double pressure,
Func<double, double> easing = null)
{
if (easing == null) {
easing = t => t; // 默認的 easing 函數
}
return size * easing(0.5 - thinning * (0.5 - pressure));
}
// This is the rate of change for simulated pressure. It could be an option.
private const double RATE_OF_PRESSURE_CHANGE = 0.275;
private const double PI = Math.PI;
private const double FIXED_PI = PI + 0.0001;
/// <summary>
/// Get an array of points (as `[x, y]`) representing the outline of a stroke.
/// </summary>
/// <param name="points">An array of StrokePoints as returned from `getStrokePoints`.</param>
/// <param name="options">An object with options.</param>
/// <returns></returns>
public static Vector[] GetStrokeOutlinePointsVectors(StrokePoint[] points, StrokeOptions options) {
var strokeOptions_Size = options.Size ?? 16;
var strokeOptions_Thinning = options.Thinning ?? 0.5;
var strokeOptions_Smoothing = options.Smoothing ?? 0.5;
var strokeOptions_SimulatePressure = options.SimulatePressure ?? true;
Func<double,double> strokeOptions_Easing = (t) => t;
var strokeOptions_Start = options.Start;
var strokeOptions_End = options.End;
var isComplete = options.Last ?? false;
var capStart = strokeOptions_Start != null ? strokeOptions_Start.Cap : true;
Func<double, double> taperStartEase = strokeOptions_Start != null
? strokeOptions_Start.Easing
: (s) => s * (2 - s);
var capEnd = strokeOptions_End != null ? strokeOptions_End.Cap : true;
Func<double, double> taperEndEase = strokeOptions_End != null
? strokeOptions_End.Easing
: (t) => --t * t * t + 1;
// We can't do anything with an empty array or a stroke with negative size.
if (points.Length == 0 || strokeOptions_Size <= 0) {
return new Vector[] { };
}
// The total length of the line
var totalLength = points[points.Length - 1].RunningLength;
var taperStart = strokeOptions_Start != null ? strokeOptions_Start.IsTaper == false ? 0 :
strokeOptions_Start.IsTaper ? Math.Max(strokeOptions_Size, totalLength) :
strokeOptions_Start.Taper != null ? (double)strokeOptions_Start.Taper : 0 : Double.NaN;
var taperEnd = strokeOptions_End != null ? strokeOptions_End.IsTaper == false ? 0 :
strokeOptions_End.IsTaper ? Math.Max(strokeOptions_Size, totalLength) :
strokeOptions_End.Taper != null ? (double)strokeOptions_End.Taper : 0 : Double.NaN;
// The minimum allowed distance between points (squared)
var minDistance = Math.Pow(strokeOptions_Size * strokeOptions_Smoothing, 2);
// Our collected left and right points
var leftPts = new List<Vector>();
var rightPts = new List<Vector>();
// Previous pressure (start with average of first five pressures,
// in order to prevent fat starts for every line. Drawn lines
// almost always start slow!
var _prevPressure_arrseg = new ArraySegment<StrokePoint>(points, 0, 10);
var prevPressure = _prevPressure_arrseg.Aggregate(points[0].Pressure,
(acc, curr) => {
var pressure = curr.Pressure;
if (strokeOptions_SimulatePressure) {
// Speed of change - how fast should the the pressure changing?
var sp = Math.Min(1, curr.Distance / strokeOptions_Size);
// Rate of change - how much of a change is there?
var rp = Math.Min(1, 1 - sp);
pressure = Math.Min(1, acc + (rp - acc) * (sp * RATE_OF_PRESSURE_CHANGE));
}
return (acc + pressure) / 2;
});
// The current radius
var radius = GetStrokeRadius(strokeOptions_Size, strokeOptions_Thinning, points[points.Length - 1].Pressure,
strokeOptions_Easing);
// The radius of the first saved point
double firstRadius = Double.NaN;
// Previous vector
var prevVector = points[0].Vector;
// Previous left and right points
var pl = points[0].Point;
var pr = pl;
// Temporary left and right points
var tl = pl;
var tr = pr;
// Keep track of whether the previous point is a sharp corner
// ... so that we don't detect the same corner twice
var isPrevPointSharpCorner = false;
/*
Find the outline's left and right points
Iterating through the points and populate the rightPts and leftPts arrays,
skipping the first and last pointsm, which will get caps later on.
*/
foreach (var _sp in points) {
var pressure = _sp.Pressure;
var point = _sp.Point;
var vector = _sp.Vector;
var distance = _sp.Distance;
var runningLength = _sp.RunningLength;
var i = Array.IndexOf<StrokePoint>(points, _sp);
// Removes noise from the end of the line
if (i < points.Length - 1 && totalLength - runningLength < 3) {
continue;
}
/*
Calculate the radius
If not thinning, the current point's radius will be half the size; or
otherwise, the size will be based on the current (real or simulated)
pressure.
*/
if (strokeOptions_Thinning == Double.NaN) {
if (strokeOptions_SimulatePressure) {
// If we're simulating pressure, then do so based on the distance
// between the current point and the previous point, and the size
// of the stroke. Otherwise, use the input pressure.
var sp = Math.Min(1, distance / strokeOptions_Size);
var rp = Math.Min(1, 1 - sp);
pressure = Math.Min(1, prevPressure + (rp - prevPressure) * (sp * RATE_OF_PRESSURE_CHANGE));
}
radius = GetStrokeRadius(strokeOptions_Size, strokeOptions_Thinning, pressure,
strokeOptions_Easing);
} else {
radius = strokeOptions_Size / 2;
}
if (firstRadius == Double.NaN) firstRadius = radius;
/*
Apply tapering
If the current length is within the taper distance at either the
start or the end, calculate the taper strengths. Apply the smaller
of the two taper strengths to the radius.
*/
var ts = runningLength < taperStart ? taperStartEase(runningLength / taperStart) : 1;
var te = runningLength < taperEnd ? taperEndEase(runningLength / taperEnd) : 1;
radius = Math.Max(0.01, radius * Math.Min(ts, te));
/* Add points to left and right */
/*
Handle sharp corners
Find the difference (dot product) between the current and next vector.
If the next vector is at more than a right angle to the current vector,
draw a cap at the current point.
*/
var nextVector = (i < points.Length - 1 ? points[i + 1] : points[i]).Vector;
var nextDpr = i < points.Length - 1 ? Vector.DotVectors(vector, nextVector) : 1.0;
var prevDpr = Vector.DotVectors(vector, prevVector);
var isPointSharpCorner = prevDpr < 0 && !isPrevPointSharpCorner;
var isNextPointSharpCorner = nextDpr < 0;
if (isPointSharpCorner || isNextPointSharpCorner) {
// It's a sharp corner. Draw a rounded cap and move on to the next point
// Considering saving these and drawing them later? So that we can avoid
// crossing future points.
var offset = Vector.MultiplyVector(Vector.PerpendicularRotationVector(prevVector), radius);
double step = 1D / 13D;
double t = 0D;
for (; t <= 1D; t += step) {
tl = Vector.RotateVectors(Vector.SubtractVectors(point, offset), point, FIXED_PI * t);
leftPts.Add(tl);
tr = Vector.RotateVectors(Vector.AddVectors(point, offset), point, FIXED_PI * -t);
rightPts.Add(tr);
}
pl = tl;
pr = tr;
if (isNextPointSharpCorner) {
isPrevPointSharpCorner = true;
}
continue;
}
isPrevPointSharpCorner = false;
// Handle the last point
if (i == points.Length - 1) {
var offset = Vector.MultiplyVector(Vector.PerpendicularRotationVector(vector), radius);
leftPts.Add(Vector.SubtractVectors(point, offset));
rightPts.Add(Vector.AddVectors(point, offset));
continue;
}
/*
Add regular points
Project points to either side of the current point, using the
calculated size as a distance. If a point's distance to the
previous point on that side greater than the minimum distance
(or if the corner is kinda sharp), add the points to the side's
points array.
*/
Vector _offset =
Vector.MultiplyVector(
Vector.PerpendicularRotationVector(Vector.InterpolateVectors(nextVector, vector, nextDpr)),
radius);
tl = Vector.SubtractVectors(point, _offset);
if (i <= 1 || Vector.DistLengthSquaredVectors(pl, tl) > minDistance) {
leftPts.Add(tl);
pl = tl;
}
tr = Vector.AddVectors(point, _offset);
if (i <= 1 || Vector.DistLengthSquaredVectors(pr, tr) > minDistance) {
rightPts.Add(tr);
pr = tr;
}
// Set variables for next iteration
prevPressure = pressure;
prevVector = vector;
}
/*
Drawing caps
Now that we have our points on either side of the line, we need to
draw caps at the start and end. Tapered lines don't have caps, but
may have dots for very short lines.
*/
var firstPoint = points[0].Point;
var lastPoint = points.Length > 1
? points[points.Length - 1].Point
: Vector.AddVectors(points[0].Point, new Vector(1, 1));
var startCap = new List<Vector>();
var endCap = new List<Vector>();
/*
Draw a dot for very short or completed strokes
If the line is too short to gather left or right points and if the line is
not tapered on either side, draw a dot. If the line is tapered, then only
draw a dot if the line is both very short and complete. If we draw a dot,
we can just return those points.
*/
if (points.Length == 1) {
if (!((strokeOptions_Start != null && (taperStart != 0 && strokeOptions_Start.IsTaper)) || (strokeOptions_End != null &&
(taperEnd != 0 && strokeOptions_End.IsTaper))) || isComplete) {
var start = Vector.ProjectVectors(firstPoint,
Vector.UnitVector(
Vector.PerpendicularRotationVector(Vector.SubtractVectors(firstPoint, lastPoint))), !double.IsNaN(firstRadius) ? -firstRadius : -radius);
var dotPts = new List<Vector>();
double step = 1D / 13D;
double t = step;
for (; t <= 1D; t += step) {
dotPts.Add(Vector.RotateVectors(start, firstPoint, FIXED_PI * 2 * t));
}
return dotPts.ToArray();
}
} else {
/*
Draw a start cap
Unless the line has a tapered start, or unless the line has a tapered end
and the line is very short, draw a start cap around the first point. Use
the distance between the second left and right point for the cap's radius.
Finally remove the first left and right points. :psyduck:
*/
if ((strokeOptions_Start != null && (taperStart != 0 && strokeOptions_Start.IsTaper)) || ((strokeOptions_End != null &&
(taperEnd != 0 && strokeOptions_End.IsTaper)) && points.Length == 1)) {
// The start point is tapered, noop
} else if (capStart) {
// Draw the round cap - add thirteen points rotating the right point around the start point to the left point
double step = 1D / 13D;
double t = step;
for (; t <= 1D; t += step) {
var pt = Vector.RotateVectors(rightPts[0], firstPoint, FIXED_PI * t);
startCap.Add(pt);
}
} else {
// Draw the flat cap - add a point to the left and right of the start point
var cornersVector = Vector.SubtractVectors(leftPts[0], rightPts[0]);
var offsetA = Vector.MultiplyVector(cornersVector, 0.5);
var offsetB = Vector.MultiplyVector(cornersVector, 0.51);
startCap.Add(Vector.SubtractVectors(firstPoint, offsetA));
startCap.Add(Vector.SubtractVectors(firstPoint, offsetB));
startCap.Add(Vector.AddVectors(firstPoint, offsetA));
startCap.Add(Vector.AddVectors(firstPoint, offsetB));
}
/*
Draw an end cap
If the line does not have a tapered end, and unless the line has a tapered
start and the line is very short, draw a cap around the last point. Finally,
remove the last left and right points. Otherwise, add the last point. Note
that This cap is a full-turn-and-a-half: this prevents incorrect caps on
sharp end turns.
*/
var direction =
Vector.PerpendicularRotationVector(Vector.NegateVector(points[points.Length - 1].Vector));
if ((strokeOptions_End != null &&
(taperEnd != 0 && strokeOptions_End.IsTaper)) ||
((strokeOptions_Start != null && (taperStart != 0 && strokeOptions_Start.IsTaper)) && points.Length == 1)) {
// Tapered end - push the last point to the line
endCap.Add(lastPoint);
} else if (capEnd) {
// Draw the round end cap
var start = Vector.ProjectVectors(lastPoint, direction, radius);
double step = 1D / 29D;
double t = step;
for (; t < 1D; t += step) {
endCap.Add(Libraries.Vector.RotateVectors(start, lastPoint, FIXED_PI * 3 * t));
}
} else {
// Draw the flat end cap
endCap.Add(Vector.AddVectors(lastPoint, Vector.MultiplyVector(direction, radius)));
endCap.Add(Vector.AddVectors(lastPoint, Vector.MultiplyVector(direction, radius * 0.99)));
endCap.Add(Vector.SubtractVectors(lastPoint, Vector.MultiplyVector(direction, radius)));
endCap.Add(Vector.SubtractVectors(lastPoint, Vector.MultiplyVector(direction, radius * 0.99)));
}
}
/*
Return the points in the correct winding order: begin on the left side, then
continue around the end cap, then come back along the right side, and finally
complete the start cap.
*/
rightPts.Reverse();
return leftPts.Concat(endCap).Concat(rightPts).Concat(startCap).ToArray();
}
}
namespace Stroke {
public class StrokeOptions
{
/// <summary>
/// The base size (diameter) of the stroke.
/// </summary>
public double? Size { get; set; }
/// <summary>
/// The effect of pressure on the stroke's size.
/// </summary>
public double? Thinning { get; set; }
/// <summary>
/// How much to soften the stroke's edges.
/// </summary>
public double? Smoothing { get; set; }
public double? Streamline { get; set; }
/// <summary>
/// An easing function to apply to each point's pressure.
/// </summary>
public Func<double, double> Easing { get; set; }
/// <summary>
/// Whether to simulate pressure based on velocity.
/// </summary>
public bool? SimulatePressure { get; set; }
/// <summary>
/// Cap, taper and easing for the start of the line.
/// </summary>
public StrokeCapOptions Start { get; set; }
/// <summary>
/// Cap, taper and easing for the end of the line.
/// </summary>
public StrokeCapOptions End { get; set; }
/// <summary>
/// Whether to handle the points as a completed stroke.
/// </summary>
public bool? Last { get; set; }
}
public class StrokeCapOptions
{
/// <summary>
/// Whether to apply a cap at the start/end of the line.
/// </summary>
public bool Cap { get; set; }
/// <summary>
/// The taper value at the start/end of the line.
/// </summary>
public double Taper { get; set; }
public bool IsTaper { get; set; }
/// <summary>
/// An easing function to apply to the taper.
/// </summary>
public Func<double, double> Easing { get; set; }
}
public class StrokePoint
{
/// <summary>
/// The point coordinates as [x, y].
/// </summary>
public Vector Point { get; set; }
/// <summary>
/// The pressure at the point.
/// </summary>
public double Pressure { get; set; }
/// <summary>
/// The distance from the previous point.
/// </summary>
public double Distance { get; set; }
/// <summary>
/// The vector at the point.
/// </summary>
public Vector Vector { get; set; }
/// <summary>
/// The running length of the stroke.
/// </summary>
public double RunningLength { get; set; }
}
}
}