Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Fix axis spine corner overextension - #5765

Open
thchr wants to merge 6 commits into
MakieOrg:masterfrom
thchr:fix-axis-spine-corner-overextension
Open

Fix axis spine corner overextension#5765
thchr wants to merge 6 commits into
MakieOrg:masterfrom
thchr:fix-axis-spine-corner-overextension

Conversation

@thchr

@thchr thchr commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

🙋‍♂️ As discussed on Slack with @jkrumbiegel, this PR seeks to fix two interrelated things:

  1. The axis lines previously (intentionally) overshot the axis corners by spinewidth / 2 to avoid the appearance of a "notch" in the corner. A more elegant way to do this is just to draw the axis lines with a :square cap instead of a :butt cap.
  2. Ticks previously started their stroke spinewidth / 2 away from the actual axis centerline. This gives some real surprises, especially with tickalign ≠ 0, since the stroke is then not placed in a natural position at e.g., tickalign = 0.5 or 1. Fix this by starting/anchoring the tickalign = 0 (and tickalign = 1) strokes to the axis' centerline.

These changes are - for me - chiefly motivated by the fact that the existing drawing approach gives headaches if one wants to restyle an exported CairoMakie figure manually in e.g. Illustrator or Inkscape afterwards: if any axis or tick linewidth is changed, or if the figure itself scaled, the "absolute" positioning hacks lead to very visible artifacts (that I have to admit to historically spending way to much time on fixing, by manually realigning all these little lines).

A fuller Claude-generated description below (especially of the ticks-change).


🤖 CairoMakie-exported axes don't survive being restroked in Illustrator/Inkscape. Two separate causes, one commit each — the first is a straightforward fix, the second involves a judgement call where input/agreement is needed.

1. Spines overshoot the corners (a48775e)

The spine geometry was extended by 0.5 * spinewidth past each axis corner to fill the corner join. That extension is baked in at axis creation, so restroking the exported figure at a different width leaves the overshoot wrong in both directions.

Now the spine ends exactly at the corner and uses linecap = :square, which fills the corner and scales with whatever stroke width the file is rendered at. Rendering is unchanged.

2. Ticks are anchored to the spine's outer edge (1d0974d)

Tick marks started at 0.5 * spinewidth outside the spine centerline and ran ticksize outward from there (shifted inward by tickalign * ticksize). Same coupling problem: widening the spine's stroke in an editor swallows the tick, narrowing it opens a gap.

Ticks are now anchored on the spine centerline — the one place no stroke width can uncover:

outer_len = (1 - tickalign) * ticksize
inner_len =      tickalign  * ticksize

The drawn mark is exactly ticksize long for every tickalign, which simply slides it across the centerline. spinewidth drops out of the tick geometry entirely.

Both changes live in Makie/, so this fixes all backends, not just Cairo.

The judgement call

Because the mark now starts on the centerline rather than the outer spine edge, half a spine width of it is covered by the spine. To keep existing figures looking the same, default tick sizes are raised by half the default spine width — ticksize 5 → 5.5 and minorticksize 3 → 3.5, for Axis, Colorbar and LineAxis — and tickspace (which drives the tick-label gap and the axis protrusion) subtracts that same half width so labels and the plot area stay put.

Default axes therefore render byte-identically to master (verified on CairoMakie: default, mirrored, trimspine, flipped, and Colorbar, majors and minors). But a figure that sets ticksize explicitly gets marks that appear spinewidth / 2 shorter than before.

Open question: the 5 → 5.5 default is a literal, so it only cancels the change when spinewidth is also default. An axis with spinewidth = 8 and untouched ticksize gets marks appearing 1.5 long (=8/2 - 5.5) instead of 5.0. One alternative is ticksize = automatic, resolved as 5f0 + 0.5f0 * spinewidth, which cancels it for any spine width. I went with the literal because the point of this change is to make ticksize an unconditional stroke length, and a spinewidth-coupled default reintroduces exactly the coupling being removed — but there's a definite trade-off.

What changing tickalign looks like before/after tickalign_before_after

spinewidth = 12, ticksize = 18. After: the drawn mark is a constant length and tickalign slides it across the centerline; at 1.0 it sits fully inside with its outer tip on the centerline. Before: the mark hung off the spine's outer edge and the buried fraction varied with tickalign.

🤖 Generated with Claude Code


Checklists

Type of change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)

Checklist

  • Added an entry in CHANGELOG.md (for new features and breaking changes)
  • Added or changed relevant sections in the documentation
  • Added unit tests for new algorithms, conversion methods, etc.
  • Added reference image tests for new plotting functions, recipes, visual options, etc.
    • My hope is that nothing will move - but we will see in CI

@github-project-automation github-project-automation Bot moved this to Work in progress in PR review Aug 28, 2026
thchr and others added 2 commits August 28, 2026 10:19
…etry

The four `Axis` spine segments (plus the opposite and mirrored spines) were
each extended by half a spine width past the axis corners so that, with the
default butt linecap, the segments would overlap into a clean corner instead
of leaving a notch. `create_linepoints` similarly padded trimmed spines by
half a tick width.

Because that extension was baked into the vertex positions as a fixed pixel
distance computed from the spine width at render time, it only lined up at
that exact stroke width. Re-stroking a CairoMakie-exported SVG/PDF in an
external editor (a common step when composing figures) moved every corner
out of alignment.

Instead, end each spine exactly at the corner (or the outer tick, when
trimmed) and set `linecap = :square` on the spine `linesegments!` calls. A
square cap extends the stroke by half its width automatically and is written
into the SVG/PDF as `stroke-linecap="square"`, so the corner coverage now
scales with the stroke when the figure is restyled. At any given stroke
width the rendered result is unchanged.

Tick marks keep their butt linecap and are untouched here.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Tick marks were placed relative to the *outer edge* of the spine stroke:
the mark started at `0.5 * spinewidth` outside the centerline and ran
`ticksize` outward from there (shifted inward by `tickalign * ticksize`).
That couples the tick geometry to the spine's stroke width, which is exactly
what breaks when a figure is exported to SVG/PDF and the spine is restroked
in a vector editor -- widening the stroke swallows the tick, narrowing it
opens a gap, and a mark meant to point outward can end up poking into the
plot area.

Anchor the mark on the spine centerline instead, which no stroke width can
uncover:

    outer_len = (1 - tickalign) * ticksize
    inner_len =      tickalign  * ticksize

The drawn mark is exactly `ticksize` long for every `tickalign`, and
`tickalign` (0 = out, 1 = in) simply slides it across the centerline.
`spinewidth` drops out of the tick geometry entirely, here and in
`mirror_ticks`.

At `tickalign = 0` the mark now starts on the centerline rather than the
outer spine edge, so half a spine width of it is covered by the spine. To
keep default axes looking exactly as they did, the default tick sizes are
raised by half the default spine width: `Axis` `ticksize` 5 -> 5.5 and
`minorticksize` 3 -> 3.5, likewise for `Colorbar` and standalone `LineAxis`.
Figures that set `ticksize` explicitly get marks that appear
`spinewidth / 2` shorter than before.

`tickspace`, which feeds the tick label gap and the axis protrusion, now
means "how far the mark reaches past the outer spine edge" and subtracts
that same half spine width, so tick labels and the plot area stay put.

Verified pixel-identical to master on CairoMakie for default, mirrored,
trimmed, flipped and Colorbar axes.

Co-Authored-By: Claude Opus 5 <[email protected]>
@thchr
thchr force-pushed the fix-axis-spine-corner-overextension branch from 1d0974d to 40f7c76 Compare August 28, 2026 08:19
Dropping the geometry extensions from `create_linepoints` also dropped the
`0.5 * tickwidth` that a trimmed spine end used to carry, so the end landed on
the outermost tick's centerline and the `:square` linecap then pushed it half a
spine width further out. With `spinewidth = 8` and a default `tickwidth = 1`
that is a 3.5px stub sticking out past the last tick.

A trimmed end should finish flush with the outer edge of that tick, i.e. half a
tick width past its center, as it did before. The cap already contributes half a
spine width, so the geometry only has to make up the difference:

    trim = 0.5 * (tickwidth - spinewidth)

Untrimmed ends are unaffected -- they still stop at the axis corner and let the
cap fill it.

Co-Authored-By: Claude Opus 5 <[email protected]>
@thchr
thchr force-pushed the fix-axis-spine-corner-overextension branch from 6bb16eb to 98e4d14 Compare August 28, 2026 11:04
@thchr

thchr commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

Gentle bump: open to making any changes here (personally currently leaning more towards a ticksize = automatic solution than the currently PR'd fixed ticksize = 5.5 - but would like feedback on whether a ticksize change could be acceptable at all).

@ffreyer

ffreyer commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

Might be nice to have framelinecap as an attribute in axis so you can switch to :round

Comment thread Makie/src/makielayout/types.jl Outdated
Comment on lines +436 to +439
"The size of the xtick marks. Marks are drawn from the spine centerline, so `spinewidth / 2` of this is covered by the spine when `xtickalign = 0`."
xticksize::Float64 = 5.5f0
"The size of the ytick marks. Marks are drawn from the spine centerline, so `spinewidth / 2` of this is covered by the spine when `ytickalign = 0`."
yticksize::Float64 = 5.5f0

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

These are changes we probably don't want, for two reasons:

  1. They change how you have to scale ticksize in relation to spinewidth. I.e. if you scale spinewidth, you now have to scale ticksize too to keep the same amount of the tick visible
  2. When the frame is (partially) transparent, you now see some of the tick under it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

On 1: As suggested below and noted as alternative in OP, a way to overcome this could be to use an automatic mode for ticksize, resolving to 5.0 + spinewidth/2.

On 2: Agreed, no way to avoid that with a partially transparent spine. I do think there are very few reasonable cases, if any, where one would want a transparent spine though.

@jkrumbiegel

Copy link
Copy Markdown
Member

I think the solution for the ticks is to fix what align means, the outside is already correct but inside isn't, if we have inside the mirror of outside then 0.5 will also give correct centering. And for someone who wants to have the tick start at the spine center they'll be able to set the align to something slightly below 1, depending on the spine width and tick size, and that will be exact, too. That will need a small computation once and can then be set in the theme. It doesn't change the meaning of tick length and doesn't churn refimages for the default outside ticks. For inside ticks it's a visual fix.

@thchr

thchr commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

For what it's worth, here's what other plotting tools are doing on "align ticks to spine-center or spine-edge" (showing the associated zero-width line, via Ctrl+Y in Illustrator):

Matlab matplotlib (via PyPlot.jl) GR (via Plots.jl) PlotlyJS(.jl)
image image image image

All, except GR (forgot), with a minimally configured "outward" pointing set of ticks. Only PlotlyJS.jl has aligned the ticks to the spine edge.

This is just as context for what other systems are doing, obviously.


For myself, the "floating tick" thing is a strange default: I don't think I'm alone in needing to postprocess generated figures - for sufficiently complicated and collaborative visualizations, it's inevitable in my experience.

If I changed this to an automatic mode for the ticksize, which defaulted to 5.0 + spinewidth/2 there would be no changes to the refimages here afaict (only 3 such cases at the moment). But maybe you had downstream packages in mind?

@thchr

thchr commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

Might be nice to have framelinecap as an attribute in axis so you can switch to :round

I can do that - before I do though, I just want to confirm you want that as API? E.g., I could imagine one might eventually want to not draw the axis as a set of four disconnected lines, but as a single connected rectangle. In that case, it'd be nicer to be able to control how the corner was drawn (I think stroke-linejoin is the .svg name).

@jkrumbiegel

jkrumbiegel commented Sep 8, 2026

Copy link
Copy Markdown
Member

If I changed this to an automatic mode for the ticksize, which defaulted to 5.0 + spinewidth/2 there would be no changes to the refimages here afaict (only 3 such cases at the moment). But maybe you had downstream packages in mind?

I don't like this ambiguity about ticksize. Actually thinking more about this, I think just adding two symbols for tick aligns could work here which simply take strokewidth into account. The numbers keep meaning the stroke-corrected thing they do now (with the inward one fixed because that's currently off), and then we just add :out_spine and :in_spine or so which are the ones that start at the spine center. This way you don't have to do the computation yourself. It's similar to how we have numbers for text aligns but also :baseline which is not equivalent to any number, as it depends on the font where the basline is relative to the boundingbox, while :top, :bottom and :center only depend on the bounding box.

And the framelines we can switch to :square as discussed while keeping the option for other styles open for later. I don't think that's important now, nobody has ever asked for that.

@jkrumbiegel

jkrumbiegel commented Sep 8, 2026

Copy link
Copy Markdown
Member

I only meant align, not ticksize. If you want to align your ticks with the center of the spine and have them look the same as when they're aligned with the edge, you need to bump the ticksize by half the spine width. I think that's acceptable and it can be easily themed.

Ticks are anchored on the spine centerline, so half a spine width of each
mark is covered by the spine. The previous commit compensated with a literal
default (`ticksize` 5 -> 5.5), which only cancels out at the default
`spinewidth = 1`: an axis with a thicker spine and an untouched `ticksize`
lost visible tick length, badly so once `spinewidth` approached `2 * ticksize`.

Make `ticksize` and `minorticksize` default to `automatic` instead, resolving
to `5 + spinewidth / 2` and `3 + spinewidth / 2`. The mark left visible
outside the spine then keeps its size at any spine width, so default axes
render exactly as they did before this PR regardless of `spinewidth`. Set to
a number, `ticksize` still means one thing -- the drawn stroke length.

`LineAxis` resolves both sizes once, up front, so the rest of the function
works with plain numbers; `Axis` does the same before handing them to
`mirror_ticks`. `resolve_ticksize_with_base` dispatches on `::Automatic`, and
its `::Float32` return annotation is what lets the resolved observables be
asserted `Observable{Float32}`, since `0.5f0 * spinewidth` is otherwise
`Float64`. The tick size attributes are left unannotated, since
`Union{Automatic, Float64}` will not convert an `Int` and half of them were
unannotated already.

`generic_plot_attributes(LineAxis)` gets the same `automatic` default, which
also settles a stale value there. `Axis`, `Colorbar` and this fallback were
last in sync in MakieOrg#721, at `ticksize = 6` / `minorticksize = 4`. The 2023
figure-size rescale then took `Axis` and `Colorbar` to 5 and 3 without
touching `defaultattributes.jl`, so the fallback kept the older numbers by
oversight -- its `spinewidth` is 1 like everywhere else, so nothing justified
the difference. A directly constructed `LineAxis(scene; ...)` therefore ends
up with visibly 5 and 3 units of tick rather than 6 and 4; the anchor change
contributes none of that, it is the rescale finally arriving. `Axis` and
`Colorbar` always pass their own tick sizes and never consult this fallback.

Verified against master on CairoMakie: `trimspine` (spinewidth 8, default
ticksize) is geometrically identical, tick columns and tips on the same
pixels, differing only in ~0.09% antialiasing. The two `ticksmirrored` tests
still change, since they set `ticksize = 10` explicitly.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Bz52CGcoLBg8Pgevw5b9ER
@thchr
thchr force-pushed the fix-axis-spine-corner-overextension branch from 15290a5 to 6638f81 Compare September 8, 2026 08:47
@thchr

thchr commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

Okay - I think I'm following now, but admit to some confusion earlier. Perhaps we're closer than I imagined? I'd been reading the earlier comment as objecting to the spinewidth/2 default extension. Checking my understanding: :out_spine and :in_spine would both be tickalign values, both anchoring the tick at the spine center, and differing only in which direction the mark extends - out of the axis versus into it? If so, they are exactly tickalign = 0 and tickalign = 1 as implemented in this PR. We could definitely add Symbol overloads of that also, if you want.


Aside from that: I've pushed the automatic version that I suggested (6638f81), so we can look at it concretely.

On ticks being spine-center-aligned by default rather than opt-in: my motivation is post-processing figures, very often ones I didn't make. An opt-in would help whoever knows to reach for it, but I'd guess that number will never climb very high. So I'd still be sent figures by collaborators in the old floating-ticks mode, unless the default is changed. I think it's a sensible default, since it allows restyling the axis lines in post-production without introducing artifacts (and obliquely supported also by 3 of 4 tools above doing this as well).

On bumping ticksize in a theme, I admit I'm not knowledgeable enough about the theming approach to follow the comment entirely.

`tickalign` value that puts a tick tip on the spine centre, if default is tip-placement at spine edge

As context, if the default is chosen as spine-edge-anchored behavior at tickalign (current master), the tickalign value that places the tick tip at the spine center becomes 0.5 * spinewidth / (ticksize + spinewidth); that's a tricky thing to set manually, since it moves with both ticksize and spinewidth:

ticksize spinewidth center-out center-in
5 1 0.083 0.917
5 8 0.308 0.692

On refimages: with automatic, the trimspine refimage — the one of the three that used the default ticksize — goes back to matching master exactly. But I was wrong to claim that all three went back: the two ticksmirrored refimages still change, because they set ticksize = 10 explicitly.

Drive-by change: The last commit also made a small fix in generic_plot_attributes(LineAxis). It still had ticksize = 6 / minorticksize = 4, which seems to just have been the result of being overlooked in a 2023 figure-size rescale, which updated the values for Axis and Colorbar to 5 and 3. It seems most consistent to set that to automatic as well, so I've done that.

On framelines: Agreed - leave it at :square for now; if there's requests for rounded or custom corners later, it could be a framecorner or similar attribute later.

@jkrumbiegel

jkrumbiegel commented Sep 8, 2026

Copy link
Copy Markdown
Member

If so, they are exactly tickalign = 0 and tickalign = 1 as implemented in this PR

Yes but I don't want the normal numbers to change meaning, they should stay edge aligned (and the inward case fixed if it's currently not correctly aligned). People who care about this specific behavior can choose the spine center alignment, for default users it will edge align as usual. So your PR should only add the symbols with the special center-aligned meaning.

@ffreyer

ffreyer commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

Between catching up on Slack comments and comments here I'm getting a bit confused. My understanding of what Julius suggested is:

  • tickalign = 0 is changed to start from the frame line center, still pointing outwards
  • tickalign = 1 is changed to start from the frame line center, still pointing inwards
  • tickalign = 0.5 correctly centers
  • tickalign = :in_spine starts from the frame line edge, pointing inwards (fixed version of tickalign = 1 on master)
  • tickalign = :out_spine starts from the frame line edge, pointing outwards (matching tickalign = 0 on master)

Is that correct?

As suggested below and noted as alternative in OP, a way to overcome this could be to use an automatic mode for ticksize, resolving to 5.0 + spinewidth/2.

The problem with that is that it still changes the meaning of ticksize = some_number. Anyone who set ticksize manually before will get different results as long as they have to consider spinewidth when setting ticksize.

On bumping ticksize in a theme, I admit I'm not knowledgeable enough about the theming approach to follow the comment entirely.

You can define something like

my_theme = Attributes(
    Axis = Attributes(
        xtickalign = 0,
        ytickalign = 0
    )
)

and apply it globally with set_theme!(my_theme) to change the defaults that Axis uses. You can use that to define a default style and put it in a package or a file for other people to use. See MakieThemes.jl for example.

@thchr

thchr commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

My understanding of what Julius suggested is:

My understanding was not quite that, rather:

  • tickalign = 0 is still refers to the tick starting at the spine (outer) edge, pointing outwards.
  • tickalign = 1 should be fixed, to start from the spine (inner) edge, point inward.
  • tickalign = 0.5 should be fixed to correctly center the tick on the spine.
    tickalign = :in_spine starts from the spine center, pointing inward.
  • tickalign = :out_spine starts from the spine center, pointing outward.

So, :out_spine <-> 0 and :in_spine <-> 1 relative to your bullets, if I got it right.

It's not my preference, but would still be an improvement. I'll try to get to it later this week.

On the automatic ticksize; agree, it cannot fix (i.e., keep invariant) cases where ticksize has been manually set. I had thought it might be acceptable with a visual change in a major version, but hear the message that the churn is not worth it.

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

Labels

None yet

Projects

Status: Work in progress

Development

Successfully merging this pull request may close these issues.

4 participants