Skip to content

Refactor arrows #4925

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 61 commits into
base: master
Choose a base branch
from
Open

Refactor arrows #4925

wants to merge 61 commits into from

Conversation

ffreyer
Copy link
Collaborator

@ffreyer ffreyer commented Apr 15, 2025

Description

Arrows has a bunch of issues which I want to fix in this pr. I'm starting this with just some planning so there is more time for people to interject if I'm going down the wrong track.

First of all, I'm planning to split up arrows into 2D and 3D because they are pretty different in implementation and sometimes in their attributes. E.g. a tipwidth would be a tipradius, quality and lighting attributes are 3D only, etc. I plan to add some glue code to make arrows still work and if things end up more similar than I imagine I may merge them again.

Conversions

Related Issues:

Arrows should rely on PointBased() conversions and flatten matrices for convenience. That should make plotting a single arrow using a single position and direction possible (similar to how Scatter accepts a Point).

For #2612 we could interpret the second argument as directions if they are Vecs or as endpoints if they are Points but I think that's a bit too specific. It also doesn't work with argument groups, i.e. if you pass (x, y, u, v). I'm currently thinking an attribute arg_mode = :directions / :endpoints would be better.

Attributes

Issues:

These just need to be connected.

Marker

Issues:

Both of these are more complex.

To fix #1688 naively, we would have to calculate the markerspace position where the triangle marker ends and the line should begin. This is complicated with generic markers because they typically are less than markersize units large. It will also leave some overlap or gap in GL backends because anti-aliasing happens before lines and scatter are composed. The full solution to this would be to either extend lines/scatter to natively support arrows (similar to marker = Rect or linecap = :round), or to generate a mesh for each arrow and render them all with poly. Option 1 is more performant, higher quality, more restrictive and requires backend changes that Simon isn't a fan of, so I'll go with Option 2.

To fix #1200 we'll need to consider arrows as three parts - a tail, a shaft and a tip. This is important for the main course:

Alignment

Issues:

PyPlot also reminded me that with a static pixel size for the arrow/head we can't draw arrows shorter than that size. I.e. at some point the arrow needs to scale as whole, rather than just shortening the shaft/tail.

2D

With #1200 in mind, I think our arrow should have 3 parts, each with a length and width:

arrow

From these user attributes we then derive effectively the same set of internal attributes: (all in markerspace)

  1. Calculate the target arrow length from target_length = lengthscale * norm(direction) (or lengthscale * norm(endpoint - startpoints))
  2. If shaftlength = automatic, calculate an internal _shaftlength = max(minshaftlength, target_length - taillength - tiplength). Otherwise _shaftlength = shaftlength
  3. Calculate the total marker length as marker_length = taillength + _shaftlength + tiplength
  4. Scale all lengths and widths by target_length / marker_length
  5. Generate the arrow from the scaled lengths and widths

This should allow you to:

  • make the arrow scale below some threshold defined by minshaftlength if shaftlength = automatic
  • always scale the arrow if shaftlength is a value
  • optionally add a tail

The tail, shaft and tip should probably be individually settable markers/meshes/point vectors. I will probably repeat the interface from #4879 here, where you either define a shape on a 0..1 x 0..1 range and it gets scaled internally, or you define a callback function that constructs it from the calculated scales.

With respect to align I'm planning to deprecate duplicate names and just have :tail, :center and :tip. I'm also planning to allow a float for fractional alignment.

I haven't really thought about normalize in 2D yet, but maybe it would make sense to have it set target_length = taillength + (min)shaftlength + tiplength. That would force all arrows to be the user defined size. Or maybe it should just not be there, and something like scale = false should have that effect. Just do the obvious and normalize(directions)

3D

I think the scaling and alignment handling will generalize to 3D. Though I think it would be good to rename width to radius. Since 3D doesn't use pixel space we don't need to build a mesh for every arrow and can instead just apply scaling to a persistent mesh through meshscatter. In the callback case that will need to be rebuild.

I'm planning to move the mesh generation bits, i.e. _mantle and _circle to GeometryBasics with this pr. They don't really belong here, and having GeometryPrimitives like Circle3D, Cone and CylinderShell (Tube? OpenCylinder?) sounds generally useful.

tl;dr goals:

  • rework arrows to have a tail, shaft and tip to allow double-headed arrows (tail hidden by default)
  • reduce align options to :tail, :center, :tip or a 0..1 float
  • fix align to consider the whole arrow, from tail to tip
  • scale the whole arrow if the shaft length drops below a minshaftlength, otherwise scale only shaft
  • add a way to always scale the whole arrow
  • add conversions to allow plotting one arrow from one Point + direction
  • add arg_mode = :directions / :endpoints to allow defining arrows based on two points
  • fix various issues with Attribute passthrough, missing argument conversions
  • maybe split arrow recipe into 2D and 3D variant to improve attribute names (and allow 2D arrows in 3D?)

PR State

TODO:

  • 2D prototype
  • 3D prototype
  • add "global" scale factor that scales everything
  • add maxshaftlength
  • check normal gen for cones (flat face may not be correct?)
  • rework bbox-relative scaling for 3D arrows to only apply with markerscale = automatic
  • clean up data_limits and boundingbox (consider align)
  • clean up attribute passthrough
  • handle deprecations (forward arrows/arrows! to arrows2d!/arrows3d! with depwarns)
  • fix colormapping in 2D (needs to be done early to work with vertex colors)
  • add (from, to) arg mode
  • update docstrings
  • update main docs
  • update tests
  • Check/fix arrows() doesn't have a colorrange attribute that controls linecolor/arrowcolor in CairoMakie #2655, arrow! and transparency does not work #4054
  • check docs, tests

Type of change

Delete options that do not apply:

  • 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.

@github-project-automation github-project-automation bot moved this to Work in progress in PR review Apr 15, 2025
@MakieBot
Copy link
Collaborator

MakieBot commented Apr 15, 2025

Benchmark Results

SHA: 1a4721cd302829521cb6138b274c51d69776e1d1

Warning

These results are subject to substantial noise because GitHub's CI runs on shared machines that are not ideally suited for benchmarking.

GLMakie
CairoMakie
WGLMakie

@ffreyer
Copy link
Collaborator Author

ffreyer commented Apr 29, 2025

First prototype for 2d arrows:

begin
    ps = [Point2f(cos(a), sin(a)) for a in range(0, 2pi, length=21)[1:end-1]]
    f,a,p = Makie.arrows2d(
        ps, 0.25 .* ps, color = (:blue, 0.5), align = :tip,
        tail = Point2f[(0, 0.5), (1, 0), (1, 1)], taillength = 8
    )
    Makie.arrows2d!(
        ps, 0.25 .* ps, color = (:red, 0.5), align = :tail
    )
    scatter!(ps, marker = Circle, color = :transparent, strokewidth = 1)
    f
end

image

I feel like this is suffering under fxaa quite a bit and the arrows aren't even as small as the the current docs examples...

@ffreyer
Copy link
Collaborator Author

ffreyer commented May 1, 2025

3D arrows, requires JuliaGeometry/GeometryBasics.jl#257

r = Rect3f(-1,-1,-1,2,2,2)
ps = Makie.coordinates(r)
zdirs = [Vec3f(0,0, z) for (x,y,z) in ps]
f = Figure()
a = LScene(f[1,1])
p = Makie.arrows3d!(
    ps, 0.5 .* ps, color = :blue, align = :tip,
    tail = Makie.Cone(Point3f(0,0,1), Point3f(0,0,0), 0.5f0), taillength = 0.06
)
Makie.arrows3d!(
    ps, -zdirs, color = :red, align = :tail
)
linesegments!(r)
linesegments!(Rect3f(Point3f(-0.5), Point3f(1)))
f

image

@ffreyer
Copy link
Collaborator Author

ffreyer commented May 2, 2025

argmode = :endpoint & colormapping:

f = Figure(size = (1000, 500))

# 2D
ps = [Point2f(cos(a), sin(a)) for a in range(0, 2pi, length=21)[1:end-1]]
a = Axis(f[1,1], aspect = DataAspect())
p = Makie.arrows2d!(
    ps, [ps[2:end]..., ps[1]], color = (:blue, 0.5),
    align = :center, lengthscale = 0.5, argmode = :endpoint,
    tail = Point2f[(0, 0.5), (1, 0), (1, 1)], taillength = 8
)
Makie.arrows2d!(
    ps, ps, color = eachindex(ps), align = :tail,
    colormap = :rainbow, lengthscale = 0.5
)
scatter!(ps, marker = Circle, color = :transparent, strokewidth = 1)

# 3D
ps = [Point3f(cos(a), sin(a), 0) for a in range(0, 2pi, length=21)[1:end-1]]
a = Axis3(f[1,2], aspect = :data)
p = Makie.arrows3d!(
    ps, [ps[2:end]..., ps[1]], color = (:blue, 0.5),
    align = :center, lengthscale = 0.5, argmode = :endpoint,
    tail = Makie.Cone(Point3f(0,0,1), Point3f(0,0,0), 0.5f0), taillength = 0.4
)
Makie.arrows3d!(
    ps, [Vec3f(0,0,1) for _ in ps], color = eachindex(ps), align = :tip,
    colormap = :rainbow, lengthscale = 0.5
)
scatter!(ps, marker = Circle, color = :transparent, strokewidth = 1)

f

image

@ffreyer
Copy link
Collaborator Author

ffreyer commented May 12, 2025

Added two tests (for now):

Test for how arrows2d interact with transformations (neutral, log10 yscale, translation, scale+rotation) Test for scaling behavior with minshaftlength and maxshaftlength (both arrow types)
Screenshot 2025-05-12 223513 Screenshot 2025-05-12 230246

I added markerspace for arrows2d (though I'm not sure how useful that is) and dropped transform_marker from arrows3d. I added that at some point to improve Axis3 arrows but it basically doesn't work anymore. Instead I fixed the behavior more explicitly - now arrows only apply transformation to their start and endpoints and never to their markers. With that:

image

Tests to add:

  • arrows with tails (double headed)
  • endpoint mode
  • colors per arrow component & per arrow, colormapping
  • changes in all arrow metrics and shapes
  • updates

Also thinking about either removing or squashing the old tests since most of them just test default arrow plots rather than more complex situations. They should also all fail due to to the length changes (i.e. that tail + shaft + tip matches the user given length, rather than just the shaft)

@ffreyer ffreyer moved this from Work in progress to Ready to review in PR review May 14, 2025
@ffreyer ffreyer marked this pull request as ready for review May 14, 2025 11:12
@ffreyer
Copy link
Collaborator Author

ffreyer commented May 14, 2025

Not sure if we can consider this a (big) bug fix. It's certainly visually breaking.

Potentially breaking changes for arrows()/arrows!():

  • directions now set the size of the full arrow, not just the shaft (bug fix?)
  • Arrows as a recipe is gone
  • arrowhead, arrowtail, arrowcolor, linecolor, linewidth, arrowsize are deprecated with forwarding (still work more or less, so not breaking?)
  • linestyle, transform_marker are completely gone
  • align no longer accept :lineend, :tailend, :headstart and :origin. Now only :head, :center, :tail or a Float (bug fix/cleanup?)
  • bounding boxes are probably different

Copy link
Member

@SimonDanisch SimonDanisch left a comment

Choose a reason for hiding this comment

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

I think visually breaking with good warnings for how to fix things is good enough to release this.
We could also still make a breaking release, to be on the safe side.

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