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
Add ability to use vector path markers #979
Conversation
I'm not sure if I 100% understand this PR, but looking at the corresponding CairoMakie PR, this looks like it's using a 1:1 Cairo representation of the bezierpath. |
The bezier path operations are not really specific to cairo, they are the same operations that pdf and svg can handle. Bezier curves are a very useful thing to support because line approximations of such curves take much more storage space, especially if we're talking about many scatter markers. I understand that you want a consistent API and feature set across backends, but it would be sad not to be able to use really useful vector features. I guess it could also be ok to make bezier paths a separate package for cairomakie. But it would be a simple way to get perfectly controlled scatter markers of equal area. |
That's not really in conflict, since we can draw beziers and vector graphics in all backends... |
I think there are two cases, where it's acceptable to use the "Cairo Language" for Beziers:
|
Anyways, the more I look at this, the more I feel like the |
I'd like to see bezierpaths becoming a thing! Would it be possible to have backend depending argument conversion? I.e. CairoMakie could use the higher level Path struct while GL will just deconstruct them into nodes? |
For the record, matplotlib does basically the same (use the AGG backend's path interface as its own generic interface). It also has a lot of back ends and converts between them. I include what I think is a fairly comprehensive overview of all the different path languages out there. I think Cairo is more or less "path complete", and if not it would be easy to add a few more path commands. Note that it does not include quadratric bezier curves, only cubic. Skia is perhaps the most complete of these path languages.
which is a subset directly from the agg's path interface
(and Inkscape Python API)
Some information about Skia's conic curves
I think postscript is the origin of all of these related path languages. conversion
Additional stuff:
for completeness:
|
While I have the same initial inclination, I am not sure that it is wise to use Julia types to distinguish between different path commands. On one hand it is nice to have a polyline just e.g. a |
99d9d97
to
1eea766
Compare
Unfortunately I don't have the time right now to look at the recent changes in detail but some thoughts on this from a different usecase: For curvy edges in GraphMakie I implemented a
Fyi the graph makie version of this code can be found here. On the long term I'd like to move the |
I'm open to suggestions in terms of performance, my goal so far was to bring vector paths in so you can set them as markers (that's a bit different from beziersegments, which are complete on their own, the path versions always start at the previous point). I have only tried to get things working but didn't focus on efficiency, yet. My next focus will be to prerender arbitrary paths for GLMakie sprite markers. I think these path primitives for 2d vector graphics are something separate from the 3d bezier generalization, so I'm not sure how much code can be effectively shared there. |
Oh yea, I played around with transforming bezier ... shapes into signed distance fields directly. It worked for filled shapes when I tested it but shapes with holes need some more thought. I'm also not sure if it's faster than rendering at high resolution and calculating a sdf from that because in the end I rely on finding the minimum of a set of vectors. Either way the code for that is here if you're interested. |
Just use |
Missing reference imagesFound 4 new images without existing references. |
using GLMakie
t = RGBAf(0,0,0,0)
r = RGBAf(1,0,0,1)
pattern = Makie.ImagePattern([t t r r; t t r r; r r r r; r r r r])
scatter(rand(10), color = pattern) doesn't work anymore, but it also doesn't work on master. Seems like the conversion pipeline for this got messed up at some point. I'd say that's something for a separate issue though. Other example plots I've tried look fine to me. The square example I posted before still has the correct size and offset as well. |
CairoMakie/src/primitives.jl
Outdated
if !(norm(scale) ≈ 0.0) | ||
if marker_converted isa Char | ||
draw_marker(ctx, marker_converted, best_font(m, font), pos, scale, strokecolor, strokewidth, offset, rotation) | ||
else | ||
draw_marker(ctx, m, pos, scale, strokecolor, strokewidth, offset, rotation) | ||
draw_marker(ctx, marker_converted, pos, scale, strokecolor, strokewidth, offset, rotation) | ||
end | ||
end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing Indention
# markers that fit into a square with sidelength 1 centered on (0, 0) | ||
|
||
const BezierCircle = let | ||
r = 0.47 # sqrt(1/pi) | ||
BezierPath([ | ||
MoveTo(Point(r, 0.0)), | ||
EllipticalArc(Point(0.0, 0), r, r, 0.0, 0.0, 2pi), | ||
ClosePath(), | ||
]) | ||
end | ||
|
||
const BezierUTriangle = let | ||
aspect = 1 | ||
h = 0.97 # sqrt(aspect) * sqrt(2) | ||
w = 0.97 # 1/sqrt(aspect) * sqrt(2) | ||
# r = Float32(sqrt(1 / (3 * sqrt(3) / 4))) | ||
p1 = Point(0, h/2) | ||
p2 = Point2(-w/2, -h/2) | ||
p3 = Point2(w/2, -h/2) | ||
centroid = (p1 + p2 + p3) / 3 | ||
bp = BezierPath([ | ||
MoveTo(p1 - centroid), | ||
LineTo(p2 - centroid), | ||
LineTo(p3 - centroid), | ||
ClosePath() | ||
]) | ||
end | ||
|
||
const BezierLTriangle = rotate(BezierUTriangle, pi/2) | ||
const BezierDTriangle = rotate(BezierUTriangle, pi) | ||
const BezierRTriangle = rotate(BezierUTriangle, 3pi/2) | ||
|
||
|
||
const BezierSquare = let | ||
r = 0.95 * sqrt(pi)/2/2 # this gives a little less area as the r=0.5 circle | ||
BezierPath([ | ||
MoveTo(Point2(r, -r)), | ||
LineTo(Point2(r, r)), | ||
LineTo(Point2(-r, r)), | ||
LineTo(Point2(-r, -r)), | ||
ClosePath() | ||
]) | ||
end | ||
|
||
const BezierCross = let | ||
cutfraction = 2/3 | ||
r = 0.5 # 1/(2 * sqrt(1 - cutfraction^2)) | ||
ri = 0.166 #r * (1 - cutfraction) | ||
|
||
first_three = Point2[(r, ri), (ri, ri), (ri, r)] | ||
all = map(0:pi/2:3pi/2) do a | ||
m = Mat2f0(sin(a), cos(a), cos(a), -sin(a)) | ||
Ref(m) .* first_three | ||
end |> x -> reduce(vcat, x) | ||
|
||
BezierPath([ | ||
MoveTo(all[1]), | ||
LineTo.(all[2:end])..., | ||
ClosePath() | ||
]) | ||
end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why do these have slightly different sizes, and sizes smaller than 1x1 (or radius 0.5)?
For reference, Bezier circle and square on top of 100x100px shader square:
scene = Scene()
scatter!(scene, Point2f(0), marker = Rect, color = :green, markersize = 100)
scatter!(scene, Point2f(0), marker = Makie.BezierSquare, color = :blue, markersize = 100)
scatter!(scene, Point2f(0), marker = Makie.BezierCircle, color = :red, markersize = 100)
scene
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok so the reasoning was to create markers that have the same area, because I had assumed these would look similar. That's an important characteristic for markers because you don't really want to convey a difference in size when you use two different marker shapes with the same markersize
setting.
I started with the circle, which I gave area = 1. That means the radius is about 0.56. Later I changed that because I wanted each shape to fit approximately within the 1x1 square. I then found out that not all shapes look like they have similar size if you correctly compute their area, probably because human visual perception introduces non-linearities when shapes differ. That's well known but I didn't exactly go on a literature search to do this (maybe I should).
So anyway, after I while I had shapes that looked good together, all approximately fitting into a 1x1 square, but effectively a bit arbitrary in size (based on my judgment). But these looked too big when mixing with character-based markers like 'X'
or 'a'
. That's because fonts at font-size X do not fill a square with size X * X with each character, they're effectively smaller. So I downscaled the whole range of markers with a factor that looked reasonably pleasing to me with both small and large letters. That is entirely subjective of course, but as markers are usually not meaningful in data space, it seemed reasonable to me to go this route. The benefit is that you can mix and match the normal markers with text/char at the same size, and won't have to adjust markersize each time. The drawback is that there's not a clean "theoretical" reason why they are of this size. I understand that making each marker fill 1x1 exactly sounds attractive theoretically, but to me it seemed to be less attractive in practice.
I find it confusing if char-based markers at size X are different than the same thing as text
at size X, so that fixes those markers. Then the question is, should the default markers match those in size or not, I said yes they should. Hence the unusual size.
The nice thing about the new system is that it's easy for users to make their own markers, and those can conform to a base size of 1x1 or whatever, if that metric is important to the users. I can imagine it might be important when tiling markers in dataspace, but I'd argue that is sufficiently niche as to not influence the decision for a default.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see, that makes sense.
There's a note on characters being of nonuniform size/area but as far as I can tell we don't explain how characters or other markers are actually scaled. I think it would be good to have that information there though. I.e. with markerspace = :pixel
- for characters
markersize
is equivalent totextsize/fontsize
which is the pixel height of the bounding box a character is placed into, to my understanding - default shape markers are scaled to character markers, meaning that
markersize = 10
results in smaller side length/diameters - custom Bezier markers use a 1:markersize scaling between units of the marker and units in the plot. I.e. a Bezier marker filling a 0..1 x 0..1 square with
markerspace = 10
will fill a 0..10 x 0..10 pixel square on the screen exactly. - Polygons, I assume, are handled the same
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes it should be documented as such, I agree. And you're right, effectively the bezier coordinates are used as they are and just multiplied with the markersize, so you can get pixel-perfect output if you want.
to_spritemarker(::Type{<: Circle}) = Circle | ||
to_spritemarker(::Type{<: Rect}) = Rect |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should we also have conversions for rounded rectangle and triangle? Or perhaps allow marker = Makie.ROUNDED_RECTANGLE
?
Hm, this seems to be already broken in the latest tagged version... I'll see if I can quickly fix it in this PR! |
Missing reference imagesFound 4 new images without existing references. |
Missing reference imagesFound 4 new images without existing references. |
Missing reference imagesFound 4 new images without existing references. |
Missing reference imagesFound 4 new images without existing references. |
Missing reference imagesFound 4 new images without existing references. |
Missing reference imagesFound 4 new images without existing references. |
This PR adds the ability to use vector path markers for Scatter. So far, we could only use glyphs as markers, however this is often not flexible enough. Even if a specific shape is represented in a font (we use DejaVu by default), that doesn't mean that the different shapes we need (square, circle, star, ngon, etc.) look well-matched to each other. The opposite is the case, the apparent sizes of our default markers were quite different.
For vector graphics, it's really useful to just specify some path operations that define a marker (as opposed to using bitmaps). Therefore, it was easy to implement this for CairoMakie, just store a vector of path operations and execute these when drawing the marker.
It was more difficult to implement support for GLMakie and WGLMakie, as these backends don't have support for drawing paths. Drawing paths is actually not a straight-forward thing in OpenGL, so here I've chosen to implement the same workaround as for glyphs. We render the paths to a small bitmap, convert it to a signed-distance-field and store it in the glyph atlas. For this, we use FreeType's outline drawing engine. It doesn't support all basic paths (e.g. EllipticalArcs) but we can replace them with cubic beziers on the fly.
The caveats to this technique are that the texture atlas could be filled if one was to generate tons of different marker shapes, because one atlas slot is needed for each marker. The other is that signed distance fields work only where there are filled areas, and not all paths are filled areas (the simplest one, a single line, is not, for example). This means that while CairoMakie will have no problem rendering unfilled markers (stroked paths only), GLMakie and WGLMakie are limited to filled markers. If it is absolutely essential to render something looking like an unfilled marker in GLMakie, a workaround could be to apply a stroke to the path and draw the resulting path. Depending on the stroke width used, and the resolution of the glyph atlas, this might not look great.
With the ability to specify our own markers, this PR also replaces the full set of markers accessible by symbols such as
:circle, :rect, :star4
etc. The goal was to make the default markers look well-matched amongst each other. The base size was chosen so that the markers do not look out-of-place compared with normal glyphs like'x'
, or'Y'
. Therefore the settingmarkersize
is not a concrete metric of the side length of a marker, it just means "looks ok compared to glyphs at font size X".Here is an overview of available markers before and after. For example, triangles are correctly centered now, stars and crosses are better matched in size to squares and circles.
Before
After