From c335989dffd8c91595195ee49949862ed31abfb5 Mon Sep 17 00:00:00 2001 From: jkrumbiegel <22495855+jkrumbiegel@users.noreply.github.com> Date: Wed, 14 Sep 2022 13:39:27 +0200 Subject: [PATCH] Add ability to use vector path markers (#979) * add bezierpaths * add bbox stuff, cairomakie routines * add elliptical arc to bezier conversion * fix .y and closepath * try enabling BezierPaths in texture atlas * fix bug * better bitmap size, adjust triangles * options for bezier from string * add some docs * improve examples * enable any size of bezierpath, normalized * fix scaling and offset * fix bbox * fix offsets * add Vector markersizes & fix aspect ratio * use billboarded rotations for all Real inputs * center within unit-square and bbox ellipticalarc * reassign markers in marker_map * cairomakie fixed * fix GLMakie * fix test syntax * fix array of mixed markers * replace all default markers * add test for most markers * microadjust markers * adjust bezier marker size relative to chars * make square little bit smaller * add test for more complex marker shapes * correct bbox fitting logic * add docs * fix sentence * make testsets easier to read * remove only for 1.3 compat * remove old scale_x/y/z code * add primitive shape for bezier for WGLMakie * replace `@cell` * fix elliptical arc scaling and rotation bugs * test squishing without ellipticalarc * first step at fixing WGLMakie + remove string for marker * remove erroneous line from merge * refactor marker scaling * refactor scaling code * fix segfault on windows * fix tests * fix CairoMakie * fix image marker * add tests for marker_offset + data space * implement wglmakie single image marker * fix CairoMakie newline rendering * fix newline rendering and clarify what's going on * implement polygon markers and remove more string markers * clean up spritemarker conversion some more * forgot import * fetch all PRs in for stale removal * use constructor * bring back precompiles * fixes + cleanup * fix indentation * fix spelling * make default marker :circle, same as in palette * fix image marker orientation * fix spaces * update bezier path docs * add more info about markersize and marker types * change to Circle marker for failing tests * remove stars test * increase default markersize from 9 to 12 * remove unused variables Co-authored-by: ffreyer Co-authored-by: Simon Co-authored-by: SimonDanisch --- .github/workflows/stale_preview_removal.yml | 16 +- CairoMakie/src/CairoMakie.jl | 6 - CairoMakie/src/overrides.jl | 1 - CairoMakie/src/primitives.jl | 57 +- CairoMakie/test/runtests.jl | 1 - GLMakie/src/GLAbstraction/GLTypes.jl | 7 +- GLMakie/src/GLMakie.jl | 2 +- GLMakie/src/drawing_primitives.jl | 5 + GLMakie/src/glshaders/particles.jl | 86 +-- GLMakie/src/glshaders/visualize_interface.jl | 3 +- MakieCore/src/basic_plots.jl | 16 +- ReferenceTests/src/tests/examples2d.jl | 12 +- ReferenceTests/src/tests/examples3d.jl | 14 - ReferenceTests/src/tests/primitives.jl | 111 +++ WGLMakie/assets/sprites.frag | 9 +- WGLMakie/src/particles.jl | 67 +- WGLMakie/test/runtests.jl | 2 - docs/examples/plotting_functions/scatter.md | 204 +++++- src/Makie.jl | 4 + src/bezier.jl | 691 +++++++++++++++++++ src/conversions.jl | 100 +-- src/layouting/boundingbox.jl | 9 +- src/theming.jl | 4 +- src/utilities/texture_atlas.jl | 208 +++++- 24 files changed, 1391 insertions(+), 244 deletions(-) create mode 100644 src/bezier.jl diff --git a/.github/workflows/stale_preview_removal.yml b/.github/workflows/stale_preview_removal.yml index ac55196b335..90f9f7dc303 100644 --- a/.github/workflows/stale_preview_removal.yml +++ b/.github/workflows/stale_preview_removal.yml @@ -33,7 +33,19 @@ jobs: parse(Int, match(r"PR(\d*)", dir)[1]) end - prs = JSON3.read(HTTP.get("https://api.github.com/repos/$repo/pulls").body) + function all_prs() + query_prs(page) = JSON3.read(HTTP.get("https://api.github.com/repos/$repo/pulls?per_page=100;page=$(page)").body) + prs = [] + page = 1 + while true + page_prs = query_prs(page) + isempty(page_prs) && break + append!(prs, page_prs) + page += 1 + end + return prs + end + prs = all_prs() open_within_threshold = map(x -> x.number, filter(prs) do pr time = DateTime(pr.updated_at[1:19], ISODateTimeFormat) return pr.state == "open" && Dates.days(now() - time) <= retention_days @@ -58,4 +70,4 @@ jobs: git config user.email "documenter@juliadocs.github.io" git commit -m "delete preview" git branch gh-pages-new $(echo "delete history" | git commit-tree HEAD^{tree}) - git push --force origin gh-pages-new:gh-pages \ No newline at end of file + git push --force origin gh-pages-new:gh-pages diff --git a/CairoMakie/src/CairoMakie.jl b/CairoMakie/src/CairoMakie.jl index aeb23dcd3f8..b169e8726de 100644 --- a/CairoMakie/src/CairoMakie.jl +++ b/CairoMakie/src/CairoMakie.jl @@ -15,12 +15,6 @@ using Makie.Observables using Makie: spaces, is_data_space, is_pixel_space, is_relative_space, is_clip_space using Makie: numbers_to_colors -const OneOrVec{T} = Union{ - T, - Vec{N1, T} where N1, - NTuple{N2, T} where N2, -} - # re-export Makie, including deprecated names for name in names(Makie, all=true) if Base.isexported(Makie, name) diff --git a/CairoMakie/src/overrides.jl b/CairoMakie/src/overrides.jl index 906c0a05695..ef66d306fa4 100644 --- a/CairoMakie/src/overrides.jl +++ b/CairoMakie/src/overrides.jl @@ -67,7 +67,6 @@ function draw_poly(scene::Scene, screen::CairoScreen, poly, points_list::Vector{ end end - draw_poly(scene::Scene, screen::CairoScreen, poly, rect::Rect2) = draw_poly(scene, screen, poly, [rect]) function draw_poly(scene::Scene, screen::CairoScreen, poly, rects::Vector{<:Rect2}) diff --git a/CairoMakie/src/primitives.jl b/CairoMakie/src/primitives.jl index 3056eabfdd8..c2eee85f94e 100644 --- a/CairoMakie/src/primitives.jl +++ b/CairoMakie/src/primitives.jl @@ -219,11 +219,18 @@ function draw_atomic_scatter(scene, ctx, transfunc, colors, markersize, strokeco isnan(pos) && return Cairo.set_source_rgba(ctx, rgbatuple(col)...) + Cairo.save(ctx) - if m isa Char - draw_marker(ctx, m, best_font(m, font), pos, scale, strokecolor, strokewidth, offset, rotation) - else - draw_marker(ctx, m, pos, scale, strokecolor, strokewidth, offset, rotation) + marker_converted = Makie.to_spritemarker(m) + # Setting a markersize of 0.0 somehow seems to break Cairos global state? + # At least it stops drawing any marker afterwards + # TODO, maybe there's something wrong somewhere else? + 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, marker_converted, pos, scale, strokecolor, strokewidth, offset, rotation) + end end Cairo.restore(ctx) end @@ -280,7 +287,7 @@ function draw_marker(ctx, marker::Char, font, pos, scale, strokecolor, strokewid set_font_matrix(ctx, old_matrix) end -function draw_marker(ctx, marker::Circle, pos, scale, strokecolor, strokewidth, marker_offset, rotation) +function draw_marker(ctx, ::Type{<: Circle}, pos, scale, strokecolor, strokewidth, marker_offset, rotation) marker_offset = marker_offset + scale ./ 2 pos += Point2f(marker_offset[1], -marker_offset[2]) @@ -304,8 +311,8 @@ function draw_marker(ctx, marker::Circle, pos, scale, strokecolor, strokewidth, nothing end -function draw_marker(ctx, marker::Rect, pos, scale, strokecolor, strokewidth, marker_offset, rotation) - s2 = Point2((widths(marker) .* scale .* (1, -1))...) +function draw_marker(ctx, ::Type{<: Rect}, pos, scale, strokecolor, strokewidth, marker_offset, rotation) + s2 = Point2((scale .* (1, -1))...) pos = pos .+ Point2f(marker_offset[1], -marker_offset[2]) Cairo.rotate(ctx, to_2d_rotation(rotation)) Cairo.rectangle(ctx, pos[1], pos[2], s2...) @@ -316,6 +323,38 @@ function draw_marker(ctx, marker::Rect, pos, scale, strokecolor, strokewidth, ma Cairo.stroke(ctx) end +function draw_marker(ctx, beziermarker::BezierPath, pos, scale, strokecolor, strokewidth, marker_offset, rotation) + Cairo.save(ctx) + Cairo.translate(ctx, pos[1], pos[2]) + Cairo.rotate(ctx, to_2d_rotation(rotation)) + Cairo.scale(ctx, scale[1], -scale[2]) # flip y for cairo + draw_path(ctx, beziermarker) + Cairo.fill_preserve(ctx) + sc = to_color(strokecolor) + Cairo.set_source_rgba(ctx, rgbatuple(sc)...) + Cairo.set_line_width(ctx, Float64(strokewidth)) + Cairo.stroke(ctx) + Cairo.restore(ctx) +end + +draw_path(ctx, bp::BezierPath) = foreach(x -> path_command(ctx, x), bp.commands) +path_command(ctx, c::MoveTo) = Cairo.move_to(ctx, c.p...) +path_command(ctx, c::LineTo) = Cairo.line_to(ctx, c.p...) +path_command(ctx, c::CurveTo) = Cairo.curve_to(ctx, c.c1..., c.c2..., c.p...) +path_command(ctx, ::ClosePath) = Cairo.close_path(ctx) +function path_command(ctx, c::EllipticalArc) + Cairo.save(ctx) + Cairo.translate(ctx, c.c...) + Cairo.rotate(ctx, c.angle) + Cairo.scale(ctx, 1, c.r2 / c.r1) + if c.a2 > c.a1 + Cairo.arc(ctx, 0, 0, c.r1, c.a1, c.a2) + else + Cairo.arc_negative(ctx, 0, 0, c.r1, c.a1, c.a2) + end + Cairo.restore(ctx) +end + function draw_marker(ctx, marker::Matrix{T}, pos, scale, strokecolor #= unused =#, strokewidth #= unused =#, @@ -413,7 +452,9 @@ function draw_glyph_collection(scene, ctx, position, glyph_collection, rotation, p3_offset = to_ndim(Point3f, offset, 0) - glyph in ('\r', '\n') && return + # Not renderable by font (e.g. '\n') + # TODO, filter out \n in GlyphCollection, and render unrenderables as box + glyph == 0 && return Cairo.save(ctx) Cairo.set_source_rgba(ctx, rgbatuple(color)...) diff --git a/CairoMakie/test/runtests.jl b/CairoMakie/test/runtests.jl index 876d4deacc2..75101be4867 100644 --- a/CairoMakie/test/runtests.jl +++ b/CairoMakie/test/runtests.jl @@ -66,7 +66,6 @@ excludes = Set([ "Image on Surface Sphere", "FEM mesh 2D", "Hbox", - "Stars", "Subscenes", "Arrows 3D", "Layouting", diff --git a/GLMakie/src/GLAbstraction/GLTypes.jl b/GLMakie/src/GLAbstraction/GLTypes.jl index 53d90a57a78..e6d7960ddd1 100644 --- a/GLMakie/src/GLAbstraction/GLTypes.jl +++ b/GLMakie/src/GLAbstraction/GLTypes.jl @@ -333,7 +333,12 @@ function RenderObject( if isa_gl_struct(v) merge!(data, gl_convert_struct(v, k)) elseif applicable(gl_convert, v) # if can't be converted to an OpenGL datatype, - data[k] = gl_convert(v) + try + data[k] = gl_convert(v) + catch e + @warn("Can't convert $(typeof(v)) to opengl, for attribute $(k)") + rethrow(e) + end else # put it in passthrough delete!(data, k) passthrough[k] = v diff --git a/GLMakie/src/GLMakie.jl b/GLMakie/src/GLMakie.jl index 020978b7bd4..a19433c5e8a 100644 --- a/GLMakie/src/GLMakie.jl +++ b/GLMakie/src/GLMakie.jl @@ -16,7 +16,7 @@ using Makie: @get_attribute, to_value, to_colormap, extrema_nan using Makie: ClosedInterval, (..) using Makie: inline!, to_native using Makie: spaces, is_data_space, is_pixel_space, is_relative_space, is_clip_space -import Makie: to_font, glyph_uv_width!, el32convert +import Makie: to_font, glyph_uv_width!, el32convert, Shape, CIRCLE, RECTANGLE, ROUNDED_RECTANGLE, DISTANCEFIELD, TRIANGLE using ShaderAbstractions using FreeTypeAbstraction diff --git a/GLMakie/src/drawing_primitives.jl b/GLMakie/src/drawing_primitives.jl index efa983e3b81..f6de625338d 100644 --- a/GLMakie/src/drawing_primitives.jl +++ b/GLMakie/src/drawing_primitives.jl @@ -212,6 +212,11 @@ function draw_atomic(screen::GLScreen, scene::Scene, @nospecialize(x::Union{Scat gl_attributes[:billboard] = map(rot-> isa(rot, Billboard), x.rotations) isnothing(gl_attributes[:distancefield][]) && delete!(gl_attributes, :distancefield) gl_attributes[:uv_offset_width][] == Vec4f(0) && delete!(gl_attributes, :uv_offset_width) + + font = get(gl_attributes, :font, Observable(Makie.defaultfont())) + scale, quad_offset = Makie.marker_attributes(marker, gl_attributes[:scale], font, gl_attributes[:quad_offset]) + gl_attributes[:scale] = scale + gl_attributes[:quad_offset] = quad_offset else connect_camera!(gl_attributes, scene.camera) end diff --git a/GLMakie/src/glshaders/particles.jl b/GLMakie/src/glshaders/particles.jl index c6a2967aa32..f051f650194 100644 --- a/GLMakie/src/glshaders/particles.jl +++ b/GLMakie/src/glshaders/particles.jl @@ -35,64 +35,7 @@ struct PointSizeRender end (x::PointSizeRender)() = glPointSize(to_pointsize(x.size[])) -""" -returns the Shape for the distancefield algorithm -""" -primitive_shape(::Union{AbstractString, Char}) = DISTANCEFIELD -primitive_shape(x::X) where {X} = primitive_shape(X) -primitive_shape(::Type{T}) where {T <: Circle} = CIRCLE -primitive_shape(::Type{T}) where {T <: Rect2} = RECTANGLE -primitive_shape(x::Shape) = x - -""" -Extracts the scale from a primitive. -""" -primitive_scale(prim::GeometryPrimitive) = Vec2f(widths(prim)) -primitive_scale(::Union{Shape, Char}) = Vec2f(40) -primitive_scale(c) = Vec2f(0.1) - -""" -Extracts the offset from a primitive. -""" -primitive_offset(x, scale::Nothing) = Vec2f(0) # default offset -primitive_offset(x, scale) = const_lift(/, scale, -2f0) # default offset - - -""" -Extracts the uv offset and width from a primitive. -""" -primitive_uv_offset_width(c::Char) = glyph_uv_width!(c) -primitive_uv_offset_width(str::AbstractString) = map(glyph_uv_width!, collect(str)) -primitive_uv_offset_width(x) = Vec4f(0,0,1,1) - -""" -Gets the texture atlas if primitive is a char. -""" -primitive_distancefield(x) = nothing -primitive_distancefield(::Union{AbstractString, Char}) = get_texture!(get_texture_atlas()) -primitive_distancefield(x::Observable) = primitive_distancefield(x[]) - -function char_scale_factor(char, font) - # uv * size(ta.data) / Makie.PIXELSIZE_IN_ATLAS[] is the padded glyph size - # normalized to the size the glyph was generated as. - ta = Makie.get_texture_atlas() - lbrt = glyph_uv_width!(ta, char, font) - width = Vec(lbrt[3] - lbrt[1], lbrt[4] - lbrt[2]) - return width .* Vec2f(size(ta.data)) ./ Makie.PIXELSIZE_IN_ATLAS[] -end -# This works the same for x being widths and offsets -rescale_glyph(char::Char, font, x) = x * char_scale_factor(char, font) -function rescale_glyph(char::Char, font, xs::Vector) - f = char_scale_factor(char, font) - map(x -> f * x, xs) -end -function rescale_glyph(str::String, font, x) - [x * char_scale_factor(char, font) for char in collect(str)] -end -function rescale_glyph(str::String, font, xs::Vector) - map((char, x) -> x * char_scale_factor(char, font), collect(str), xs) -end @nospecialize """ @@ -215,6 +158,12 @@ function draw_scatter( return draw_scatter(shader_cache, (RECTANGLE, p[2]), data) end +function texture_distancefield(shape) + df = Makie.primitive_distancefield(to_value(shape)) + isnothing(df) && return nothing + return get_texture!(df) +end + """ Main assemble functions for scatter particles. Sprites are anything like distance fields, images and simple geometries @@ -223,31 +172,18 @@ function draw_scatter(shader_cache, (marker, position), data) rot = get!(data, :rotation, Vec4f(0, 0, 0, 1)) rot = vec2quaternion(rot) delete!(data, :rotation) - # Rescale to include glyph padding and shape - if isa(to_value(marker), Union{AbstractString, Char}) - scale = data[:scale] - font = get(data, :font, Observable(Makie.defaultfont())) - quad_offset = get(data, :quad_offset, Observable(Vec2f(0))) - - # The same scaling that needs to be applied to scale also needs to apply - # to offset. - data[:quad_offset] = map(rescale_glyph, marker, font, quad_offset) - data[:scale] = map(rescale_glyph, marker, font, scale) - end @gen_defaults! data begin - shape = const_lift(x-> Int32(primitive_shape(x)), marker) + shape = Makie.marker_to_sdf_shape(marker) position = position => GLBuffer marker_offset = Vec3f(0) => GLBuffer; - - scale = const_lift(primitive_scale, marker) => GLBuffer - + scale = Vec2f(0) => GLBuffer rotation = rot => GLBuffer image = nothing => Texture end @gen_defaults! data begin - quad_offset = primitive_offset(marker, scale) => GLBuffer + quad_offset = Vec2f(0) => GLBuffer intensity = nothing => GLBuffer color_map = nothing => Texture color_norm = nothing @@ -257,9 +193,9 @@ function draw_scatter(shader_cache, (marker, position), data) stroke_color = RGBA{Float32}(0,0,0,0) => GLBuffer stroke_width = 0f0 glow_width = 0f0 - uv_offset_width = const_lift(primitive_uv_offset_width, marker) => GLBuffer + uv_offset_width = Makie.primitive_uv_offset_width(marker) => GLBuffer - distancefield = primitive_distancefield(marker) => Texture + distancefield = texture_distancefield(shape) => Texture indices = const_lift(length, position) => to_index_buffer # rotation and billboard don't go along billboard = rotation == Vec4f(0,0,0,1) => "if `billboard` == true, particles will always face camera" diff --git a/GLMakie/src/glshaders/visualize_interface.jl b/GLMakie/src/glshaders/visualize_interface.jl index 457bfbf818b..65c48feff49 100644 --- a/GLMakie/src/glshaders/visualize_interface.jl +++ b/GLMakie/src/glshaders/visualize_interface.jl @@ -1,4 +1,3 @@ -@enum Shape CIRCLE RECTANGLE ROUNDED_RECTANGLE DISTANCEFIELD TRIANGLE @enum CubeSides TOP BOTTOM FRONT BACK RIGHT LEFT struct Grid{N,T <: AbstractRange} @@ -70,7 +69,7 @@ struct GLVisualizeShader <: AbstractLazyShader paths::Tuple kw_args::Dict{Symbol,Any} function GLVisualizeShader( - shader_cache::GLAbstraction.ShaderCache, paths::String...; + shader_cache::GLAbstraction.ShaderCache, paths::String...; view = Dict{String,String}(), kw_args... ) # TODO properly check what extensions are available diff --git a/MakieCore/src/basic_plots.jl b/MakieCore/src/basic_plots.jl index b474346b721..45e32185162 100644 --- a/MakieCore/src/basic_plots.jl +++ b/MakieCore/src/basic_plots.jl @@ -32,7 +32,7 @@ Plots an image on range `x, y` (defaults to dimensions). - `transparency::Bool = false` adjusts how the plot deals with transparency. In GLMakie `transparency = true` results in using Order Independent Transparency. - `fxaa::Bool = false` adjusts whether the plot is rendered with fxaa (anti-aliasing). - `inspectable::Bool = true` sets whether this plot should be seen by `DataInspector`. -- `depth_shift::Float32 = 0f0` adjusts the depth value of a plot after all other transformations, i.e. in clip space, where `0 <= depth <= 1`. This only applies to GLMakie and WGLMakie and can be used to adjust render order (like a tunable overdraw). +- `depth_shift::Float32 = 0f0` adjusts the depth value of a plot after all other transformations, i.e. in clip space, where `0 <= depth <= 1`. This only applies to GLMakie and WGLMakie and can be used to adjust render order (like a tunable overdraw). - `model::Makie.Mat4f` sets a model matrix for the plot. This replaces adjustments made with `translate!`, `rotate!` and `scale!`. - `color` is set by the plot. - `colormap::Union{Symbol, Vector{<:Colorant}} = [:black, :white` sets the colormap that is sampled for numeric `color`s. @@ -238,7 +238,7 @@ Creates a connected line plot for each element in `(x, y, z)`, `(x, y)` or `posi - `transparency::Bool = false` adjusts how the plot deals with transparency. In GLMakie `transparency = true` results in using Order Independent Transparency. - `fxaa::Bool = false` adjusts whether the plot is rendered with fxaa (anti-aliasing). Note that line plots already use a different form of anti-aliasing. - `inspectable::Bool = true` sets whether this plot should be seen by `DataInspector`. -- `depth_shift::Float32 = 0f0` adjusts the depth value of a plot after all other transformations, i.e. in clip space, where `0 <= depth <= 1`. This only applies to GLMakie and WGLMakie and can be used to adjust render order (like a tunable overdraw). +- `depth_shift::Float32 = 0f0` adjusts the depth value of a plot after all other transformations, i.e. in clip space, where `0 <= depth <= 1`. This only applies to GLMakie and WGLMakie and can be used to adjust render order (like a tunable overdraw). - `model::Makie.Mat4f` sets a model matrix for the plot. This replaces adjustments made with `translate!`, `rotate!` and `scale!`. - `color` sets the color of the plot. It can be given as a named color `Symbol` or a `Colors.Colorant`. Transparency can be included either directly as an alpha value in the `Colorant` or as an additional float in a tuple `(color, alpha)`. The color can also be set for each point in the line by passing a `Vector` of colors or be used to index the `colormap` by passing a `Real` number or `Vector{<: Real}`. - `colormap::Union{Symbol, Vector{<:Colorant}} = :viridis` sets the colormap that is sampled for numeric `color`s. @@ -284,9 +284,9 @@ Plots a line for each pair of points in `(x, y, z)`, `(x, y)`, or `positions`. - `transparency::Bool = false` adjusts how the plot deals with transparency. In GLMakie `transparency = true` results in using Order Independent Transparency. - `fxaa::Bool = false` adjusts whether the plot is rendered with fxaa (anti-aliasing). Note that line plots already use a different form of anti-aliasing. - `inspectable::Bool = true` sets whether this plot should be seen by `DataInspector`. -- `depth_shift::Float32 = 0f0` adjusts the depth value of a plot after all other transformations, i.e. in clip space, where `0 <= depth <= 1`. This only applies to GLMakie and WGLMakie and can be used to adjust render order (like a tunable overdraw). +- `depth_shift::Float32 = 0f0` adjusts the depth value of a plot after all other transformations, i.e. in clip space, where `0 <= depth <= 1`. This only applies to GLMakie and WGLMakie and can be used to adjust render order (like a tunable overdraw). - `model::Makie.Mat4f` sets a model matrix for the plot. This replaces adjustments made with `translate!`, `rotate!` and `scale!`. -- `color` sets the color of the plot. It can be given as a named color `Symbol` or a `Colors.Colorant`. Transparency can be included either directly as an alpha value in the `Colorant` or as an additional float in a tuple `(color, alpha)`. The color can also be set for each point in the line by passing a `Vector` or be used to index the `colormap` by passing a `Real` number or `Vector{<: Real}`. +- `color` sets the color of the plot. It can be given as a named color `Symbol` or a `Colors.Colorant`. Transparency can be included either directly as an alpha value in the `Colorant` or as an additional float in a tuple `(color, alpha)`. The color can also be set for each point in the line by passing a `Vector` or be used to index the `colormap` by passing a `Real` number or `Vector{<: Real}`. - `colormap::Union{Symbol, Vector{<:Colorant}} = :viridis` sets the colormap that is sampled for numeric `color`s. - `colorrange::Tuple{<:Real, <:Real}` sets the values representing the start and end points of `colormap`. - `nan_color::Union{Symbol, <:Colorant} = RGBAf(0,0,0,0)` sets a replacement color for `color = NaN`. @@ -364,7 +364,7 @@ Plots a marker for each element in `(x, y, z)`, `(x, y)`, or `positions`. ### Specific to `Scatter` - `cycle::Vector{Symbol} = [:color]` sets which attributes to cycle when creating multiple plots. -- `marker::Union{Symbol, Char, Matrix{<:Colorant}}` sets the scatter marker. +- `marker::Union{Symbol, Char, Matrix{<:Colorant}, BezierPath, Polygon}` sets the scatter marker. - `markersize::Union{<:Real, Vec2f} = 9` sets the size of the marker. - `markerspace::Symbol = :pixel` sets the space in which `markersize` is given. See `Makie.spaces()` for possible inputs. - `strokewidth::Real = 0` sets the width of the outline around a marker. @@ -440,9 +440,9 @@ Plots a mesh for each element in `(x, y, z)`, `(x, y)`, or `positions` (similar - `transparency::Bool = false` adjusts how the plot deals with transparency. In GLMakie `transparency = true` results in using Order Independent Transparency. - `fxaa::Bool = true` adjusts whether the plot is rendered with fxaa (anti-aliasing). - `inspectable::Bool = true` sets whether this plot should be seen by `DataInspector`. -- `depth_shift::Float32 = 0f0` adjusts the depth value of a plot after all other transformations, i.e. in clip space, where `0 <= depth <= 1`. This only applies to GLMakie and WGLMakie and can be used to adjust render order (like a tunable overdraw). +- `depth_shift::Float32 = 0f0` adjusts the depth value of a plot after all other transformations, i.e. in clip space, where `0 <= depth <= 1`. This only applies to GLMakie and WGLMakie and can be used to adjust render order (like a tunable overdraw). - `model::Makie.Mat4f` sets a model matrix for the plot. This replaces adjustments made with `translate!`, `rotate!` and `scale!`. -- `color` sets the color of the plot. It can be given as a named color `Symbol` or a `Colors.Colorant`. Transparency can be included either directly as an alpha value in the `Colorant` or as an additional float in a tuple `(color, alpha)`. The color can also be set for each scattered mesh by passing a `Vector` of colors or be used to index the `colormap` by passing a `Real` number or `Vector{<: Real}`. +- `color` sets the color of the plot. It can be given as a named color `Symbol` or a `Colors.Colorant`. Transparency can be included either directly as an alpha value in the `Colorant` or as an additional float in a tuple `(color, alpha)`. The color can also be set for each scattered mesh by passing a `Vector` of colors or be used to index the `colormap` by passing a `Real` number or `Vector{<: Real}`. - `colormap::Union{Symbol, Vector{<:Colorant}} = :viridis` sets the colormap that is sampled for numeric `color`s. - `colorrange::Tuple{<:Real, <:Real}` sets the values representing the start and end points of `colormap`. - `nan_color::Union{Symbol, <:Colorant} = RGBAf(0,0,0,0)` sets a replacement color for `color = NaN`. @@ -451,7 +451,7 @@ Plots a mesh for each element in `(x, y, z)`, `(x, y)`, or `positions` (similar ### Generic 3D - `shading = true` enables lighting. -- `diffuse::Vec3f = Vec3f(0.4)` sets how strongly the red, green and blue channel react to diffuse (scattered) light. +- `diffuse::Vec3f = Vec3f(0.4)` sets how strongly the red, green and blue channel react to diffuse (scattered) light. - `specular::Vec3f = Vec3f(0.2)` sets how strongly the object reflects light in the red, green and blue channels. - `shininess::Real = 32.0` sets how sharp the reflection is. - `ssao::Bool = false` adjusts whether the plot is rendered with ssao (screen space ambient occlusion). Note that this only makes sense in 3D plots and is only applicable with `fxaa = true`. diff --git a/ReferenceTests/src/tests/examples2d.jl b/ReferenceTests/src/tests/examples2d.jl index eed9a459955..3c3d4b54d33 100644 --- a/ReferenceTests/src/tests/examples2d.jl +++ b/ReferenceTests/src/tests/examples2d.jl @@ -374,7 +374,7 @@ end ax, Rect2f(xs[i][i] - 2s, xs[i][j] - 2s, 4s, 4s), space = space, linewidth = 2, color = :red) scatter!( - ax, Point2f(xs[i][i], xs[i][j]), color = :orange, + ax, Point2f(xs[i][i], xs[i][j]), color = :orange, marker = Circle, markersize = 5scales[j], space = space, markerspace = mspace) text!( ax, "$space\n$mspace", position = Point2f(xs[i][i], xs[i][j]), @@ -416,7 +416,7 @@ end ax, Rect2f(xs[i][i] - 2s, xs[i][j] - 2s, 4s, 4s), space = space, linewidth = 2, color = :red) scatter!( - ax, Point2f(xs[i][i], xs[i][j]), color = :orange, + ax, Point2f(xs[i][i], xs[i][j]), color = :orange, marker = Circle, markersize = 5scales[j], space = space, markerspace = mspace) text!( ax, "$space\n$mspace", position = Point2f(xs[i][i], xs[i][j]), @@ -544,3 +544,11 @@ end end f end + +@reference_test "marker offset in data space" begin + f = Figure() + ax = Axis(f[1, 1]; xticks=0:1, yticks=0:10) + scatter!(ax, fill(0, 10), 0:9, marker=Rect, marker_offset=Vec2f(0,0), transform_marker=true, markerspace=:data, markersize=Vec2f.(1, LinRange(0.1, 1, 10))) + lines!(ax, Rect(0, 0, 1, 10), color=:red) + f +end diff --git a/ReferenceTests/src/tests/examples3d.jl b/ReferenceTests/src/tests/examples3d.jl index f3314db4f3e..13f7e9bcb3a 100644 --- a/ReferenceTests/src/tests/examples3d.jl +++ b/ReferenceTests/src/tests/examples3d.jl @@ -393,20 +393,6 @@ end mesh(Sphere(Point3f(0), 1f0), color=:blue) end -@reference_test "Stars" begin - stars = 100_000 - scene = Scene(backgroundcolor=:black, camera=cam2d!) - scatter!( - scene, - map(i -> (RNG.randn(Point3f) .- 0.5) .* 10, 1:stars), - color=RNG.rand(stars), - colormap=[(:white, 0.4), (:blue, 0.4), (:yellow, 0.4)], strokewidth=0, - markersize=RNG.rand(range(2, stop=8, length=100), stars), - ) - update_cam!(scene, Rect3f(Vec3f(-5), Vec3f(10))) - scene -end - @reference_test "Unicode Marker" begin scatter(Point3f[(1, 0, 0), (0, 1, 0), (0, 0, 1)], marker=[:x, :circle, :cross], markersize=35) diff --git a/ReferenceTests/src/tests/primitives.jl b/ReferenceTests/src/tests/primitives.jl index bbb53440919..d34ef9ac0d8 100644 --- a/ReferenceTests/src/tests/primitives.jl +++ b/ReferenceTests/src/tests/primitives.jl @@ -217,3 +217,114 @@ end end s end + + +@reference_test "BezierPath markers" begin + f = Figure(resolution = (800, 800)) + ax = Axis(f[1, 1]) + + markers = [ + :rect, + :circle, + :cross, + :x, + :utriangle, + :rtriangle, + :dtriangle, + :ltriangle, + :pentagon, + :hexagon, + :octagon, + :star4, + :star5, + :star6, + :star8, + :vline, + :hline, + # for comparison with characters + 'x', + 'X', + ] + + for (i, marker) in enumerate(markers) + scatter!(Point2f.(1:5, i), marker = marker, markersize = range(10, 30, length = 5), color = :black) + scatter!(Point2f.(1:5, i), markersize = 4, color = :white) + end + + f +end + + +@reference_test "complex_bezier_markers" begin + f = Figure(resolution = (800, 800)) + ax = Axis(f[1, 1]) + + arrow = BezierPath([ + MoveTo(Point(0, 0)), + LineTo(Point(0.3, -0.3)), + LineTo(Point(0.15, -0.3)), + LineTo(Point(0.3, -1)), + LineTo(Point(0, -0.9)), + LineTo(Point(-0.3, -1)), + LineTo(Point(-0.15, -0.3)), + LineTo(Point(-0.3, -0.3)), + ClosePath() + ]) + + circle_with_hole = BezierPath([ + MoveTo(Point(1, 0)), + EllipticalArc(Point(0, 0), 1, 1, 0, 0, 2pi), + MoveTo(Point(0.5, 0.5)), + LineTo(Point(0.5, -0.5)), + LineTo(Point(-0.5, -0.5)), + LineTo(Point(-0.5, 0.5)), + ClosePath(), + ]) + + batsymbol_string = "M96.84 141.998c-4.947-23.457-20.359-32.211-25.862-13.887-11.822-22.963-37.961-16.135-22.041 6.289-3.005-1.295-5.872-2.682-8.538-4.191-8.646-5.318-15.259-11.314-19.774-17.586-3.237-5.07-4.994-10.541-4.994-16.229 0-19.774 21.115-36.758 50.861-43.694.446-.078.909-.154 1.372-.231-22.657 30.039 9.386 50.985 15.258 24.645l2.528-24.367 5.086 6.52H103.205l5.07-6.52 2.543 24.367c5.842 26.278 37.746 5.502 15.414-24.429 29.777 6.951 50.891 23.936 50.891 43.709 0 15.136-12.406 28.651-31.609 37.267 14.842-21.822-10.867-28.266-22.549-5.549-5.502-18.325-21.147-9.341-26.125 13.886z" + batsymbol = Makie.scale( + BezierPath(batsymbol_string, fit = true, flipy = true, bbox = Rect2f((0, 0), (1, 1)), keep_aspect = false), + 1.5 + ) + + gh_string = "M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" + github = BezierPath(gh_string, fit = true, flipy = true) + + two_circles_with_holes = Makie.scale(BezierPath([ + MoveTo(Point(2.25, 0)), + EllipticalArc(Point(1.25, 0), 1, 1, 0, 0, 2pi), + ClosePath(), + MoveTo(Point(-0.25, 0)), + EllipticalArc(Point(-1.25, 0), 1, 1, 0, 0, 2pi), + ClosePath(), + MoveTo(Point(2, 0)), + EllipticalArc(Point(1.25, 0), 0.75, 0.75, 0, 0, -2pi), + ClosePath(), + MoveTo(Point(-1, 0)), + EllipticalArc(Point(-1.25, 0), 0.25, 0.25, 0, 0, -2pi), + ClosePath(), + ]), 0.5) + + markers = [ + arrow, + circle_with_hole, + batsymbol, + github, + two_circles_with_holes, + ] + + for (i, marker) in enumerate(markers) + scatter!(Point2f.(1:5, i), marker = marker, markersize = range(10, 50, length = 5), color = :black) + end + + limits!(ax, 0, 6, 0, length(markers) + 1) + + f +end + +@reference_test "polygon markers" begin + p_big = decompose(Point2f, Circle(Point2f(0), 1)) + p_small = decompose(Point2f, Circle(Point2f(0), 0.5)) + marker = [Polygon(p_big, [p_small]), Polygon(reverse(p_big), [p_small]), Polygon(p_big, [reverse(p_small)]), Polygon(reverse(p_big), [reverse(p_small)])] + scatter(1:4, fill(0, 4), marker=marker, markersize=100, color=1:4, axis=(limits=(0, 5, -1, 1),)) +end diff --git a/WGLMakie/assets/sprites.frag b/WGLMakie/assets/sprites.frag index ecf78e368c7..ba66067617c 100644 --- a/WGLMakie/assets/sprites.frag +++ b/WGLMakie/assets/sprites.frag @@ -46,10 +46,15 @@ float rounded_rectangle(vec2 uv, vec2 tl, vec2 br){ return -((length(max(vec2(0.0), d)) + min(0.0, max(d.x, d.y)))-tl.x); } -void fill(vec4 fillcolor, vec2 uv, float infill, inout vec4 color){ +void fill(bool image, vec4 fillcolor, vec2 uv, float infill, inout vec4 color){ color = mix(color, fillcolor, infill); } +void fill(sampler2D image, vec4 fillcolor, vec2 uv, float infill, inout vec4 color){ + vec4 im_color = texture(image, uv.yx); + color = mix(color, im_color, infill); +} + in float frag_uvscale; in float frag_distancefield_scale; in vec4 frag_uv_offset_width; @@ -84,6 +89,6 @@ void main() { signed_distance *= frag_uvscale; float inside = aastep(0.0, signed_distance); vec4 final_color = vec4(frag_color.xyz, 0); - fill(frag_color, frag_uv, inside, final_color); + fill(image, frag_color, frag_uv, inside, final_color); fragment_color = final_color; } diff --git a/WGLMakie/src/particles.jl b/WGLMakie/src/particles.jl index e393ec08b0e..6d43a2d48a0 100644 --- a/WGLMakie/src/particles.jl +++ b/WGLMakie/src/particles.jl @@ -92,37 +92,6 @@ function create_shader(scene::Scene, plot::MeshScatter) instance, VertexArray(; per_instance...); uniform_dict...) end -@enum Shape CIRCLE RECTANGLE ROUNDED_RECTANGLE DISTANCEFIELD TRIANGLE - -primitive_shape(::Union{String,Char,Vector{Char}}) = Cint(DISTANCEFIELD) -primitive_shape(x::X) where {X} = Cint(primitive_shape(X)) -primitive_shape(::Type{<:Circle}) = Cint(CIRCLE) -primitive_shape(::Type{<:Rect2}) = Cint(RECTANGLE) -primitive_shape(::Type{T}) where {T} = error("Type $(T) not supported") -primitive_shape(x::Shape) = Cint(x) - -function char_scale_factor(char, font) - # uv * size(ta.data) / Makie.PIXELSIZE_IN_ATLAS[] is the padded glyph size - # normalized to the size the glyph was generated as. - ta = Makie.get_texture_atlas() - lbrt = glyph_uv_width!(ta, char, font) - width = Vec(lbrt[3] - lbrt[1], lbrt[4] - lbrt[2]) - width * Vec2f(size(ta.data)) / Makie.PIXELSIZE_IN_ATLAS[] -end - -# This works the same for x being widths and offsets -rescale_glyph(char::Char, font, x) = x * char_scale_factor(char, font) -function rescale_glyph(char::Char, font, xs::Vector) - f = char_scale_factor(char, font) - map(x -> f * x, xs) -end -function rescale_glyph(str::String, font, x) - [x * char_scale_factor(char, font) for char in collect(str)] -end -function rescale_glyph(str::String, font, xs::Vector) - map((char, x) -> x * char_scale_factor(char, font), collect(str), xs) -end - using Makie: to_spritemarker function scatter_shader(scene::Scene, attributes) @@ -130,17 +99,18 @@ function scatter_shader(scene::Scene, attributes) per_instance_keys = (:pos, :rotations, :markersize, :color, :intensity, :uv_offset_width, :quad_offset, :marker_offset) uniform_dict = Dict{Symbol,Any}() - - if haskey(attributes, :marker) && attributes[:marker][] isa Union{Char, Vector{Char},String} + uniform_dict[:image] = false + if haskey(attributes, :marker) font = get(attributes, :font, Observable(Makie.defaultfont())) - attributes[:markersize] = map(rescale_glyph, attributes[:marker], font, attributes[:markersize]) - attributes[:quad_offset] = map(rescale_glyph, attributes[:marker], font, attributes[:quad_offset]) - end - - if haskey(attributes, :marker) && attributes[:marker][] isa Union{Vector{Char},String} - x = pop!(attributes, :marker) - attributes[:uv_offset_width] = lift(x -> Makie.glyph_uv_width!.(collect(x)), x) - uniform_dict[:shape_type] = Cint(3) + marker = lift(Makie.to_spritemarker, attributes[:marker]) + markersize = lift(Makie.to_2d_scale, attributes[:markersize]) + msize, offset = Makie.marker_attributes(marker, markersize, font, attributes[:quad_offset]) + attributes[:markersize] = msize + attributes[:quad_offset] = offset + attributes[:uv_offset_width] = Makie.primitive_uv_offset_width(marker) + if to_value(marker) isa AbstractMatrix + uniform_dict[:image] = Sampler(lift(el32convert, marker)) + end end per_instance = filter(attributes) do (k, v) @@ -161,8 +131,9 @@ function scatter_shader(scene::Scene, attributes) end get!(uniform_dict, :shape_type) do - return lift(x -> primitive_shape(to_spritemarker(x)), attributes[:marker]) + return Makie.marker_to_sdf_shape(attributes[:marker]) end + if uniform_dict[:shape_type][] == 3 atlas = Makie.get_texture_atlas() uniform_dict[:distancefield] = Sampler(atlas.data, minfilter=:linear, @@ -173,18 +144,6 @@ function scatter_shader(scene::Scene, attributes) uniform_dict[:distancefield] = Observable(false) end - if !haskey(per_instance, :uv_offset_width) - get!(uniform_dict, :uv_offset_width) do - return if haskey(attributes, :marker) && - to_spritemarker(attributes[:marker][]) isa Char - lift(x -> Makie.glyph_uv_width!(to_spritemarker(x)), - attributes[:marker]) - else - Vec4f(0) - end - end - end - handle_color!(uniform_dict, per_instance) instance = uv_mesh(Rect2(-0.5f0, -0.5f0, 1f0, 1f0)) diff --git a/WGLMakie/test/runtests.jl b/WGLMakie/test/runtests.jl index 3fce5c7c820..2b08e707ff1 100644 --- a/WGLMakie/test/runtests.jl +++ b/WGLMakie/test/runtests.jl @@ -44,7 +44,6 @@ excludes = Set([ "UnicodeMarker", # Not sure, looks pretty similar to me! Maybe blend mode? "Test heatmap + image overlap", - "Stars", "heatmaps & surface", "OldAxis + Surface", "Order Independent Transparency", @@ -53,7 +52,6 @@ excludes = Set([ "Animated surface and wireframe", "Array of Images Scatter", "Image Scatter different sizes", - "scatter image markers", "pattern barplot", # not implemented yet "scatter with stroke", "scatter with glow" diff --git a/docs/examples/plotting_functions/scatter.md b/docs/examples/plotting_functions/scatter.md index ef63e51ca64..e333598070d 100644 --- a/docs/examples/plotting_functions/scatter.md +++ b/docs/examples/plotting_functions/scatter.md @@ -43,16 +43,19 @@ scatter(points, color = 1:30, markersize = range(5, 30, length = 30), ``` \end{examplefigure} -### Available markers +### Markers -As markers, you can use almost any unicode character. -Currently, such glyphs are picked from the `TeX Gyre Heros Makie` font, because it offers a wide range of symbols. -There is also a number of markers that can be referred to as a symbol, so that it's not necessary to find out the respective unicode character. +There are a couple different categories of markers you can use with `scatter`: -The backslash character examples have to be tab-completed in the REPL or editor so they are converted into unicode. +- `Char`s like `'x'` or `'α'`. The glyphs are taken from Makie's default font `TeX Gyre Heros Makie`. +- `BezierPath` objects which can be used to create custom marker shapes. Most default markers which are accessed by symbol such as `:circle` or `:rect` convert to `BezierPath`s internally. +- `Polygon`s, which are equivalent to constructing `BezierPath`s exclusively out of `LineTo` commands. +- `Matrix{<:Colorant}` objects which are plotted as image scatters. +- Special markers like `Circle` and `Rect` which have their own backend implementations and can be faster to display. -!!! note - The scatter markers have the same sizes that the glyphs in TeX Gyre Heros Makie have. This means that they are not matched in size or area. Currently, Makie does not have the option to use area matched markers, and sometimes manual adjustment might be necessary to achieve a good visual result. +#### Default markers + +Here is an example plot showing different shapes that are accessible by `Symbol`s, as well as a few characters. \begin{examplefigure}{svg = true} ```julia @@ -61,8 +64,8 @@ CairoMakie.activate!() # hide Makie.inline!(true) # hide markers_labels = [ + (:circle, ":circle"), (:rect, ":rect"), - (:star5, ":star5"), (:diamond, ":diamond"), (:hexagon, ":hexagon"), (:cross, ":cross"), @@ -73,12 +76,11 @@ markers_labels = [ (:rtriangle, ":rtriangle"), (:pentagon, ":pentagon"), (:star4, ":star4"), + (:star5, ":star5"), + (:star6, ":star6"), (:star8, ":star8"), (:vline, ":vline"), (:hline, ":hline"), - (:x, ":x"), - (:+, ":+"), - (:circle, ":circle"), ('a', "'a'"), ('B', "'B'"), ('↑', "'\\uparrow'"), @@ -105,6 +107,184 @@ f ``` \end{examplefigure} +#### Markersize + +The `markersize` attribute scales the scatter size relative to the scatter marker's base size. +Therefore, `markersize` cannot be directly understood in terms of a unit like `px`, it depends on _what_ is scaled. + +For `Char` markers, `markersize` is equivalent to the font size when displaying the same characters using `text`. + +\begin{examplefigure}{svg = true} +```julia +using CairoMakie +CairoMakie.activate!() # hide +Makie.inline!(true) # hide + +f, ax, sc = scatter(1, 1, marker = 'A', markersize = 50) +text!(2, 1, text = "A", textsize = 50, align = (:center, :center)) +xlims!(ax, -1, 4) +f +``` +\end{examplefigure} + +The default `BezierPath` markers like `:circle`, `:rect`, `:utriangle`, etc. have been chosen such that they approximately match `Char` markers of the same markersize. +This makes it easier to switch out markers without the overall look changing too much. +However, both `Char` and `BezierPath` markers are not exactly `markersize` high or wide. +We can visualize this by plotting some `Char`s, `BezierPath`s, `Circle` and `Rect` in front of a line of width `50`. +You can see that only the special markers `Circle` and `Rect` match the line width because their base size is 1 x 1, however they don't match the `Char`s or `BezierPath`s very well. + +\begin{examplefigure}{svg = true} +```julia +using CairoMakie +CairoMakie.activate!() # hide +Makie.inline!(true) # hide + +f, ax, l = lines([0, 1], [1, 1], linewidth = 50, color = :gray80) +for (marker, x) in zip(['X', 'x', :circle, :rect, :utriangle, Circle, Rect], range(0.1, 0.9, length = 7)) + scatter!(ax, x, 1, marker = marker, markersize = 50, color = :black) +end +f +``` +\end{examplefigure} + +If you need a marker that has some exact base size, so that you can match it with lines or other plot objects of known size, or because you want to use the marker in data space, you can construct it yourself using `BezierPath` or `Polygon`. +A marker with a base size of 1 x 1, e.g., will be scaled like `lines` when `markersize` and `linewidth` are the same, just like `Circle` and `Rect` markers. + +Here, we construct a hexagon polygon with radius `1`, which we can then use to tile a surface in data coordinates by setting `markerspace = :data`. + +\begin{examplefigure}{svg = true} +```julia +using CairoMakie +CairoMakie.activate!() # hide +Makie.inline!(true) # hide + +hexagon = Makie.Polygon([Point2f(cos(a), sin(a)) for a in range(1/6 * pi, 13/6 * pi, length = 7)]) + +points = Point2f[(0, 0), (sqrt(3), 0), (sqrt(3)/2, 1.5)] + +scatter(points, + marker = hexagon, + markersize = 1, + markerspace = :data, + color = 1:3, + axis = (; aspect = 1, limits = (-2, 4, -2, 4))) +``` +\end{examplefigure} + +### Bezier path markers + +Bezier paths are the basis for vector graphic formats such as svg and pdf and consist of a couple different operations that can define complex shapes. + +A `BezierPath` contains a vector of path commands, these are `MoveTo`, `LineTo`, `CurveTo`, `EllipticalArc` and `ClosePath`. +A filled shape should start with `MoveTo` and end with `ClosePath`. + +!!! note + Unfilled markers (like a single line or curve) are possible in CairoMakie but not in GLMakie and WGLMakie, because these backends have to render the marker as a filled shape to a texture first. + If no filling can be rendered, the marker will be invisible. + CairoMakie, on the other hand can stroke such markers without problem. + +Here is an example with a simple arrow that is centered on its tip, built from path elements. + +\begin{examplefigure}{svg = true} +```julia +using CairoMakie +CairoMakie.activate!() # hide +Makie.inline!(true) # hide + +arrow_path = BezierPath([ + MoveTo(Point(0, 0)), + LineTo(Point(0.3, -0.3)), + LineTo(Point(0.15, -0.3)), + LineTo(Point(0.3, -1)), + LineTo(Point(0, -0.9)), + LineTo(Point(-0.3, -1)), + LineTo(Point(-0.15, -0.3)), + LineTo(Point(-0.3, -0.3)), + ClosePath() +]) + +scatter(1:5, + marker = arrow_path, + markersize = range(20, 50, length = 5), + rotations = range(0, 2pi, length = 6)[1:end-1], +) +``` +\end{examplefigure} + +#### Holes + +Paths can have holes, just start a new subpath with `MoveTo` that is inside the main path. +The holes have to be in clockwise direction if the outside is in anti-clockwise direction, or vice versa. +For example, a circle with a square cut out can be made by one `EllipticalArc` that goes anticlockwise, and a square inside which goes clockwise: + +\begin{examplefigure}{svg = true} +```julia +using CairoMakie +CairoMakie.activate!() # hide +Makie.inline!(true) # hide + +circle_with_hole = BezierPath([ + MoveTo(Point(1, 0)), + EllipticalArc(Point(0, 0), 1, 1, 0, 0, 2pi), + MoveTo(Point(0.5, 0.5)), + LineTo(Point(0.5, -0.5)), + LineTo(Point(-0.5, -0.5)), + LineTo(Point(-0.5, 0.5)), + ClosePath(), +]) + +scatter(1:5, + marker = circle_with_hole, + markersize = 30, +) +``` +\end{examplefigure} + +#### Construction from svg path strings + +You can also create a bezier path from an [svg path specification string](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/d#path_commands). +You can automatically resize the path and flip the y-axis (svgs usually have a coordinate system where y increases downwards) with the keywords `fit` and `yflip`. +By default, the bounding box for the fitted path is a square of width 1 centered on zero. +You can pass a different bounding `Rect` with the `bbox` keyword argument. +By default, the aspect of the path is left intact, and if it's not matching the new bounding box, the path is centered so it fits inside. +Set `keep_aspect = false` to squeeze the path into the bounding box, disregarding its original aspect ratio. + +Here's an example with an svg string that contains the bat symbol: + +\begin{examplefigure}{svg = true} +```julia +using CairoMakie +CairoMakie.activate!() # hide +Makie.inline!(true) # hide + +batsymbol_string = "M96.84 141.998c-4.947-23.457-20.359-32.211-25.862-13.887-11.822-22.963-37.961-16.135-22.041 6.289-3.005-1.295-5.872-2.682-8.538-4.191-8.646-5.318-15.259-11.314-19.774-17.586-3.237-5.07-4.994-10.541-4.994-16.229 0-19.774 21.115-36.758 50.861-43.694.446-.078.909-.154 1.372-.231-22.657 30.039 9.386 50.985 15.258 24.645l2.528-24.367 5.086 6.52H103.205l5.07-6.52 2.543 24.367c5.842 26.278 37.746 5.502 15.414-24.429 29.777 6.951 50.891 23.936 50.891 43.709 0 15.136-12.406 28.651-31.609 37.267 14.842-21.822-10.867-28.266-22.549-5.549-5.502-18.325-21.147-9.341-26.125 13.886z" + +batsymbol = BezierPath(batsymbol_string, fit = true, flipy = true) + +scatter(1:10, marker = batsymbol, markersize = 50, color = :black) +``` +\end{examplefigure} + +### Polygon markers + +One can also use `GeometryBasics.Polgyon` as a marker. +A polygon always needs one vector of points which forms the outline. +It can also take an optional vector of vectors of points, each of which forms a hole in the outlined shape. + +In this example, a small circle is cut out of a larger circle: + +\begin{examplefigure}{svg = true} +```julia +using CairoMakie, GeometryBasics +CairoMakie.activate!() # hide +Makie.inline!(true) # hide + +p_big = decompose(Point2f, Circle(Point2f(0), 1)) +p_small = decompose(Point2f, Circle(Point2f(0), 0.5)) +scatter(1:4, fill(0, 4), marker=Polygon(p_big, [p_small]), markersize=100, color=1:4, axis=(limits=(0, 5, -1, 1),)) +``` +\end{examplefigure} + ### Marker rotation Markers can be rotated using the `rotations` attribute, which also allows to pass a vector. @@ -152,7 +332,7 @@ f ### Marker space -By default marker sizes are given in pixel units. You can change this by adjusting `markerspace`. For example, you can have a marker scaled in data units by setting `markerspace = :data`. +By default marker sizes are given in pixel units. You can change this by adjusting `markerspace`. For example, you can have a marker scaled in data units by setting `markerspace = :data`. \begin{examplefigure}{svg = true} ```julia diff --git a/src/Makie.jl b/src/Makie.jl index 2cdade54bcd..e47b0be98f7 100644 --- a/src/Makie.jl +++ b/src/Makie.jl @@ -88,6 +88,7 @@ const NativeFont = FreeTypeAbstraction.FTFont include("documentation/docstringextension.jl") include("utilities/quaternions.jl") +include("bezier.jl") include("types.jl") include("utilities/texture_atlas.jl") include("interaction/observables.jl") @@ -167,6 +168,9 @@ include("interaction/inspector.jl") include("documentation/documentation.jl") include("display.jl") +# bezier paths +export BezierPath, MoveTo, LineTo, CurveTo, EllipticalArc, ClosePath + # help functions and supporting functions export help, help_attributes, help_arguments diff --git a/src/bezier.jl b/src/bezier.jl new file mode 100644 index 00000000000..f2dcc142ed4 --- /dev/null +++ b/src/bezier.jl @@ -0,0 +1,691 @@ +const BEZIERPATH_BITMAP_SIZE = Ref(256) + +struct MoveTo + p::Point2{Float64} +end + +MoveTo(x, y) = MoveTo(Point(x, y)) + +struct LineTo + p::Point2{Float64} +end + +LineTo(x, y) = LineTo(Point(x, y)) + +struct CurveTo + c1::Point2{Float64} + c2::Point2{Float64} + p::Point2{Float64} +end + +CurveTo(cx1, cy1, cx2, cy2, p1, p2) = CurveTo( + Point(cx1, cy1), Point(cx2, cy2), Point(p1, p1) +) + +struct EllipticalArc + c::Point2{Float64} + r1::Float64 + r2::Float64 + angle::Float64 + a1::Float64 + a2::Float64 +end + +EllipticalArc(cx, cy, r1, r2, angle, a1, a2) = EllipticalArc(Point(cx, cy), + r1, r2, angle, a1, a2) + +struct ClosePath end + +const PathCommand = Union{MoveTo, LineTo, CurveTo, EllipticalArc, ClosePath} + +struct BezierPath + commands::Vector{PathCommand} +end + +# so that the same bezierpath with a different instance of a vector hashes the same +# and we don't create the same texture atlas entry twice +Base.:(==)(b1::BezierPath, b2::BezierPath) = b1.commands == b2.commands +Base.hash(b::BezierPath) = hash(b.commands) +Base.broadcastable(b::BezierPath) = Ref(b) + +function Base.:+(pc::P, p::Point2) where P <: PathCommand + fnames = fieldnames(P) + return P(map(f -> getfield(pc, f) + p, fnames)...) +end + +scale(bp::BezierPath, s::Real) = BezierPath([scale(x, Vec(s, s)) for x in bp.commands]) +scale(bp::BezierPath, v::VecTypes{2}) = BezierPath([scale(x, v) for x in bp.commands]) +translate(bp::BezierPath, v::VecTypes{2}) = BezierPath([translate(x, v) for x in bp.commands]) + +translate(m::MoveTo, v::VecTypes{2}) = MoveTo(m.p .+ v) +translate(l::LineTo, v::VecTypes{2}) = LineTo(l.p .+ v) +translate(c::CurveTo, v::VecTypes{2}) = CurveTo(c.c1 .+ v, c.c2 .+ v, c.p .+ v) +translate(e::EllipticalArc, v::VecTypes{2}) = EllipticalArc(e.c .+ v, e.r1, e.r2, e.angle, e.a1, e.a2) +translate(c::ClosePath, v::VecTypes{2}) = c + +scale(m::MoveTo, v::VecTypes{2}) = MoveTo(m.p .* v) +scale(l::LineTo, v::VecTypes{2}) = LineTo(l.p .* v) +scale(c::CurveTo, v::VecTypes{2}) = CurveTo(c.c1 .* v, c.c2 .* v, c.p .* v) +scale(c::ClosePath, v::VecTypes{2}) = c +function scale(e::EllipticalArc, v::VecTypes{2}) + x, y = v + if abs(x) != abs(y) + throw(ArgumentError("Currently you can only scale EllipticalArc such that abs(x) == abs(y) if the angle != 0")) + end + ang, a1, a2 = if x > 0 && y > 0 + e.angle, e.a1, e.a2 + elseif x < 0 && y < 0 + e.angle + pi, e.a1, e.a2 + elseif x < 0 && y > 0 + pi - e.angle, -e.a1, -e.a2 + else + pi - e.angle, pi-e.a1, pi-e.a2 + end + EllipticalArc(e.c .* v, e.r1 * abs(x), e.r2 * abs(y), ang, a1, a2) +end + +rotmatrix2d(a) = Mat2(cos(a), sin(a), -sin(a), cos(a)) +rotate(m::MoveTo, a) = MoveTo(rotmatrix2d(a) * m.p) +rotate(c::ClosePath, a) = c +rotate(l::LineTo, a) = LineTo(rotmatrix2d(a) * l.p) +function rotate(c::CurveTo, a) + m = rotmatrix2d(a) + CurveTo(m * c.c1, m * c.c2, m *c.p) +end +function rotate(e::EllipticalArc, a) + m = rotmatrix2d(a) + newc = m * e.c + newangle = e.angle + a + EllipticalArc(newc, e.r1, e.r2, newangle, e.a1, e.a2) +end +rotate(b::BezierPath, a) = BezierPath(PathCommand[rotate(c::PathCommand, a) for c in b.commands]) + +function fit_to_bbox(b::BezierPath, bb_target::Rect2; keep_aspect = true) + bb_path = bbox(b) + ws_path = widths(bb_path) + ws_target = widths(bb_target) + + center_target = origin(bb_target) + 0.5 * widths(bb_target) + center_path = origin(bb_path) + 0.5 * widths(bb_path) + + scale_factor = ws_target ./ ws_path + scale_factor_aspect = if keep_aspect + min.(scale_factor, minimum(scale_factor)) + else + scale_factor + end + + bb_t = translate(scale(translate(b, -center_path), scale_factor_aspect), center_target) +end + +function fit_to_unit_square(b::BezierPath, keep_aspect = true) + fit_to_bbox(b, Rect2((0.0, 0.0), (1.0, 1.0)), keep_aspect = keep_aspect) +end + +Base.:+(pc::EllipticalArc, p::Point2) = EllipticalArc(pc.c + p, pc.r1, pc.r2, pc.angle, pc.a1, pc.a2) +Base.:+(pc::ClosePath, p::Point2) = pc +Base.:+(bp::BezierPath, p::Point2) = BezierPath(bp.commands .+ Ref(p)) + +# 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 + +const BezierX = rotate(BezierCross, pi/4) + +function bezier_ngon(n, radius, angle) + points = [radius * Point2f(cos(a + angle), sin(a + angle)) + for a in range(0, 2pi, length = n+1)[1:end-1]] + BezierPath([ + MoveTo(points[1]); + LineTo.(points[2:end]) + ]) +end + +function bezier_star(n, inner_radius, outer_radius, angle) + points = [ + (isodd(i) ? outer_radius : inner_radius) * + Point2f(cos(a + angle), sin(a + angle)) + for (i, a) in enumerate(range(0, 2pi, length = 2n+1)[1:end-1])] + BezierPath([ + MoveTo(points[1]); + LineTo.(points[2:end]) + ]) +end + +function BezierPath(poly::Polygon) + commands = Makie.PathCommand[] + points = reinterpret(Point2f, poly.exterior) + ext_direction = sign(area(points)) #signed area gives us clockwise / anti-clockwise + push!(commands, MoveTo(points[1])) + for i in 2:length(points) + push!(commands, LineTo(points[i])) + end + + for inter in poly.interiors + points = reinterpret(Point2f, inter) + # holes, in bezierpath, always need to have the opposite winding order + if sign(area(points)) == ext_direction + points = reverse(points) + end + push!(commands, MoveTo(points[1])) + for i in 2:length(points) + push!(commands, LineTo(points[i])) + end + end + push!(commands, ClosePath()) + return BezierPath(commands) +end + +function BezierPath(svg::AbstractString; fit = false, bbox = nothing, flipy = false, keep_aspect = true) + commands = parse_bezier_commands(svg) + p = BezierPath(commands) + if flipy + p = scale(p, Vec(1, -1)) + end + if fit + if bbox === nothing + p = fit_to_bbox(p, Rect2f((-0.5, -0.5), (1.0, 1.0)), keep_aspect = keep_aspect) + else + p = fit_to_bbox(p, bbox, keep_aspect = keep_aspect) + end + end + p +end + +function parse_bezier_commands(svg) + + # args = [e.match for e in eachmatch(r"([a-zA-Z])|(\-?\d*\.?\d+)", svg)] + args = [e.match for e in eachmatch(r"(?:0(?=\d))|(?:[a-zA-Z])|(?:\-?\d*\.?\d+)", svg)] + + i = 1 + + commands = PathCommand[] + lastcomm = nothing + function lastp() + c = commands[end] + if isnothing(lastcomm) + Point(0, 0) + elseif c isa ClosePath + r = reverse(commands) + backto = findlast(x -> !(x isa ClosePath), r) + if isnothing(backto) + error("No point to go back to") + end + r[backto].p + elseif c isa EllipticalArc + let + ϕ = c.angle + a2 = c.a2 + rx = c.r1 + ry = c.r2 + m = Mat2(cos(ϕ), sin(ϕ), -sin(ϕ), cos(ϕ)) + m * Point(rx * cos(a2), ry * sin(a2)) + c.c + end + else + c.p + end + end + + while i <= length(args) + + comm = args[i] + + # command letter is omitted, use last command + if isnothing(match(r"[a-zA-Z]", comm)) + comm = lastcomm + i -= 1 + end + + if comm == "M" + x, y = parse.(Float64, args[i+1:i+2]) + push!(commands, MoveTo(Point2(x, y))) + i += 3 + elseif comm == "m" + x, y = parse.(Float64, args[i+1:i+2]) + push!(commands, MoveTo(Point2(x, y) + lastp())) + i += 3 + elseif comm == "L" + x, y = parse.(Float64, args[i+1:i+2]) + push!(commands, LineTo(Point2(x, y))) + i += 3 + elseif comm == "l" + x, y = parse.(Float64, args[i+1:i+2]) + push!(commands, LineTo(Point2(x, y) + lastp())) + i += 3 + elseif comm == "H" + x = parse(Float64, args[i+1]) + push!(commands, LineTo(Point2(x, lastp()[2]))) + i += 2 + elseif comm == "h" + x = parse(Float64, args[i+1]) + push!(commands, LineTo(X(x) + lastp())) + i += 2 + elseif comm == "Z" + push!(commands, ClosePath()) + i += 1 + elseif comm == "z" + push!(commands, ClosePath()) + i += 1 + elseif comm == "C" + x1, y1, x2, y2, x3, y3 = parse.(Float64, args[i+1:i+6]) + push!(commands, CurveTo(Point2(x1, y1), Point2(x2, y2), Point2(x3, y3))) + i += 7 + elseif comm == "c" + x1, y1, x2, y2, x3, y3 = parse.(Float64, args[i+1:i+6]) + l = lastp() + push!(commands, CurveTo(Point2(x1, y1) + l, Point2(x2, y2) + l, Point2(x3, y3) + l)) + i += 7 + elseif comm == "S" + x1, y1, x2, y2 = parse.(Float64, args[i+1:i+4]) + prev = commands[end] + reflected = prev.p + (prev.p - prev.c2) + push!(commands, CurveTo(reflected, Point2(x1, y1), Point2(x2, y2))) + i += 5 + elseif comm == "s" + x1, y1, x2, y2 = parse.(Float64, args[i+1:i+4]) + prev = commands[end] + reflected = prev.p + (prev.p - prev.c2) + l = lastp() + push!(commands, CurveTo(reflected, Point2(x1, y1) + l, Point2(x2, y2) + l)) + i += 5 + elseif comm == "A" + args[i+1:i+7] + r1, r2 = parse.(Float64, args[i+1:i+2]) + angle = parse(Float64, args[i+3]) + large_arc_flag, sweep_flag = parse.(Bool, args[i+4:i+5]) + x2, y2 = parse.(Float64, args[i+6:i+7]) + x1, y1 = lastp() + + push!(commands, EllipticalArc(x1, y1, x2, y2, r1, r2, + angle, large_arc_flag, sweep_flag)) + i += 8 + elseif comm == "a" + r1, r2 = parse.(Float64, args[i+1:i+2]) + angle = parse(Float64, args[i+3]) + large_arc_flag, sweep_flag = parse.(Bool, args[i+4:i+5]) + x1, y1 = lastp() + x2, y2 = parse.(Float64, args[i+6:i+7]) .+ (x1, y1) + + push!(commands, EllipticalArc(x1, y1, x2, y2, r1, r2, + angle, large_arc_flag, sweep_flag)) + i += 8 + elseif comm == "v" + dy = parse(Float64, args[i+1]) + l = lastp() + push!(commands, LineTo(Point2(l[1], l[2] + dy))) + i += 2 + else + for c in commands + println(c) + end + error("Parsing $comm not implemented.") + end + + lastcomm = comm + + end + + commands +end + +function EllipticalArc(x1, y1, x2, y2, rx, ry, ϕ, largearc::Bool, sweepflag::Bool) + # https://www.w3.org/TR/SVG11/implnote.html#ArcImplementationNotes + + p1 = Point(x1, y1) + p2 = Point(x2, y2) + + m1 = Mat2(cos(ϕ), -sin(ϕ), sin(ϕ), cos(ϕ)) + x1′, y1′ = m1 * (0.5 * (p1 - p2)) + + tempsqrt = (rx^2 * ry^2 - rx^2 * y1′^2 - ry^2 * x1′^2) / + (rx^2 * y1′^2 + ry^2 * x1′^2) + + c′ = (largearc == sweepflag ? -1 : 1) * + sqrt(tempsqrt) * Point(rx * y1′ / ry, -ry * x1′ / rx) + + c = Mat2(cos(ϕ), sin(ϕ), -sin(ϕ), cos(ϕ)) * c′ + 0.5 * (p1 + p2) + + vecangle(u, v) = sign(u[1] * v[2] - u[2] * v[1]) * + acos(dot(u, v) / (norm(u) * norm(v))) + + px(sign) = Point((sign * x1′ - c′[1]) / rx, (sign * y1′ - c′[2]) / rx) + + θ1 = vecangle(Point(1.0, 0.0), px(1)) + Δθ_pre = mod(vecangle(px(1), px(-1)), 2pi) + Δθ = if Δθ_pre > 0 && !sweepflag + Δθ_pre - 2pi + elseif Δθ_pre < 0 && sweepflag + Δθ_pre + 2pi + else + Δθ_pre + end + + EllipticalArc(c, rx, ry, ϕ, θ1, θ1 + Δθ) +end + +################################################### +# Freetype rendering of paths for GLMakie sprites # +################################################### + +function make_outline(path) + n_contours::FT_Int = 0 + n_points::FT_UInt = 0 + points = FT_Vector[] + tags = Int8[] + contours = Int16[] + flags = Int32(0) + for command in path.commands + new_contour, n_newpoints, newpoints, newtags = convert_command(command) + if new_contour + n_contours += 1 + if n_contours > 1 + push!(contours, n_points - 1) # -1 because of C zero-based indexing + end + end + n_points += n_newpoints + append!(points, newpoints) + append!(tags, newtags) + end + push!(contours, n_points - 1) + @assert n_points == length(points) == length(tags) + @assert n_contours == length(contours) + push!(contours, n_points) + # Manually create outline, since FT_Outline_New seems to be problematic on windows somehow + outline = FT_Outline( + n_contours, + n_points, + pointer(points), + pointer(tags), + pointer(contours), + 0 + ) + # Return Ref + arrays that went into outline, so the GC doesn't abandon them + return (Ref(outline), points, tags, contours) +end + +ftvec(p) = FT_Vector(round(Int, p[1]), round(Int, p[2])) + +function convert_command(m::MoveTo) + true, 1, ftvec.([m.p]), [FT_Curve_Tag_On] +end + +function convert_command(l::LineTo) + false, 1, ftvec.([l.p]), [FT_Curve_Tag_On] +end + +function convert_command(c::CurveTo) + false, 3, ftvec.([c.c1, c.c2, c.p]), [FT_Curve_Tag_Cubic, FT_Curve_Tag_Cubic, FT_Curve_Tag_On] +end + +function render_path(path) + bitmap_size_px = BEZIERPATH_BITMAP_SIZE[] + # in the outline, 1 unit = 1/64px + scale_factor = bitmap_size_px * 64 + + # we transform the path into the unit square and we can + # scale and translate this to a 4096x4096 grid, which is 64px x 64px + # when rendered to bitmap + + # freetype has no ClosePath and EllipticalArc, so those need to be replaced + path_replaced = replace_nonfreetype_commands(path) + + path_unit_square = fit_to_unit_square(path_replaced, false) + + path_transformed = Makie.scale( + path_unit_square, + scale_factor, + ) + + outline_ref = make_outline(path_transformed) + + w = bitmap_size_px + h = bitmap_size_px + pitch = w * 1 # 8 bit gray + pixelbuffer = zeros(UInt8, h * pitch) + bitmap_ref = Ref{FT_Bitmap}() + bitmap_ref[] = FT_Bitmap( + h, + w, + pitch, + Base.unsafe_convert(Ptr{UInt8}, pixelbuffer), + 256, + FT_PIXEL_MODE_GRAY, + C_NULL, + C_NULL + ) + + FT_Outline_Get_Bitmap( + Makie.FreeTypeAbstraction.FREE_FONT_LIBRARY[], + outline_ref[1], + bitmap_ref, + ) + + reshape(pixelbuffer, (w, h)) +end + +# FreeType can only handle lines and cubic / conic beziers so ClosePath +# and EllipticalArc need to be replaced +function replace_nonfreetype_commands(path) + newpath = BezierPath(copy(path.commands)) + last_move_to = nothing + i = 1 + while i <= length(newpath.commands) + c = newpath.commands[i] + if c isa MoveTo + last_move_to = c + elseif c isa EllipticalArc + bp = elliptical_arc_to_beziers(c) + splice!(newpath.commands, i, bp.commands) + elseif c isa ClosePath + if last_move_to === nothing + error("Got ClosePath but no previous MoveTo") + end + newpath.commands[i] = LineTo(last_move_to.p) + end + i += 1 + end + newpath +end + + +Makie.convert_attribute(b::BezierPath, ::key"marker", ::key"scatter") = b +Makie.convert_attribute(ab::AbstractVector{<:BezierPath}, ::key"marker", ::key"scatter") = ab + +struct BezierSegment + from::Point2f + c1::Point2f + c2::Point2f + to::Point2f +end + +struct LineSegment + from::Point2f + to::Point2f +end + +function bbox(b::BezierPath) + prev = b.commands[1] + bb = nothing + for comm in b.commands[2:end] + if comm isa MoveTo || comm isa ClosePath + continue + else + endp = endpoint(prev) + _bb = cleanup_bbox(bbox(endp, comm)) + bb = bb === nothing ? _bb : union(bb, _bb) + end + prev = comm + end + bb +end + +segment(p, l::LineTo) = LineSegment(p, l.p) +segment(p, c::CurveTo) = BezierSegment(p, c.c1, c.c2, c.p) + +endpoint(m::MoveTo) = m.p +endpoint(l::LineTo) = l.p +endpoint(c::CurveTo) = c.p +function endpoint(e::EllipticalArc) + point_at_angle(e, e.a2) +end + +function point_at_angle(e::EllipticalArc, theta) + M = abs(e.r1) * cos(theta) + N = abs(e.r2) * sin(theta) + Point2f( + e.c[1] + cos(e.angle) * M - sin(e.angle) * N, + e.c[2] + sin(e.angle) * M + cos(e.angle) * N + ) +end + +function cleanup_bbox(bb::Rect2f) + if any(x -> x < 0, bb.widths) + p = bb.origin .+ (bb.widths .< 0) .* bb.widths + return Rect2f(p, abs.(bb.widths)) + end + return bb +end + +bbox(p, x::Union{LineTo, CurveTo}) = bbox(segment(p, x)) +function bbox(p, e::EllipticalArc) + bbox(elliptical_arc_to_beziers(e)) +end + +function bbox(ls::LineSegment) + Rect2f(ls.from, ls.to - ls.from) +end + +function bbox(b::BezierSegment) + + p0 = b.from + p1 = b.c1 + p2 = b.c2 + p3 = b.to + + mi = [min.(p0, p3)...] + ma = [max.(p0, p3)...] + + c = -p0 + p1 + b = p0 - 2p1 + p2 + a = -p0 + 3p1 - 3p2 + 1p3 + + h = [(b.*b - a.*c)...] + + if h[1] > 0 + h[1] = sqrt(h[1]) + t = (-b[1] - h[1]) / a[1] + if t > 0 && t < 1 + s = 1.0-t + q = s*s*s*p0[1] + 3.0*s*s*t*p1[1] + 3.0*s*t*t*p2[1] + t*t*t*p3[1] + mi[1] = min(mi[1],q) + ma[1] = max(ma[1],q) + end + t = (-b[1] + h[1])/a[1] + if t>0 && t<1 + s = 1.0-t + q = s*s*s*p0[1] + 3.0*s*s*t*p1[1] + 3.0*s*t*t*p2[1] + t*t*t*p3[1] + mi[1] = min(mi[1],q) + ma[1] = max(ma[1],q) + end + end + + if h[2]>0.0 + h[2] = sqrt(h[2]) + t = (-b[2] - h[2])/a[2] + if t>0.0 && t<1.0 + s = 1.0-t + q = s*s*s*p0[2] + 3.0*s*s*t*p1[2] + 3.0*s*t*t*p2[2] + t*t*t*p3[2] + mi[2] = min(mi[2],q) + ma[2] = max(ma[2],q) + end + t = (-b[2] + h[2])/a[2] + if t>0.0 && t<1.0 + s = 1.0-t + q = s*s*s*p0[2] + 3.0*s*s*t*p1[2] + 3.0*s*t*t*p2[2] + t*t*t*p3[2] + mi[2] = min(mi[2],q) + ma[2] = max(ma[2],q) + end + end + + Rect2f(Point(mi...), Point(ma...) - Point(mi...)) +end + + +function elliptical_arc_to_beziers(arc::EllipticalArc) + delta_a = abs(arc.a2 - arc.a1) + n_beziers = ceil(Int, delta_a / 0.5pi) + angles = range(arc.a1, arc.a2, length = n_beziers + 1) + + startpoint = Point2f(cos(arc.a1), sin(arc.a1)) + curves = map(angles[1:end-1], angles[2:end]) do start, stop + theta = stop - start + kappa = 4/3 * tan(theta/4) + c1 = Point2f(cos(start) - kappa * sin(start), sin(start) + kappa * cos(start)) + c2 = Point2f(cos(stop) + kappa * sin(stop), sin(stop) - kappa * cos(stop)) + b = Point2f(cos(stop), sin(stop)) + CurveTo(c1, c2, b) + end + + path = BezierPath([LineTo(startpoint), curves...]) + path = scale(path, Vec(arc.r1, arc.r2)) + path = rotate(path, arc.angle) + path = translate(path, arc.c) +end diff --git a/src/conversions.jl b/src/conversions.jl index eab00888221..95491813309 100644 --- a/src/conversions.jl +++ b/src/conversions.jl @@ -1149,28 +1149,44 @@ function convert_attribute(value::Union{Symbol, String}, k::key"algorithm") end, k) end -const _marker_map = Dict( - :rect => '■', - :star5 => '★', - :diamond => '◆', - :hexagon => '⬢', - :cross => '✚', - :xcross => '❌', - :utriangle => '▲', - :dtriangle => '▼', - :ltriangle => '◀', - :rtriangle => '▶', - :pentagon => '⬟', - :octagon => '⯄', - :star4 => '✦', - :star6 => '🟋', - :star8 => '✷', - :vline => '┃', - :hline => '━', - :+ => '+', - :x => 'x', - :circle => '●' -) +const DEFAULT_MARKER_MAP = Dict{Symbol, BezierPath}() + +function default_marker_map() + # The bezier markers should not look out of place when used together with text + # where both markers and text are given the same size, i.e. the marker and textsizes + # should correspond approximately in a visual sense. + + # All the basic bezier shapes are approximately built in a 1 by 1 square centered + # around the origin, with slight deviations to match them better to each other. + + # An 'x' of DejaVu sans is only about 55pt high at 100pt font size, so if the marker + # shapes are just used as is, they look much too large in comparison. + # To me, a factor of 0.75 looks ok compared to both uppercase and lowercase letters of Dejavu. + if isempty(DEFAULT_MARKER_MAP) + size_factor = 0.75 + DEFAULT_MARKER_MAP[:rect] = scale(BezierSquare, size_factor) + DEFAULT_MARKER_MAP[:diamond] = scale(rotate(BezierSquare, pi/4), size_factor) + DEFAULT_MARKER_MAP[:hexagon] = scale(bezier_ngon(6, 0.5, pi/2), size_factor) + DEFAULT_MARKER_MAP[:cross] = scale(BezierCross, size_factor) + DEFAULT_MARKER_MAP[:xcross] = scale(BezierX, size_factor) + DEFAULT_MARKER_MAP[:utriangle] = scale(BezierUTriangle, size_factor) + DEFAULT_MARKER_MAP[:dtriangle] = scale(BezierDTriangle, size_factor) + DEFAULT_MARKER_MAP[:ltriangle] = scale(BezierLTriangle, size_factor) + DEFAULT_MARKER_MAP[:rtriangle] = scale(BezierRTriangle, size_factor) + DEFAULT_MARKER_MAP[:pentagon] = scale(bezier_ngon(5, 0.5, pi/2), size_factor) + DEFAULT_MARKER_MAP[:octagon] = scale(bezier_ngon(8, 0.5, pi/2), size_factor) + DEFAULT_MARKER_MAP[:star4] = scale(bezier_star(4, 0.25, 0.6, pi/2), size_factor) + DEFAULT_MARKER_MAP[:star5] = scale(bezier_star(5, 0.28, 0.6, pi/2), size_factor) + DEFAULT_MARKER_MAP[:star6] = scale(bezier_star(6, 0.30, 0.6, pi/2), size_factor) + DEFAULT_MARKER_MAP[:star8] = scale(bezier_star(8, 0.33, 0.6, pi/2), size_factor) + DEFAULT_MARKER_MAP[:vline] = scale(scale(BezierSquare, (0.2, 1.0)), size_factor) + DEFAULT_MARKER_MAP[:hline] = scale(scale(BezierSquare, (1.0, 0.2)), size_factor) + DEFAULT_MARKER_MAP[:+] = scale(BezierCross, size_factor) + DEFAULT_MARKER_MAP[:x] = scale(BezierX, size_factor) + DEFAULT_MARKER_MAP[:circle] = scale(BezierCircle, size_factor) + end + return DEFAULT_MARKER_MAP +end """ available_marker_symbols() @@ -1179,8 +1195,8 @@ Displays all available marker symbols. """ function available_marker_symbols() println("Marker Symbols:") - for (k, v) in _marker_map - println(" ", k, " => ", v) + for (k, v) in default_marker_map() + println(" :", k) end end @@ -1198,11 +1214,24 @@ Note, that this will draw markers always as 1 pixel. """ struct FastPixel end +""" +Vector of anything that is accepted as a single marker will give each point it's own marker. +Note that it needs to be a uniform vector with the same element type! +""" +to_spritemarker(marker::AbstractVector) = map(to_spritemarker, marker) +to_spritemarker(marker::AbstractVector{Char}) = marker # Don't dispatch to the above! to_spritemarker(x::FastPixel) = x to_spritemarker(x::Circle) = x -to_spritemarker(::Type{<: Circle}) = Circle(Point2f(0), 1f0) -to_spritemarker(::Type{<: Rect}) = Rect(Vec2f(0), Vec2f(1)) +to_spritemarker(::Type{<: Circle}) = Circle +to_spritemarker(::Type{<: Rect}) = Rect to_spritemarker(x::Rect) = x +to_spritemarker(b::BezierPath) = b +to_spritemarker(b::Polygon) = BezierPath(b) +to_spritemarker(b) = error("Not a valid scatter marker: $(typeof(b))") + +function to_spritemarker(str::String) + error("Using strings for multiple char markers is deprecated. Use `collect(string)` or `['x', 'o', ...]` instead. Found: $(str)") +end """ to_spritemarker(b, marker::Char) @@ -1225,29 +1254,16 @@ to_spritemarker(marker::AbstractMatrix{<: Colorant}) = marker A `Symbol` - Available options can be printed with `available_marker_symbols()` """ function to_spritemarker(marker::Symbol) - if haskey(_marker_map, marker) - return to_spritemarker(_marker_map[marker]) + if haskey(default_marker_map(), marker) + return to_spritemarker(default_marker_map()[marker]) else @warn("Unsupported marker: $marker, using ● instead") return '●' end end -to_spritemarker(marker::String) = marker -to_spritemarker(marker::AbstractVector{Char}) = String(marker) -""" -Vector of anything that is accepted as a single marker will give each point it's own marker. -Note that it needs to be a uniform vector with the same element type! -""" -function to_spritemarker(marker::AbstractVector) - marker = to_spritemarker.(marker) - if isa(marker, AbstractVector{Char}) - String(marker) - else - marker - end -end + convert_attribute(value, ::key"marker", ::key"scatter") = to_spritemarker(value) convert_attribute(value, ::key"isovalue", ::key"volume") = Float32(value) diff --git a/src/layouting/boundingbox.jl b/src/layouting/boundingbox.jl index fbfe4175700..0faee6ef67e 100644 --- a/src/layouting/boundingbox.jl +++ b/src/layouting/boundingbox.jl @@ -22,9 +22,10 @@ function gl_bboxes(gl::GlyphCollection) scales = gl.scales.sv isa Vec2f ? (gl.scales.sv for _ in gl.extents) : gl.scales.sv map(gl.glyphs, gl.extents, scales) do c, ext, scale hi_bb = height_insensitive_boundingbox_with_advance(ext) + # TODO c != 0 filters out all non renderables, which is not always desired Rect2f( Makie.origin(hi_bb) * scale, - (c != '\n') * widths(hi_bb) * scale + (c != 0) * widths(hi_bb) * scale ) end end @@ -124,9 +125,9 @@ function boundingbox(plot::Text) return bb end -_is_latex_string(x::AbstractVector{<:LaTeXString}) = true -_is_latex_string(x::LaTeXString) = true -_is_latex_string(other) = false +_is_latex_string(x::AbstractVector{<:LaTeXString}) = true +_is_latex_string(x::LaTeXString) = true +_is_latex_string(other) = false function text_bb(str, font, size) rot = Quaternionf(0,0,0,1) diff --git a/src/theming.jl b/src/theming.jl index 0793d24a3ac..27fc6bec109 100644 --- a/src/theming.jl +++ b/src/theming.jl @@ -63,8 +63,8 @@ const minimal_default = Attributes( colgap = 24, backgroundcolor = :white, colormap = :viridis, - marker = Circle, - markersize = 9, + marker = :circle, + markersize = 12, markercolor = :black, markerstrokecolor = :black, markerstrokewidth = 0, diff --git a/src/utilities/texture_atlas.jl b/src/utilities/texture_atlas.jl index 89bdd5a80c2..56a07303aa4 100644 --- a/src/utilities/texture_atlas.jl +++ b/src/utilities/texture_atlas.jl @@ -1,6 +1,6 @@ mutable struct TextureAtlas rectangle_packer::RectanglePacker - mapping::Dict{Tuple{UInt64, String}, Int} # styled glyph to index in sprite_attributes + mapping::Dict{Union{BezierPath, Tuple{UInt64, String}}, Int} # styled glyph to index in sprite_attributes index::Int data::Matrix{Float16} # rectangles we rendered our glyphs into in normalized uv coordinates @@ -172,6 +172,9 @@ function glyph_index!(atlas::TextureAtlas, glyph, font::NativeFont) return insert_glyph!(atlas, glyph, font) end +function glyph_index!(atlas::TextureAtlas, b::BezierPath) + return insert_glyph!(atlas, b) +end function glyph_uv_width!(atlas::TextureAtlas, glyph, font::NativeFont) return atlas.uv_rectangles[glyph_index!(atlas, glyph, font)] @@ -181,6 +184,11 @@ function glyph_uv_width!(glyph) return glyph_uv_width!(get_texture_atlas(), glyph, defaultfont()) end +function glyph_uv_width!(b::BezierPath) + atlas = get_texture_atlas() + return atlas.uv_rectangles[glyph_index!(atlas, b)] +end + function insert_glyph!(atlas::TextureAtlas, glyph, font::NativeFont) glyphindex = FreeTypeAbstraction.glyph_index(font, glyph) return get!(atlas.mapping, (glyphindex, FreeTypeAbstraction.fontname(font))) do @@ -216,6 +224,40 @@ function insert_glyph!(atlas::TextureAtlas, glyph, font::NativeFont) end end +function insert_glyph!(atlas::TextureAtlas, path::BezierPath) + return get!(atlas.mapping, path) do + # We save glyphs as signed distance fields, i.e. we save the distance + # a pixel is away from the edge of a symbol (continuous at the edge). + # To get accurate distances we want to draw the symbol at high + # resolution and then downsample to the PIXELSIZE_IN_ATLAS. + downsample = 5 + # To draw a symbol from a sdf we essentially do `color * (sdf > 0)`. For + # antialiasing we smooth out the step function `sdf > 0`. That means we + # need a few values outside the symbol. To guarantee that we have those + # at all relevant scales we add padding to the rendered bitmap and the + # resulting sdf. + pad = GLYPH_PADDING[] + + uv_pixel = render(atlas, path, downsample, pad) + tex_size = Vec2f(size(atlas.data) .- 1) # starts at 1 + + # 0 based + idx_left_bottom = minimum(uv_pixel) + idx_right_top = maximum(uv_pixel) + + # transform to normalized texture coordinates + # -1 for indexing offset + uv_left_bottom_pad = (idx_left_bottom) ./ tex_size + uv_right_top_pad = (idx_right_top .- 1) ./ tex_size + + uv_offset_rect = Vec4f(uv_left_bottom_pad..., uv_right_top_pad...) + i = atlas.index + push!(atlas.uv_rectangles, uv_offset_rect) + atlas.index = i + 1 + return i + end +end + """ sdistancefield(img, downsample, pad) Calculates a distance fields, that is downsampled `downsample` time, @@ -255,10 +297,11 @@ function remove_font_render_callback!(f) end end -function render(atlas::TextureAtlas, glyph, font, downsample=5, pad=6) +function render(atlas::TextureAtlas, glyph_index, font, downsample=5, pad=6) # TODO: Is this needed or should newline be filtered before this? - if FreeTypeAbstraction.glyph_index(font, glyph) == FreeTypeAbstraction.glyph_index(font, '\n') # don't render newline - glyph = ' ' + if glyph_index == 0 # don't render newline and others + # TODO, render them as box and filter out newlines in GlyphCollection + glyph_index = FreeTypeAbstraction.glyph_index(font, ' ') end # the target pixel size of our distance field @@ -266,7 +309,26 @@ function render(atlas::TextureAtlas, glyph, font, downsample=5, pad=6) # we render the font `downsample` sizes times bigger # Make sure the font doesn't have a mutated font matrix from e.g. Cairo FreeTypeAbstraction.FreeType.FT_Set_Transform(font, C_NULL, C_NULL) - bitmap, extent = renderface(font, glyph, pixelsize * downsample) + bitmap, extent = renderface(font, glyph_index, pixelsize * downsample) + # Our downsampeld & padded distancefield + sd = sdistancefield(bitmap, downsample, pad) + rect = Rect2(0, 0, size(sd)...) + uv = push!(atlas.rectangle_packer, rect) # find out where to place the rectangle + uv == nothing && error("texture atlas is too small. Resizing not implemented yet. Please file an issue at Makie if you encounter this") #TODO resize surface + # write distancefield into texture + atlas.data[uv.area] = sd + for f in get(font_render_callbacks, pixelsize, ()) + # update everyone who uses the atlas image directly (e.g. in GLMakie) + f(sd, uv.area) + end + # return the area we rendered into! + return uv.area +end + +function render(atlas::TextureAtlas, b::BezierPath, downsample=5, pad=6) + # the target pixel size of our distance field + pixelsize = PIXELSIZE_IN_ATLAS[] + bitmap = render_path(b) # Our downsampeld & padded distancefield sd = sdistancefield(bitmap, downsample, pad) rect = Rect2(0, 0, size(sd)...) @@ -281,3 +343,139 @@ function render(atlas::TextureAtlas, glyph, font, downsample=5, pad=6) # return the area we rendered into! return uv.area end + +@enum Shape CIRCLE RECTANGLE ROUNDED_RECTANGLE DISTANCEFIELD TRIANGLE + +""" +returns the Shape type for the distancefield shader +""" +marker_to_sdf_shape(x) = error("$(x) is not a valid scatter marker shape.") + +marker_to_sdf_shape(::AbstractMatrix) = RECTANGLE # Image marker +marker_to_sdf_shape(::Union{BezierPath, Char}) = DISTANCEFIELD +marker_to_sdf_shape(::Type{T}) where {T <: Circle} = CIRCLE +marker_to_sdf_shape(::Type{T}) where {T <: Rect} = RECTANGLE +marker_to_sdf_shape(x::Shape) = x + +function marker_to_sdf_shape(arr::AbstractVector) + isempty(arr) && error("Marker array can't be empty") + shape1 = marker_to_sdf_shape(first(arr)) + for elem in arr + shape2 = marker_to_sdf_shape(elem) + shape1 !== shape2 && error("Can't use an array of markers that require different primitive_shapes $(typeof.(arr)).") + end + return shape1 +end + +function marker_to_sdf_shape(marker::Observable) + return lift(marker; ignore_equal_values=true) do marker + return Cint(marker_to_sdf_shape(to_spritemarker(marker))) + end +end + +""" +Gets the texture atlas if primitive shape needs it. +""" +function primitive_distancefield(shape) + if Cint(shape) === Cint(DISTANCEFIELD) + return get_texture_atlas() + else + return nothing + end +end + +function primitive_distancefield(marker::Observable) + return lift(primitive_distancefield, marker; ignore_equal_values=true) +end +""" +Extracts the offset from a primitive. +""" +primitive_offset(x, scale::Nothing) = Vec2f(0) # default offset +primitive_offset(x, scale) = scale ./ -2f0 # default offset + +""" +Extracts the uv offset and width from a primitive. +""" +primitive_uv_offset_width(x) = Vec4f(0,0,1,1) +primitive_uv_offset_width(b::Union{Char, BezierPath}) = glyph_uv_width!(b) +primitive_uv_offset_width(x::AbstractVector) = map(primitive_uv_offset_width, x) +function primitive_uv_offset_width(marker::Observable) + return lift(primitive_uv_offset_width, marker; ignore_equal_values=true) +end + +_bcast(x::Vec) = (x,) +_bcast(x) = x + +# Calculates the scaling factor from unpadded size -> padded size +# Here we assume the glyph to be representative of Makie.PIXELSIZE_IN_ATLAS[] +# regardless of its true size. +function marker_scale_factor(char::Char, font) + ta = Makie.get_texture_atlas() + lbrt = glyph_uv_width!(ta, char, font) + uv_width = Vec(lbrt[3] - lbrt[1], lbrt[4] - lbrt[2]) + full_pixel_size_in_atlas = uv_width .* Vec2f(size(ta.data) .- 1) + return full_pixel_size_in_atlas ./ Makie.PIXELSIZE_IN_ATLAS[] +end + +# full_pad / unpadded_atlas_width +function bezierpath_pad_scale_factor(bp) + ta = Makie.get_texture_atlas() + lbrt = glyph_uv_width!(bp) + uv_width = Vec(lbrt[3] - lbrt[1], lbrt[4] - lbrt[2]) + full_pixel_size_in_atlas = uv_width * Vec2f(size(ta.data) .- 1) + full_pad = 2f0 * Makie.GLYPH_PADDING[] # left + right pad + return full_pad ./ (full_pixel_size_in_atlas .- full_pad) +end + +function marker_scale_factor(path::BezierPath) + # padded_width = (unpadded_target_width + unpadded_target_width * pad_per_unit) + path_width = widths(Makie.bbox(path)) + return (1f0 .+ bezierpath_pad_scale_factor(path)) .* path_width +end + +function rescale_marker(pathmarker::BezierPath, font, markersize) + return markersize .* marker_scale_factor(pathmarker) +end + +function rescale_marker(pathmarker::AbstractVector{T}, font, markersize) where T <: BezierPath + return _bcast(markersize) .* marker_scale_factor.(pathmarker) +end + +# Rect / Circle dont need no rescaling +rescale_marker(char, font, markersize) = markersize + +function rescale_marker(char::AbstractVector{Char}, font, markersize) + return _bcast(markersize) .* marker_scale_factor.(char, font) +end + +function rescale_marker(char::Char, font, markersize) + factor = marker_scale_factor.(char, font) + return markersize .* factor +end + +function offset_bezierpath(bp::BezierPath, markersize::Vec2, markeroffset::Vec2) + bb = Makie.bbox(bp) + pad_offset = (origin(bb) .- 0.5f0 .* bezierpath_pad_scale_factor(bp) .* widths(bb)) + return markersize .* pad_offset +end + +function offset_bezierpath(bp, scale, offset) + return offset_bezierpath.(bp, _bcast(scale), _bcast(offset)) +end + +function offset_marker(marker::Union{T, AbstractVector{T}}, font, markersize, markeroffset) where T <: BezierPath + return offset_bezierpath(marker, markersize, markeroffset) +end + +function offset_marker(marker::Union{T, AbstractVector{T}}, font, markersize, markeroffset) where T <: Char + return rescale_marker(marker, font, markeroffset) +end + +offset_marker(marker, font, markersize, markeroffset) = markeroffset + +function marker_attributes(marker, markersize, font, marker_offset) + scale = map(rescale_marker, marker, font, markersize; ignore_equal_values=true) + quad_offset = map(offset_marker, marker, font, markersize, marker_offset; ignore_equal_values=true) + + return scale, quad_offset +end