// Copyright (c) Six Labors. // Licensed under the Six Labors Split License. using System; using System.Collections.Generic; using System.Numerics; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; namespace SixLabors.ImageSharp.Drawing { /// /// Internal logic for integrating linear paths. /// internal class InternalPath { /// /// The epsilon for float comparison /// private const float Epsilon = 0.003f; private const float Epsilon2 = 0.2f; /// /// The points. /// private readonly PointData[] points; /// /// Materialized points projected from . /// private PointF[]? materializedPoints; /// /// The closed path. /// private readonly bool closedPath; /// /// Initializes a new instance of the class. /// /// The segments. /// if set to true [is closed path]. /// Whether to remove close and collinear vertices internal InternalPath(IReadOnlyList segments, bool isClosedPath, bool removeCloseAndCollinear = true) : this(Simplify(segments, isClosedPath, removeCloseAndCollinear), isClosedPath) { } /// /// Initializes a new instance of the class. /// /// The points. /// if set to true [is closed path]. internal InternalPath(ReadOnlyMemory points, bool isClosedPath) : this(Simplify(points.Span, isClosedPath, true), isClosedPath) { } /// /// Initializes a new instance of the class. /// /// The points. /// if set to true [is closed path]. private InternalPath(PointData[] points, bool isClosedPath) { this.points = points; this.closedPath = isClosedPath; if (this.points.Length > 0) { float minX, minY, maxX, maxY, length; length = 0; minX = minY = float.MaxValue; maxX = maxY = float.MinValue; foreach (PointData point in this.points) { length += point.Length; minX = Math.Min(point.Point.X, minX); minY = Math.Min(point.Point.Y, minY); maxX = Math.Max(point.Point.X, maxX); maxY = Math.Max(point.Point.Y, maxY); } this.Bounds = new RectangleF(minX, minY, maxX - minX, maxY - minY); this.Length = length; } else { this.Bounds = RectangleF.Empty; this.Length = 0; } } /// /// Gets the bounds. /// /// /// The bounds. /// public RectangleF Bounds { get; } /// /// Gets the length. /// /// /// The length. /// public float Length { get; } /// /// Gets the length. /// public int PointCount => this.points.Length; /// /// Gets the points. /// /// The internal ReadOnlyMemory Points() => this.materializedPoints ??= this.CreatePoints(); /// /// Calculates the point a certain distance a path. /// /// The distance along the path to find details of. /// /// Returns details about a point along a path. /// /// Thrown if no points found. internal SegmentInfo PointAlongPath(float distanceAlongPath) { int pointCount = this.PointCount; if (this.closedPath) { // Move the distance back to the beginning since this is a closed polygon. distanceAlongPath %= this.Length; pointCount--; } for (int i = 0; i < pointCount; i++) { int next = WrapArrayIndex(i + 1, this.PointCount); if (distanceAlongPath < this.points[next].Length) { float t = distanceAlongPath / this.points[next].Length; Vector2 point = Vector2.Lerp(this.points[i].Point, this.points[next].Point, t); Vector2 diff = this.points[i].Point - this.points[next].Point; return new SegmentInfo { Point = point, Angle = (float)(Math.Atan2(diff.Y, diff.X) % (Math.PI * 2)) }; } distanceAlongPath -= this.points[next].Length; } // Closed paths will never reach this point. // For open paths we're going to create a new virtual point that extends past the path. // The position and angle for that point are calculated based upon the last two points. PointF a = this.points[Math.Max(this.points.Length - 2, 0)].Point; PointF b = this.points[^1].Point; Vector2 delta = a - b; float angle = (float)(Math.Atan2(delta.Y, delta.X) % (Math.PI * 2)); Matrix4x4 transform = Matrix4x4.CreateRotationZ(angle - MathF.PI) * Matrix4x4.CreateTranslation(b.X, b.Y, 0); return new SegmentInfo { Point = PointF.Transform(new PointF(distanceAlongPath, 0), transform), Angle = angle }; } // Modulo is a very slow operation. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int WrapArrayIndex(int i, int arrayLength) => i < arrayLength ? i : i - arrayLength; private PointF[] CreatePoints() { PointF[] result = new PointF[this.points.Length]; for (int i = 0; i < result.Length; i++) { result[i] = this.points[i].Point; } return result; } [MethodImpl(MethodImplOptions.AggressiveInlining)] private static PointOrientation CalculateOrientation(Vector2 p, Vector2 q, Vector2 r) { // See http://www.geeksforgeeks.org/orientation-3-ordered-points/ // for details of below formula. Vector2 qp = q - p; Vector2 rq = r - q; float val = (qp.Y * rq.X) - (qp.X * rq.Y); if (val is > -Epsilon and < Epsilon) { return PointOrientation.Collinear; // colinear } return (val > 0) ? PointOrientation.Clockwise : PointOrientation.Counterclockwise; // clock or counterclock wise } /// /// Simplifies the collection of segments. /// /// The segments. /// Weather the path is closed or open. /// Whether to remove close and collinear vertices /// /// The . /// private static PointData[] Simplify(IReadOnlyList segments, bool isClosed, bool removeCloseAndCollinear) { // Pre-compute capacity from identity-transform vertex counts to avoid List resizing. int totalPoints = 0; for (int s = 0; s < segments.Count; s++) { totalPoints += segments[s].LinearVertexCount(Vector2.One); } List simplified = new(totalPoints); // Track indices where collinear direction reversals represent user-intended // geometry: interior points of multi-point linear segments, and junction // points between two linear segments (e.g. PathBuilder LineTo → LineTo). // Reversals at all other indices (flattened curves, curve junctions) are // artifacts and should be removed normally. HashSet? linearReversalIndices = null; ILineSegment? prevSeg = null; foreach (ILineSegment seg in segments) { int start = simplified.Count; int segmentCount = seg.LinearVertexCount(Vector2.One); CollectionsMarshal.SetCount(simplified, start + segmentCount); Span destination = CollectionsMarshal.AsSpan(simplified).Slice(start, segmentCount); seg.CopyTo(destination, skipFirstPoint: false, Vector2.One); if (seg is LinearLineSegment) { // Interior points of a multi-point linear segment (e.g. DrawLine with 3+ points). if (segmentCount > 2) { linearReversalIndices ??= []; for (int i = start + 1; i < start + segmentCount - 1; i++) { _ = linearReversalIndices.Add(i); } } // Junction between two linear segments (e.g. PathBuilder LineTo → LineTo). if (prevSeg is LinearLineSegment && start > 0) { linearReversalIndices ??= []; _ = linearReversalIndices.Add(start); } } prevSeg = seg; } return Simplify(CollectionsMarshal.AsSpan(simplified), isClosed, removeCloseAndCollinear, linearReversalIndices); } private static PointData[] Simplify(ReadOnlySpan points, bool isClosed, bool removeCloseAndCollinear, HashSet? linearReversalIndices = null) { int polyCorners = points.Length; if (polyCorners == 0) { return []; } List results = new(polyCorners); Vector2 lastPoint = points[0]; if (!isClosed) { results.Add(new PointData { Point = points[0], Orientation = PointOrientation.Collinear, Length = 0 }); } else { int prev = polyCorners; do { prev--; if (prev == 0) { // All points are common, shouldn't match anything results.Add( new PointData { Point = points[0], Orientation = PointOrientation.Collinear, Length = 0, }); return [.. results]; } } while (removeCloseAndCollinear && Equivalent(points[0], points[prev], Epsilon2)); // skip points too close together polyCorners = prev + 1; lastPoint = points[prev]; results.Add( new PointData { Point = points[0], Orientation = CalculateOrientation(lastPoint, points[0], points[1]), Length = Vector2.Distance(lastPoint, points[0]), }); lastPoint = points[0]; } for (int i = 1; i < polyCorners; i++) { int next = WrapArrayIndex(i + 1, polyCorners); PointOrientation or = CalculateOrientation(lastPoint, points[i], points[next]); if (removeCloseAndCollinear && or == PointOrientation.Collinear && next != 0) { // Preserve collinear points that represent a direction reversal (U-turn) // within a single segment. E.g. (10,10)→(90,10)→(20,10): the middle point // is collinear but the stroker needs to see the reversal. // Don't preserve reversals at segment boundaries — these arise from joining // different path segments (e.g. arc-to-arc) and are not user-intended. bool preserve = false; if (linearReversalIndices == null || linearReversalIndices.Contains(i)) { Vector2 incoming = (Vector2)points[i] - lastPoint; Vector2 outgoing = (Vector2)points[next] - (Vector2)points[i]; float inLen = incoming.Length(); float outLen = outgoing.Length(); preserve = inLen > Epsilon && outLen > Epsilon && Vector2.Dot(incoming, outgoing) < 0; } if (!preserve) { continue; } } results.Add( new PointData { Point = points[i], Orientation = or, Length = Vector2.Distance(lastPoint, points[i]), }); lastPoint = points[i]; } if (isClosed && removeCloseAndCollinear) { // walk back removing collinear points while (results.Count > 2 && results[^1].Orientation == PointOrientation.Collinear) { results.RemoveAt(results.Count - 1); } } return [.. results]; } /// /// Determines whether two points are within the specified coordinate threshold of one another. /// /// The first point. /// The second point. /// The per-axis distance threshold. /// /// when both coordinates are within ; otherwise, . /// [MethodImpl(MethodImplOptions.AggressiveInlining)] private static bool Equivalent(PointF source1, PointF source2, float threshold) { Vector2 abs = Vector2.Abs(source1 - source2); return abs.X < threshold && abs.Y < threshold; } private struct PointData { public PointF Point; public PointOrientation Orientation; public float Length; } } }