Skip to content
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

Merged
merged 79 commits into from Sep 14, 2022
Merged

Add ability to use vector path markers #979

merged 79 commits into from Sep 14, 2022

Conversation

jkrumbiegel
Copy link
Collaborator

@jkrumbiegel jkrumbiegel commented May 24, 2021

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 setting markersize 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
grafik
After
grafik

@jkrumbiegel jkrumbiegel marked this pull request as draft May 28, 2021 03:31
@SimonDanisch
Copy link
Member

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.
Makie shouldn't rely on any abstractions that only make sense for one backend and it would be better, if this can be translate to a generic bezierpath recipe.
Although, if the representation isn't too awkward for other backends and is still drawable there, we could adopt that representation in Makie too, I suppose ;)
I'm not too worried about the details, but I want to make sure, that this is planned with the idea in mind, that this will be supported for all backends in the long run.

@jkrumbiegel
Copy link
Collaborator Author

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.

@SimonDanisch
Copy link
Member

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

That's not really in conflict, since we can draw beziers and vector graphics in all backends...
I just don't want to have very implementation specific things (like using Cairo draw commands) bleed into the generic, backend agnostic Makie API, if it's easy to avoid ;)

@SimonDanisch
Copy link
Member

SimonDanisch commented Jun 24, 2021

I think there are two cases, where it's acceptable to use the "Cairo Language" for Beziers:

  1. It's a very good representation for Beziers, and anything we would come up for a generic representation would look similar
  2. It's a lot of work to translate the svg based vector graphics into something more generic, and we currently don't have that time. In that case, I'd leave a todo, for whenever we have time to port/convert things to a representation, that can also be drawn by (W)GLMakie.

@SimonDanisch
Copy link
Member

Anyways, the more I look at this, the more I feel like the BezierPath struct is a fair way to represent a complex shape that we get from an SVG.
So maybe that should just be the most highlevel type to represent vector graphics that come from SVGs/PDFs.
To convert it to more low level types like actual bezier paths and lines can then be tackled when we implement it for the other backends.

@hexaeder
Copy link
Contributor

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?
I've been playing around on how this could be used for spline drawing (context self edges in GraphMakie). I've slightly changed the structs to allow for 3d pathes, though.

@goretkin
Copy link
Contributor

goretkin commented Sep 16, 2021

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.

matplotlib path interface:

    The code types are:

    - ``STOP``   :  1 vertex (ignored)
        A marker for the end of the entire path (currently not required and
        ignored)

    - ``MOVETO`` :  1 vertex
        Pick up the pen and move to the given vertex.

    - ``LINETO`` :  1 vertex
        Draw a line from the current position to the given vertex.

    - ``CURVE3`` :  1 control point, 1 endpoint
        Draw a quadratic Bezier curve from the current position, with the given
        control point, to the given end point.

    - ``CURVE4`` :  2 control points, 1 endpoint
        Draw a cubic Bezier curve from the current position, with the given
        control points, to the given end point.

    - ``CLOSEPOLY`` : 1 vertex (ignored)
        Draw a line segment to the start point of the current polyline.

which is a subset directly from the agg's path interface

        path_cmd_stop     = 0,        //----path_cmd_stop    
        path_cmd_move_to  = 1,        //----path_cmd_move_to 
        path_cmd_line_to  = 2,        //----path_cmd_line_to 
        path_cmd_curve3   = 3,        //----path_cmd_curve3  
        path_cmd_curve4   = 4,        //----path_cmd_curve4  
        path_cmd_curveN   = 5,        //----path_cmd_curveN
        path_cmd_catrom   = 6,        //----path_cmd_catrom
        path_cmd_ubspline = 7,        //----path_cmd_ubspline
        path_cmd_end_poly = 0x0F,     //----path_cmd_end_poly
        path_cmd_mask     = 0x0F      //----path_cmd_mask    

SVG path interface

drawto_command::=
    moveto
    | closepath
    | lineto
    | horizontal_lineto
    | vertical_lineto
    | curveto
    | smooth_curveto
    | quadratic_bezier_curveto
    | smooth_quadratic_bezier_curveto
    | elliptical_arc

[...]

(and Inkscape Python API)

HTML Canvas path interface

path.moveTo(x, y)
path.closePath()
path.lineTo(x, y)
path.quadraticCurveTo(cpx, cpy, x, y)
path.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y)
path.arcTo(x1, y1, x2, y2, radius)
path.arc(x, y, radius, startAngle, endAngle [, counterclockwise ])
path.ellipse(x, y, radiusX, radiusY, rotation, startAngle, endAngle [, counterclockwise])
path.rect(x, y, w, h)

cairo path interface

void | cairo_close_path ()
void | cairo_arc ()
void | cairo_arc_negative ()
void | cairo_curve_to ()
void | cairo_line_to ()
void | cairo_move_to ()
void | cairo_rectangle ()
void | cairo_glyph_path ()
void | cairo_text_path ()
void | cairo_rel_curve_to ()
void | cairo_rel_line_to ()
void | cairo_rel_move_to ()
void | cairo_path_extents ()

skia path interface

kMove_Verb |  
kLine_Verb |  
kQuad_Verb |  
kConic_Verb |  
kCubic_Verb |  
kClose_Verb |  
kDone_Verb

Some information about Skia's conic curves

The conic Bézier curve — also known as the rational quadratic Bézier curve — is a relatively recent addition to the family of Bézier curves. Like the quadratic Bézier curve, the rational quadratic Bézier curve involves a start point, an end point, and one control point. But the rational quadratic Bézier curve also requires a weight value. It's called a rational quadratic because the parametric formulas involve ratios.

The parametric equations for X and Y are ratios that share the same denominator. Here is the equation for the denominator for t ranging from 0 to 1 and a weight value of w:

d(t) = (1 – t)² + 2wt(1 – t) + t²

In theory, a rational quadratic can involve three separate weight values, one for each of the three terms, but these can be simplified to just one weight value on the middle term.

The parametric equations for the X and Y coordinates are similar to the parametric equations for the quadratic Bézier except that the middle term also includes the weight value, and the expression is divided by the denominator:

x(t) = ((1 – t)²x₀ + 2wt(1 – t)x₁ + t²x₂)) ÷ d(t)

y(t) = ((1 – t)²y₀ + 2wt(1 – t)y₁ + t²y₂)) ÷ d(t)

Rational quadratic Bézier curves are also called conics because they can exactly represent segments of any conic section — hyperbolas, parabolas, ellipses, and circles.

I think postscript is the origin of all of these related path languages.
postscript path interface, Section 4.3
image

conversion

Additional stuff:
maybe older than postscript: G-code

G5 creates a cubic B-spline in the XY plane with the X and Y axes only. P and Q parameters are required. I and J are required for the first G5 command in a series. For subsequent G5 commands, either both I and J must be specified, or neither. If I and J are unspecified, the starting direction of the cubic will automatically match the ending direction of the previous cubic (as if I and J are the negation of the previous P and Q

G5 [E<pos>] [F<rate>] I<pos> J<pos> P<pos> Q<pos> [S<power>] X<pos> Y<pos>

for completeness:
HP PCL 5 or HPGL/2 ?? path interface

This command, BR or Bezier Relative , draws bezier
     curves using relative coordinates. This command uses
     the current pen position as the first control point,
     and specifies the other three control points as
     relative increments from the first point.  For more
     information see the PCL 5 Printer Language Technical
     Reference Manual.
[...]
     BR0,3048,4572,0,3556,2032,
     -508,1016,2540,508,2540,-5080;     Draws a Bezier curve
                                   using the current
                                   position (1016,5080) as
                                   the first control point.
                                   The specified control
                                   points for the first
                                   curve are (0,3048),
                                   (4572,0), and
                                   (3556,2032). The second
                                   curve uses the last
                                   control point of the
                                   previous curve as the
                                   first control point
                                   (3554,2032). The other
                                   three control points for
                                   the second curve are (-
                                   508,1016), (2540,508) and
                                   (2540,-5080).

@goretkin
Copy link
Contributor

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 Vector{LineTo}, and in this case it would be efficient. However in general it would be Vector{PathCommand}, and I wonder how the performance would compare to encoding the path command in the "value domain" vs the "type domain".

@hexaeder
Copy link
Contributor

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 BezierSegments recipe which is like LineSegments but plots arbitrary bezier paths. I know your coming from the low-level direction but I think it would be nice to have the same types for all kinds of path usages.

  • I've found to be the type instability of Vector{PathCommand} in the BezierPath struct a real pain. Is the Union splitting approach (PathCommand as Union instead of abstract type) sufficient there? For my use case I mitigated this by defining BezierPath <: AbstractPath. For some basic paths, i.e. just lines, I defined Line <: BezierPath to be able to have type stable collections of Pathes.
  • 3D. BezierPathes totally make sense in 3 dimensions therefore it would be really nice to have this parametric

Fyi the graph makie version of this code can be found here. On the long term I'd like to move the BezierPaths recipe to Makie. Currently i discretize all of the pathes and draw them as lines, long term it would be really cool if Cairo would draw them as "real" pathes while GL defaults to discretization.

@jkrumbiegel
Copy link
Collaborator Author

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.

@ffreyer
Copy link
Collaborator

ffreyer commented Dec 13, 2021

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.

@ffreyer
Copy link
Collaborator

ffreyer commented Jan 14, 2022

The alignment and size should be correct now. I tested with

marker = BezierPath([
    MoveTo(Point(5, 5)),
    LineTo(Point(25, 5)),
    LineTo(Point(25, 25)),
    LineTo(Point(5, 25)),
    ClosePath()
])

scene = Scene()
scatter!(scene, Point2f(-1, -1), marker = marker, markersize = 1)
scene

which puts a 20px x 20px marker (5px, 5px) away from the bottom corner of the window.
Screenshot from 2022-01-14 15-04-51
(The transparent red overlay shows the full quad for debugging)

@SimonDanisch
Copy link
Member

Just use Circle ;) From what I've seen, we didn't implement fetching the size from the concrete circle anyways...

@github-actions
Copy link
Contributor

Missing reference images

Found 4 new images without existing references.
Upload new reference images before merging this PR.

@ffreyer
Copy link
Collaborator

ffreyer commented Sep 10, 2022

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.

Comment on lines 228 to 234
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
Copy link
Collaborator

Choose a reason for hiding this comment

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

Missing Indention

Comment on lines +129 to +189
# 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
Copy link
Collaborator

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

Screenshot from 2022-09-10 20-17-14
and with the radii set to 0.5:
Screenshot from 2022-09-10 20-23-10

Copy link
Collaborator Author

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.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

image

here you can see a radius 0.5 circle at the top, plus some other new default markers below, and two characters. I would find it pretty impractical if all default markers were of the size as the large circle.

Copy link
Collaborator

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 to textsize/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

Copy link
Collaborator Author

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.

src/conversions.jl Outdated Show resolved Hide resolved
Comment on lines +1225 to +1226
to_spritemarker(::Type{<: Circle}) = Circle
to_spritemarker(::Type{<: Rect}) = Rect
Copy link
Collaborator

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?

@SimonDanisch
Copy link
Member

t = RGBAf(0,0,0,0)

Hm, this seems to be already broken in the latest tagged version... I'll see if I can quickly fix it in this PR!

@github-actions
Copy link
Contributor

Missing reference images

Found 4 new images without existing references.
Upload new reference images before merging this PR.

@github-actions
Copy link
Contributor

Missing reference images

Found 4 new images without existing references.
Upload new reference images before merging this PR.

@github-actions
Copy link
Contributor

Missing reference images

Found 4 new images without existing references.
Upload new reference images before merging this PR.

@github-actions
Copy link
Contributor

Missing reference images

Found 4 new images without existing references.
Upload new reference images before merging this PR.

@github-actions
Copy link
Contributor

Missing reference images

Found 4 new images without existing references.
Upload new reference images before merging this PR.

@github-actions
Copy link
Contributor

Missing reference images

Found 4 new images without existing references.
Upload new reference images before merging this PR.

@jkrumbiegel jkrumbiegel merged commit c335989 into master Sep 14, 2022
@jkrumbiegel jkrumbiegel deleted the jk/bezierpaths branch September 14, 2022 11:39
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.

None yet

6 participants