using ChunkyImageLib.DataHolders; using Drawie.Backend.Core.Numerics; using Drawie.Backend.Core.Vector; using Drawie.Numerics; namespace ChunkyImageLib.Operations; public class EllipseHelper { /// /// Separates the ellipse's inner area into a bunch of horizontal lines and one big rectangle for drawing. /// public static (List lines, RectI rect) SplitEllipseFillIntoRegions(IReadOnlyList ellipse, RectI ellipseBounds) { if (ellipse.Count == 0) return (new(), RectI.Empty); List lines = new(); VecD ellipseCenter = ellipseBounds.Center; VecD inscribedRectSize = ellipseBounds.Size * Math.Sqrt(2) / 2; inscribedRectSize.X -= 2; inscribedRectSize.Y -= 2; RectI inscribedRect = (RectI)RectD.FromCenterAndSize(ellipseCenter, inscribedRectSize).RoundInwards(); if (inscribedRect.IsZeroOrNegativeArea) inscribedRect = RectI.Empty; bool[] added = new bool[ellipseBounds.Height]; for (var i = 0; i < ellipse.Count; i++) { var point = ellipse[i]; if (!added[point.Y - ellipseBounds.Top] && i > 0 && ellipse[i - 1].Y == point.Y && point.X - ellipse[i - 1].X > 1 && point.Y > ellipseBounds.Top && point.Y < ellipseBounds.Bottom - 1) { int fromX = ellipse[i - 1].X + 1; int toX = point.X; int y = ellipse[i - 1].Y; added[point.Y - ellipseBounds.Top] = true; if (y >= inscribedRect.Top && y < inscribedRect.Bottom) { lines.Add(new VecI(fromX, y)); lines.Add(new VecI(inscribedRect.Left, y)); lines.Add(new VecI(inscribedRect.Right, y)); lines.Add(new VecI(toX, y)); } else { lines.Add(new VecI(fromX, y)); lines.Add(new VecI(toX, y)); } } } return (lines, inscribedRect); } /// /// Splits the ellipse into a bunch of horizontal lines. /// The resulting list contains consecutive pairs of s, each pair has one for the start of the line and one for the end. /// public static List SplitEllipseIntoLines(HashSet ellipse) { List lines = new(); var sorted = ellipse.OrderBy( a => a, Comparer.Create((a, b) => a.Y != b.Y ? a.Y - b.Y : a.X - b.X) ); int minX = int.MaxValue; int maxX = int.MinValue; VecI? prev = null; foreach (var point in sorted) { if (prev.HasValue && point.Y != prev.Value.Y) { int prevY = prev.Value.Y; lines.Add(new(minX, prevY)); lines.Add(new(maxX, prevY)); minX = int.MaxValue; maxX = int.MinValue; } minX = Math.Min(point.X, minX); maxX = Math.Max(point.X, maxX); prev = point; } if (prev != null) { lines.Add(new(minX, prev.Value.Y)); lines.Add(new(maxX, prev.Value.Y)); } return lines; } public static HashSet GenerateEllipseFromRect(RectI rect, double rotationRad = 0) { if (rect.IsZeroOrNegativeArea) return new(); float radiusX = (rect.Width - 1) / 2.0f; float radiusY = (rect.Height - 1) / 2.0f; if (rotationRad == 0) return GenerateMidpointEllipse(radiusX, radiusY, rect.Center.X, rect.Center.Y); return GenerateMidpointEllipse(radiusX, radiusY, rect.Center.X, rect.Center.Y, rotationRad); } /// /// Draws an ellipse using it's center and radii /// /// Here is a usage example: /// Let's say you want an ellipse that's 3 pixels wide and 3 pixels tall located in the top right corner of the canvas /// It's center is at (1.5; 1.5). That's in the middle of a pixel /// The radii are both equal to 1. Notice that it's 1 and not 1.5, since we want the ellipse to land in the middle of the pixel, not outside of it. /// See desmos (note the inverted y axis): https://www.desmos.com/calculator/tq9uqg0hcq /// /// Another example: /// 4x4 ellipse in the top right corner of the canvas /// Center is at (2; 2). It's a place where 4 pixels meet /// Both radii are 1.5. Making them 2 would make the ellipse touch the edges of pixels, whereas we want it to stay in the middle /// public static HashSet GenerateMidpointEllipse( double halfWidth, double halfHeight, double centerX, double centerY, HashSet? listToFill = null) { listToFill ??= new HashSet(); if (halfWidth < 1 || halfHeight < 1) { AddFallbackRectangle(halfWidth, halfHeight, centerX, centerY, listToFill); return listToFill; } // ellipse formula: halfHeight^2 * x^2 + halfWidth^2 * y^2 - halfHeight^2 * halfWidth^2 = 0 // Make sure we are always at the center of a pixel double currentX = Math.Ceiling(centerX - 0.5) + 0.5; double currentY = centerY + halfHeight; double currentSlope; // from PI/2 to PI/4 do { AddRegionPoints(listToFill, currentX, centerX, currentY, centerY); // calculate next pixel coords currentX++; if ((Math.Pow(halfHeight, 2) * Math.Pow(currentX - centerX, 2)) + (Math.Pow(halfWidth, 2) * Math.Pow(currentY - centerY - 0.5, 2)) - (Math.Pow(halfWidth, 2) * Math.Pow(halfHeight, 2)) >= 0) { currentY--; } // calculate how far we've advanced double derivativeX = 2 * Math.Pow(halfHeight, 2) * (currentX - centerX); double derivativeY = 2 * Math.Pow(halfWidth, 2) * (currentY - centerY); currentSlope = -(derivativeX / derivativeY); } while (currentSlope > -1 && currentY - centerY > 0.5); // from PI/4 to 0 while (currentY - centerY >= 0) { AddRegionPoints(listToFill, currentX, centerX, currentY, centerY); currentY--; if ((Math.Pow(halfHeight, 2) * Math.Pow(currentX - centerX + 0.5, 2)) + (Math.Pow(halfWidth, 2) * Math.Pow(currentY - centerY, 2)) - (Math.Pow(halfWidth, 2) * Math.Pow(halfHeight, 2)) < 0) { currentX++; } } return listToFill; } /// /// Constructs pixel-perfect ellipse outline represented as a vector path. /// This function is quite heavy, for less precise but faster results use . /// /// The rectangle that the ellipse should fit into. /// A vector path that represents an ellipse outline. public static VectorPath ConstructEllipseOutline(RectI rectangle) { if (EllipseCache.Ellipses.TryGetValue(rectangle.Size, out var cachedPath)) { VectorPath finalPath = new(cachedPath); finalPath.Transform(Matrix3X3.CreateTranslation(rectangle.TopLeft.X, rectangle.TopLeft.Y)); return finalPath; } if (rectangle.Width < 3 || rectangle.Height < 3) { VectorPath rectPath = new(); rectPath.AddRect((RectD)rectangle); return rectPath; } if (rectangle is { Width: 3, Height: 3 }) { return CreateThreePixelCircle((VecI)rectangle.Center); } var center = rectangle.Size / 2d; RectI rect = new RectI(0, 0, rectangle.Width, rectangle.Height); var points = GenerateEllipseFromRect(rect, 0).ToList(); points.Sort((vec, vec2) => Math.Sign((vec - center).Angle - (vec2 - center).Angle)); List finalPoints = new(); for (int i = 0; i < points.Count; i++) { VecI prev = points[Mod(i - 1, points.Count)]; VecI point = points[i]; VecI next = points[Mod(i + 1, points.Count)]; bool atBottom = point.Y >= center.Y; bool onRight = point.X >= center.X; if (atBottom) { if (onRight) { if (prev.Y != point.Y) finalPoints.Add(new(point.X + 1, point.Y)); finalPoints.Add(new(point.X + 1, point.Y + 1)); if (next.X != point.X) finalPoints.Add(new(point.X, point.Y + 1)); } else { if (prev.X != point.X) finalPoints.Add(new(point.X + 1, point.Y + 1)); finalPoints.Add(new(point.X, point.Y + 1)); if (next.Y != point.Y) finalPoints.Add(point); } } else { if (onRight) { if (prev.X != point.X) finalPoints.Add(point); finalPoints.Add(new(point.X + 1, point.Y)); if (next.Y != point.Y) finalPoints.Add(new(point.X + 1, point.Y + 1)); } else { if (prev.Y != point.Y) finalPoints.Add(new(point.X, point.Y + 1)); finalPoints.Add(point); if (next.X != point.X) finalPoints.Add(new(point.X + 1, point.Y)); } } } VectorPath path = new(); path.MoveTo(new VecF(finalPoints[0].X, finalPoints[0].Y)); for (var index = 1; index < finalPoints.Count; index++) { var point = finalPoints[index]; path.LineTo(new VecF(point.X, point.Y)); } path.Close(); EllipseCache.Ellipses[rectangle.Size] = new VectorPath(path); path.Transform(Matrix3X3.CreateTranslation(rectangle.TopLeft.X, rectangle.TopLeft.Y)); return path; } public static VectorPath CreateThreePixelCircle(VecI rectanglePos) { var path = new VectorPath(); path.MoveTo(new VecF(0, 0)); path.LineTo(new VecF(0, -1)); path.LineTo(new VecF(1, -1)); path.LineTo(new VecF(1, 0)); path.LineTo(new VecF(2, 0)); path.LineTo(new VecF(2, 1)); path.LineTo(new VecF(2, 1)); path.LineTo(new VecF(1, 1)); path.LineTo(new VecF(1, 2)); path.LineTo(new VecF(0, 2)); path.LineTo(new VecF(0, 1)); path.LineTo(new VecF(-1, 1)); path.LineTo(new VecF(-1, 0)); path.Close(); path.Transform(Matrix3X3.CreateTranslation(rectanglePos.X, rectanglePos.Y)); return path; } private static int Mod(int x, int m) => (x % m + m) % m; // This function works, but honestly Skia produces better results, and it doesn't require so much // computation on the CPU. I'm leaving this, because once I (or someone else) figure out how to // make it better, and it will be useful. // Desmos with all the math https://www.desmos.com/calculator/m9lgg7s9zu private static HashSet GenerateMidpointEllipse(double halfWidth, double halfHeight, double centerX, double centerY, double rotationRad) { var listToFill = new HashSet(); // formula ((x - h)cos(tetha) + (y - k)sin(tetha))^2 / a^2 + (-(x-h)sin(tetha)+(y-k)cos(tetha))^2 / b^2 = 1 //double topMostTetha = GetTopMostAlpha(halfWidth, halfHeight, rotationRad); //VecD possiblyTopmostPoint = GetTethaPoint(topMostTetha, halfWidth, halfHeight, rotationRad); //VecD possiblyMinPoint = GetTethaPoint(topMostTetha + Math.PI, halfWidth, halfHeight, rotationRad); // less than, because y grows downwards //VecD actualTopmost = possiblyTopmostPoint.Y < possiblyMinPoint.Y ? possiblyTopmostPoint : possiblyMinPoint; //rotationRad = double.Round(rotationRad, 1); double currentTetha = 0; double tethaStep = 0.001; VecI[] lastPoints = new VecI[2]; do { VecD point = GetTethaPoint(currentTetha, halfWidth, halfHeight, rotationRad); VecI floored = new((int)Math.Floor(point.X + centerX), (int)Math.Floor(point.Y + centerY)); AddPoint(listToFill, floored, lastPoints); currentTetha += tethaStep; } while (currentTetha < Math.PI * 2); return listToFill; } private static void AddPoint(HashSet listToFill, VecI floored, VecI[] lastPoints) { if (!listToFill.Add(floored)) return; if (lastPoints[0] == default) { lastPoints[0] = floored; return; } if (lastPoints[1] == default) { lastPoints[1] = floored; return; } if (IsLShape(lastPoints, floored)) { listToFill.Remove(lastPoints[1]); lastPoints[0] = floored; lastPoints[1] = default; return; } lastPoints[0] = lastPoints[1]; lastPoints[1] = floored; } private static bool IsLShape(VecI[] points, VecI third) { VecI first = points[0]; VecI second = points[1]; return first.X != third.X && first.Y != third.Y && (second - first).TaxicabLength == 1 && (second - third).TaxicabLength == 1; } private static bool IsInsideEllipse(double x, double y, double centerX, double centerY, double halfWidth, double halfHeight, double rotationRad) { double lhs = Math.Pow(x * Math.Cos(rotationRad) + y * Math.Sin(rotationRad), 2) / Math.Pow(halfWidth, 2); double rhs = Math.Pow(-x * Math.Sin(rotationRad) + y * Math.Cos(rotationRad), 2) / Math.Pow(halfHeight, 2); return lhs + rhs <= 1; } private static VecD GetDerivative(double x, double halfWidth, double halfHeight, double rotationRad, double tetha) { double xDerivative = halfWidth * Math.Cos(tetha) * Math.Cos(rotationRad) - halfHeight * Math.Sin(tetha) * Math.Sin(rotationRad); double yDerivative = halfWidth * Math.Cos(tetha) * Math.Sin(rotationRad) + halfHeight * Math.Sin(tetha) * Math.Cos(rotationRad); return new VecD(xDerivative, yDerivative); } private static VecD GetTethaPoint(double alpha, double halfWidth, double halfHeight, double rotation) { double x = (halfWidth * Math.Cos(alpha) * Math.Cos(rotation) - halfHeight * Math.Sin(alpha) * Math.Sin(rotation)); double y = halfWidth * Math.Cos(alpha) * Math.Sin(rotation) + halfHeight * Math.Sin(alpha) * Math.Cos(rotation); return new VecD(x, y); } private static double GetTopMostAlpha(double halfWidth, double halfHeight, double rotationRad) { if (rotationRad == 0) return 0; double tethaRot = Math.Cos(rotationRad) / Math.Sin(rotationRad); return Math.Atan((halfHeight * tethaRot) / halfWidth); } private static void AddFallbackRectangle(double halfWidth, double halfHeight, double centerX, double centerY, HashSet coordinates) { int left = (int)Math.Floor(centerX - halfWidth); int top = (int)Math.Floor(centerY - halfHeight); int right = (int)Math.Floor(centerX + halfWidth); int bottom = (int)Math.Floor(centerY + halfHeight); for (int x = left; x <= right; x++) { coordinates.Add(new VecI(x, top)); if (top != bottom) coordinates.Add(new VecI(x, bottom)); } for (int y = top + 1; y < bottom; y++) { coordinates.Add(new VecI(left, y)); if (left != right) coordinates.Add(new VecI(right, y)); } } private static void AddRegionPoints(HashSet coordinates, double x, double xc, double y, double yc) { int xFloor = (int)Math.Floor(x); int yFloor = (int)Math.Floor(y); int xFloorInv = (int)Math.Floor(-x + 2 * xc); int yFloorInv = (int)Math.Floor(-y + 2 * yc); //top and bottom or left and right if (xFloor == xFloorInv || yFloor == yFloorInv) { coordinates.Add(new VecI(xFloorInv, yFloorInv)); coordinates.Add(new VecI(xFloor, yFloor)); } //part of the arc else { coordinates.Add(new VecI(xFloorInv, yFloor)); coordinates.Add(new VecI(xFloor, yFloor)); coordinates.Add(new VecI(xFloorInv, yFloorInv)); coordinates.Add(new VecI(xFloor, yFloorInv)); } } /// /// This function generates a vector path that represents an oval. For pixel-perfect circle use . /// /// The rectangle that the ellipse should fit into. /// A vector path that represents an oval. public static VectorPath GenerateEllipseVectorFromRect(RectD location) { VectorPath path = new(); path.AddOval(location); path.Close(); return path; } }