diff --git a/Project.toml b/Project.toml index 1d13375..046f4b8 100644 --- a/Project.toml +++ b/Project.toml @@ -4,6 +4,8 @@ authors = ["Tim Holy and contributors"] version = "0.1.1" [deps] +LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" +Rotations = "6038ab10-8711-5258-84ad-4b1120ba62dc" StaticArrays = "90137ffa-7385-5640-81b9-e52037218182" [weakdeps] @@ -20,8 +22,7 @@ Test = "1" julia = "1.10" [extras] -LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" [targets] -test = ["LinearAlgebra", "Test"] +test = ["Test"] diff --git a/src/FlyThroughPaths.jl b/src/FlyThroughPaths.jl index 22d09ef..10a77a6 100644 --- a/src/FlyThroughPaths.jl +++ b/src/FlyThroughPaths.jl @@ -1,8 +1,10 @@ module FlyThroughPaths using StaticArrays +using Rotations +using LinearAlgebra -export ViewState, Path, Pause, ConstrainedMove, BezierMove +export ViewState, Path # exports for the extensions export capture_view, set_view! @@ -11,6 +13,14 @@ include("viewstate.jl") include("pathchange.jl") include("path.jl") +# path changes +include("pathchanges/beziermove.jl") +include("pathchanges/constrainedmove.jl") +include("pathchanges/pause.jl") +include("pathchanges/slerpmove.jl") + +export BezierMove, ConstrainedMove, Pause, SlerpMove + function __init__() if isdefined(Base.Experimental, :register_error_hint) Base.Experimental.register_error_hint(MethodError) do io, exc, argtypes, kwargs diff --git a/src/pathchange.jl b/src/pathchange.jl index 3eed4a5..6b7a509 100644 --- a/src/pathchange.jl +++ b/src/pathchange.jl @@ -23,121 +23,11 @@ an `action` callback. """ abstract type PathChange{T<:Real} end -struct Pause{T} <: PathChange{T} - duration::T - action - - function Pause{T}(t, action=nothing) where T - t >= zero(T) || throw(ArgumentError("t must be non-negative")) - new{T}(t, action) - end -end - -""" - Pause(duration, [action]) - -Pause at the current position for `duration`. -""" -Pause(duration::T) where T = Pause{T}(duration) - -Base.convert(::Type{Pause{T}}, p::Pause) where T = Pause{T}(p.duration, p.action) -Base.convert(::Type{PathChange{T}}, p::Pause) where T = convert(Pause{T}, p) - -struct ConstrainedMove{T} <: PathChange{T} - duration::T - target::ViewState{T} - constraint::Symbol - speed::Symbol - action - function ConstrainedMove{T}(t, target, constraint, speed, action=nothing) where T - t >= zero(T) || throw(ArgumentError("t must be non-negative")) - constraint in (:none,:rotation) || throw(ArgumentError("Unknown constraint: $constraint")) - speed in (:constant,:sinusoidal) || throw(ArgumentError("Unknown speed: $speed")) - new{T}(t, target, constraint, speed, action) - end -end - -""" - ConstrainedMove(duration::T, target::ViewState{T}, [constraint, speed, action]) where T <: Real - -Create a `ConstrainedMove` which represents a movement from the current -[`ViewState`](@ref) to a `target` [`ViewState`](@ref) over a specified -`duration`. The movement can be constrained by `:rotation` or -unconstrained by `:none`, and can proceed at a `:constant` or `:sinusoidal` -speed. - -# Arguments -- `duration::T`: The duration of the movement, where `T` is a subtype of `Real`. -- `target::ViewState{T}`: The target state to reach at the end of the movement. -- `constraint::Symbol`: The type of constraint on the movement (`:none` or `:rotation`). -- `speed::Symbol`: The speed pattern of the movement (`:constant` or `:sinusoidal`). -- `action`: An optional callback to be called at each step of the movement. - - -# Examples -```julia -# Move to a new view state over 5 seconds with no rotation and constant speed -move = ConstrainedMove(5.0, new_view_state, :none, :constant) -path *= move -``` - -This type of `PathChange` is useful for animations where the view needs to -transition smoothly between two states under certain constraints. -""" -ConstrainedMove{T}(duration, target; constraint=:none, speed=:constant, action=nothing) where T = - ConstrainedMove{T}(duration, target, constraint, speed, action) -ConstrainedMove(duration, target::ViewState{T}, args...) where T = ConstrainedMove{T}(duration, target, args...) -ConstrainedMove(duration, target::ViewState{T}; kwargs...) where T = ConstrainedMove{T}(duration, target; kwargs...) - -Base.convert(::Type{ConstrainedMove{T}}, m::ConstrainedMove) where T = ConstrainedMove{T}(m.duration, m.target, m.constraint, m.speed, m.action) -Base.convert(::Type{PathChange{T}}, m::ConstrainedMove) where T = convert(ConstrainedMove{T}, m) - -struct BezierMove{T} <: PathChange{T} - duration::T - target::ViewState{T} - controls::Vector{ViewState{T}} - action - - function BezierMove{T}(t, target, controls, action=nothing) where T - t >= zero(T) || throw(ArgumentError("t must be non-negative")) - new{T}(t, target, controls, action) - end -end - -""" - BezierMove(duration::T, target::ViewState{T}, controls::Vector{ViewState{T}}, [action]) where T <: Real - -Create a `BezierMove` which represents a movement from the current -[`ViewState`](@ref) to a `target` [`ViewState`](@ref) over a specified -`duration`. The movement is defined by a series of control points, which -are interpolated between to form a smooth curve. - -See the [Wikipedia article on Bezier curves](https://en.wikipedia.org/wiki/B%C3%A9zier_curve) for more details. - -# Arguments -- `duration::T`: The duration of the movement, where `T` is a subtype of `Real`. -- `target::ViewState{T}`: The target state to reach at the end of the movement. -- `controls::Vector{ViewState{T}}`: The control points of the movement. -- `action`: An optional callback to be called at each step of the movement. - -# Examples -```julia -# Move to a new view state over 5 seconds with no rotation and constant speed -move = BezierMove(5.0, new_view_state, [new_view_state]) -path *= move -``` -""" -BezierMove(duration, target::ViewState{R}, controls::Vector{ViewState{S}}, args...) where {R,S} = BezierMove{promote_type(R,S)}(duration, target, controls, args...) - -Base.convert(::Type{BezierMove{T}}, m::BezierMove) where T = BezierMove{T}(m.duration, m.target, m.controls, m.action) -Base.convert(::Type{PathChange{T}}, m::BezierMove) where T = convert(BezierMove{T}, m) - # Common API duration(c::PathChange{T}) where T = c.duration::T target(oldtarget::ViewState{T}, c::PathChange{T}) where T = c.target::ViewState{T} -target(oldtarget::ViewState{T}, ::Pause{T}) where T = oldtarget Base.@nospecializeinfer function act(@nospecialize(action), t::Real) action === nothing && return nothing @@ -145,52 +35,6 @@ Base.@nospecializeinfer function act(@nospecialize(action), t::Real) return nothing end -# Compute the view from a PathChange at (relative) time t - -function (pause::Pause{T})(view::ViewState{T}, t) where T - checkt(t, pause) - action = pause.action - if action !== nothing - tf = t / duration(move) - act(action, tf) - end - return view -end - -function (move::ConstrainedMove{T})(view::ViewState{T}, t) where T - checkt(t, move) - (; target, constraint, speed, action) = move - tf = t / duration(move) - f = speed === :constant ? tf : (1 - cospi(tf))/2 - (; eyeposition, lookat, upvector, fov) = view - eyeposition_new = something(target.eyeposition, eyeposition) - lookat_new = something(target.lookat, lookat) - upvector_new = something(target.upvector, upvector) - fov_new = something(target.fov, fov) - lookatf = (1 - f) * lookat + f * lookat_new - if constraint === :none - eyeposition = (1 - f) * eyeposition + f * eyeposition_new - elseif constraint === :rotation - vold = eyeposition - lookat - vnew = eyeposition_new - lookat_new - eyeposition = cospi(f/2) * vold + sinpi(f/2) * vnew + lookatf - end - upvector = (1 - f) * upvector + f * upvector_new - fov = (1 - f) * fov + f * fov_new - lookat = lookatf - act(action, f) - return ViewState{T}(eyeposition, lookat, upvector, fov) -end - -function (move::BezierMove{T})(view::ViewState{T}, t) where T - filldef(vs) = filldefaults(vs, view) - checkt(t, move) - tf = t / duration(move) - act(move.action, tf) - list = [view, filldef.(move.controls)..., filldef(move.target)] - return evaluate(list, tf) -end - # Recursive evaluation of bezier curves, https://en.wikipedia.org/wiki/B%C3%A9zier_curve#Recursive_definition function evaluate(list, t) length(list) == 1 && return list[1] diff --git a/src/pathchanges/beziermove.jl b/src/pathchanges/beziermove.jl new file mode 100644 index 0000000..0fb5d22 --- /dev/null +++ b/src/pathchanges/beziermove.jl @@ -0,0 +1,58 @@ +#= +# BezierMove + +This moves the camera along a Bezier path parametrized by control points. + +## Example + +## Implementation +=# + +struct BezierMove{T} <: PathChange{T} + duration::T + target::ViewState{T} + controls::Vector{ViewState{T}} + action + + function BezierMove{T}(t, target, controls, action=nothing) where T + t >= zero(T) || throw(ArgumentError("t must be non-negative")) + new{T}(t, target, controls, action) + end +end + +function (move::BezierMove{T})(view::ViewState{T}, t) where T + filldef(vs) = filldefaults(vs, view) + checkt(t, move) + tf = t / duration(move) + act(move.action, tf) + list = [view, filldef.(move.controls)..., filldef(move.target)] + return evaluate(list, tf) +end + +""" + BezierMove(duration::T, target::ViewState{T}, controls::Vector{ViewState{T}}, [action]) where T <: Real + +Create a `BezierMove` which represents a movement from the current +[`ViewState`](@ref) to a `target` [`ViewState`](@ref) over a specified +`duration`. The movement is defined by a series of control points, which +are interpolated between to form a smooth curve. + +See the [Wikipedia article on Bezier curves](https://en.wikipedia.org/wiki/B%C3%A9zier_curve) for more details. + +# Arguments +- `duration::T`: The duration of the movement, where `T` is a subtype of `Real`. +- `target::ViewState{T}`: The target state to reach at the end of the movement. +- `controls::Vector{ViewState{T}}`: The control points of the movement. +- `action`: An optional callback to be called at each step of the movement. + +# Examples +```julia +# Move to a new view state over 5 seconds with no rotation and constant speed +move = BezierMove(5.0, new_view_state, [new_view_state]) +path *= move +``` +""" +BezierMove(duration, target::ViewState{R}, controls::Vector{ViewState{S}}, args...) where {R,S} = BezierMove{promote_type(R,S)}(duration, target, controls, args...) + +Base.convert(::Type{BezierMove{T}}, m::BezierMove) where T = BezierMove{T}(m.duration, m.target, m.controls, m.action) +Base.convert(::Type{PathChange{T}}, m::BezierMove) where T = convert(BezierMove{T}, m) diff --git a/src/pathchanges/constrainedmove.jl b/src/pathchanges/constrainedmove.jl new file mode 100644 index 0000000..eccba21 --- /dev/null +++ b/src/pathchanges/constrainedmove.jl @@ -0,0 +1,94 @@ +#= +# ConstrainedMove + +A [`ConstrainedMove`](@ref) is a [`PathChange`](@ref) that moves the camera +from the current [`ViewState`](@ref) to a `target` [`ViewState`](@ref) under +a specified `constraint` and some interpolation / easing in `speed`. + +## Example + +TODO: add an example here. + +## Implementation + +First, we define the actual struct according to the contract for [`PathChange`](@ref). +=# + + +struct ConstrainedMove{T} <: PathChange{T} + duration::T + target::ViewState{T} + constraint::Symbol + speed::Symbol + action + function ConstrainedMove{T}(t, target, constraint, speed, action=nothing) where T + t >= zero(T) || throw(ArgumentError("t must be non-negative")) + constraint in (:none,:rotation) || throw(ArgumentError("Unknown constraint: $constraint")) + speed in (:constant,:sinusoidal) || throw(ArgumentError("Unknown speed: $speed")) + new{T}(t, target, constraint, speed, action) + end +end + +# This is the implementation of the constrained move. + +function (move::ConstrainedMove{T})(view::ViewState{T}, t) where T + checkt(t, move) + (; target, constraint, speed, action) = move + tf = t / duration(move) + f = speed === :constant ? tf : (1 - cospi(tf))/2 + (; eyeposition, lookat, upvector, fov) = view + eyeposition_new = something(target.eyeposition, eyeposition) + lookat_new = something(target.lookat, lookat) + upvector_new = something(target.upvector, upvector) + fov_new = something(target.fov, fov) + lookatf = (1 - f) * lookat + f * lookat_new + if constraint === :none + eyeposition = (1 - f) * eyeposition + f * eyeposition_new + elseif constraint === :rotation + vold = eyeposition - lookat + vnew = eyeposition_new - lookat_new + eyeposition = cospi(f/2) * vold + sinpi(f/2) * vnew + lookatf + end + upvector = (1 - f) * upvector + f * upvector_new + fov = (1 - f) * fov + f * fov_new + lookat = lookatf + act(action, f) + return ViewState{T}(eyeposition, lookat, upvector, fov) +end + +# Then, we define more constructors, as well as the [`PathChange`](@ref) API. + +""" + ConstrainedMove(duration::T, target::ViewState{T}, [constraint, speed, action]) where T <: Real + +Create a `ConstrainedMove` which represents a movement from the current +[`ViewState`](@ref) to a `target` [`ViewState`](@ref) over a specified +`duration`. The movement can be constrained by `:rotation` or +unconstrained by `:none`, and can proceed at a `:constant` or `:sinusoidal` +speed. + +# Arguments +- `duration::T`: The duration of the movement, where `T` is a subtype of `Real`. +- `target::ViewState{T}`: The target state to reach at the end of the movement. +- `constraint::Symbol`: The type of constraint on the movement (`:none` or `:rotation`). +- `speed::Symbol`: The speed pattern of the movement (`:constant` or `:sinusoidal`). +- `action`: An optional callback to be called at each step of the movement. + + +# Examples +```julia +# Move to a new view state over 5 seconds with no rotation and constant speed +move = ConstrainedMove(5.0, new_view_state, :none, :constant) +path *= move +``` + +This type of `PathChange` is useful for animations where the view needs to +transition smoothly between two states under certain constraints. +""" +ConstrainedMove{T}(duration, target; constraint=:none, speed=:constant, action=nothing) where T = + ConstrainedMove{T}(duration, target, constraint, speed, action) +ConstrainedMove(duration, target::ViewState{T}, args...) where T = ConstrainedMove{T}(duration, target, args...) +ConstrainedMove(duration, target::ViewState{T}; kwargs...) where T = ConstrainedMove{T}(duration, target; kwargs...) + +Base.convert(::Type{ConstrainedMove{T}}, m::ConstrainedMove) where T = ConstrainedMove{T}(m.duration, m.target, m.constraint, m.speed, m.action) +Base.convert(::Type{PathChange{T}}, m::ConstrainedMove) where T = convert(ConstrainedMove{T}, m) diff --git a/src/pathchanges/pause.jl b/src/pathchanges/pause.jl new file mode 100644 index 0000000..ef2f6fa --- /dev/null +++ b/src/pathchanges/pause.jl @@ -0,0 +1,39 @@ +#= +# Pause + +[`Pause`](@ref) is a move that encodes a pause, i.e., no movement in the camera state at all. +The pause lasts for `duration` time, and has an `action` callback. + +The construction is very simple - simply `Pause(duration)`. +=# +struct Pause{T} <: PathChange{T} + duration::T + action + + function Pause{T}(t, action=nothing) where T + t >= zero(T) || throw(ArgumentError("t must be non-negative")) + new{T}(t, action) + end +end + +function (pause::Pause{T})(view::ViewState{T}, t) where T + checkt(t, pause) + action = pause.action + if action !== nothing + tf = t / duration(move) + act(action, tf) + end + return view +end + +target(oldtarget::ViewState{T}, ::Pause{T}) where T = oldtarget + +""" + Pause(duration, [action]) + +Pause at the current position for `duration`. +""" +Pause(duration::T) where T = Pause{T}(duration) + +Base.convert(::Type{Pause{T}}, p::Pause) where T = Pause{T}(p.duration, p.action) +Base.convert(::Type{PathChange{T}}, p::Pause) where T = convert(Pause{T}, p) diff --git a/src/pathchanges/slerpmove.jl b/src/pathchanges/slerpmove.jl new file mode 100644 index 0000000..3490a42 --- /dev/null +++ b/src/pathchanges/slerpmove.jl @@ -0,0 +1,102 @@ +""" + slerp(p1, p2, t01) + +Spherical linear interpolation between two n-vectors. + +t01 must be between 0 and 1 - 0 means the result equals p1, 1 means the result equals p2. +""" +function slerp(p1::AbstractVector, p2::AbstractVector, t01) + np1, np2 = normalize(p1), normalize(p2) + if _disqualified_for_slerp(np1, np2) + return p1 + end + p1n, p2n = norm(p1), norm(p2) + Ω = acos(clamp(dot(np1, np2), -1, 1)) + sinΩ = sin(Ω) + # Interpolate magnitudes and directions separately, + # then combine the results. + return slerp(p1n, p2n, t01) * (sin((1-t01)*Ω)/sinΩ * np1 + sin(t01*Ω)/sinΩ * np2) +end + +# Slerp on scalars is just linear interpolation, +# since there can't be a slerp on a scalar. +function slerp(p1::Number, p2::Number, t01) + if _disqualified_for_slerp(p1, p2) + return p1 + end + return (1-t01)*p1 + t01*p2 +end + +function _disqualified_for_slerp(p1, p2) + return iszero(p1) || iszero(p2) || any(isnan, p1) || any(isnan, p2) || + p1 == p2 +end + +slerp(p1::ViewState, p2::ViewState, t01) = ViewState( + slerp(p1.eyeposition, p2.eyeposition, t01), + slerp(p1.lookat, p2.lookat, t01), + slerp(p1.upvector, p2.upvector, t01), + slerp(p1.fov, p2.fov, t01) +) + +slerp(p1::ViewState, p2::ViewState, center::ViewState, t01) = ViewState(; + eyeposition =slerp(p1.eyeposition - center.eyeposition, p2.eyeposition - center.eyeposition, t01) + center.eyeposition, + lookat =slerp(p1.lookat - center.lookat, p2.lookat - center.lookat, t01) + center.lookat, + upvector =slerp(p1.upvector - center.upvector, p2.upvector - center.upvector, t01) + center.upvector, + fov =slerp(p1.fov - center.fov, p2.fov - center.fov, t01) + center.fov +) + +#= + +function _showslerp(p1, p2) + f, a, p = scatter(p1, label="p1") + scatter!(a, p2, label="p2") + lines!(a, slerp.((p1,), (p2,), 0:0.01:1); color = 0:0.01:1) + f +end + +=# + + +struct SlerpMove{T} <: PathChange{T} + duration::T + target::ViewState{T} + center::ViewState{T} + action +end + +function SlerpMove(duration::T, target::ViewState{T}, action = nothing) where T + center = ViewState{T}(; + eyeposition = zero(SVector{3, T}), + lookat = zero(SVector{3, T}), + fov = 0.0, + upvector = zero(SVector{3, T}) + ) + SlerpMove{T}(duration, target, center, action) +end + +function SlerpMove(duration::T1, target::ViewState{T2}) where {T1, T2} + SlerpMove(T2(duration), target, nothing) +end + +function SlerpMove(duration::T1, target::ViewState{T2}, center::ViewState{T2}, action = nothing) where {T1, T2} + SlerpMove(T2(duration), target, filldefaults(center, ViewState{T2}(; + eyeposition = zero(SVector{3, T2}), + lookat = zero(SVector{3, T2}), + fov = 0.0, + upvector = zero(SVector{3, T2}) +)), action) +end + +function (change::SlerpMove)(view, t) + finalstate = filldefaults(change.target, view) + # @show view finalstate + return slerp(view, finalstate, change.center, t/change.duration) +end + +function duration(change::SlerpMove) + return change.duration +end + + +