Skip to content

Commit 9b612ed

Browse files
Merge pull request #417 from SixLabors/js/round-join-arc-guard
Round join tessellation and CPU edge accuracy
2 parents 61bd770 + 9bbd0a5 commit 9b612ed

256 files changed

Lines changed: 800 additions & 511 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎Directory.Build.props‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,13 @@
1818
<!-- Import the shared global .props file -->
1919
<Import Project="$(MSBuildThisFileDirectory)shared-infrastructure\msbuild\props\SixLabors.Global.props" />
2020

21+
<!--
22+
Suppress the advisory against Microsoft.Build.Tasks.Git 8.0.0, pulled in by the SourceLink
23+
package pinned in shared infrastructure, while we wait for the ability to update it.
24+
https://github.com/advisories/GHSA-23fw-v26w-5fgq
25+
-->
26+
<ItemGroup>
27+
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-23fw-v26w-5fgq" />
28+
</ItemGroup>
29+
2130
</Project>

‎src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/path_lowering.wgsl‎

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -84,8 +84,8 @@ fn stroke_normalize_positive_angle(angle: f32) -> f32 {
8484
return a;
8585
}
8686

87-
// Port of the CPU stroker's GetArcSubdivisionCount (round cap tessellation): returns the number
88-
// of interior vertices needed to keep the arc's chordal error within the arc detail scale.
87+
// Port of the CPU stroker's GetArcSubdivisionCount (round join and cap tessellation): returns the
88+
// number of interior vertices needed to keep the arc's chordal error within the arc detail scale.
8989
fn stroke_arc_subdivision_count(radius: f32, sweep: f32, arc_detail_scale: f32) -> u32 {
9090
let safe_radius = max(radius, TANGENT_THRESH);
9191
let safe_scale = max(arc_detail_scale, 0.01);
@@ -129,8 +129,8 @@ fn stroke_chain_arc(
129129
}
130130

131131
// Port of PolygonStroker.CalcArc (round joins): sweeps from offset o1 to offset o2 around corner
132-
// v1 with a fixed angular step derived from the arc detail scale. Both endpoints are appended,
133-
// so the caller's chain need not already sit on the arc.
132+
// v1 with the interior vertex count from stroke_arc_subdivision_count, shared with round caps.
133+
// Both endpoints are appended, so the caller's chain need not already sit on the arc.
134134
fn stroke_calc_arc(
135135
path_ix: u32, last: ptr<function, vec2f>,
136136
v1: vec2f, o1: vec2f, o2: vec2f,
@@ -141,15 +141,13 @@ fn stroke_calc_arc(
141141
// must rotate o1; absolute angles of the offsets are not computable here.
142142
let cross_oo = (o1.x * o2.y) - (o1.y * o2.x);
143143
let sweep = stroke_normalize_positive_angle(atan2(cross_oo, dot(o1, o2)));
144-
let da = acos(half_width / (half_width + (0.125 / arc_detail_scale))) * 2.0;
145144
stroke_chain_point(path_ix, last, v1 + o1, transform);
146-
// Bounded for GPU safety; matches the CPU count for all real detail scales.
147-
let n = clamp(i32(sweep / da), 0, 1024);
148-
let step = sweep / f32(n + 1);
145+
let n = stroke_arc_subdivision_count(half_width, sweep, arc_detail_scale);
146+
let step = sweep / f32(n + 1u);
149147
let rot_c = cos(step);
150148
let rot_s = sin(step);
151149
var offset = o1;
152-
for (var i = 0; i < n; i += 1) {
150+
for (var i = 1u; i <= n; i += 1u) {
153151
offset = vec2((offset.x * rot_c) - (offset.y * rot_s), (offset.x * rot_s) + (offset.y * rot_c));
154152
stroke_chain_point(path_ix, last, v1 + offset, transform);
155153
}

‎src/ImageSharp.Drawing/Processing/Backends/DefaultRasterizer.Linearizer.cs‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -700,11 +700,15 @@ private void SplitAcrossBands(int x0, int y0, int x1, int y1, uint tag)
700700

701701
while (currentBand != endBand)
702702
{
703-
// Walk to the band boundary in the direction of travel, interpolating X in
704-
// 64-bit so the dx * deltaY product cannot overflow 32-bit fixed point.
703+
// Walk to the band boundary in the direction of travel. X at the boundary is
704+
// interpolated from the segment's own endpoint and rounded to nearest, so every
705+
// boundary sits within half a fixed-point unit of the true edge. Stepping from the
706+
// previous boundary instead accumulates the truncation of each division along the
707+
// segment. The product is formed in 64-bit so dx * deltaY cannot overflow.
705708
int bandBoundaryY = dy > 0 ? bandTopStart + ((currentBand + 1) * bandHeight) : bandTopStart + (currentBand * bandHeight);
706-
int deltaY = bandBoundaryY - currentY;
707-
int nextX = currentX + (int)(((long)dx * deltaY) / dy);
709+
long numerator = (long)dx * (bandBoundaryY - y0);
710+
long half = dy / 2;
711+
int nextX = x0 + (int)((numerator ^ dy) >= 0 ? (numerator + half) / dy : (numerator - half) / dy);
708712
int rowTop = bandTopStart + (currentBand * bandHeight);
709713

710714
// Each retained segment is stored in the local coordinate space of its owning band.

‎src/ImageSharp.Drawing/Processing/Backends/DefaultRasterizer.Stroke.cs‎

Lines changed: 25 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,31 @@ internal static partial class DefaultRasterizer
202202
rowBandCount));
203203
}
204204

205+
/// <summary>
206+
/// Returns the tessellation segment count used for one round join or cap arc.
207+
/// Ported to the GPU as <c>stroke_arc_subdivision_count</c> in <c>path_lowering.wgsl</c>;
208+
/// changes here must be mirrored there.
209+
/// </summary>
210+
/// <param name="radius">The arc radius.</param>
211+
/// <param name="angle">The arc sweep angle in radians.</param>
212+
/// <param name="arcDetailScale">The tessellation detail scale.</param>
213+
/// <returns>The number of intermediate tessellation points.</returns>
214+
public static int GetArcSubdivisionCount(float radius, double angle, double arcDetailScale)
215+
{
216+
double safeRadius = Math.Max(radius, StrokeDirectionEpsilon);
217+
double safeScale = Math.Max(arcDetailScale, 0.01D);
218+
219+
// Chordal-error step: theta is the largest angular step whose chord midpoint
220+
// deviates from the arc by at most 0.125 / scale pixels, so tessellation density
221+
// adapts to both radius and requested detail.
222+
double ratio = safeRadius / (safeRadius + (0.125D / safeScale));
223+
ratio = Math.Clamp(ratio, -1D, 1D);
224+
double theta = Math.Acos(ratio) * 2D;
225+
return theta <= 0D
226+
? 0
227+
: Math.Max(0, (int)(angle / theta));
228+
}
229+
205230
/// <summary>
206231
/// Expands one stroked centerline geometry once into retained per-band line storage.
207232
/// </summary>
@@ -1453,31 +1478,6 @@ private static void AppendEdgeInterval(
14531478
}
14541479
}
14551480

1456-
/// <summary>
1457-
/// Returns the tessellation segment count used for one round join or cap arc.
1458-
/// Ported to the GPU as <c>stroke_arc_subdivision_count</c> in <c>path_lowering.wgsl</c>;
1459-
/// changes here must be mirrored there.
1460-
/// </summary>
1461-
/// <param name="radius">The arc radius.</param>
1462-
/// <param name="angle">The arc sweep angle in radians.</param>
1463-
/// <param name="arcDetailScale">The tessellation detail scale.</param>
1464-
/// <returns>The number of intermediate tessellation points.</returns>
1465-
private static int GetArcSubdivisionCount(float radius, double angle, double arcDetailScale)
1466-
{
1467-
double safeRadius = Math.Max(radius, StrokeDirectionEpsilon);
1468-
double safeScale = Math.Max(arcDetailScale, 0.01D);
1469-
1470-
// AGG's chordal-error step: theta is the largest angular step whose chord midpoint
1471-
// deviates from the arc by at most 0.125 / scale pixels, so tessellation density
1472-
// adapts to both radius and requested detail.
1473-
double ratio = safeRadius / (safeRadius + (0.125D / safeScale));
1474-
ratio = Math.Clamp(ratio, -1D, 1D);
1475-
double theta = Math.Acos(ratio) * 2D;
1476-
return theta <= 0D
1477-
? 0
1478-
: Math.Max(0, (int)(angle / theta));
1479-
}
1480-
14811481
/// <summary>
14821482
/// Returns the stroke offset unit normal for a normalized tangent.
14831483
/// </summary>

‎src/ImageSharp.Drawing/Processing/Backends/DefaultRasterizer.StrokeLinearizer.cs‎

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -898,7 +898,8 @@ private void CalcMiter(
898898

899899
/// <summary>
900900
/// Direct port of <c>PolygonStroker.CalcArc</c>. Emits intermediate arc vertices
901-
/// around a join center between two offset vectors. Ported to the GPU as
901+
/// around a join center between two offset vectors. The interior vertex count comes from
902+
/// <see cref="GetArcSubdivisionCount"/>, shared with the round cap arcs. Ported to the GPU as
902903
/// <c>stroke_calc_arc</c> in <c>path_lowering.wgsl</c>; changes here must be mirrored there.
903904
/// </summary>
904905
/// <param name="contour">The active contour state.</param>
@@ -922,11 +923,6 @@ private void CalcArc(
922923
float strokeWidth = this.stroke.HalfWidth;
923924
double a1 = Math.Atan2(dy1, dx1);
924925
double a2 = Math.Atan2(dy2, dx2);
925-
926-
// AGG's chordal-error step: da is the largest angular step whose chord stays within
927-
// 0.125 / arc-detail-scale pixels of the true arc.
928-
double widthAbs = strokeWidth;
929-
double da = Math.Acos(widthAbs / (widthAbs + (0.125D / this.stroke.ArcDetailScale))) * 2D;
930926
this.AppendContourPoint(ref contour, new Vector2(x + dx1, y + dy1), contained);
931927

932928
// Wrap the end angle forward so the sweep is always positive.
@@ -937,8 +933,9 @@ private void CalcArc(
937933

938934
// Distribute the sweep evenly over n interior points so the last step lands exactly
939935
// on the end offset.
940-
int n = (int)((a2 - a1) / da);
941-
da = (a2 - a1) / (n + 1);
936+
double sweep = a2 - a1;
937+
int n = GetArcSubdivisionCount(strokeWidth, sweep, this.stroke.ArcDetailScale);
938+
double da = sweep / (n + 1);
942939
a1 += da;
943940
for (int i = 0; i < n; i++)
944941
{

‎src/ImageSharp.Drawing/Processing/Backends/DefaultRasterizer.cs‎

Lines changed: 30 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,8 @@ internal static partial class DefaultRasterizer
5555
private static readonly int WordBitCount = nint.Size * 8;
5656

5757
/// <summary>
58-
/// Right-shift that converts an accumulated doubled cell area (max 2 * 256 * 256) down to the
59-
/// 0..256 coverage step domain used by <see cref="Context.AreaToCoverage"/>.
58+
/// Left-shift that scales a 24.8 winding cover (256 per fully covered pixel) up to the doubled
59+
/// cell area domain accumulated by the cells (<see cref="FullCoverageArea"/> per fully covered pixel).
6060
/// </summary>
6161
private const int AreaToCoverageShift = 9;
6262

@@ -98,24 +98,24 @@ internal static partial class DefaultRasterizer
9898
private const int CrossingShift = ProfileIdBits + 1;
9999

100100
/// <summary>
101-
/// Number of discrete coverage steps per fully covered pixel (one 24.8 unit of winding).
101+
/// The doubled cell area of one fully covered pixel (2 * 256 * 256), one 24.8 unit of winding.
102102
/// </summary>
103-
private const int CoverageStepCount = 256;
103+
private const int FullCoverageArea = (FixedOne * FixedOne) << 1;
104104

105105
/// <summary>
106-
/// Bitmask implementing modulo 2 * <see cref="CoverageStepCount"/> for even-odd wrapping.
106+
/// Bitmask implementing modulo 2 * <see cref="FullCoverageArea"/> for even-odd wrapping.
107107
/// </summary>
108-
private const int EvenOddMask = (CoverageStepCount * 2) - 1;
108+
private const int EvenOddMask = (FullCoverageArea * 2) - 1;
109109

110110
/// <summary>
111111
/// Length of one even-odd winding period; values past the midpoint mirror back down.
112112
/// </summary>
113-
private const int EvenOddPeriod = CoverageStepCount * 2;
113+
private const int EvenOddPeriod = FullCoverageArea * 2;
114114

115115
/// <summary>
116-
/// Multiplier converting integer coverage steps to normalized [0, 1] coverage.
116+
/// Multiplier converting doubled cell area to normalized [0, 1] coverage.
117117
/// </summary>
118-
private const float CoverageScale = 1F / CoverageStepCount;
118+
private const float CoverageScale = 1F / FullCoverageArea;
119119

120120
/// <summary>
121121
/// Gets the preferred scene row height used by the CPU rasterizer.
@@ -1625,20 +1625,21 @@ private void CaptureCrossings(int x0, int y0, int x1, int y1, uint tag)
16251625
/// </summary>
16261626
/// <param name="area">
16271627
/// The accumulated doubled signed area in fixed-point cell units; a fully covered pixel
1628-
/// corresponds to 2 * 256 * 256, which <see cref="AreaToCoverageShift"/> maps to <see cref="CoverageStepCount"/>.
1628+
/// corresponds to <see cref="FullCoverageArea"/>.
16291629
/// </param>
16301630
/// <returns>The normalized coverage value in [0, 1].</returns>
16311631
[MethodImpl(MethodImplOptions.AggressiveInlining)]
16321632
private readonly float AreaToCoverage(int area)
16331633
{
1634-
int signedArea = area >> AreaToCoverageShift;
1635-
int absoluteArea = signedArea < 0 ? -signedArea : signedArea;
1634+
// The area keeps its full precision. Quantizing it to 1/256 steps before the blend
1635+
// moves edge pixels up to one output level away from the exact composite.
1636+
int absoluteArea = area < 0 ? -area : area;
16361637
float coverage;
16371638

16381639
if (this.intersectionRule == IntersectionRule.NonZero)
16391640
{
16401641
// Non-zero winding clamps absolute winding accumulation to [0, 1].
1641-
if (absoluteArea >= CoverageStepCount)
1642+
if (absoluteArea >= FullCoverageArea)
16421643
{
16431644
coverage = 1F;
16441645
}
@@ -1649,14 +1650,14 @@ private readonly float AreaToCoverage(int area)
16491650
}
16501651
else
16511652
{
1652-
// Even-odd wraps every 2*CoverageStepCount and mirrors second half.
1653+
// Even-odd wraps every 2 * FullCoverageArea and mirrors the second half.
16531654
int wrapped = absoluteArea & EvenOddMask;
1654-
if (wrapped > CoverageStepCount)
1655+
if (wrapped > FullCoverageArea)
16551656
{
16561657
wrapped = EvenOddPeriod - wrapped;
16571658
}
16581659

1659-
coverage = wrapped >= CoverageStepCount ? 1F : wrapped * CoverageScale;
1660+
coverage = wrapped >= FullCoverageArea ? 1F : wrapped * CoverageScale;
16601661
}
16611662

16621663
if (this.coverageBoost != 0F)
@@ -1992,10 +1993,12 @@ private void RowDownR(int rowIndex, int p0x, int p0y, int p1x, int p1y)
19921993
}
19931994

19941995
// pp/mod/lift/rem implement an integer DDA that advances y at column boundaries
1995-
// without accumulating rounding error; the remainder carries the exact fraction.
1996+
// without accumulating rounding error; the remainder carries the exact fraction. The
1997+
// half-divisor start term rounds every boundary to the nearest unit instead of
1998+
// flooring it, so the walked edge sits within half a unit of the true line.
19961999
int dx = p1x - p0x;
19972000
int dy = p1y - p0y;
1998-
int pp = (FixedOne - fx0) * dy;
2001+
int pp = ((FixedOne - fx0) * dy) + (dx >> 1);
19992002
int cy = p0y + (pp / dx);
20002003

20012004
this.Cell(rowIndex, columnIndex0, fx0, p0y, FixedOne, cy);
@@ -2073,7 +2076,7 @@ private void RowUpR(int rowIndex, int p0x, int p0y, int p1x, int p1y)
20732076

20742077
int dx = p1x - p0x;
20752078
int dy = p0y - p1y;
2076-
int pp = (FixedOne - fx0) * dy;
2079+
int pp = ((FixedOne - fx0) * dy) + (dx >> 1);
20772080
int cy = p0y - (pp / dx);
20782081

20792082
this.Cell(rowIndex, columnIndex0, fx0, p0y, FixedOne, cy);
@@ -2151,7 +2154,7 @@ private void RowDownL(int rowIndex, int p0x, int p0y, int p1x, int p1y)
21512154

21522155
int dx = p0x - p1x;
21532156
int dy = p1y - p0y;
2154-
int pp = fx0 * dy;
2157+
int pp = (fx0 * dy) + (dx >> 1);
21552158
int cy = p0y + (pp / dx);
21562159

21572160
this.Cell(rowIndex, columnIndex0, fx0, p0y, 0, cy);
@@ -2229,7 +2232,7 @@ private void RowUpL(int rowIndex, int p0x, int p0y, int p1x, int p1y)
22292232

22302233
int dx = p0x - p1x;
22312234
int dy = p0y - p1y;
2232-
int pp = fx0 * dy;
2235+
int pp = (fx0 * dy) + (dx >> 1);
22332236
int cy = p0y - (pp / dx);
22342237

22352238
this.Cell(rowIndex, columnIndex0, fx0, p0y, 0, cy);
@@ -2301,8 +2304,9 @@ private void LineDownR(int rowIndex0, int rowIndex1, int x0, int y0, int x1, int
23012304
int fy1 = y1 - (rowIndex1 << FixedShift);
23022305

23032306
// p/delta/mod/rem implement an integer DDA that advances x at row boundaries
2304-
// without per-row floating-point math.
2305-
int p = (FixedOne - fy0) * dx;
2307+
// without per-row floating-point math. The half-divisor start term rounds every
2308+
// boundary to the nearest unit instead of flooring it.
2309+
int p = ((FixedOne - fy0) * dx) + (dy >> 1);
23062310
int delta = p / dy;
23072311
int cx = x0 + delta;
23082312

@@ -2353,7 +2357,7 @@ private void LineUpR(int rowIndex0, int rowIndex1, int x0, int y0, int x1, int y
23532357
int fy1 = y1 - (rowIndex1 << FixedShift);
23542358

23552359
// Upward version of the same integer DDA stepping as LineDownR.
2356-
int p = fy0 * dx;
2360+
int p = (fy0 * dx) + (dy >> 1);
23572361
int delta = p / dy;
23582362
int cx = x0 + delta;
23592363

@@ -2403,7 +2407,7 @@ private void LineDownL(int rowIndex0, int rowIndex1, int x0, int y0, int x1, int
24032407
int fy1 = y1 - (rowIndex1 << FixedShift);
24042408

24052409
// Right-to-left variant of the integer DDA.
2406-
int p = (FixedOne - fy0) * dx;
2410+
int p = ((FixedOne - fy0) * dx) + (dy >> 1);
24072411
int delta = p / dy;
24082412
int cx = x0 - delta;
24092413

@@ -2453,7 +2457,7 @@ private void LineUpL(int rowIndex0, int rowIndex1, int x0, int y0, int x1, int y
24532457
int fy1 = y1 - (rowIndex1 << FixedShift);
24542458

24552459
// Upward + right-to-left variant of the integer DDA.
2456-
int p = fy0 * dx;
2460+
int p = (fy0 * dx) + (dy >> 1);
24572461
int delta = p / dy;
24582462
int cx = x0 - delta;
24592463

‎src/ImageSharp.Drawing/Processing/StrokeOptions.cs‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,22 @@ namespace SixLabors.ImageSharp.Drawing.Processing;
66
/// <inheritdoc cref="PolygonClipper.StrokeOptions" />
77
public sealed class StrokeOptions : IEquatable<StrokeOptions?>
88
{
9+
private double arcDetailScale = 1D;
10+
911
/// <inheritdoc cref="PolygonClipper.StrokeOptions.MiterLimit" />
1012
public double MiterLimit { get; set; } = 4D;
1113

1214
/// <inheritdoc cref="PolygonClipper.StrokeOptions.ArcDetailScale" />
13-
public double ArcDetailScale { get; set; } = 1D;
15+
/// <exception cref="ArgumentOutOfRangeException">The value is not greater than zero.</exception>
16+
public double ArcDetailScale
17+
{
18+
get => this.arcDetailScale;
19+
set
20+
{
21+
Guard.MustBeGreaterThan(value, 0, nameof(this.ArcDetailScale));
22+
this.arcDetailScale = value;
23+
}
24+
}
1425

1526
/// <inheritdoc cref="PolygonClipper.StrokeOptions.LineJoin" />
1627
public LineJoin LineJoin { get; set; } = LineJoin.Bevel;

0 commit comments

Comments
 (0)