diff --git a/CHANGELOG.md b/CHANGELOG.md index d96e22cf1f8..5bb9b98bee0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,8 @@ - Renamed `Tesselation/tesselation` to `Tessellation/tessellation` [GeometryBasics#227](https://github.com/JuliaGeometry/GeometryBasics.jl/pull/227) [#4564](https://github.com/MakieOrg/Makie.jl/pull/4564) - Added `Makie.mesh` option for `MetaMesh` which applies some of the bundled information [#4368](https://github.com/MakieOrg/Makie.jl/pull/4368), [#4496](https://github.com/MakieOrg/Makie.jl/pull/4496) - `Voronoiplot`s automatic colors are now defined based on the underlying point set instead of only those generators appearing in the tessellation. This makes the selected colors consistent between tessellations when generators might have been deleted or added. [#4357](https://github.com/MakieOrg/Makie.jl/pull/4357) -- Split `marker_offset` handling from marker centering and fix various bugs with it [#4594](https://github.com/MakieOrg/Makie.jl/pull/4594). +- Added `viewmode = :free` and translation, zoom, limit reset and cursor-focus interactions to Axis3. [4131](https://github.com/MakieOrg/Makie.jl/pull/4131) +- Split `marker_offset` handling from marker centering and fix various bugs with it [#4594](https://github.com/MakieOrg/Makie.jl/pull/4594) - Added `transform_marker` attribute to meshscatter and changed the default behavior to not transform marker/mesh vertices [#4606](https://github.com/MakieOrg/Makie.jl/pull/4606) - Fixed some issues with meshscatter not correctly transforming with transform functions and float32 rescaling [#4606](https://github.com/MakieOrg/Makie.jl/pull/4606) diff --git a/CairoMakie/src/primitives.jl b/CairoMakie/src/primitives.jl index 1c97209542a..cd75cb8f13f 100644 --- a/CairoMakie/src/primitives.jl +++ b/CairoMakie/src/primitives.jl @@ -579,15 +579,15 @@ function draw_glyph_collection( # TODO: f32convert may run into issues here if markerspace is :data or # :transformed (repeated application in glyphpos etc) transform_func = transformation.transform_func[] - p = apply_transform(transform_func, position, space) + transformed = apply_transform(transform_func, position, space) + p = model * to_ndim(Point4d, to_ndim(Point3d, transformed, 0), 1) Makie.is_data_space(space) && is_clipped(clip_planes, p) && return Makie.clip_to_space(scene.camera, markerspace) * Makie.space_to_clip(scene.camera, space) * Makie.f32_convert_matrix(scene.float32convert, space) * - model * - to_ndim(Point4d, to_ndim(Point3d, p, 0), 1) + p end Cairo.save(ctx) diff --git a/ReferenceTests/src/tests/figures_and_makielayout.jl b/ReferenceTests/src/tests/figures_and_makielayout.jl index 6660fd7d2db..7bc25908bda 100644 --- a/ReferenceTests/src/tests/figures_and_makielayout.jl +++ b/ReferenceTests/src/tests/figures_and_makielayout.jl @@ -306,6 +306,48 @@ end f end +@reference_test "Axis3 viewmodes, xreversed, aspect, perspectiveness" begin + fig = Figure(size = (800, 1200)) + + protrusions = (40, 30, 20, 10) + perspectiveness = Observable(0.0) + cat = GeometryBasics.expand_faceviews(load(Makie.assetpath("cat.obj"))) + cs = 1:length(Makie.coordinates(cat)) + + for (bx, by, viewmode) in [(1,1,:fit), (1,2,:fitzoom), (2,1,:free), (2,2,:stretch)] + gl = GridLayout(fig[by, bx]) + Label(gl[0, 1:2], "viewmode = :$viewmode") + for (x, rev) in enumerate((true, false)) + for (y, aspect) in enumerate((:data, :equal, (1.2, 0.8, 1.0))) + ax = Axis3(gl[y, x], viewmode = viewmode, xreversed = rev, aspect = aspect, + protrusions = protrusions, perspectiveness = perspectiveness) + mesh!(ax, cat, color = cs) + + # for debug purposes + # layout area + fullarea = lift(ax.layoutobservables.computedbbox, ax.layoutobservables.protrusions) do bbox, prot + mini = minimum(bbox) - Vec2(prot.left, prot.bottom) + maxi = maximum(bbox) + Vec2(prot.right, prot.top) + return Rect2f(mini, maxi - mini) + end + p = poly!(fig.scene, fullarea, color = RGBf(1, 0.8, 0.6), strokecolor = :red, strokewidth = 1.5) + translate!(p, 0, 0, -10_000) + # axis area = layout area - protrusions + p = poly!(fig.scene, ax.layoutobservables.computedbbox, color = RGBf(0.8, 0.9, 1), strokecolor = :blue, strokewidth = 1.5, linestyle = :dash) + translate!(p, 0, 0, -10_000) + end + end + end + + fig + + st = Stepper(fig) + Makie.step!(st) + + perspectiveness[] = 1.0 + Makie.step!(st) +end + @reference_test "Colorbar for recipes" begin fig, ax, pl = barplot(1:3; color=1:3, colormap=Makie.Categorical(:viridis), figure=(;size=(800, 800))) Colorbar(fig[1, 2], pl; size=100) @@ -459,31 +501,31 @@ end @reference_test "Button - Slider - Toggle - Textbox" begin f = Figure(size = (500, 250)) Makie.Button(f[1, 1:2]) - Makie.Button(f[2, 1:2], buttoncolor = :orange, cornerradius = 20, + Makie.Button(f[2, 1:2], buttoncolor = :orange, cornerradius = 20, strokecolor = :red, strokewidth = 2, # TODO: allocate space for this fontsize = 16, labelcolor = :blue) IntervalSlider(f[1, 3]) - sl = IntervalSlider(f[2, 3], range = 0:100, linewidth = 20, + sl = IntervalSlider(f[2, 3], range = 0:100, linewidth = 20, color_inactive = :orange, color_active_dimmed = :lightgreen) Makie.set_close_to!(sl, 30, 70) Toggle(f[3, 1]) Toggle(f[4, 1], framecolor_inactive = :lightblue, rimfraction = 0.6) Toggle(f[3, 2], active = true) - Toggle(f[4, 2], active = true, framecolor_inactive = :lightblue, + Toggle(f[4, 2], active = true, framecolor_inactive = :lightblue, framecolor_active = :yellow, rimfraction = 0.6) Makie.Slider(f[3, 3]) - sl = Makie.Slider(f[4, 3], range = 0:100, linewidth = 20, color_inactive = :cyan, + sl = Makie.Slider(f[4, 3], range = 0:100, linewidth = 20, color_inactive = :cyan, color_active_dimmed = :lightgreen) Makie.set_close_to!(sl, 30) gl = GridLayout(f[5, 1:3]) Textbox(gl[1, 1]) - Textbox(gl[1, 2], bordercolor = :red, cornerradius = 0, + Textbox(gl[1, 2], bordercolor = :red, cornerradius = 0, placeholder = "test string", fontsize = 16, textcolor_placeholder = :blue) - tb = Textbox(gl[1, 3], bordercolor = :black, cornerradius = 20, + tb = Textbox(gl[1, 3], bordercolor = :black, cornerradius = 20, fontsize =10, textcolor = :red, boxcolor = :lightblue) Makie.set!(tb, "some string") diff --git a/docs/src/reference/blocks/axis3.md b/docs/src/reference/blocks/axis3.md index e23347dc0f0..ef1687f93cb 100644 --- a/docs/src/reference/blocks/axis3.md +++ b/docs/src/reference/blocks/axis3.md @@ -1,5 +1,64 @@ # Axis3 +## Axis3 interactions + +Like Axis, Axis3 has a few predefined interactions enabled. + +### Rotation + +You can rotate the view by left-clicking and dragging. +This interaction is registered as `:dragrotate` and uses the `DragRotate` type. + +### Zoom + +You can zoom in an axis by scrolling in and out. +By default, the zoom is focused on the center of the Axis. +You can set `zoommode = :cursor` to focus the zoom on the cursor instead. +If you press `x`, `y` or `z` while scrolling, the zoom is restricted to that dimension. +If you press two keys simultaneously, the zoom will be restricted to the corresponding plane instead. +These keys can be changed with the attributes `xzoomkey`, `yzoomkey` and `zzoomkey`. +You can also restrict the zoom dimensions all the time by setting the axis attributes `xzoomlock`, `yzoomlock` or `zzoomlock` to `true`. + +With `viewmode = :free` the behavior of the zoom changes. +Instead of affecting just the content of the axis, zooming affects the axis as a whole. +It also disables `zoommode = :cursor`. +This interaction is registered as `:scrollzoom` and uses the `ScrollZoom` type. + +### Translation + +You can translate the view of the Axis3 by right-clicking and dragging. +If you press `x`, `y` or `z` while translating, the translation is restricted to that dimension. +If you press two keys simultaneously, the translation will be restricted to the corresponding plane instead. +These keys can be changed with the attributes `xtranslationkey`, `ytranslationkey` and `ztranslationkey`. +You can also restrict the translation all the time by setting the axis attributes `xtranslationlock`, `ytranslationlock` or `ztranslationlock` to `true`. + +With `viewmode = :free` another option for translation is added. +By pressing `control` while right-click dragging, the translation will affect the placement of the axis in the window instead of the content within the axis. +This interaction is registered as `:translation` and uses the `DragPan` type. + +### Limit reset + +You can reset the limits, i.e. zoom and translation with `ctrl + left click`. +This is the same as calling `reset_limits!(ax)`. +It sets the limits back to the values stored in `ax.limits`. +If they are `nothing` this computes automatic limits. +If you have previously called `limits!`, `xlims!`, `ylims!` or `zlims!` then `ax.limits` will be set and kept by this interaction. +You can reset the rotation of the axis with `shift + left click`. +If `viewmode = :free` this will also reset the translation of the axis (not just of the content). +If you trigger both simultaneously, i.e. press `ctrl + shift + leftclick`, the axis will be fully reset. +This includes `ax.limits` which are reset to `nothing` via `autolimits!(ax)` +This interaction is registered as `:limitreset` and uses the `LimitReset` type. + +### Center on point + +You can center the axis on your cursor with `alt + left click`. +Note that depending on the plot type, this may mean different things. +For most, a point on the surface of the plot is used. +For `meshscatter`, `scatter` and derived plots the position of the scattered mesh/marker is used. +This interaction is registered as `:cursorfocus` and uses the `FocusOnCursor` type. + + + ## Attributes ```@attrdocs diff --git a/src/camera/projection_math.jl b/src/camera/projection_math.jl index 187cdfaf75f..f0c6f8b2f94 100644 --- a/src/camera/projection_math.jl +++ b/src/camera/projection_math.jl @@ -130,6 +130,9 @@ from `eyeposition` to `lookat` will be used. All inputs must be supplied as 3-vectors. """ function lookat(eyePos::Vec{3, T}, lookAt::Vec{3, T}, up::Vec{3, T}) where T + return lookat_basis(eyePos, lookAt, up) * translationmatrix(-eyePos) +end +function lookat_basis(eyePos::Vec{3, T}, lookAt::Vec{3, T}, up::Vec{3, T}) where T zaxis = normalize(eyePos-lookAt) xaxis = normalize(cross(up, zaxis)) yaxis = normalize(cross(zaxis, xaxis)) @@ -139,7 +142,7 @@ function lookat(eyePos::Vec{3, T}, lookAt::Vec{3, T}, up::Vec{3, T}) where T xaxis[2], yaxis[2], zaxis[2], T0, xaxis[3], yaxis[3], zaxis[3], T0, T0, T0, T0, T1 - ) * translationmatrix(-eyePos) + ) end function lookat(::Type{T}, eyePos::Vec{3}, lookAt::Vec{3}, up::Vec{3}) where T @@ -240,8 +243,8 @@ end Decomposes a transformation matrix into a translation vector, scale vector and rotation Quaternion. Note that this is only valid for a transformation matrix -created with matching order, i.e. -`transform = translation_matrix * scale_matrix * rotation_matrix`. The model +created with matching order, i.e. +`transform = translation_matrix * scale_matrix * rotation_matrix`. The model matrix created by `Transformation` is one such matrix. """ function decompose_translation_scale_rotation_matrix(model::Mat4{T}) where T @@ -285,9 +288,9 @@ end """ decompose_translation_scale_matrix(transform::Mat4) -Like `decompose_translation_scale_rotation_matrix(transform)` but skips the +Like `decompose_translation_scale_rotation_matrix(transform)` but skips the extraction of the rotation component. This still works if a rotation is involved -and requires the same order of operations, i.e. +and requires the same order of operations, i.e. `transform = translation_matrix * scale_matrix * rotation_matrix`. """ function decompose_translation_scale_matrix(model::Mat4{T}) where T @@ -390,7 +393,7 @@ function project(proj_view::Mat4{T1}, resolution::Vec2, point::Point{N, T2}) whe # at this point the visible range is strictly -1..1 so FLoat64 doesn't matter p = (clip ./ clip[4])[Vec(1, 2)] p = Vec2{T}(p[1], p[2]) - return (0.5 .* (p .+ 1) .* (resolution .- 1)) .+ 1 + return 0.5 .* (p .+ 1) .* resolution end # TODO: consider warning here to discourage risky functions diff --git a/src/interaction/events.jl b/src/interaction/events.jl index 20e10ce29a6..90e3e06f640 100644 --- a/src/interaction/events.jl +++ b/src/interaction/events.jl @@ -303,4 +303,4 @@ ispressed(e::Events, op::Exclusively, waspressed = nothing) = op.x == union(e.ke # collections ispressed(parent, set::Set, waspressed = nothing) = all(x -> ispressed(parent, x, waspressed), set) ispressed(parent, set::Vector, waspressed = nothing) = all(x -> ispressed(parent, x, waspressed), set) -ispressed(parent, set::Tuple, waspressed = nothing) = all(x -> ispressed(parent, x, waspressed), set) +ispressed(parent, set::Tuple, waspressed = nothing) = all(x -> ispressed(parent, x, waspressed), set) \ No newline at end of file diff --git a/src/interaction/ray_casting.jl b/src/interaction/ray_casting.jl index a5c5ab5af6c..4ff0a5bf7b0 100644 --- a/src/interaction/ray_casting.jl +++ b/src/interaction/ray_casting.jl @@ -207,6 +207,23 @@ function is_point_on_ray(p::Point3{T1}, ray::Ray{T2}) where {T1 <: Real, T2 <: R end +function ray_plane_intersection(plane::Plane3{T1}, ray::Ray{T2}, epsilon = 1e-6) where {T1 <: Real, T2 <: Real} + # --- p --- plane with normal (assumed normalized) + # ↓ + # : distance d along plane normal direction + # ↖ : + # r ray with direction (assumed normalized) + + d = distance(plane, ray.origin) # signed distance + cos_angle = dot(-plane.normal, ray.direction) + + if abs(cos_angle) > epsilon + return ray.origin + d / cos_angle * ray.direction + else + return Point3f(NaN) + end +end + ################################################################################ ### Ray casting (positions from ray-plot intersections) ################################################################################ @@ -287,7 +304,7 @@ function position_on_plot(plot::Union{Lines, LineSegments}, idx, ray::Ray; apply p0, p1 = apply_transform_and_model(plot, plot[1][][(idx-1):idx]) pos = closest_point_on_line(f32_convert(plot, p0), f32_convert(plot, p1), ray) - + if apply_transform return inv_f32_convert(plot, Point3d(pos)) else @@ -295,7 +312,7 @@ function position_on_plot(plot::Union{Lines, LineSegments}, idx, ray::Ray; apply p3d = p4d[Vec(1, 2, 3)] / p4d[4] itf = inverse_transform(transform_func(plot)) out = Makie.apply_transform(itf, p3d, to_value(get(plot, :space, :data))) - return out + return out end end @@ -321,7 +338,7 @@ function position_on_plot(plot::Union{Heatmap, Image}, idx, ray::Ray; apply_tran end function position_on_plot(plot::Mesh, idx, ray::Ray; apply_transform = true) - positions = decompose(Point3, plot.mesh[]) + positions = decompose(Point3d, plot.mesh[]) ray = transform(inv(plot.model[]), inv_f32_convert(plot, ray)) tf = transform_func(plot) space = to_value(get(plot, :space, :data)) @@ -418,7 +435,7 @@ function position_on_plot(plot::Volume, idx, ray::Ray; apply_transform = true) tf = transform_func(plot) if tf === nothing - + ray = transform(inv(plot.model[]), ray) pos = ray_rect_intersection(Rect3(min, max .- min), ray) if apply_transform @@ -433,7 +450,7 @@ function position_on_plot(plot::Volume, idx, ray::Ray; apply_transform = true) w = max - min ps = Point3d[min + (x, y, z) .* w for x in (0, 1) for y in (0, 1) for z in (0, 1)] fs = decompose(GLTriangleFace, QuadFace{Int}[ - (1, 2, 4, 3), (7, 8, 6, 5), (5, 6, 2, 1), + (1, 2, 4, 3), (7, 8, 6, 5), (5, 6, 2, 1), (3, 4, 8, 7), (1, 3, 7, 5), (6, 8, 4, 2) ]) diff --git a/src/makielayout/blocks/axis3d.jl b/src/makielayout/blocks/axis3d.jl index 433f9ed0abf..f6c0b1c5f05 100644 --- a/src/makielayout/blocks/axis3d.jl +++ b/src/makielayout/blocks/axis3d.jl @@ -1,4 +1,4 @@ -struct OrthographicCamera <: AbstractCamera end +struct Axis3Camera <: AbstractCamera end function initialize_block!(ax::Axis3) @@ -9,15 +9,31 @@ function initialize_block!(ax::Axis3) end notify(ax.protrusions) - finallimits = Observable(Rect3f(Vec3f(0f0, 0f0, 0f0), Vec3f(100f0, 100f0, 100f0))) + finallimits = Observable(Rect3d(Vec3d(0.0), Vec3d(100.0))) setfield!(ax, :finallimits, finallimits) - scenearea = lift(round_to_IRect2D, blockscene, ax.layoutobservables.computedbbox) + scenearea = lift(blockscene, ax.layoutobservables.computedbbox, ax.layoutobservables.protrusions) do bbox, prot + mini = minimum(bbox) - Vec2(prot.left, prot.bottom) + maxi = maximum(bbox) + Vec2(prot.right, prot.top) + return round_to_IRect2D(Rect2f(mini, maxi - mini)) + end + + onany(blockscene, scenearea, ax.clip_decorations) do area, clip + if clip + blockscene.viewport[] = area + elseif blockscene.viewport[] != root(blockscene).viewport[] + blockscene.viewport[] = root(blockscene).viewport[] + end + end scene = Scene(blockscene, scenearea, clear = false, backgroundcolor = ax.backgroundcolor) ax.scene = scene - cam = OrthographicCamera() + cam = Axis3Camera() cameracontrols!(scene, cam) + scene.theme.clip_planes = map(scene, scene.transformation.model, ax.finallimits) do model, lims + _planes = planes(lims) + return apply_transform.(Ref(model), _planes) + end mi1 = Observable(!(pi/2 <= mod1(ax.azimuth[], 2pi) < 3pi/2)) mi2 = Observable(0 <= mod1(ax.azimuth[], 2pi) < pi) @@ -38,19 +54,24 @@ function initialize_block!(ax::Axis3) return end - matrices = lift(calculate_matrices, scene, finallimits, scene.viewport, ax.elevation, ax.azimuth, - ax.perspectiveness, ax.aspect, ax.viewmode, ax.xreversed, ax.yreversed, ax.zreversed) + setfield!(ax, :axis_offset, Observable(Vec2d(0))) + setfield!(ax, :zoom_mult, Observable(1.0)) - on(scene, matrices) do (model, view, proj, eyepos) + matrices = lift(calculate_matrices, scene, finallimits, scene.viewport, ax.protrusions, + ax.elevation, ax.azimuth, ax.perspectiveness, ax.aspect, ax.viewmode, + ax.xreversed, ax.yreversed, ax.zreversed, + ax.zoom_mult, ax.axis_offset, ax.near) + + on(scene, matrices) do (model, view, proj, lookat, eyepos) cam = camera(scene) Makie.set_proj_view!(cam, proj, view) scene.transformation.model[] = model - cam.eyeposition[] = eyepos - viewdir = -normalize(eyepos) + + viewdir = normalize(lookat - eyepos) up = Vec3d(0, 0, 1) - lookat = Vec3d(0) - u_z = normalize(eyepos .- lookat) + u_z = -viewdir u_x = normalize(cross(up, u_z)) + cam.eyeposition[] = eyepos cam.upvector[] = cross(u_z, u_x) cam.view_direction[] = viewdir end @@ -88,7 +109,7 @@ function initialize_block!(ax::Axis3) zticks, zticklabels, zlabel = add_ticks_and_ticklabels!(blockscene, scene, ax, 3, finallimits, ticknode_3, mi3, mi1, mi2, ax.azimuth, ax.xreversed, ax.yreversed, ax.zreversed) - titlepos = lift(scene, scene.viewport, ax.titlegap, ax.titlealign) do a, titlegap, align + titlepos = lift(scene, ax.layoutobservables.computedbbox, ax.titlegap, ax.titlealign) do a, titlegap, align align_factor = halign2num(align, "Horizontal title align $align not supported.") x = a.origin[1] + align_factor * a.widths[1] @@ -142,6 +163,7 @@ function initialize_block!(ax::Axis3) # adjustlimits!(ax) # we have no aspect constraints here currently, so just update final limits ax.finallimits[] = lims + return end function process_event(event) @@ -158,10 +180,11 @@ function initialize_block!(ax::Axis3) on(process_event, scene, ax.scrollevents) on(process_event, scene, ax.keysevents) - register_interaction!(ax, - :dragrotate, - DragRotate()) - + register_interaction!(ax, :dragrotate, DragRotate()) + register_interaction!(ax, :limitreset, LimitReset()) + register_interaction!(ax, :scrollzoom, ScrollZoom(0.05, NaN)) + register_interaction!(ax, :translation, DragPan(NaN)) + register_interaction!(ax, :cursorfocus, FocusOnCursor(length(ax.scene.plots))) # in case the user set limits already notify(ax.limits) @@ -169,13 +192,13 @@ function initialize_block!(ax::Axis3) return end -function calculate_matrices(limits, viewport, elev, azim, perspectiveness, aspect, - viewmode, xreversed, yreversed, zreversed) +function calculate_matrices(limits, viewport, protrusions, elev, azim, perspectiveness, aspect, + viewmode, xreversed, yreversed, zreversed, zoom_mult, scene_offset, near) ori = limits.origin ws = widths(limits) - limits = Rect3f( + limits = Rect3d( ( ori[1] + (xreversed ? ws[1] : zero(ws[1])), ori[2] + (yreversed ? ws[2] : zero(ws[2])), @@ -190,84 +213,110 @@ function calculate_matrices(limits, viewport, elev, azim, perspectiveness, aspec ws = widths(limits) - t = Makie.translationmatrix(-Float64.(limits.origin)) - s = if aspect === :equal + if aspect === :equal scales = 2 ./ Float64.(ws) elseif aspect === :data - scales = 2 ./ max.(maximum(ws), Float64.(ws)) + scales = 2 .* sign.(ws) ./ max.(maximum(ws), Float64.(ws)) elseif aspect isa VecTypes{3} scales = 2 ./ Float64.(ws) .* Float64.(aspect) ./ maximum(aspect) else error("Invalid aspect $aspect") - end |> Makie.scalematrix + end - t2 = Makie.translationmatrix(-0.5 .* ws .* scales) - model = t2 * s * t + # center and scale axis bbox so that the longest side is -1..1 + # then rotate (and permute axes) according to azimuth and elevation + model = + translationmatrix(-0.5 .* ws .* scales) * + scalematrix(scales) * + translationmatrix(-Float64.(limits.origin)) ang_max = 90 ang_min = 0.5 @assert 0 <= perspectiveness <= 1 - angle = ang_min + (ang_max - ang_min) * perspectiveness - - # vFOV = 2 * Math.asin(sphereRadius / distance); - # distance = sphere_radius / Math.sin(vFov / 2) + fov = ang_min + (ang_max - ang_min) * perspectiveness - # radius = sqrt(3) / tand(angle / 2) - radius = sqrt(3) / sind(angle / 2) + # After model content is normalized to a -1..1^3 box, i.e. within radius sqrt(3) + radius = zoom_mult * sqrt(3) / sind(fov / 2) + camdir = Vec3d(cos(elev) * cos(azim), cos(elev) * sin(azim), sin(elev)) + eyepos = radius * camdir - x = radius * cos(elev) * cos(azim) - y = radius * cos(elev) * sin(azim) - z = radius * sin(elev) + if viewmode == :free + up = Vec3d(0, 0, 1) + u_z = camdir + u_x = normalize(cross(up, u_z)) + u_y = cross(u_z, u_x) - eyepos = Vec3{Float64}(x, y, z) + lookat = zoom_mult * sqrt(3) * (scene_offset[1] * u_x + scene_offset[2] * u_y) + eyepos += lookat + else + lookat = Vec3d(0) + end - lookat_matrix = lookat(eyepos, Vec3{Float64}(0), Vec3{Float64}(0, 0, 1)) + lookat_matrix = Makie.lookat(eyepos, lookat, Vec3d(0,0,1)) w = width(viewport) h = height(viewport) projection_matrix = projectionmatrix( - lookat_matrix * model, limits, eyepos, radius, azim, elev, angle, - w, h, scales, viewmode) + lookat_matrix * model, limits, radius, fov, + w, h, to_protrusions(protrusions), viewmode, near) - return model, lookat_matrix, projection_matrix, eyepos + return model, lookat_matrix, projection_matrix, lookat, eyepos end -function projectionmatrix(viewmatrix, limits, eyepos, radius, azim, elev, angle, width, height, scales, viewmode) - near = 0.5 * (radius - sqrt(3)) - far = radius + 2 * sqrt(3) +function projectionmatrix(viewmatrix, limits, radius, fov, width, height, protrusions, viewmode, near_limit) + # model normalizes the the longest axis of the axis bbox to -1..1, so its + # bounding sphere has a radius of sqrt(3) + # The distance of the camera to the center of the bounding sphere is "radius" + near_limit > 0.0 || error("near value must be > 0, but is $near_limit.") + near = max(near_limit, radius - sqrt(3)) + far = max((1 + 1e-3) * near, radius + sqrt(3)) aspect_ratio = width / height - projection_matrix = if viewmode in (:fit, :fitzoom, :stretch) + projection_matrix = if viewmode in (:free, :fit, :fitzoom, :stretch) if height > width - angle = angle / aspect_ratio + fov = fov / aspect_ratio end - pm = Makie.perspectiveprojection(Float64, angle, aspect_ratio, near, far) + # this transforms w.r.t scene viewport, i.e. protrusions are not yet + # included + pm = Makie.perspectiveprojection(Float64, fov, aspect_ratio, near, far) + + # protrusions shrink the effective viewport which causes clip space + # coordinates in the real viewport to grow + dx = (protrusions.left - protrusions.right) / width + dy = (protrusions.bottom - protrusions.top) / height + w = (width - protrusions.left - protrusions.right) + h = (height - protrusions.bottom - protrusions.top) if viewmode in (:fitzoom, :stretch) - points = decompose(Point3f, limits) - projpoints = Ref(pm * viewmatrix) .* to_ndim.(Point4f, points, 1) + # coordinates of axis rect w.r.t real viewport + points = decompose(Point3d, limits) + projpoints = Ref(pm * viewmatrix) .* to_ndim.(Point4d, points, 1) - maxx = maximum(x -> abs(x[1] / x[4]), projpoints) - maxy = maximum(x -> abs(x[2] / x[4]), projpoints) + # convert to effective viewport + w = w/width; h = h/height + maxx = maximum(x -> abs(x[1] / (w * x[4])), projpoints) + maxy = maximum(x -> abs(x[2] / (h * x[4])), projpoints) - ratio_x = maxx - ratio_y = maxy + # normalization to map max x/y to 1 in effective viewport + ratio_x = 1.0 / maxx + ratio_y = 1.0 / maxy if viewmode === :fitzoom - if ratio_y > ratio_x - pm = Makie.scalematrix(Vec3(1/ratio_y, 1/ratio_y, 1)) * pm - else - pm = Makie.scalematrix(Vec3(1/ratio_x, 1/ratio_x, 1)) * pm - end + s = min(ratio_x, ratio_y) + pm = transformationmatrix(Vec3(dx, dy, 0), Vec3(s, s, 1)) * pm else - pm = Makie.scalematrix(Vec3(1/ratio_x, 1/ratio_y, 1)) * pm + pm = transformationmatrix(Vec3(dx, dy, 0), Vec3(ratio_x, ratio_y, 1)) * pm end + else + wh = min(w, h) / min(width, height) # works for :fit + pm = transformationmatrix(Vec3(dx, dy, 0), Vec3(wh, wh, 1)) * pm end + pm else error("Invalid viewmode $viewmode") @@ -280,22 +329,8 @@ function update_state_before_display!(ax::Axis3) end function autolimits!(ax::Axis3) - xlims = getlimits(ax, 1) - ylims = getlimits(ax, 2) - zlims = getlimits(ax, 3) - - ori = Vec3f(xlims[1], ylims[1], zlims[1]) - widths = Vec3f(xlims[2] - xlims[1], ylims[2] - ylims[1], zlims[2] - zlims[1]) - - enlarge_factor = 0.1 - - nori = ori .- (0.5 * enlarge_factor) * widths - nwidths = widths .* (1 + enlarge_factor) - - lims = Rect3f(nori, nwidths) - - ax.finallimits[] = lims - nothing + ax.limits[] = (nothing, nothing, nothing) + return end to_protrusions(x::Number) = GridLayoutBase.RectSides{Float32}(x, x, x, x) @@ -382,7 +417,7 @@ function add_gridlines_and_frames!(topscene, scene, ax, dim::Int, limits, tickno end end gridline1 = linesegments!(scene, endpoints, color = attr(:gridcolor), - linewidth = attr(:gridwidth), + linewidth = attr(:gridwidth), clip_planes = Plane3f[], xautolimits = false, yautolimits = false, zautolimits = false, transparency = true, visible = attr(:gridvisible), inspectable = false) @@ -399,14 +434,13 @@ function add_gridlines_and_frames!(topscene, scene, ax, dim::Int, limits, tickno end end gridline2 = linesegments!(scene, endpoints2, color = attr(:gridcolor), - linewidth = attr(:gridwidth), + linewidth = attr(:gridwidth), clip_planes = Plane3f[], xautolimits = false, yautolimits = false, zautolimits = false, transparency = true, visible = attr(:gridvisible), inspectable = false) - framepoints = lift(limits, scene.camera.projectionview, scene.viewport, min1, min2, xreversed, yreversed, zreversed - ) do lims, _, pxa, mi1, mi2, xrev, yrev, zrev - o = pxa.origin + framepoints = lift(limits, min1, min2, xreversed, yreversed, zreversed + ) do lims, mi1, mi2, xrev, yrev, zrev rev1 = (xrev, yrev, zrev)[d1] rev2 = (xrev, yrev, zrev)[d2] @@ -424,23 +458,13 @@ function add_gridlines_and_frames!(topscene, scene, ax, dim::Int, limits, tickno # p7 = dpoint(minimum(lims)[dim], f(!mi1)(lims)[d1], f(!mi2)(lims)[d2]) # p8 = dpoint(maximum(lims)[dim], f(!mi1)(lims)[d1], f(!mi2)(lims)[d2]) - # we are going to transform the 3d frame points into 2d of the topscene - # because otherwise the frame lines can - # be cut when they lie directly on the scene boundary - o = scene.viewport[].origin - return map([p1, p2, p3, p4, p5, p6]) do p3d - # This strip z here (set it to 0) and translate to coerce z sorting - # to be correct in CairoMakie (which is based on plot.transformation) - return Point2f(o + Makie.project(scene, p3d)) - end + return [p1, p2, p3, p4, p5, p6] end colors = Observable{Any}() map!(vcat, colors, attr(:spinecolor_1), attr(:spinecolor_2), attr(:spinecolor_3)) - framelines = linesegments!(topscene, framepoints, color = colors, linewidth = attr(:spinewidth), - # transparency = true, - visible = attr(:spinesvisible), inspectable = false) - # -10000 is the far value in campixel - translate!(framelines, 0, 0, -10000) + framelines = linesegments!(scene, framepoints, color = colors, linewidth = attr(:spinewidth), + transparency = true, visible = attr(:spinesvisible), inspectable = false, + xautolimits = false, yautolimits = false, zautolimits = false, clip_planes = Plane3f[]) return gridline1, gridline2, framelines end @@ -477,7 +501,7 @@ function add_ticks_and_ticklabels!(topscene, scene, ax, dim::Int, limits, tickno diff_f1 = f1 - f1_oppo diff_f2 = f2 - f2_oppo - o = pxa.origin + o = (origin(pxa) - origin(topscene.viewport[])) return map(ticks) do t p1 = dpoint(t, f1, f2) @@ -501,17 +525,16 @@ function add_ticks_and_ticklabels!(topscene, scene, ax, dim::Int, limits, tickno end ticks = linesegments!(topscene, tick_segments, - xautolimits = false, yautolimits = false, zautolimits = false, transparency = true, inspectable = false, color = attr(:tickcolor), linewidth = attr(:tickwidth), visible = attr(:ticksvisible)) - # -10000 is the far value in campixel + # move ticks behind plots, -10000 is the far value in campixel translate!(ticks, 0, 0, -10000) labels_positions = Observable{Any}() map!(topscene, labels_positions, scene.viewport, scene.camera.projectionview, tick_segments, ticklabels, attr(:ticklabelpad)) do pxa, pv, ticksegs, ticklabs, pad - o = pxa.origin + o = (origin(pxa) - origin(topscene.viewport[])) points = map(ticksegs) do (tstart, tend) offset = pad * Makie.GeometryBasics.normalize(Point2f(tend - tstart)) @@ -548,7 +571,7 @@ function add_ticks_and_ticklabels!(topscene, scene, ax, dim::Int, limits, tickno attr(:labeloffset), attr(:labelrotation), attr(:labelalign), xreversed, yreversed, zreversed ) do pxa, pv, lims, miv, min1, min2, labeloffset, lrotation, lalign, xrev, yrev, zrev - o = pxa.origin + o = (origin(pxa) - origin(topscene.viewport[])) rev1 = (xrev, yrev, zrev)[d1] rev2 = (xrev, yrev, zrev)[d2] diff --git a/src/makielayout/interactions.jl b/src/makielayout/interactions.jl index fdaa7dca1f0..92ad8ca9ffa 100644 --- a/src/makielayout/interactions.jl +++ b/src/makielayout/interactions.jl @@ -227,6 +227,7 @@ function process_interaction(::LimitReset, event::MouseEvent, ax::Axis) return Consume(false) end + function process_interaction(s::ScrollZoom, event::ScrollEvent, ax::Axis) # use vertical zoom zoom = event.y @@ -355,7 +356,7 @@ function process_interaction(dp::DragPan, event::MouseEvent, ax) end -function process_interaction(dr::DragRotate, event::MouseEvent, ax3d) +function process_interaction(dr::DragRotate, event::MouseEvent, ax3d::Axis3) if event.type !== MouseEventTypes.leftdrag return Consume(false) end @@ -367,3 +368,184 @@ function process_interaction(dr::DragRotate, event::MouseEvent, ax3d) return Consume(true) end + + +function process_interaction(interaction::DragPan, event::MouseEvent, ax::Axis3) + if event.type !== MouseEventTypes.rightdrag || (event.px == event.prev_px) + return Consume(false) + end + + tlimits = ax.targetlimits + mini = minimum(tlimits[]) + ws = widths(tlimits[]) + + # restrict to direction + x_translate = !(ax.xtranslationlock[]) && ispressed(ax, ax.xtranslationkey[]) + y_translate = !(ax.ytranslationlock[]) && ispressed(ax, ax.ytranslationkey[]) + z_translate = !(ax.ztranslationlock[]) && ispressed(ax, ax.ztranslationkey[]) + + if !(x_translate || y_translate || z_translate) # none restricted -> all active + xyz_translate = (true, true, true) + else + xyz_translate = (x_translate, y_translate, z_translate) + end + + # Perform translation + if (ax.viewmode[] == :free) && ispressed(ax, ax.axis_translation_mod[]) + + ws = widths(ax.layoutobservables.computedbbox[])[2] + ax.axis_offset[] -= Vec2d(2 .* (event.px - event.prev_px) ./ ws) + + else + + #= + # Faster but less acurate (dependent on aspect ratio) + scene_area = viewport(ax.scene)[] + relative_delta = (event.px - event.prev_px) ./ widths(scene_area) + + # Get u_x (screen right direction) and u_y (screen up direction) + u_z = ax.scene.camera.view_direction[] + u_y = ax.scene.camera.upvector[] + u_x = cross(u_z, u_y) + + translation = - 2.0 * (relative_delta[1] * u_x + relative_delta[2] * u_y) .* ws + =# + + # Slower but more accurate + model = ax.scene.transformation.model[] + world_center = to_ndim(Point3f, model * to_ndim(Point4d, mini .+ 0.5 * ws, 1), NaN) + # make plane_normal perpendicular to the allowed trnaslation directions + # allow_normal = xyz_translate == (true, true, true) ? (1, 1, 1) : (1 .- xyz_translate) + # plane = Plane3f(world_center, allow_normal .* ax.scene.camera.view_direction[]) + plane = Plane3f(world_center, ax.scene.camera.view_direction[]) + p0 = ray_plane_intersection(plane, ray_from_projectionview(ax.scene, event.prev_px)) + p1 = ray_plane_intersection(plane, ray_from_projectionview(ax.scene, event.px)) + delta = p1 - p0 + translation = isfinite(delta) ? - inv(model[Vec(1,2,3), Vec(1,2,3)]) * delta : Point3d(0) + + tlimits[] = Rect3f(mini + xyz_translate .* translation, ws) + end + + return Consume(true) +end + + +function process_interaction(interaction::ScrollZoom, event::ScrollEvent, ax::Axis3) + # use vertical zoom + zoom = event.y + + if zoom == 0 + return Consume(false) + end + + tlimits = ax.targetlimits + mini = minimum(tlimits[]) + maxi = maximum(tlimits[]) + center = 0.5 .* (mini .+ maxi) + + # restrict to direction + x_zoom = !(ax.xzoomlock[]) && ispressed(ax, ax.xzoomkey[]) + y_zoom = !(ax.yzoomlock[]) && ispressed(ax, ax.yzoomkey[]) + z_zoom = !(ax.zzoomlock[]) && ispressed(ax, ax.zzoomkey[]) + + if !(x_zoom || y_zoom || z_zoom) # none restricted -> all active + xyz_zoom = (true, true, true) + else + xyz_zoom = (x_zoom, y_zoom, z_zoom) + end + + zoom_mult = (1f0 - interaction.speed)^zoom + + if ax.viewmode[] == :free + + ax.zoom_mult[] = ax.zoom_mult[] * zoom_mult + + else + # Compute target + mode = ax.zoommode[] + target = Point3f(NaN) + model = ax.scene.transformation.model[] + + if mode == :cursor + # try to find position of plot object under cursor + mp = mouseposition_px(ax) + ray = ray_from_projectionview(ax.scene, mp) # world space + pos = Point3f(NaN) + plot, idx = pick(ax.scene) + if plot !== nothing + n = findfirst(==(plot), ax.scene.plots) + if !isnothing(n) && (n > 9) # user plot + pos = position_on_plot(plot, idx, ray, apply_transform = true) + # ^ applying transform also applies model transform so we stay in world space for this + end + end + + if !isfinite(pos) + # fall back on using intersection between view ray and center view plane + # (meaning plane parallel to screen, going through center of Axis3 limits) + world_center = to_ndim(Point3f, model * to_ndim(Point4d, center, 1), NaN) + plane = Plane3f(world_center, -ax.scene.camera.view_direction[]) + pos = ray_plane_intersection(plane, ray) # world space + end + # axis space, i.e. pre ax.scene.transformation.model applies, same as targetlimits space + target = to_ndim(Point3f, inv(model) * to_ndim(Point4f, pos, 1), NaN) + elseif mode == :center + target = center # axis space + else + error("$(ax.zoommode[]) is not a valid mode for zooming. Should be :center or :cursor.") + end + + mini = ifelse.(xyz_zoom, target .+ zoom_mult .* (mini .- target), mini) + maxi = ifelse.(xyz_zoom, target .+ zoom_mult .* (maxi .- target), maxi) + tlimits[] = Rect3f(mini, maxi - mini) + end + + # NOTE this might be problematic if we add scrolling to something like Menu + return Consume(true) +end + +function process_interaction(::LimitReset, event::MouseEvent, ax::Axis3) + consumed = false + if event.type === MouseEventTypes.leftclick + if ispressed(ax.scene, Keyboard.left_control) + ax.zoom_mult[] = 1.0 + if ispressed(ax.scene, Keyboard.left_shift) + autolimits!(ax) + else + reset_limits!(ax) + end + consumed = true + end + if ispressed(ax.scene, Keyboard.left_shift) + ax.axis_offset[] = Vec2d(0) + ax.elevation[] = pi/8 + ax.azimuth[] = 1.275 * pi + consumed = true + end + end + + return Consume(consumed) +end + +function process_interaction(focus::FocusOnCursor, ::Union{MouseEvent, KeyEvent}, ax::Axis3) + if ispressed(ax, ax.cursorfocuskey[]) && is_mouseinside(ax.scene) && (time() > focus.last_time + focus.timeout) + xy = events(ax.scene).mouseposition[] + plot, idx = pick(ax.scene, xy) + if isnothing(plot) || (parent_scene(plot) !== ax.scene) || (plot.space[] != :data) || + (findfirst(p -> p === plot, ax.scene.plots) <= focus.skip) # is axis decoration + return Consume(false) + end + + ray = Ray(ax.scene, xy .- minimum(viewport(ax.scene)[])) + p3d = position_on_plot(plot, idx, ray, apply_transform = false) + if !isnan(p3d) + tlimits = ax.targetlimits + ws = widths(tlimits[]) + tlimits[] = Rect3f(p3d - 0.5 * ws, ws) + focus.last_time = time() # to avoid double triggers + return Consume(true) + end + end + + return Consume(false) +end diff --git a/src/makielayout/types.jl b/src/makielayout/types.jl index 83321578634..c7c6a4d7d6c 100644 --- a/src/makielayout/types.jl +++ b/src/makielayout/types.jl @@ -194,6 +194,13 @@ end struct DragRotate end +mutable struct FocusOnCursor + last_time::Float64 + timeout::Float64 + skip::Int64 +end +FocusOnCursor(skip, timeout = 0.1) = FocusOnCursor(time(), timeout, skip) + struct ScrollEvent x::Float32 y::Float32 @@ -1549,11 +1556,13 @@ end @Block Axis3 <: AbstractAxis begin scene::Scene - finallimits::Observable{Rect3f} + finallimits::Observable{Rect3d} mouseeventhandle::MouseEventHandle scrollevents::Observable{ScrollEvent} keysevents::Observable{KeysEvent} interactions::Dict{Symbol, Tuple{Bool, Any}} + axis_offset::Observable{Vec2d} # center of scene -> center of Axis3 + zoom_mult::Observable{Float64} @attributes begin """ Global state for the x dimension conversion. @@ -1597,6 +1606,8 @@ end if aesthetics are more important than neutral presentation. """ perspectiveness = 0f0 + "Sets the minimum value for `near`. Increasing this value will make objects close to the camera clip earlier. Reducing this value too much results in depth values becoming inaccurate. Must be > 0." + near = 1e-3 """ Controls the lengths of the three axes relative to each other. @@ -1621,6 +1632,10 @@ end - `:stretch` pulls the cuboid corners to the frame edges such that the available space is filled completely. The chosen `aspect` is not maintained using this setting, so `:stretch` should not be used if a particular aspect is needed. + - `:free` behaves like `:fit` but changes some interactions. Zooming affects the whole axis rather + than just the content. This allows you to zoom in on content without it getting clipped by the 3D + bounding box of the Axis3. `zoommode = :cursor` is disabled. Translations can no also affect the axis as + a whole with `control + right drag`. """ viewmode = :fitzoom # :fit :fitzoom :stretch "The background color" @@ -1828,7 +1843,7 @@ end "Controls if the xz panel is visible" xzpanelvisible = true "The limits that the axis tries to set given other constraints like aspect. Don't set this directly, use `xlims!`, `ylims!` or `limits!` instead." - targetlimits = Rect3f(Vec3f(0, 0, 0), Vec3f(1, 1, 1)) + targetlimits = Rect3d(Vec3d(0), Vec3d(1)) "The limits that the user has manually set. They are reinstated when calling `reset_limits!` and are set to nothing by `autolimits!`. Can be either a tuple (xlow, xhigh, ylow, yhigh, zlow, zhigh) or a tuple (nothing_or_xlims, nothing_or_ylims, nothing_or_zlims). Are set by `xlims!`, `ylims!`, `zlims!` and `limits!`." limits = (nothing, nothing, nothing) "The relative margins added to the autolimits in x direction." @@ -1843,6 +1858,45 @@ end yreversed::Bool = false "Controls if the z axis goes upwards (false) or downwards (true) in default camera orientation." zreversed::Bool = false + "Controls whether decorations are cut off outside the layout area assigned to the axis." + clip_decorations::Bool = false + + # Interaction + "Locks interactive zooming in the x direction." + xzoomlock::Bool = false + "Locks interactive zooming in the y direction." + yzoomlock::Bool = false + "Locks interactive zooming in the z direction." + zzoomlock::Bool = false + "The key for limiting zooming to the x direction." + xzoomkey::IsPressedInputType = Keyboard.x + "The key for limiting zooming to the y direction." + yzoomkey::IsPressedInputType = Keyboard.y + "The key for limiting zooming to the z direction." + zzoomkey::IsPressedInputType = Keyboard.z + """ + Controls what reference point is used when zooming. Can be `:center` for centered zooming or `:cursor` + for zooming centered approximately where the cursor is. This is disabled with `viewmode = :free`. + """ + zoommode::Symbol = :center + + "Locks interactive translation in the x direction." + xtranslationlock::Bool = false + "Locks interactive translation in the y direction." + ytranslationlock::Bool = false + "Locks interactive translation in the z direction." + ztranslationlock::Bool = false + "The key for limiting translation to the x direction." + xtranslationkey::IsPressedInputType = Keyboard.x + "The key for limiting translations to the y direction." + ytranslationkey::IsPressedInputType = Keyboard.y + "The key for limiting translations to the y direction." + ztranslationkey::IsPressedInputType = Keyboard.z + "Sets the key that must be pressed to translate the whole axis (as opposed to the content) with `viewmode = :free`." + axis_translation_mod::IsPressedInputType = Keyboard.left_control | Keyboard.right_control + + "Sets the key/button for centering the Axis3 on the currently hovered position." + cursorfocuskey::IsPressedInputType = Keyboard.left_alt & Mouse.left end end diff --git a/src/utilities/Plane.jl b/src/utilities/Plane.jl index e90aa9adf08..8ff27880d69 100644 --- a/src/utilities/Plane.jl +++ b/src/utilities/Plane.jl @@ -13,7 +13,7 @@ end Plane(point::Point, normal::Vec) Plane(normal::Vec, distance::Real) -Creates a Plane with the given `normal` containing the given `point`. The +Creates a Plane with the given `normal` containing the given `point`. The internal representation uses a `distance = dot(point, normal)` which can also be constructed directly. """ @@ -57,7 +57,7 @@ end """ min_clip_distance(planes::Vector{<: Plane}, point) -Returns the smallest absolute distance between the point each clip plane. If +Returns the smallest absolute distance between the point each clip plane. If the point is clipped by any plane, only negative distances are considered. """ function min_clip_distance(planes::Vector{<: Plane}, point::VecTypes) @@ -76,7 +76,7 @@ gl_plane_format(plane::Plane3) = to_ndim(Vec4f, plane.normal, plane.distance) """ planes(rect::Rect3) -Converts a 3D rect into a set of planes. Using these as clip planes will remove +Converts a 3D rect into a set of planes. Using these as clip planes will remove everything outside the rect. """ function planes(rect::Rect3) @@ -91,6 +91,16 @@ function planes(rect::Rect3) Plane3f(Vec3f( 0, 0, -1), -maxi[3]) ] end +function planes(rect::Rect2) + mini = minimum(rect) + maxi = maximum(rect) + return [ + Plane3f(Vec3f( 1, 0, 0), mini[1]), + Plane3f(Vec3f( 0, 1, 0), mini[2]), + Plane3f(Vec3f(-1, 0, 0), -maxi[1]), + Plane3f(Vec3f( 0, -1, 0), -maxi[2]), + ] +end """ is_clipped(plane, point) @@ -217,7 +227,7 @@ end """ to_mesh(plane[; origin = Point3f(0), scale = 1]) -Generates a mesh corresponding to a finite section of the `plane` centered at +Generates a mesh corresponding to a finite section of the `plane` centered at `origin` and extending by `scale` in each direction. """ function to_mesh(plane::Plane3{T}; origin = Point3f(0), scale = 1) where T @@ -284,7 +294,7 @@ function to_clip_space(pv::Mat4, ipv::Mat4, plane::Plane3) if distances[i] * distances[j] <= 0.0 # sign change # d(t) = m t + b, find t where distance d(t) = 0 t = - distances[i] / (distances[j] - distances[i]) - + # interpolating in clip_space does not work p = pv * to_ndim(Point4f, (world_corners[j] - world_corners[i]) * t + world_corners[i], 1) push!(zero_points, p[Vec(1,2,3)] / p[4]) diff --git a/test/events.jl b/test/events.jl index 524d029e69e..01a237b820e 100644 --- a/test/events.jl +++ b/test/events.jl @@ -8,6 +8,11 @@ Base.:(==)(l::Not, r::Not) = l.x == r.x Base.:(==)(l::And, r::And) = l.left == r.left && l.right == r.right Base.:(==)(l::Or, r::Or) = l.left == r.left && l.right == r.right +function Base.isapprox(a::Rect{N, T}, b::Rect{N, T}; kwargs...) where {N, T} + return isapprox(minimum(a), minimum(b); kwargs...) && + isapprox(widths(a), widths(b); kwargs...) +end + @testset "Events" begin @testset "Mouse and Keyboard state" begin events = Makie.Events() @@ -200,7 +205,7 @@ Base.:(==)(l::Or, r::Or) = l.left == r.left && l.right == r.right e = events(scene) cam3d!(scene, fixed_axis=true, cad=false, zoom_shift_lookat=false) cc = cameracontrols(scene) - + # Verify initial camera state @test cc.lookat[] == Vec3f(0) @test cc.eyeposition[] == Vec3f(3) @@ -485,6 +490,77 @@ Base.:(==)(l::Or, r::Or) = l.left == r.left && l.right == r.right e.scroll[] = (0.0, 1.0) @test !blocked[] end + + + # TODO: test more + @testset "Axis Interactions - Axis3" begin + f = Figure(size = (400, 400)) + a = Axis3(f[1, 1]) + p = scatter!(a, Rect3f(Point3f(1,2,3), Vec3f(1,2,3))) + Makie.update_state_before_display!(f) + e = events(f) + + names = (:dragrotate, :translation, :limitreset, :scrollzoom) + types = (Makie.DragRotate, Makie.DragPan, Makie.LimitReset, Makie.ScrollZoom) + for (name, type) in zip(names, types) + @test haskey(a.interactions, name) + @test a.interactions[name][1] == true + @test a.interactions[name][2] isa type + end + + # Test a series of user interactions with the Axis3 + @test a.targetlimits[] ≈ Rect3f(Point3f(0.95, 1.9, 2.85), Vec3f(1.1, 2.2, 3.3)) + + # translations + e.mouseposition[] = (200, 200) + e.mousebutton[] = MouseButtonEvent(Mouse.right, Mouse.press) + e.mouseposition[] = (150, 250) + e.mousebutton[] = MouseButtonEvent(Mouse.right, Mouse.release) + @test a.targetlimits[] ≈ Rect3f(Point3f(1.0789851, 1.4260418, 1.802376), Vec3f(1.1, 2.2, 3.3)) + + e.keyboardbutton[] = KeyEvent(Keyboard.x, Keyboard.press) + e.mouseposition[] = (150, 250) + e.mousebutton[] = MouseButtonEvent(Mouse.right, Mouse.press) + e.mouseposition[] = (200, 200) + e.mousebutton[] = MouseButtonEvent(Mouse.right, Mouse.release) + @test a.targetlimits[] ≈ Rect3f(Point3f(0.95, 1.4260418, 1.802376), Vec3f(1.1, 2.2, 3.3)) + + e.keyboardbutton[] = KeyEvent(Keyboard.x, Keyboard.release) + e.keyboardbutton[] = KeyEvent(Keyboard.y, Keyboard.press) + e.keyboardbutton[] = KeyEvent(Keyboard.z, Keyboard.press) + e.mouseposition[] = (150, 250) + e.mousebutton[] = MouseButtonEvent(Mouse.right, Mouse.press) + e.mouseposition[] = (200, 200) + e.mousebutton[] = MouseButtonEvent(Mouse.right, Mouse.release) + @test a.targetlimits[] ≈ Rect3f(Point3f(0.95, 1.9, 2.85), Vec3f(1.1, 2.2, 3.3)) + + e.keyboardbutton[] = KeyEvent(Keyboard.y, Keyboard.release) + e.keyboardbutton[] = KeyEvent(Keyboard.z, Keyboard.release) + + # reset + e.keyboardbutton[] = KeyEvent(Keyboard.left_control, Keyboard.press) + e.mousebutton[] = MouseButtonEvent(Mouse.left, Mouse.press) + e.mousebutton[] = MouseButtonEvent(Mouse.left, Mouse.release) + e.keyboardbutton[] = KeyEvent(Keyboard.left_control, Keyboard.release) + @test a.targetlimits[] ≈ Rect3f(Point3f(0.95, 1.9, 2.85), Vec3f(1.1, 2.2, 3.3)) + + # zooming + e.keyboardbutton[] = KeyEvent(Keyboard.x, Keyboard.press) + e.scroll[] = (0.0, 4.0) + e.keyboardbutton[] = KeyEvent(Keyboard.x, Keyboard.release) + @test a.targetlimits[] ≈ Rect3f(Point3f(1.0520215, 1.9, 2.85), Vec3f(0.8959568, 2.2, 3.3)) + e.keyboardbutton[] = KeyEvent(Keyboard.y, Keyboard.press) + e.scroll[] = (0.0, 4.0) + e.keyboardbutton[] = KeyEvent(Keyboard.y, Keyboard.release) + @test a.targetlimits[] ≈ Rect3f(Point3f(1.0520215, 2.104043, 2.85), Vec3f(0.8959568, 1.7919136, 3.3)) + e.keyboardbutton[] = KeyEvent(Keyboard.z, Keyboard.press) + e.scroll[] = (0.0, 4.0) + e.keyboardbutton[] = KeyEvent(Keyboard.z, Keyboard.release) + @test a.targetlimits[] ≈ Rect3f(Point3f(1.0520215, 2.104043, 3.1560647), Vec3f(0.8959568, 1.7919136, 2.6878703)) + e.mouseposition[] = (200, 200) + e.scroll[] = (0.0, -4.0) + @test a.targetlimits[] ≈ Rect3f(Point3f(0.95, 1.9, 2.85), Vec3f(1.1, 2.2, 3.3)) + end end @testset "Builtin interaction helpers" begin diff --git a/test/projection_math.jl b/test/projection_math.jl index fc5139128ad..3f8d952a693 100644 --- a/test/projection_math.jl +++ b/test/projection_math.jl @@ -5,19 +5,31 @@ using Makie @test eltype(Makie.rotationmatrix_x(1)) == Float64 @test eltype(Makie.rotationmatrix_x(1f0)) == Float32 end + @testset "Projection between spaces in 3D" begin # Set up an LScene and some points there sc = Scene(size = (650, 400), camera = cam3d!) + update_cam!(sc, Vec3d(0, 1, 0), Vec3d(0)) corner_points_px = [Point3f(0, 0, 0), Point3f(650, 400, 0)] @testset "Clip space and pixel space equivalence" begin far_bottom_left_clip = Point3f(-1) near_top_right_clip = Point3f(1) - + fbl_px = Makie.project(sc, :clip, :pixel, far_bottom_left_clip) ntr_px = Makie.project(sc, :clip, :pixel, near_top_right_clip) - @test Point2f(fbl_px) == Point2f(0, 0) - @test Point2f(ntr_px) == Point2f(650, 400) + @test fbl_px == Point3f(0, 0, 10_000) + @test ntr_px == Point3f(650, 400, -10_000) + + ipv = inv(sc.camera.projectionview[]) + div4(p) = p[Vec(1,2,3)] / p[4] + bottom_left_data = div4(ipv * Point4d(-1, -1, 0, 1)) + top_right_data = div4(ipv * Point4d(+1, +1, 0, 1)) + @test Makie.project(sc, bottom_left_data) ≈ Point2f(0, 0) atol = 1e-4 + @test Makie.project(sc, top_right_data) ≈ Point2f(650, 400) atol = 1e-4 + + @test Makie.project(sc, :pixel, :data, Vec2f(0, 0)) ≈ bottom_left_data + @test Makie.project(sc, :pixel, :data, Vec2f(650, 400)) ≈ top_right_data end @testset "No warnings in projections between spaces" begin @@ -29,6 +41,7 @@ using Makie end end end + end end