// Copyright (c) Six Labors. // Licensed under the Six Labors Split License. using System; using System.Collections.Generic; using System.Runtime.CompilerServices; namespace SixLabors.PolygonClipper { #pragma warning disable SA1201 // Elements should appear in the correct order /// /// Generates polygonal stroke geometry for contours with configurable joins and caps. /// /// /// This type performs two phases: /// /// Expand each source contour into one or two stroke-side outlines with joins/caps. /// /// Optionally resolve generated overlaps/self-intersections using /// with positive fill semantics. /// /// /// The emitted contours are implicitly closed (first vertex is not duplicated at the end). /// /// The static method is the recommended /// entry point. It routes calls through an internal thread-local pool of reusable stroker /// instances and automatically resets temporary state between calls. /// /// Instance members are not thread-safe for concurrent use. /// public sealed class PolygonStroker { // Numerical tolerances used while collapsing near-duplicate source points and // while testing near-parallel line intersections. private const double VertexDistanceEpsilon = 1E-14D; private const double IntersectionEpsilon = 1E-30D; private const double Pi = Math.PI; private const double PiMul2 = Math.PI * 2D; // The inner miter limit used to clamp joins on acute interior angles. private const double InnerMiterLimit = 1.01D; // Keep at most 2 warm instances per option-set (one active shape and one spare) // to reduce churn without retaining many rarely reused configurations. private const int MaxPooledStrokersPerOptions = 2; // Discard oversized scratch buffers so a single pathological stroke does not pin // large arrays in thread-local pools for the lifetime of the thread. private const int MaxRetainedScratchBytes = 256 * 1024; private static readonly StrokeOptions DefaultStrokeOptions = new(); [ThreadStatic] private static Dictionary>? strokersByOptions; // Scratch buffers reused across contours to keep per-call allocations down. private ArrayBuilder outVertices = new(1); private ArrayBuilder srcVertices = new(16); // Streaming-state fields used by the Accumulate() state machine. private int closed; private int outVertex; private Status prevStatus; private int srcVertex; private Status status; private double strokeWidth = 0.5D; private double widthAbs = 0.5D; private double widthEps = 0.5D / 1024D; private int widthSign = 1; /// /// Initializes a new instance of the class with the specified stroke options. /// /// The stroke options. /// Thrown when is null. /// /// This constructor is intended for advanced/manual usage. /// For typical call patterns, prefer the static /// method to use internal pooling automatically. /// public PolygonStroker(StrokeOptions options) { ArgumentNullException.ThrowIfNull(options); this.NormalizeOutput = options.NormalizeOutput; this.LineJoin = options.LineJoin; this.LineCap = options.LineCap; this.MiterLimit = options.MiterLimit; this.ArcDetailScale = options.ArcDetailScale; } /// /// Internal state machine used by to stream stroked output vertices. /// private enum Status { /// Initial setup and input normalization. Initial, /// Ready to emit the first command for the contour. Ready, /// Emit start-cap vertices for open contours. Cap1, /// Emit end-cap vertices for open contours. Cap2, /// Emit joins for the first stroke side. Outline1, /// Switch from first side to second side for closed paths. CloseFirst, /// Emit joins for the second stroke side. Outline2, /// Flush buffered vertices from the current join/cap computation. OutVertices, /// Emit end-poly marker for first stroke side. EndPoly1, /// Emit end-poly marker for second stroke side. EndPoly2, /// Stop emitting commands. Stop } private readonly struct StrokeOptionsKey : IEquatable { public StrokeOptionsKey(StrokeOptions options) { this.NormalizeOutput = options.NormalizeOutput; this.LineJoin = options.LineJoin; this.LineCap = options.LineCap; this.MiterLimit = options.MiterLimit; this.ArcDetailScale = options.ArcDetailScale; } public bool NormalizeOutput { get; } public LineJoin LineJoin { get; } public LineCap LineCap { get; } public double MiterLimit { get; } public double ArcDetailScale { get; } public bool Equals(StrokeOptionsKey other) => this.NormalizeOutput == other.NormalizeOutput && this.LineJoin == other.LineJoin && this.LineCap == other.LineCap && this.MiterLimit == other.MiterLimit && this.ArcDetailScale == other.ArcDetailScale; public override bool Equals(object? obj) => obj is StrokeOptionsKey other && this.Equals(other); public override int GetHashCode() => HashCode.Combine( this.NormalizeOutput, this.LineJoin, this.LineCap, this.MiterLimit, this.ArcDetailScale); } /// /// Strokes with using optional /// . /// /// Input polygon to stroke. /// Stroke width. /// /// Stroke options controlling joins, caps and approximation behavior. /// When null, default are used. /// /// The stroked polygon contours. /// Thrown when is null. /// Preferred entry point. Uses internal thread-local reusable instances. public static Polygon Stroke(Polygon polygon, double width, StrokeOptions? options = null) { StrokeOptions effectiveOptions = options ?? DefaultStrokeOptions; StrokeOptionsKey key = new(effectiveOptions); PolygonStroker stroker = Rent(key, effectiveOptions); try { stroker.Width = width; return stroker.Stroke(polygon); } finally { Return(key, stroker); } } /// /// Strokes using this instance's configured options and width. /// /// Input polygon to stroke. /// The stroked polygon contours. /// Thrown when is null. /// Instance execution is not thread-safe for concurrent use. public Polygon Stroke(Polygon polygon) { ArgumentNullException.ThrowIfNull(polygon); if (polygon.Count == 0) { return []; } Polygon allContours = new(Math.Max(2, polygon.Count * 2)); for (int i = 0; i < polygon.Count; i++) { Contour contour = polygon[i]; // Close explicit or near-seam contours to avoid tiny stitch gaps, // but keep clearly open polylines open so caps are emitted. bool isClosed = IsContourClosedForEmission(contour, this.widthAbs * 2D); Polygon stroked = this.ProcessPathToPolygon(contour, isClosed); if (stroked.Count > 0) { allContours.Join(stroked); } } if (allContours.Count == 0) { return []; } if (!this.NormalizeOutput) { return allContours; } // Stroker emission already follows positive-fill assumptions, so skip // extra input-orientation normalization and only resolve overlaps. return SelfIntersectionRemover.Process(allContours, normalizeInputForPositiveFill: false); } /// /// Strokes after setting the current stroke width. /// /// Input polygon to stroke. /// Stroke width. /// The stroked polygon contours. /// Instance execution is not thread-safe for concurrent use. public Polygon Stroke(Polygon polygon, double width) { this.Width = width; return this.Stroke(polygon); } /// /// Gets the miter limit used to clamp outer miter joins. /// public double MiterLimit { get; } /// /// Gets the tessellation detail scale used for round joins and round caps. /// Higher values produce more vertices and smoother curves. /// public double ArcDetailScale { get; } /// /// Gets the outer line join style used for stroking corners. /// public LineJoin LineJoin { get; } /// /// Gets the line cap style used for open path ends. /// public LineCap LineCap { get; } /// /// Gets a value indicating whether generated contours should be normalized by resolving /// self-intersections and overlaps. /// public bool NormalizeOutput { get; } /// /// Gets or sets the stroke width. /// /// /// Positive values produce conventional outward stroking. Negative values are supported /// and flip the side orientation while preserving magnitude. /// public double Width { get => this.strokeWidth * 2D; set { this.strokeWidth = value * 0.5D; if (this.strokeWidth < 0D) { this.widthAbs = -this.strokeWidth; this.widthSign = -1; } else { this.widthAbs = this.strokeWidth; this.widthSign = 1; } this.widthEps = this.strokeWidth / 1024D; } } /// /// Converts a single contour into stroked polygon contours. /// /// The source contour. /// Whether the contour should be emitted as closed. /// The generated stroked contour set for this input contour. private Polygon ProcessPathToPolygon(Contour contour, bool isClosed) { ArgumentNullException.ThrowIfNull(contour); int pointCount = contour.Count; if (pointCount < 2) { return []; } bool hasExplicitClosure = pointCount > 1 && contour[0] == contour[^1]; if (isClosed && hasExplicitClosure) { // Keep one implicit closure path in the stroker state machine. // Duplicate terminal vertices are re-added at final contour emission. pointCount--; } if (pointCount < 2) { return []; } if (pointCount == 2) { Vertex p0 = contour[0]; Vertex p1 = contour[1]; if (Vertex.DistanceSquared(p0, p1) <= VertexDistanceEpsilon * VertexDistanceEpsilon) { // Degenerate segment behaves like a stroked point. return [this.GeneratePointCap(p0.X, p0.Y)]; } } this.Reset(); for (int i = 0; i < pointCount; i++) { Vertex point = contour[i]; this.Add(point.X, point.Y, PathCommand.LineTo); } if (isClosed) { this.ClosePath(); } Polygon result = new(isClosed ? 2 : 1); this.FinishPath(result); return result; } [MethodImpl(MethodImplOptions.AggressiveInlining)] private static PolygonStroker Rent(in StrokeOptionsKey key, StrokeOptions options) { Dictionary>? pools = strokersByOptions; if (pools != null && pools.TryGetValue(key, out Stack? pool) && pool.Count > 0) { return pool.Pop(); } return new PolygonStroker(options); } [MethodImpl(MethodImplOptions.AggressiveInlining)] private static void Return(in StrokeOptionsKey key, PolygonStroker stroker) { stroker.ResetForReuse(); // Pool only compact instances; large retained buffers are intentionally dropped. if (stroker.GetRetainedScratchBytes() > MaxRetainedScratchBytes) { return; } Dictionary> pools = strokersByOptions ??= []; if (!pools.TryGetValue(key, out Stack? pool)) { pool = new Stack(MaxPooledStrokersPerOptions); pools[key] = pool; } if (pool.Count < MaxPooledStrokersPerOptions) { pool.Push(stroker); } } [MethodImpl(MethodImplOptions.AggressiveInlining)] private int GetRetainedScratchBytes() => (this.outVertices.Capacity * Unsafe.SizeOf()) + (this.srcVertices.Capacity * Unsafe.SizeOf()); [MethodImpl(MethodImplOptions.AggressiveInlining)] private void ResetForReuse() { this.outVertices.Clear(); this.srcVertices.Clear(); this.closed = 0; this.outVertex = 0; this.prevStatus = Status.Initial; this.srcVertex = 0; this.status = Status.Initial; } /// /// Returns whether a contour should be treated as closed when emitting stroke geometry. /// /// The contour to inspect. /// Current stroke width. /// if the contour should be treated as closed; otherwise . [MethodImpl(MethodImplOptions.AggressiveInlining)] private static bool IsContourClosedForEmission(Contour contour, double strokeWidth) { int count = contour.Count; if (count < 3) { return false; } if (contour[0] == contour[^1]) { return true; } Vertex delta = contour[0] - contour[^1]; double closeThreshold = Math.Max(strokeWidth, 1E-3D); return delta.LengthSquared() <= closeThreshold * closeThreshold; } /// /// Marks the current path as closed before finishing the outline. /// private void ClosePath() { this.closed = (int)PathFlags.Close; this.status = Status.Initial; } /// /// Resets the stroker state for reuse. /// private void Reset() { // Reuse builders to avoid per-contour allocations. this.srcVertices.Clear(); this.outVertices.Clear(); this.srcVertex = 0; this.outVertex = 0; this.closed = 0; this.status = Status.Initial; } /// /// Consumes commands from and materializes final contour lists. /// /// Destination polygon that receives generated contours. private void FinishPath(Polygon result) { Vertex current = default; Vertex lastPoint = default; bool hasLastPoint = false; Contour? currentContour = null; PathCommand command; while (!(command = this.Accumulate(ref current)).Stop()) { if (command.MoveTo()) { // Start a new contour. Commit any previous contour that is already complete. if (currentContour is { Count: >= 3 }) { result.Add(currentContour); } currentContour = new Contour(16); hasLastPoint = false; } if (command.Vertex()) { currentContour ??= new Contour(16); // Drop immediate duplicate vertices to avoid zero-length segments // entering the intersection-removal pass. if (!hasLastPoint || current != lastPoint) { currentContour.Add(current); lastPoint = current; hasLastPoint = true; } } if (command.EndPoly()) { if (currentContour is { Count: >= 3 }) { result.Add(currentContour); } currentContour = null; hasLastPoint = false; } } if (currentContour is { Count: >= 3 }) { result.Add(currentContour); } } /// /// Adds a path command and coordinate into the source stream. /// /// X coordinate. /// Y coordinate. /// Path command associated with the coordinate. [MethodImpl(MethodImplOptions.AggressiveInlining)] private void Add(double x, double y, PathCommand cmd) { this.status = Status.Initial; if (cmd.MoveTo()) { // MoveTo starts a new source contour. if (this.srcVertices.Length != 0) { this.srcVertices.RemoveLast(); } this.Add(x, y); } else if (cmd.Vertex()) { this.Add(x, y); } else { // Non-vertex command updates close flags. this.closed = cmd.GetCloseFlag(); } } /// /// Appends a source vertex, collapsing trailing duplicates when needed. /// /// X coordinate. /// Y coordinate. /// Cached edge length hint. [MethodImpl(MethodImplOptions.AggressiveInlining)] private void Add(double x, double y, double distance = 0D) { if (this.srcVertices.Length > 1) { ref StrokeVertexDistance vd1 = ref this.srcVertices[^2]; ref StrokeVertexDistance vd2 = ref this.srcVertices[^1]; bool ret = vd1.Measure(vd2); if (!ret && this.srcVertices.Length != 0) { // If the previous segment collapses, remove the duplicate tail. this.srcVertices.RemoveLast(); } } this.srcVertices.Add(new StrokeVertexDistance(x, y, distance)); } /// /// Streams stroke output as path commands/vertices from the current source contour. /// /// Receives the emitted vertex when a vertex command is returned. /// The next path command. private PathCommand Accumulate(ref Vertex point) { ref ArrayBuilder src = ref this.srcVertices; PathCommand cmd = PathCommand.LineTo; while (!cmd.Stop()) { switch (this.status) { case Status.Initial: // Normalize degenerate tail/head duplicates before any join math. this.CloseVertexPath(this.closed != 0); if (src.Length < 3) { // Very short contours cannot be treated as closed reliably. this.closed = 0; } this.status = Status.Ready; break; case Status.Ready: // Require enough vertices for either open (2) or closed (3+) processing. if (src.Length < 2 + (this.closed != 0 ? 1 : 0)) { cmd = PathCommand.Stop; break; } this.status = this.closed != 0 ? Status.Outline1 : Status.Cap1; cmd = PathCommand.MoveTo; this.srcVertex = 0; this.outVertex = 0; break; case Status.Cap1: // Open path: emit start cap first. ref StrokeVertexDistance start = ref src[0]; ref StrokeVertexDistance startNext = ref src[1]; this.CalcCap(ref start, ref startNext, start.Distance); this.srcVertex = 1; this.prevStatus = Status.Outline1; this.status = Status.OutVertices; this.outVertex = 0; break; case Status.Cap2: // Open path: emit terminal cap before reversing through side 2. int lastIndex = src.Length - 1; ref StrokeVertexDistance end = ref src[lastIndex]; ref StrokeVertexDistance endPrev = ref src[lastIndex - 1]; this.CalcCap(ref end, ref endPrev, endPrev.Distance); this.prevStatus = Status.Outline2; this.status = Status.OutVertices; this.outVertex = 0; break; case Status.Outline1: int srcLength = src.Length; if (this.closed != 0) { if (this.srcVertex >= srcLength) { // Closed path switches to second side through an explicit endpoly. this.prevStatus = Status.CloseFirst; this.status = Status.EndPoly1; break; } } else if (this.srcVertex >= srcLength - 1) { this.status = Status.Cap2; break; } // Emit join vertices for side 1 (forward traversal). int index = this.srcVertex; int prevIndex = index == 0 ? srcLength - 1 : index - 1; int nextIndex = index + 1 == srcLength ? 0 : index + 1; ref StrokeVertexDistance prev = ref src[prevIndex]; ref StrokeVertexDistance curr = ref src[index]; ref StrokeVertexDistance next = ref src[nextIndex]; this.CalcJoin( ref prev, ref curr, ref next, prev.Distance, curr.Distance); this.srcVertex++; this.prevStatus = this.status; this.status = Status.OutVertices; this.outVertex = 0; break; case Status.CloseFirst: // Start second side as a new contour command stream. cmd = PathCommand.MoveTo; this.status = Status.Outline2; break; case Status.Outline2: int srcLength2 = src.Length; if (this.srcVertex <= (this.closed == 0 ? 1 : 0)) { this.status = Status.EndPoly2; this.prevStatus = Status.Stop; break; } this.srcVertex--; // Emit join vertices for side 2 (reverse traversal). int reverseIndex = this.srcVertex; int reverseNextIndex = reverseIndex + 1 == srcLength2 ? 0 : reverseIndex + 1; int reversePrevIndex = reverseIndex == 0 ? srcLength2 - 1 : reverseIndex - 1; ref StrokeVertexDistance reverseNext = ref src[reverseNextIndex]; ref StrokeVertexDistance reverseCurr = ref src[reverseIndex]; ref StrokeVertexDistance reversePrev = ref src[reversePrevIndex]; this.CalcJoin( ref reverseNext, ref reverseCurr, ref reversePrev, reverseCurr.Distance, reversePrev.Distance); this.prevStatus = this.status; this.status = Status.OutVertices; this.outVertex = 0; break; case Status.OutVertices: if (this.outVertex >= this.outVertices.Length) { // Re-enter previous phase once buffered join/cap points are flushed. this.status = this.prevStatus; } else { point = this.outVertices[this.outVertex++]; return cmd; } break; case Status.EndPoly1: this.status = this.prevStatus; // First side is emitted counter-clockwise. return PathCommand.EndPoly | (PathCommand)(PathFlags.Close | PathFlags.Ccw); case Status.EndPoly2: this.status = this.prevStatus; // Second side is emitted clockwise. return PathCommand.EndPoly | (PathCommand)(PathFlags.Close | PathFlags.Cw); case Status.Stop: cmd = PathCommand.Stop; break; } } return cmd; } /// /// Removes duplicate tail/head points and optionally enforces closed-loop source topology. /// /// Whether closing normalization should be applied. private void CloseVertexPath(bool close) { // Collapse duplicated trailing points while preserving a measured segment. while (this.srcVertices.Length > 1) { ref StrokeVertexDistance vd1 = ref this.srcVertices[^2]; ref StrokeVertexDistance vd2 = ref this.srcVertices[^1]; bool ret = vd1.Measure(vd2); if (ret) { break; } StrokeVertexDistance tail = this.srcVertices[^1]; if (this.srcVertices.Length != 0) { this.srcVertices.RemoveLast(); } if (this.srcVertices.Length != 0) { this.srcVertices.RemoveLast(); } this.Add(tail.X, tail.Y, tail.Distance); } if (!close) { return; } // For closed paths, also remove zero-length seam between final and initial points. while (this.srcVertices.Length > 1) { ref StrokeVertexDistance vd1 = ref this.srcVertices[^1]; ref StrokeVertexDistance vd2 = ref this.srcVertices[0]; bool ret = vd1.Measure(vd2); if (ret) { break; } if (this.srcVertices.Length != 0) { this.srcVertices.RemoveLast(); } } } /// /// Emits interpolated arc vertices between two offset vectors around a join center. /// /// Join center X. /// Join center Y. /// First offset vector X. /// First offset vector Y. /// Second offset vector X. /// Second offset vector Y. private void CalcArc(double x, double y, double dx1, double dy1, double dx2, double dy2) { double strokeWidth = this.strokeWidth; double a1 = Math.Atan2(dy1 * this.widthSign, dx1 * this.widthSign); double a2 = Math.Atan2(dy2 * this.widthSign, dx2 * this.widthSign); // Derive angular step from arc detail scale and stroke radius. double da = Math.Acos(this.widthAbs / (this.widthAbs + (0.125D / this.ArcDetailScale))) * 2D; this.AddPoint(x + dx1, y + dy1); if (this.widthSign > 0) { if (a1 > a2) { a2 += PiMul2; } // Sweep forward for positive widths. int n = (int)((a2 - a1) / da); da = (a2 - a1) / (n + 1); a1 += da; for (int i = 0; i < n; i++) { this.AddPoint(x + (Math.Cos(a1) * strokeWidth), y + (Math.Sin(a1) * strokeWidth)); a1 += da; } } else { if (a1 < a2) { a2 -= PiMul2; } // Sweep backward for negative widths. int n = (int)((a1 - a2) / da); da = (a1 - a2) / (n + 1); a1 -= da; for (int i = 0; i < n; i++) { this.AddPoint(x + (Math.Cos(a1) * strokeWidth), y + (Math.Sin(a1) * strokeWidth)); a1 -= da; } } this.AddPoint(x + dx2, y + dy2); } /// /// Emits miter/revert/round join geometry, including fallback behavior when intersection is unstable. /// /// Previous source vertex. /// Current source vertex. /// Next source vertex. /// First offset vector X. /// First offset vector Y. /// Second offset vector X. /// Second offset vector Y. /// Requested line join mode. /// Miter limit in stroke-width units. /// Distance of bevel midpoint from join center. private void CalcMiter( ref StrokeVertexDistance v0, ref StrokeVertexDistance v1, ref StrokeVertexDistance v2, double dx1, double dy1, double dx2, double dy2, LineJoin lineJoin, double miterLimit, double bevelDistance) { Vertex p0 = new(v0.X, v0.Y); Vertex p1 = new(v1.X, v1.Y); Vertex p2 = new(v2.X, v2.Y); Vertex offset1 = new(dx1, -dy1); Vertex offset2 = new(dx2, -dy2); double xi = v1.X; double yi = v1.Y; double intersectionDistance = 1D; double limit = this.widthAbs * miterLimit; bool miterLimitExceeded = true; bool intersectionFailed = true; // Intersect the two offset support lines to obtain the geometric miter apex. if (TryCalcIntersection( p0 + offset1, p1 + offset1, p1 + offset2, p2 + offset2, out Vertex intersection)) { xi = intersection.X; yi = intersection.Y; intersectionDistance = Vertex.Distance(p1, intersection); if (intersectionDistance <= limit) { this.AddPoint(xi, yi); miterLimitExceeded = false; } intersectionFailed = false; } else { // If lines are parallel/near-parallel, probe a fallback candidate. double x2 = v1.X + dx1; double y2 = v1.Y - dy1; Vertex probe = new(x2, y2); if ((CrossProduct(v0, v1, probe) < 0D) == (CrossProduct(v1, v2, probe) < 0D)) { this.AddPoint(v1.X + dx1, v1.Y - dy1); miterLimitExceeded = false; } } if (!miterLimitExceeded) { return; } // Join-style-specific overflow behavior when the true miter exceeds limit. switch (lineJoin) { case LineJoin.MiterRevert: this.AddPoint(v1.X + dx1, v1.Y - dy1); this.AddPoint(v1.X + dx2, v1.Y - dy2); break; case LineJoin.MiterRound: this.CalcArc(v1.X, v1.Y, dx1, -dy1, dx2, -dy2); break; default: if (intersectionFailed) { // No reliable apex: project a clipped bevel using local tangent/perpendicular vectors. miterLimit *= this.widthSign; this.AddPoint(v1.X + dx1 + (dy1 * miterLimit), v1.Y - dy1 + (dx1 * miterLimit)); this.AddPoint(v1.X + dx2 - (dy2 * miterLimit), v1.Y - dy2 - (dx2 * miterLimit)); } else { // Blend from bevel corners toward true intersection to honor miter limit. double x1 = v1.X + dx1; double y1 = v1.Y - dy1; double x2 = v1.X + dx2; double y2 = v1.Y - dy2; intersectionDistance = (limit - bevelDistance) / (intersectionDistance - bevelDistance); this.AddPoint(x1 + ((xi - x1) * intersectionDistance), y1 + ((yi - y1) * intersectionDistance)); this.AddPoint(x2 + ((xi - x2) * intersectionDistance), y2 + ((yi - y2) * intersectionDistance)); } break; } } /// /// Emits cap geometry for an open contour endpoint. /// /// Cap anchor vertex. /// Adjacent source vertex used to determine tangent direction. /// Length of the incident segment. private void CalcCap(ref StrokeVertexDistance v0, ref StrokeVertexDistance v1, double len) { this.outVertices.Clear(); double strokeWidth = this.strokeWidth; if (len < VertexDistanceEpsilon) { this.AddPoint(v0.X, v0.Y); this.AddPoint(v1.X, v1.Y); return; } double dx1 = (v1.Y - v0.Y) / len; double dy1 = (v1.X - v0.X) / len; double dx2 = 0D; double dy2 = 0D; dx1 *= strokeWidth; dy1 *= strokeWidth; if (this.LineCap != LineCap.Round) { if (this.LineCap == LineCap.Square) { // Square caps extend half-width in tangent direction. dx2 = dy1 * this.widthSign; dy2 = dx1 * this.widthSign; } this.AddPoint(v0.X - dx1 - dx2, v0.Y + dy1 - dy2); this.AddPoint(v0.X + dx1 - dx2, v0.Y - dy1 - dy2); } else { // Round cap emitted as half-circle arc around endpoint. double da = Math.Acos(this.widthAbs / (this.widthAbs + (0.125D / this.ArcDetailScale))) * 2D; int n = (int)(Pi / da); da = Pi / (n + 1); this.AddPoint(v0.X - dx1, v0.Y + dy1); if (this.widthSign > 0) { double a1 = Math.Atan2(dy1, -dx1) + da; for (int i = 0; i < n; i++) { this.AddPoint(v0.X + (Math.Cos(a1) * strokeWidth), v0.Y + (Math.Sin(a1) * strokeWidth)); a1 += da; } } else { double a1 = Math.Atan2(-dy1, dx1) - da; for (int i = 0; i < n; i++) { this.AddPoint(v0.X + (Math.Cos(a1) * strokeWidth), v0.Y + (Math.Sin(a1) * strokeWidth)); a1 -= da; } } this.AddPoint(v0.X + dx1, v0.Y - dy1); } } /// /// Emits join geometry for a source vertex using configured inner/outer join rules. /// /// Previous source vertex. /// Current source vertex. /// Next source vertex. /// Length of segment v0-v1. /// Length of segment v1-v2. private void CalcJoin(ref StrokeVertexDistance v0, ref StrokeVertexDistance v1, ref StrokeVertexDistance v2, double len1, double len2) { const double eps = VertexDistanceEpsilon; double strokeWidth = this.strokeWidth; double widthAbs = this.widthAbs; if (len1 < eps || len2 < eps) { this.outVertices.Clear(); // Degenerate neighborhood: use best available segment direction for both offsets. double l1 = len1 >= eps ? len1 : len2; double l2 = len2 >= eps ? len2 : len1; double invL1 = strokeWidth / l1; double invL2 = strokeWidth / l2; Vertex p0 = new(v0.X, v0.Y); Vertex p1 = new(v1.X, v1.Y); Vertex p2 = new(v2.X, v2.Y); Vertex seg1 = p1 - p0; Vertex seg2 = p2 - p1; double offX1 = seg1.Y * invL1; double offY1 = seg1.X * invL1; double offX2 = seg2.Y * invL2; double offY2 = seg2.X * invL2; this.AddPoint(v1.X + offX1, v1.Y - offY1); this.AddPoint(v1.X + offX2, v1.Y - offY2); return; } Vertex v0Vertex = new(v0.X, v0.Y); Vertex v1Vertex = new(v1.X, v1.Y); Vertex v2Vertex = new(v2.X, v2.Y); Vertex segForward = v1Vertex - v0Vertex; Vertex segNext = v2Vertex - v1Vertex; double invLen1 = strokeWidth / len1; double invLen2 = strokeWidth / len2; double dx1 = segForward.Y * invLen1; double dy1 = segForward.X * invLen1; double dx2 = segNext.Y * invLen2; double dy2 = segNext.X * invLen2; this.outVertices.Clear(); // Cross-product sign classifies whether we are on an inner corner or outer corner // relative to stroke direction. double cp = Vertex.Cross(segNext, segForward); if (Math.Abs(cp) > double.Epsilon && (cp > 0D) == (strokeWidth > 0D)) { double limit = Math.Min(len1, len2) / widthAbs; if (limit < InnerMiterLimit) { limit = InnerMiterLimit; } this.CalcMiter(ref v0, ref v1, ref v2, dx1, dy1, dx2, dy2, LineJoin.MiterRevert, limit, 0D); } else { // Outer join path. Vertex averageOffset = new Vertex(dx1 + dx2, dy1 + dy2) * 0.5D; double bevelDistance = averageOffset.Length(); if (this.LineJoin is LineJoin.Round or LineJoin.Bevel && this.ArcDetailScale * (this.widthAbs - bevelDistance) < this.widthEps) { // Near-collinear optimization: collapse to single intersection point when possible. Vertex outerOffset1 = new(dx1, -dy1); Vertex outerOffset2 = new(dx2, -dy2); if (TryCalcIntersection( v0Vertex + outerOffset1, v1Vertex + outerOffset1, v1Vertex + outerOffset2, v2Vertex + outerOffset2, out Vertex intersection)) { this.AddPoint(intersection.X, intersection.Y); } else { this.AddPoint(v1.X + dx1, v1.Y - dy1); } return; } switch (this.LineJoin) { case LineJoin.Miter: case LineJoin.MiterRevert: case LineJoin.MiterRound: this.CalcMiter(ref v0, ref v1, ref v2, dx1, dy1, dx2, dy2, this.LineJoin, this.MiterLimit, bevelDistance); break; case LineJoin.Round: this.CalcArc(v1.X, v1.Y, dx1, -dy1, dx2, -dy2); break; default: this.AddPoint(v1.X + dx1, v1.Y - dy1); this.AddPoint(v1.X + dx2, v1.Y - dy2); break; } } } /// /// Appends a computed output vertex to the current join/cap vertex buffer. /// /// X coordinate. /// Y coordinate. [MethodImpl(MethodImplOptions.AggressiveInlining)] private void AddPoint(double x, double y) => this.outVertices.Add(new Vertex(x, y)); /// /// Creates cap geometry for a single-point contour. /// /// Point X. /// Point Y. /// An implicitly closed contour representing the cap footprint. private Contour GeneratePointCap(double x, double y) { if (this.LineCap == LineCap.Round) { // Emit a full circle when a contour collapses to a point. double da = Math.Acos(this.widthAbs / (this.widthAbs + (0.125D / this.ArcDetailScale))) * 2D; int n = Math.Max(4, (int)(PiMul2 / da)); double angleStep = PiMul2 / n; Contour result = new(n); for (int i = 0; i < n; i++) { double angle = i * angleStep; result.Add(new Vertex( x + (Math.Cos(angle) * this.strokeWidth), y + (Math.Sin(angle) * this.strokeWidth))); } return result; } double w = this.strokeWidth; Contour square = [ new Vertex(x - w, y - w), new Vertex(x + w, y - w), new Vertex(x + w, y + w), new Vertex(x - w, y + w) ]; return square; } /// /// Computes the oriented area/cross-product used for turn classification. /// /// First segment start. /// First segment end. /// Third point. /// Signed cross product value. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static double CrossProduct(in StrokeVertexDistance a, in StrokeVertexDistance b, in Vertex point) => ((point.X - b.X) * (b.Y - a.Y)) - ((point.Y - b.Y) * (b.X - a.X)); /// /// Computes line intersection for two infinite lines defined by segment endpoints. /// /// First line start. /// First line end. /// Second line start. /// Second line end. /// Receives the intersection point when available. /// if lines intersect robustly; otherwise . [MethodImpl(MethodImplOptions.AggressiveInlining)] private static bool TryCalcIntersection(in Vertex a, in Vertex b, in Vertex c, in Vertex d, out Vertex intersection) { Vertex ab = b - a; Vertex cd = d - c; double denominator = Vertex.Cross(ab, cd); if (Math.Abs(denominator) < IntersectionEpsilon) { // Parallel or numerically unstable near-parallel lines. intersection = default; return false; } double t = Vertex.Cross(c - a, cd) / denominator; intersection = a + (ab * t); return true; } } #pragma warning restore SA1201 // Elements should appear in the correct order }