From 4a8901edd3a1acd8d0adf03f25d00eb7e28406bb Mon Sep 17 00:00:00 2001 From: Chris Marchesi Date: Sat, 19 Sep 2026 10:31:03 -0700 Subject: [PATCH] dashed_plotter: use rotational direction while closing When processing the joins for dashed closes where the on segment traverses both the last point and the initial point, this ensures that the caps are plotted correctly to prevent twisting and other artifacts. This was ultimately caused by the fact that when we do the close we ultimately transfer the main plotter state in part to the initial segment state (aka the initial polygon), but we missed transferring any existing clockwise state. --- spec/086_stroke_dash_close_multiple_join.zig | 36 ++++++++++++++++++ ...oke_dash_close_multiple_join_pixelated.png | Bin 0 -> 1456 bytes ...stroke_dash_close_multiple_join_smooth.png | Bin 0 -> 1552 bytes spec/main_bench.zig | 2 + spec/main_spec.zig | 6 +++ src/internal/tess/dashed_plotter.zig | 2 + 6 files changed, 46 insertions(+) create mode 100644 spec/086_stroke_dash_close_multiple_join.zig create mode 100644 spec/files/086_stroke_dash_close_multiple_join_pixelated.png create mode 100644 spec/files/086_stroke_dash_close_multiple_join_smooth.png diff --git a/spec/086_stroke_dash_close_multiple_join.zig b/spec/086_stroke_dash_close_multiple_join.zig new file mode 100644 index 0000000..a4c4bce --- /dev/null +++ b/spec/086_stroke_dash_close_multiple_join.zig @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: 0BSD +// Copyright © 2024-2026 Chris Marchesi + +//! Case: Renders a closed dashed path that traverses multiple corners on the +//! close. This tests that direction is retained on the close and the correct +//! caps are applied on the correct contours. +const Io = @import("std").Io; +const mem = @import("std").mem; + +const z2d = @import("z2d"); + +pub const filename = "086_stroke_dash_close_multiple_join"; + +pub fn render(io: Io, alloc: mem.Allocator, aa_mode: z2d.options.AntiAliasMode) !z2d.Surface { + const width = 300; + const height = 300; + var sfc = try z2d.Surface.init(.image_surface_rgb, alloc, width, height); + + var context = z2d.Context.init(io, alloc, &sfc); + defer context.deinit(); + context.setSourceToPixel(.{ .rgb = .{ .r = 0xFF, .g = 0xFF, .b = 0xFF } }); + context.setAntiAliasingMode(aa_mode); + context.setDashes(&.{ 90, 30 }); + context.setDashOffset(60); + context.setLineWidth(6); + + try context.moveTo(50, 150); + try context.lineTo(150, 250); + try context.lineTo(250, 50); + try context.lineTo(50, 100); + try context.closePath(); + + try context.stroke(); + + return sfc; +} diff --git a/spec/files/086_stroke_dash_close_multiple_join_pixelated.png b/spec/files/086_stroke_dash_close_multiple_join_pixelated.png new file mode 100644 index 0000000000000000000000000000000000000000..79d339b6ddb7289e475b7898d41cd1f2a540184d GIT binary patch literal 1456 zcmeAS@N?(olHy`uVBq!ia0y~yVAKI&4kn<;H+e}V1_su0PZ!6KiaBrZ94tyU=V5X9 z|KC6V6nlEI^dtp0e}x0@l22)WDok7V6R3kYQ1<1y_q+3r;Xi~={tZqDwXorC$-XOp zul{KrzsHH*ceC&CGjD&ott|MRz=Pd)_Q{=|%T{wz_ciCvdGzQRb@_M97lTuv?3MKZ*F+ucA_Tnw7KT5ZQ{-UwROJd z@08ztVczy8*TjYIPyhW8YPLtnM)yr&+`9>@b1GClICt+>D8B^~y(1h}=J;+)`HeqS zA2ipzY`T+~fA#6SIT|_7fy`8^doxcSa??98H*Edar!$px9svcbB|j^E5uTP0)a85j zw^EsSSsswdmR2vjo1=Q0LV1*WdySgup(@=IbFW=(oPTEPAukZ~-pTvtv|D|nCRpc+ zyg#sOgDrQ_^0QAIE21K1vF^P5?6bv^7T3rL)^8Q!I;#)W>71BbrlIequ;%FIBKNuH zE(>zco;dM$<-booM-HXkne^L}>E7hohl(^#ovw_27udHtQi1=)k*na)5HUV<@5r^e ztlLD6sqI|ma??%XD*w*-B;}$zu`@2z?vzgxce)ce!z=Ecz&3@0y)B7AtB&clMCkA& zIKDI4rhHcF;ck7P&^484g_KmB*&FTwKO4|nTN|CQ)Hx!32%7bc4d zd{aKYtBFX^^*`S(clbmrKG1M7~MwF#D~Zj;p6V0iLuLQAh_9s~Cq-wI}l#Qa0A z&oSK=PAnJ7(eDZ9ian9nWZ)WErnp1#kiyZO_7i)Bj%i*wdop{LLEz3j?;zvWonGbs zNmAN9VFz{#UMNUBkaVxB z&KKO&nyj7CnDg+7(!4)!-zeogRE)Lxy!vL~jibU+l@HW6uiwzO*}L+}@k`DTrw(4V zdG7sE<;~L*W%EAtmWXaEj&na=uduh3b#BPzgY%EcB>EnoQu?ag(D%j_#@e~xU46v9 zZLQ93ujTvPI`wVz9Ygs;k%u0ct$p9K{((zk`EAo3zyDgs-gppan^#-;LuA?x@2Run zr0cj=-&u4)_C3$W4QFSU@4BA*aN~x{*5O?d|S^+$A?39$)#tyRh# zREcqX?Gbntsc^;EX+?TtzE`kZh%sN0$I2`_YPylT^uXyZbPk4XuqUy>A$ z_nNqbnYfgH9~@{_xWTNjsRZwxm&LbR+8#Hk)>K(#(CIsI5fdMLu#o{K1dtyXE$Ub(aNa0@l!C+W7zXB$#@Utg&R(yK@bAxWr`;T0T z%e@bt2&t=D@+6wKP5AKuFu;7mc8~aCUM&y^JXDxJsU7qx)3;7(^In!fv5TfnAMfF% zUxS?MX51on+cDrk`i%ITC`NlE#RJJ-*Hyox)_!l=^a)S&zl2M|@&yyNMYIR9XSFtY zmZfYq`}xViu`9F()yH|CSDLdoLz+UUeB+3*0cvdV;>A(W4DqB~1*W(TBs7C5CIbmwplj zwlJ<{&HcNWA%J}2$IhdJwL$i3gz{CqIc>V8-LJf)Q*QbUeb+vIt7*}y1lur zy|!>)wWVe0{O3>g<8R`H(5QM&EW2mDXv>s4>U0p?e&~6UU&(+*5((CnHZZm;PqOAVclb&cupWz=f?)A$&gi`Q3K6yXDAY51u+r`w~Hdl7Gf-O7OjM^wStHJDD zl?iknNl)Ra0b!&TMhrFTq*zqk1 ztB!5pe_+0f56UHzIP0gqcduThmF{z5wtTdh-NbL_Jyd)?OR<6bT&$32px#+YRYz3? zNV1_(5#cBWCEIX_h&0BD*#ypp@XaI&d5$yK9 zFpg6~X;-j7IRBRH8g=42*JsGXTPp6r-ak6rY21%AGGK!Eb#lhpp$lcC35Eb6nJ}@Q oNNn+a literal 0 HcmV?d00001 diff --git a/spec/main_bench.zig b/spec/main_bench.zig index 970a4ff..2b64d30 100644 --- a/spec/main_bench.zig +++ b/spec/main_bench.zig @@ -100,6 +100,7 @@ const _082_stroke_hairline_clip = @import("082_stroke_hairline_clip.zig"); const _083_star_simplify_offset = @import("083_star_simplify_offset.zig"); const _084_offset_ghostty_conformance = @import("084_offset_ghostty_conformance.zig"); const _085_deja_sans_ignore_invalid_points = @import("085_deja_sans_ignore_invalid_points.zig"); +const _086_stroke_dash_close_multiple_join = @import("086_stroke_dash_close_multiple_join.zig"); ////////////////////////////////////////////////////////////////////////////// @@ -222,6 +223,7 @@ pub fn main() !void { try addPathBenchmark(&bench, _083_star_simplify_offset); try addPathBenchmark(&bench, _084_offset_ghostty_conformance); try addPathBenchmark(&bench, _085_deja_sans_ignore_invalid_points); + try addPathBenchmark(&bench, _086_stroke_dash_close_multiple_join); var threaded: Io.Threaded = .init_single_threaded; const io = threaded.io(); diff --git a/spec/main_spec.zig b/spec/main_spec.zig index 53454f2..5178a4b 100644 --- a/spec/main_spec.zig +++ b/spec/main_spec.zig @@ -99,6 +99,7 @@ const _082_stroke_hairline_clip = @import("082_stroke_hairline_clip.zig"); const _083_star_simplify_offset = @import("083_star_simplify_offset.zig"); const _084_offset_ghostty_conformance = @import("084_offset_ghostty_conformance.zig"); const _085_deja_sans_ignore_invalid_points = @import("085_deja_sans_ignore_invalid_points.zig"); +const _086_stroke_dash_close_multiple_join = @import("086_stroke_dash_close_multiple_join.zig"); ////////////////////////////////////////////////////////////////////////////// @@ -195,6 +196,7 @@ pub fn main() !void { try pathExportRun(io, alloc, _083_star_simplify_offset); try pathExportRun(io, alloc, _084_offset_ghostty_conformance); try pathExportRun(io, alloc, _085_deja_sans_ignore_invalid_points); + try pathExportRun(io, alloc, _086_stroke_dash_close_multiple_join); } ////////////////////////////////////////////////////////////////////////////// @@ -539,6 +541,10 @@ test "085_deja_sans_ignore_invalid_points" { try pathTestRun(testing.io, testing.allocator, _085_deja_sans_ignore_invalid_points); } +test "086_stroke_dash_close_multiple_join" { + try pathTestRun(testing.io, testing.allocator, _086_stroke_dash_close_multiple_join); +} + ////////////////////////////////////////////////////////////////////////////// fn compositorExportRun(io: Io, alloc: mem.Allocator, subject: anytype) !void { diff --git a/src/internal/tess/dashed_plotter.zig b/src/internal/tess/dashed_plotter.zig index e55af07..cbacea0 100644 --- a/src/internal/tess/dashed_plotter.zig +++ b/src/internal/tess/dashed_plotter.zig @@ -580,6 +580,8 @@ const Plotter = struct { // We need to replace the initial polygon with the outer due to the // direction of the concat happened in. self.initial_polygon.on.outer = self.outer; + // Retain clockwise direction from existing polygon + self.initial_polygon.on.clockwise_ = self.clockwise_; // Our first cap points are based entirely off of the plotter state // (not the initial state).