From a52526200bbd12bd9f3195b9f12b7dc59c6530b4 Mon Sep 17 00:00:00 2001 From: Anshul Singhvi Date: Sun, 14 Jul 2024 10:39:25 +0530 Subject: [PATCH 1/4] Refactor all current path changes to their own folder --- src/FlyThroughPaths.jl | 10 +- src/pathchange.jl | 156 ----------------------------- src/pathchanges/beziermove.jl | 58 +++++++++++ src/pathchanges/constrainedmove.jl | 94 +++++++++++++++++ src/pathchanges/pause.jl | 39 ++++++++ 5 files changed, 200 insertions(+), 157 deletions(-) create mode 100644 src/pathchanges/beziermove.jl create mode 100644 src/pathchanges/constrainedmove.jl create mode 100644 src/pathchanges/pause.jl diff --git a/src/FlyThroughPaths.jl b/src/FlyThroughPaths.jl index 22d09ef..0eb6c83 100644 --- a/src/FlyThroughPaths.jl +++ b/src/FlyThroughPaths.jl @@ -1,8 +1,9 @@ module FlyThroughPaths using StaticArrays +using Rotations -export ViewState, Path, Pause, ConstrainedMove, BezierMove +export ViewState, Path # exports for the extensions export capture_view, set_view! @@ -11,6 +12,13 @@ include("viewstate.jl") include("pathchange.jl") include("path.jl") +# path changes +include("pathchanges/beziermove.jl") +include("pathchanges/constrainedmove.jl") +include("pathchanges/pause.jl") + +export BezierMove, ConstrainedMove, Pause + 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) From 920d8f44c6d8e43928119e2a57c4b968247fc96a Mon Sep 17 00:00:00 2001 From: Anshul Singhvi Date: Sun, 14 Jul 2024 10:54:29 +0530 Subject: [PATCH 2/4] Add Rotations.jl --- Project.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/Project.toml b/Project.toml index 0b43618..6b6bb50 100644 --- a/Project.toml +++ b/Project.toml @@ -4,6 +4,7 @@ authors = ["Tim Holy and contributors"] version = "0.1.0" [deps] +Rotations = "6038ab10-8711-5258-84ad-4b1120ba62dc" StaticArrays = "90137ffa-7385-5640-81b9-e52037218182" [weakdeps] From 08f5a75c232ad9c562b629c7eeb8745b91fd1bae Mon Sep 17 00:00:00 2001 From: Anshul Singhvi Date: Sat, 3 May 2025 18:39:33 -0400 Subject: [PATCH 3/4] Add a slerp (spherical interpolation) move for orbits etc. This is still pretty hacky, but it works. The idea is that you can slerp every aspect of the view state, and you can also center it about some point so that you can rotate about that point. This is not meant to be necessarily immediately user-usable, but rather a more foundational move for e.g. orbiting around a point or rotating a camera. You can use e.g. Rotations.AngleAxis to set the final point and then this will go there but on the surface of a sphere parameterized by the original and final points, and the center point. --- Project.toml | 4 +- src/FlyThroughPaths.jl | 6 +- src/pathchanges/slerpmove.jl | 103 +++++++++++++++++++++++++++++++++++ 3 files changed, 109 insertions(+), 4 deletions(-) create mode 100644 src/pathchanges/slerpmove.jl diff --git a/Project.toml b/Project.toml index f5dce16..046f4b8 100644 --- a/Project.toml +++ b/Project.toml @@ -4,6 +4,7 @@ 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" @@ -21,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 0eb6c83..10a77a6 100644 --- a/src/FlyThroughPaths.jl +++ b/src/FlyThroughPaths.jl @@ -1,7 +1,8 @@ module FlyThroughPaths using StaticArrays -using Rotations +using Rotations +using LinearAlgebra export ViewState, Path # exports for the extensions @@ -16,8 +17,9 @@ include("path.jl") include("pathchanges/beziermove.jl") include("pathchanges/constrainedmove.jl") include("pathchanges/pause.jl") +include("pathchanges/slerpmove.jl") -export BezierMove, ConstrainedMove, Pause +export BezierMove, ConstrainedMove, Pause, SlerpMove function __init__() if isdefined(Base.Experimental, :register_error_hint) diff --git a/src/pathchanges/slerpmove.jl b/src/pathchanges/slerpmove.jl new file mode 100644 index 0000000..3d4a93a --- /dev/null +++ b/src/pathchanges/slerpmove.jl @@ -0,0 +1,103 @@ +""" + 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) + # @show p1 p2 + 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 + + + From 87309910f75ffda91d9e71e33133ed056a9b764c Mon Sep 17 00:00:00 2001 From: Anshul Singhvi Date: Sat, 3 May 2025 18:56:30 -0400 Subject: [PATCH 4/4] remove show --- src/pathchanges/slerpmove.jl | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pathchanges/slerpmove.jl b/src/pathchanges/slerpmove.jl index 3d4a93a..3490a42 100644 --- a/src/pathchanges/slerpmove.jl +++ b/src/pathchanges/slerpmove.jl @@ -6,7 +6,6 @@ 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) - # @show p1 p2 np1, np2 = normalize(p1), normalize(p2) if _disqualified_for_slerp(np1, np2) return p1