Skip to content

Commit 3c4c8e7

Browse files
committed
Initial core implementation
This lacks integration with Makie but is otherwise reasonably functional.
1 parent 552e0af commit 3c4c8e7

File tree

8 files changed

+464
-5
lines changed

8 files changed

+464
-5
lines changed

.github/workflows/CI.yml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,7 @@ jobs:
2424
matrix:
2525
version:
2626
- '1.10'
27-
- '1.6'
28-
- 'nightly'
27+
- '1'
2928
os:
3029
- ubuntu-latest
3130
arch:

Project.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,12 @@ uuid = "c11bb9a7-2755-425a-88f3-ebe93bbdb91f"
33
authors = ["Tim Holy <[email protected]> and contributors"]
44
version = "1.0.0-DEV"
55

6+
[deps]
7+
StaticArrays = "90137ffa-7385-5640-81b9-e52037218182"
8+
69
[compat]
7-
julia = "1.6.7"
10+
StaticArrays = "1"
11+
julia = "1.10"
812

913
[extras]
1014
Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"

README.md

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,157 @@
44
[![Dev](https://img.shields.io/badge/docs-dev-blue.svg)](https://HolyLab.github.io/FlyThroughPaths.jl/dev/)
55
[![Build Status](https://github.com/HolyLab/FlyThroughPaths.jl/actions/workflows/CI.yml/badge.svg?branch=main)](https://github.com/HolyLab/FlyThroughPaths.jl/actions/workflows/CI.yml?query=branch%3Amain)
66
[![Coverage](https://codecov.io/gh/HolyLab/FlyThroughPaths.jl/branch/main/graph/badge.svg)](https://codecov.io/gh/HolyLab/FlyThroughPaths.jl)
7+
8+
9+
All of the examples below assume you've loaded the package with `using FlyThroughPaths`.
10+
11+
## Generic tools
12+
13+
### Representation of paths and view state
14+
15+
Paths are parametrized by time `t`, represented in units of seconds. All paths implicitly start at `t=0`.
16+
17+
The representation of view state is independent of any particular plotting package, although our parametrization is inspired by [Makie's 3D camera](https://docs.makie.org/stable/explanations/cameras/#3d_camera):
18+
19+
- `eyeposition`: the 3d coordinates of the camera
20+
- `lookat`: the 3d coordinates of the point of the camera's "focus" (center of gaze)
21+
- `upvector`: the 3d direction that will correspond to the top of the view. Any component of this vector in the direction of `lookat - eyeposition` is ignored/discarded.
22+
- `fov`: the angle (in degrees) of the cone centered on `lookat - eyeposition` that should be captured.
23+
24+
Set these as follows:
25+
26+
```julia
27+
julia> state = ViewState(eyeposition=[-10, 0, 0], lookat=[0, 0, 0], upvector=[0, 0, 1], fov=45)
28+
ViewState{Float32}(eyeposition=[-10.0, 0.0, 0.0], lookat=[0.0, 0.0, 0.0], upvector=[0.0, 0.0, 1.0], fov=45.0)
29+
```
30+
31+
You can set just a subset of these:
32+
```julia
33+
julia> newstate = ViewState(eyeposition=[-5, 0, 0])
34+
ViewState{Float32}(eyeposition=[-5.0, 0.0, 0.0])
35+
```
36+
37+
This syntax is often used for updating a previous view; for the unspecified settings, the previous value is left intact.
38+
39+
40+
### Initializing a path
41+
42+
```julia
43+
julia> path = Path(state)
44+
Path{Float32}(ViewState{Float32}(eyeposition=[-10.0, 0.0, 0.0], lookat=[0.0, 0.0, 0.0], upvector=[0.0, 0.0, 1.0], fov=45.0), FlyThroughPaths.PathChange{Float32}[])
45+
```
46+
47+
The path starts at `state` at time `t=0`.
48+
49+
### Evaluating at a particular time
50+
51+
Once you have a path, you can get the current `ViewState` with `path(t)`:
52+
53+
```julia
54+
julia> path(0)
55+
ViewState{Float32}(eyeposition=[-10.0, 0.0, 0.0], lookat=[0.0, 0.0, 0.0], upvector=[0.0, 0.0, 1.0], fov=45.0)
56+
57+
julia> path(10)
58+
ViewState{Float32}(eyeposition=[-10.0, 0.0, 0.0], lookat=[0.0, 0.0, 0.0], upvector=[0.0, 0.0, 1.0], fov=45.0)
59+
```
60+
61+
So far, nothing much is happening. Things get more interesting when we add movements.
62+
63+
### Holding steady
64+
65+
The simplest thing you can do is insert a pause:
66+
67+
```julia
68+
julia> path2 = path * Pause(5)
69+
Path{Float32}(ViewState{Float32}(eyeposition=[-10.0, 0.0, 0.0], lookat=[0.0, 0.0, 0.0], upvector=[0.0, 0.0, 1.0], fov=45.0), FlyThroughPaths.PathChange{Float32}[Pause{Float32}(5.0f0, nothing)])
70+
```
71+
72+
The view will hold steady for 5 seconds. Typically you add `Pause` when you also plan to add other movements later.
73+
74+
### Moving the camera, option 1: constrained movements
75+
76+
This option is typically used for things like rotations around a center point.
77+
78+
```julia
79+
julia> path2 = path * ConstrainedMove(5, newstate; constraint=:none, speed=:constant)
80+
Path{Float32}(ViewState{Float32}(eyeposition=[-10.0, 0.0, 0.0], lookat=[0.0, 0.0, 0.0], upvector=[0.0, 0.0, 1.0], fov=45.0), FlyThroughPaths.PathChange{Float32}[ConstrainedMove{Float32}(5.0f0, ViewState{Float32}(eyeposition=[-5.0, 0.0, 0.0]), :none, :constant, nothing)])
81+
```
82+
83+
This indicates that over a 5-second period, the camera state gradually adopts any values specified in `newstate`.
84+
85+
```julia
86+
julia> path2(0)
87+
ViewState{Float32}(eyeposition=[-10.0, 0.0, 0.0], lookat=[0.0, 0.0, 0.0], upvector=[0.0, 0.0, 1.0], fov=45.0)
88+
89+
julia> path2(5)
90+
ViewState{Float32}(eyeposition=[-5.0, 0.0, 0.0], lookat=[0.0, 0.0, 0.0], upvector=[0.0, 0.0, 1.0], fov=45.0)
91+
92+
julia> path2(2.5)
93+
ViewState{Float32}(eyeposition=[-7.5, 0.0, 0.0], lookat=[0.0, 0.0, 0.0], upvector=[0.0, 0.0, 1.0], fov=45.0)
94+
```
95+
96+
Keyword options include:
97+
98+
- `constraint` (`:none` or `:rotation`): specify a value to keep constant during motion. `:rotation` performs a rotation around `lookat`. Note that if the separation between `eyeposition` and `lookat` is not constant, then the trajectory will be elliptical rather than circular.
99+
- `speed` controls how the change is made across time:
100+
- `:constant`: speed is instantaneously set to a new constant value that will arrive at the endpoint at the specified time
101+
- `:sinusoidal`: speed will initially increase (starting at a speed of 0), achieve a maximum at the midpoint, and then decrease back to 0.
102+
103+
104+
### Moving the camera, option 2: Bezier movements
105+
106+
With this option, you can approximately simulate the feeling of flight, preserving momentum:
107+
108+
```
109+
path2 = path * BezierMove(Δt::Real, P1::ViewState, P2::ViewState...)
110+
```
111+
112+
where a `bezier` path is specified as indicated in this diagram:
113+
114+
![bezier diagram](https://upload.wikimedia.org/wikipedia/commons/thumb/d/d0/Bezier_curve.svg/640px-Bezier_curve.svg.png)
115+
116+
The starting state, `P0` in the diagram, is taken from the endpoint of `path`. Over the next `Δt` seconds, one then moves towards the last `ViewState` argument of `bezier`, orienting successively towards any prior arguments. Probably the most robust option is to use `bezier(Δt, P1, P2, P3)`, which can be interpreted as "depart `P0` traveling towards `P1`, and arrive at `P3` as if you had come from `P2`." The view does not actually pass through `P1` and `P2`, but these set the initial and final tangents of the curve.
117+
118+
To see this in action, let's create a move that "rotates" around the origin but moves outward (to a more distant orbit) on its way there:
119+
120+
```julia
121+
julia> move = BezierMove(5, ViewState(eyeposition=[0, 10, 0]), [ViewState(eyeposition=[-20, 20, 0])])
122+
BezierMove{Float32}(5.0f0, ViewState{Float32}(eyeposition=[0.0, 10.0, 0.0]), ViewState{Float32}[ViewState{Float32}(eyeposition=[-20.0, 20.0, 0.0])], nothing)
123+
124+
julia> path2 = path * move;
125+
126+
julia> path2(2.5)
127+
ViewState{Float32}(eyeposition=[-12.5, 12.5, 0.0], lookat=[0.0, 0.0, 0.0], upvector=[0.0, 0.0, 1.0], fov=45.0)
128+
```
129+
130+
## Backend-specific tools
131+
132+
These require interaction with a plotting package supported by one of the extensions. Currently supported:
133+
134+
- [Makie](https://docs.makie.org/stable/)
135+
136+
You need to load the visualization package, e.g., `using GLMakie`, in your session before any of the commands below will work.
137+
138+
### Capturing the current view state
139+
140+
This can be handy for constructing a path, for example you can interactively set the approximate position and view parameters and then query them for use by the tools above.
141+
142+
```
143+
state = capture_view(scene)
144+
```
145+
146+
`state` is a `ViewState` object.
147+
148+
### Setting the current view state
149+
150+
```
151+
oldstate = set_view!(camera, path, t)
152+
```
153+
154+
This updates the current `camera` settings from `path` at time `t`.
155+
156+
### Displaying the path
157+
158+
```
159+
plot(path)
160+
```

src/FlyThroughPaths.jl

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,26 @@
11
module FlyThroughPaths
22

3-
# Write your package code here.
3+
using StaticArrays
4+
5+
export ViewState, Path, Pause, ConstrainedMove, BezierMove
6+
# exports for the extensions
7+
export capture_view, set_view!
8+
9+
function capture_view end
10+
function set_view! end
11+
12+
include("viewstate.jl")
13+
include("pathchange.jl")
14+
include("path.jl")
15+
16+
function __init__()
17+
if isdefined(Base.Experimental, :register_error_hint)
18+
Base.Experimental.register_error_hint(MethodError) do io, exc, argtypes, kwargs
19+
if exc.f === capture_view || exc.f === set_view!
20+
print(io, '\n', exc.f, " requires that you first load a plotting backend (e.g., `using GLMakie`)")
21+
end
22+
end
23+
end
24+
end
425

526
end

src/path.jl

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
struct Path{T}
2+
initialview::ViewState{T}
3+
changes::Vector{PathChange{T}}
4+
end
5+
Path{T}(initialview::ViewState) where T = Path{T}(initialview, PathChange{T}[])
6+
Path(initialview::ViewState{T}) where T = Path{T}(initialview)
7+
8+
function Base.:*(path::Path{R}, change::PathChange{S}) where {R,S}
9+
T = promote_type(R, S)
10+
Path{T}(path.initialview, PathChange{T}[path.changes..., change])
11+
end
12+
13+
function (path::Path{T})(t) where T
14+
view = path.initialview
15+
tend = zero(T)
16+
t < tend && return view
17+
for change in path.changes
18+
tnext = tend + duration(change)
19+
if t <= tnext
20+
return change(view, t - tend)
21+
end
22+
tend, view = tnext, filldefaults(target(view, change), view)
23+
end
24+
return view
25+
end

src/pathchange.jl

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
abstract type PathChange{T<:Real} end
2+
3+
struct Pause{T} <: PathChange{T}
4+
duration::T
5+
action
6+
7+
function Pause{T}(t, action=nothing) where T
8+
t >= zero(T) || throw(ArgumentError("t must be non-negative"))
9+
new{T}(t, action)
10+
end
11+
end
12+
Pause(duration::T) where T = Pause{T}(duration)
13+
14+
Base.convert(::Type{Pause{T}}, p::Pause) where T = Pause{T}(p.duration, p.action)
15+
Base.convert(::Type{PathChange{T}}, p::Pause) where T = convert(Pause{T}, p)
16+
17+
struct ConstrainedMove{T} <: PathChange{T}
18+
duration::T
19+
target::ViewState{T}
20+
constraint::Symbol
21+
speed::Symbol
22+
action
23+
24+
function ConstrainedMove{T}(t, target, constraint, speed, action=nothing) where T
25+
t >= zero(T) || throw(ArgumentError("t must be non-negative"))
26+
constraint in (:none,:rotation) || throw(ArgumentError("Unknown constraint: $constraint"))
27+
speed in (:constant,:sinusoidal) || throw(ArgumentError("Unknown speed: $speed"))
28+
new{T}(t, target, constraint, speed, action)
29+
end
30+
end
31+
ConstrainedMove{T}(duration, target; constraint=:none, speed=:constant, action=nothing) where T =
32+
ConstrainedMove{T}(duration, target, constraint, speed, action)
33+
ConstrainedMove(duration, target::ViewState{T}, args...) where T = ConstrainedMove{T}(duration, target, args...)
34+
ConstrainedMove(duration, target::ViewState{T}; kwargs...) where T = ConstrainedMove{T}(duration, target; kwargs...)
35+
36+
Base.convert(::Type{ConstrainedMove{T}}, m::ConstrainedMove) where T = ConstrainedMove{T}(m.duration, m.target, m.constraint, m.speed, m.action)
37+
Base.convert(::Type{PathChange{T}}, m::ConstrainedMove) where T = convert(ConstrainedMove{T}, m)
38+
39+
struct BezierMove{T} <: PathChange{T}
40+
duration::T
41+
target::ViewState{T}
42+
controls::Vector{ViewState{T}}
43+
action
44+
45+
function BezierMove{T}(t, target, controls, action=nothing) where T
46+
t >= zero(T) || throw(ArgumentError("t must be non-negative"))
47+
new{T}(t, target, controls, action)
48+
end
49+
end
50+
BezierMove(duration, target::ViewState{R}, controls::Vector{ViewState{S}}, args...) where {R,S} = BezierMove{promote_type(R,S)}(duration, target, controls, args...)
51+
52+
Base.convert(::Type{BezierMove{T}}, m::BezierMove) where T = BezierMove{T}(m.duration, m.target, m.controls, m.action)
53+
Base.convert(::Type{PathChange{T}}, m::BezierMove) where T = convert(BezierMove{T}, m)
54+
55+
# Common API
56+
57+
duration(c::PathChange{T}) where T = c.duration::T
58+
59+
target(oldtarget::ViewState{T}, c::PathChange{T}) where T = c.target::ViewState{T}
60+
target(oldtarget::ViewState{T}, ::Pause{T}) where T = oldtarget
61+
62+
Base.@nospecializeinfer function act(@nospecialize(action), t::Real)
63+
action === nothing && return nothing
64+
action(t)
65+
return nothing
66+
end
67+
68+
# Compute the view from a PathChange at (relative) time t
69+
70+
function (pause::Pause{T})(view::ViewState{T}, t) where T
71+
checkt(t, pause)
72+
action = pause.action
73+
if action !== nothing
74+
tf = t / duration(move)
75+
act(action, tf)
76+
end
77+
return view
78+
end
79+
80+
function (move::ConstrainedMove{T})(view::ViewState{T}, t) where T
81+
checkt(t, move)
82+
(; target, constraint, speed, action) = move
83+
tf = t / duration(move)
84+
f = speed === :constant ? tf : (1 - cospi(tf))/2
85+
(; eyeposition, lookat, upvector, fov) = view
86+
eyeposition_new = something(target.eyeposition, eyeposition)
87+
lookat_new = something(target.lookat, lookat)
88+
upvector_new = something(target.upvector, upvector)
89+
fov_new = something(target.fov, fov)
90+
lookatf = (1 - f) * lookat + f * lookat_new
91+
if constraint === :none
92+
eyeposition = (1 - f) * eyeposition + f * eyeposition_new
93+
elseif constraint === :rotation
94+
vold = eyeposition - lookat
95+
vnew = eyeposition_new - lookat_new
96+
eyeposition = cospi(f/2) * vold + sinpi(f/2) * vnew + lookatf
97+
end
98+
upvector = (1 - f) * upvector + f * upvector_new
99+
fov = (1 - f) * fov + f * fov_new
100+
lookat = lookatf
101+
act(action, f)
102+
return ViewState{T}(eyeposition, lookat, upvector, fov)
103+
end
104+
105+
function (move::BezierMove{T})(view::ViewState{T}, t) where T
106+
filldef(vs) = filldefaults(vs, view)
107+
checkt(t, move)
108+
tf = t / duration(move)
109+
act(move.action, tf)
110+
list = [view, filldef.(move.controls)..., filldef(move.target)]
111+
return evaluate(list, tf)
112+
end
113+
114+
# Recursive evaluation of bezier curves, https://en.wikipedia.org/wiki/B%C3%A9zier_curve#Recursive_definition
115+
function evaluate(list, t)
116+
length(list) == 1 && return list[1]
117+
return (1 - t) * evaluate(list[1:end-1], t) + t * evaluate(list[2:end], t)
118+
end
119+
120+
@noinline checkt(t, change::PathChange{T}) where T = zero(T) <= t <= duration(change) ||
121+
throw(ArgumentError("t=$t is not in [0, $(duration(change))]"))

0 commit comments

Comments
 (0)