Skip to content

Fix radial gridlines and outer spine collapsing on polar plots (#20388, #26972)#31701

Open
SharadhNaidu wants to merge 1 commit into
matplotlib:mainfrom
SharadhNaidu:fix-20388-polar-arc-unwrap
Open

Fix radial gridlines and outer spine collapsing on polar plots (#20388, #26972)#31701
SharadhNaidu wants to merge 1 commit into
matplotlib:mainfrom
SharadhNaidu:fix-20388-polar-arc-unwrap

Conversation

@SharadhNaidu
Copy link
Copy Markdown

@SharadhNaidu SharadhNaidu commented May 18, 2026

This is a fix for two related polar plot bugs that have been hanging around for a while: #20388 (radial grid disappears with certain set_theta_direction(-1) + set_theta_zero_location combinations) and #26972 (outer spine outline disappears when set_theta_offset is used). They look like separate problems but they're caused by the same thing.

What's actually going on

Path.arc(theta1, theta2) unwraps the requested angular span into [0, 360]:

eta2 = theta2 - 360 * np.floor((theta2 - theta1) / 360)
if theta2 != theta1 and eta2 <= eta1:
    eta2 += 360

That's the right thing for most callers. The problem is that the polar code paths (PolarTransform.transform_path_non_affine and Spine._adjust_location for the polar spine) build up low/high from direction * limit + offset. When the resulting span is supposed to be exactly 360°, floating-point arithmetic often makes it 360 + tiny_epsilon instead. Path.arc then unwraps that to a near-zero span and the full circle quietly collapses into a near-empty arc - so the grid or spine just vanishes.

The chunking loop in PolarTransform.transform_path_non_affine (while td - last_td > 360: ...) was there to work around this, but the chunking threshold and Path.arc's unwrap threshold use slightly different floating-point comparisons. When the difference falls in that narrow gap, you end up with one Path.arc call that draws basically nothing.

anntzer did the bisect on #20388 and identified this exact mechanism. timhoffm replied on the same thread suggesting the fix:

Simplest solution seems to be introducing a flag Path.arc(..., unwrap_angles=True).

That's what this PR does.

Changes

  • lib/matplotlib/path.py - add an unwrap_angles=True keyword-only parameter to Path.arc. Default is True so existing callers see no behaviour change at all. When set to False, theta1/theta2 are used directly, which lets callers produce multi-turn arcs and avoid the floating-point edge case.
  • lib/matplotlib/projections/polar.py - the constant-radius arc branch now calls Path.arc(..., unwrap_angles=False) directly instead of using the chunking loop. I verified the output is byte-identical to the old code for every common case (full circles, partial arcs, reversed arcs, near-360 gridlines).
  • lib/matplotlib/spines.py - the polar outer spine now also passes unwrap_angles=False. This is the second place the bug manifests and is what makes [Bug]: set_theta_offset removes grid outline #26972 go away too.
  • Tests in test_path.py (covers the new parameter and the floating-point edge case) and test_polar.py (regression tests for both Radial grid missing in polar plots with ax.set_theta_direction(-1) and ax.set_theta_zero_location #20388 and [Bug]: set_theta_offset removes grid outline #26972, parametrised across multiple angles/offsets).
  • A next_whats_new entry for the new parameter.
  • path.pyi updated to match the new signature so stubtest stays happy.

A few things worth flagging

I went through the polar baseline images and none of the scenarios they cover happen to hit a buggy parameter combo, so this PR doesn't need any baseline regenerations. I checked polar_theta_position in particular since ('NW', 30, clockwise) looked suspicious - it's fine, spine spread is ~373 px (not collapsed).

I'm aware of #26991 which targets #26972 with a different approach (tolerance-based comparison inside Path.arc itself). That approach would also work but it changes behaviour for every Path.arc caller; the explicit-flag approach here keeps the default exactly the same and only opts in the two polar code paths. Happy to discuss if reviewers prefer the other direction.

Tests

I'd recommend running lib/matplotlib/tests/test_polar.py::test_polar_inverted_theta_outer_spine and test_polar_theta_offset_outer_spine to see the regression tests for both issues. The new test_arc_unwrap_angles in test_path.py covers the API addition directly.

Closes #20388
Closes #26972


AI Disclosure

AI was used to help proofread the grammar of this PR description and to understand some parts of the existing codebase during development. The code changes and design decisions were developed independently.

@SharadhNaidu SharadhNaidu force-pushed the fix-20388-polar-arc-unwrap branch 2 times, most recently from 9828bb6 to 6f8613d Compare May 18, 2026 12:27
Path.arc previously unwrapped the angular span to fit within 360
degrees, which silently collapsed near-but-not-quite-full-circle
spans caused by floating-point noise (delta = 360 + epsilon) into
near-empty arcs. This dropped the outer spine in several polar
configurations and skipped radial gridlines drawn via the chunking
loop in PolarTransform.transform_path_non_affine.

Add an unwrap_angles keyword-only parameter to Path.arc (default
True, preserving existing behaviour). PolarTransform's chunking
loop is preserved unchanged; only the final partial-chunk Path.arc
call uses unwrap_angles=False, which is exactly where the FP edge
case triggers. Spine._adjust_location's polar branch also passes
unwrap_angles=False so the outer spine is no longer collapsed when
set_theta_offset is set.

Closes matplotlib#20388
Closes matplotlib#26972
@SharadhNaidu SharadhNaidu force-pushed the fix-20388-polar-arc-unwrap branch from 6f8613d to 3e7188b Compare May 18, 2026 12:32
@SharadhNaidu
Copy link
Copy Markdown
Author

Visual before/after

Hosted on a side branch of my fork so this comment doesn't blow up the PR diff.

#20388set_theta_direction(-1) + set_theta_zero_location('N', angle)

Before:
20388 before

After:
20388 after

#26972set_theta_offset(literal_float)

Before:
26972 before

After:
26972 after

The bug only triggers on specific parameter values where the computed angular span lands on 360 + epsilon after direction * limit + offset arithmetic. For example set_theta_offset(1.570796327) (the literal float from #26972) triggers the bug; set_theta_offset(np.pi / 2) does not, because the more precise float lands on the other side of the FP boundary.

Note on Path.wedge

Path.wedge calls Path.arc(..., is_wedge=True) internally and therefore always gets the default unwrap_angles=True. I intentionally didn't extend unwrap_angles to Path.wedge in this PR:

  • No internal matplotlib code calls Path.wedge (grepped — zero hits).
  • User-written wedge calls use literal angles like Path.wedge(0, 360), which don't carry FP noise.
  • Multi-turn wedge geometry is degenerate (self-overlapping filled region), so the parameter would have no useful new capability there.
  • Trivially addable later if anyone has a concrete use case.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: set_theta_offset removes grid outline Radial grid missing in polar plots with ax.set_theta_direction(-1) and ax.set_theta_zero_location

1 participant