Skip to content

Confirm / Fix miter joins in Canvas#4162

Merged
HalfWhitt merged 8 commits intobeeware:mainfrom
HalfWhitt:canvas-miter-join
Feb 7, 2026
Merged

Confirm / Fix miter joins in Canvas#4162
HalfWhitt merged 8 commits intobeeware:mainfrom
HalfWhitt:canvas-miter-join

Conversation

@HalfWhitt
Copy link
Copy Markdown
Member

@HalfWhitt HalfWhitt commented Feb 4, 2026

I noticed while working on #4159 that the Qt backend was beveling its line joins instead of mitering them. Thankfully it's a one-line fix.

Animation of problem and fix

As long as I was making such a minor change and it wouldn't be confusing, I went ahead and standardized the docstrings in the test file.

(Marking as "misc" instead of "bugfix" because the Qt Canvas widget has yet to be in a release.)

PR Checklist:

  • All new features have been tested
  • All new features have been documented
  • I have read the CONTRIBUTING.md file
  • I will abide by the code of conduct

@HalfWhitt
Copy link
Copy Markdown
Member Author

HalfWhitt commented Feb 4, 2026

Interesting. The Windows failure is a pretty minor difference in pixels, but Android is a bigger problem:

Android miter failure

@corranwebster
Copy link
Copy Markdown
Contributor

corranwebster commented Feb 4, 2026

Interesting. The Windows failure is a pretty minor difference in pixels, but Android is a bigger problem:

Windows miter failure

Most backends have a "miter limit" parameter or something like that that bevels sharp miter joins because they can get arbitrarily long. You may need to ensure that this is also set appropriately on all platforms.

Edit to add: this is probably what you want https://developer.android.com/reference/android/graphics/Paint#getStrokeMiter()

@johnzhou721
Copy link
Copy Markdown
Contributor

My 2 cents -- the HTML canvas has a default miter limit ratio of 10. Qt's value is 2... it may make sense to set the miter limit to 10 to match HTML5 on all platforms; or better yet, expose values for the user to do so. I think as a default for this PR as a bugfix, though, mitering and using a limit of 10 is fine.

(P.S... considering your job I think I know why you'd like miter joins :) )

@HalfWhitt
Copy link
Copy Markdown
Member Author

HalfWhitt commented Feb 5, 2026

It turns out it's a little more complicated than that... Qt treats the miter limit differently.

Miter limit

The miter limit describes how far a miter join can extend from the join point. This is used to reduce artifacts between line joins where the lines are close to parallel.

This value does only have effect when the pen style is set to Qt::MiterJoin. The value is specified in units of the pen’s width, e.g. a miter limit of 5 in width 10 is 50 pixels long. The default miter limit is 2, i.e. twice the pen width in pixels.

  1. It measures from the path itself*, not from the inside corner of the stroke edge. This alone wouldn't be a big deal; we'd just set it to 5 instead of 10.
  2. It measures along the outside of the stroke edge, rather than along the center line. The degree to which this matters gets less the sharper the angle is, at least...
  3. It doesn't specify when to abort mitering and switch to beveling; it instead specifies where to stop drawing the miter.

*That is, from the path intersection projected over to the edge of the stroke.

I doesn't seem like there is a way to individually turn mitering entirely on and off on a per-corner basis, short of a complicated approach of us drawing individual pairs of segments separately.

@HalfWhitt
Copy link
Copy Markdown
Member Author

HalfWhitt commented Feb 5, 2026

On the other hand, Windows seems to be the same as HTML:

The miter length is the distance from the intersection of the line walls on the inside of the join to the intersection of the line walls outside of the join. The miter length can be large when the angle between two lines is small. The miter limit is the maximum allowed ratio of miter length to stroke width. The default value is 10.0f.

If the miter length of the join of the intersection exceeds the limit of the join, then the join will be beveled to keep it within the limit of the join of the intersection.

And yet it's mitering even an 11º angle, when the cutoff should be about 11.5º...

Edit: upon rereading the description, I think it's like Qt too, in that it just stops at the limit, rather than switching mitering entirely off if it would exceed the limit.

@corranwebster
Copy link
Copy Markdown
Contributor

I doesn't seem like there is a way to individually turn mitering entirely on and off on a per-corner basis, short of a complicated approach of us drawing individual pairs of segments separately.

I think this might have to be just one of those differences between the backends (like text rendering) where they intrinsically do things differently. Pixel-perfect agreement on everything cross-platform is probably too much to hope for. We certainly don't want to be getting into the business of figuring out corner shapes individually.

stroke.setStyle(Paint.Style.STROKE)
stroke.setStrokeWidth(2.0)
stroke.setColor(BLACK)
stroke.setStrokeMiter(10.0)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think should be 5 (actually probably sqrt(24)) given how it is measured differently?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, I pushed this before reading that.

Took me a sec to figure out where you got sqrt(24) from, but thank you, that makes sense. (For some reason I had it in my head that the difference would depend on the angle, but right, we're picking the specific angle for the cutoff.)

Copy link
Copy Markdown
Member Author

@HalfWhitt HalfWhitt Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My initial intuition was partly correct. That starts truncating at the same angle that HTML stops mitering, which I guess it as good as we can do.

I wish we could make it consitently stop drawing at the same distance that's the bevel cutoff for the HTML spec. But we can't (for all but one angle), because a fixed distance where Qt measures it (along the stroke edge) doesn't stay consistent with where the miter limit should be when measured up from the inside corner:

Qt miter comparison animated

(The green measurement on the upper right is the Qt limit drawn at sqrt(24), and the bit that's sliding up and down is where the HTML spec puts the miter limit at each angle.)

Copy link
Copy Markdown
Member Author

@HalfWhitt HalfWhitt Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, wait, I just noticed where this comment is. Android does things properly, it just has a different default value. It's Qt that's the especially weird one.

@HalfWhitt HalfWhitt marked this pull request as ready for review February 5, 2026 23:45
@HalfWhitt
Copy link
Copy Markdown
Member Author

HalfWhitt commented Feb 5, 2026

To summarize:

  • Android behaves properly, it just has a different default miter limit than HTML canvas's 10. Setting it to 10 makes it work perfectly.
  • When the limit is exceeded, Qt just stops drawing the miter past that point, rather than fully aborting the miter and instead beveling. It also measures miter length in an entirely different way. I've set its value so that it starts truncating at the same ~11.5º as the standard spec. (However, as the angle continues to narrow, it truncates further and further past the specced limit point.)
  • Windows, similarly, truncates instead of aborting. According to the documentation, how the miter is measured and where the limit is placed should coincide with the HTML spec. However in test results, it's definitely rendering past where that should be. It looks almost indistinguishable from Qt, in fact.

@HalfWhitt
Copy link
Copy Markdown
Member Author

HalfWhitt commented Feb 6, 2026

Okay... Winforms is definitely not behaving as documented. 😡 Here's an animation of varying the default limit (10) down to 5 and back up (with red lines denoting where the cutoff should be at 10):

Windows miter limit animated

This is clearly not measuring from the inside of the miter, like it says. All of them are the same height, so it must be measuring from the actual intersection of the path.

But it does seem to be starting the truncation at the right angle, at least.

@HalfWhitt HalfWhitt requested review from corranwebster and freakboy3742 and removed request for corranwebster February 6, 2026 00:45
@HalfWhitt
Copy link
Copy Markdown
Member Author

Oops. I hadn't even realised one could remove a review request, let alone that I'd done so.

Copy link
Copy Markdown
Member

@freakboy3742 freakboy3742 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where do I submit my application for danger pay for having to do this much geometry, this early in the morning? 🤣

This all makes sense to me. The only comment I'd make is that given we know there's some minor platform discrepancies, we should add a platform note in the Canvas widget docs. I've pushed a draft of a new note (and also changed the linting order so spelling is done first...). I'm happy for this to be merged with that note, or if you think there is a need for any other clarification, let me know.

@HalfWhitt
Copy link
Copy Markdown
Member Author

I'm happy for this to be merged with that note, or if you think there is a need for any other clarification, let me know.

Looks good to me, thanks!

@HalfWhitt HalfWhitt merged commit afac874 into beeware:main Feb 7, 2026
57 checks passed
@HalfWhitt HalfWhitt deleted the canvas-miter-join branch February 8, 2026 18:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants