diff --git a/examples/turntable_video.py b/examples/turntable_video.py index dd80d07..1b921d3 100644 --- a/examples/turntable_video.py +++ b/examples/turntable_video.py @@ -9,7 +9,7 @@ from f3d_extras import ( download_file_if_url, image_sequence_to_video, - turntable_state_interpolator, + turntable_interpolator, ) @@ -46,16 +46,12 @@ def main(): engine.window.camera.position = initial_camera_position engine.window.camera.reset_to_bounds(zoom_factor=camera_zoom_factor) - camera_state_interpolator = turntable_state_interpolator( - engine.window.camera.state, - engine.options["scene.up_direction"], # type: ignore - turns=turns, - ) + camera_state_interpolator = turntable_interpolator(engine, turns=turns) def render_images(): t = 0.0 while t < duration: - engine.window.camera.state = camera_state_interpolator(float(t) / duration) + camera_state_interpolator(t / duration) yield engine.window.render_to_image() t += 1.0 / fps diff --git a/f3d_extras/__init__.py b/f3d_extras/__init__.py index 5ee10de..20d4a39 100644 --- a/f3d_extras/__init__.py +++ b/f3d_extras/__init__.py @@ -1,5 +1,5 @@ from .files import download_file, download_file_if_url -from .turntable import turntable_state_interpolator +from .turntable import turntable_interpolator, turntable_state_interpolator from .video import ( ffmpeg_output_args_mp4, ffmpeg_output_args_webm, diff --git a/f3d_extras/turntable.py b/f3d_extras/turntable.py index b08f6a5..637b6c3 100644 --- a/f3d_extras/turntable.py +++ b/f3d_extras/turntable.py @@ -6,6 +6,25 @@ from numpy.typing import NDArray +def turntable_interpolator( + engine: f3d.Engine, *, turns: float = 1 +) -> Callable[[float], None]: + """Return a `t: float -> None` function that interpolates from `0 <= t <= 1` + to spin the camera `turns` times around the engine's current focal point + about its current `up_direction`.""" + + state_interpolator = turntable_state_interpolator( + initial_state=engine.window.camera.state, + axis=engine.options["scene.up_direction"], # type: ignore + turns=turns, + ) + + def f(t: float): + engine.window.camera.state = state_interpolator(t) + + return f + + def turntable_state_interpolator( initial_state: f3d.CameraState, axis: tuple[float, float, float], diff --git a/tests/test_turntable.py b/tests/test_turntable.py index 44ce48c..1b469dd 100644 --- a/tests/test_turntable.py +++ b/tests/test_turntable.py @@ -3,21 +3,23 @@ import numpy as np import pytest -from f3d import CameraState +from f3d import CameraState, Engine from numpy import linspace from numpy.typing import NDArray from pytest import approx # type: ignore -from f3d_extras.turntable import turntable_state_interpolator +from f3d_extras.turntable import turntable_interpolator, turntable_state_interpolator + +TEST_PARAMS = [ + # foc, pos, turns, axis, sample_count, expected_angles + ((0, 0, 0), (1, 0, 0), 1, (0, 0, 1), 10, linspace(0, 360, 10)), + ((1, 2, 3), (4, 3, 2), 2, (0, 1, 0), 12, linspace(0, 720, 12)), + ((1, 2, 3), (10, 11, 12), -3, (3, 4, 5), 15, linspace(1080, 0, 15)), +] @pytest.mark.parametrize( - "foc, pos, turns, axis, sample_count, expected_angles", - [ - ((0, 0, 0), (1, 0, 0), 1, (0, 0, 1), 10, linspace(0, 360, 10)), - ((1, 2, 3), (4, 3, 2), 2, (0, 1, 0), 12, linspace(0, 720, 12)), - ((1, 2, 3), (10, 11, 12), -3, (3, 4, 5), 15, linspace(1080, 0, 15)), - ], + "foc, pos, turns, axis, sample_count, expected_angles", TEST_PARAMS ) def test_turntable_camera_state_func( foc: tuple[float, float, float], @@ -32,29 +34,66 @@ def test_turntable_camera_state_func( initial_state.position = pos f = turntable_state_interpolator(initial_state, axis, turns=turns) - def proj(xyz: tuple[float, float, float]): - p = np.array(xyz, dtype=np.float64) - a = np.array(axis, dtype=np.float64) - a /= np.linalg.norm(a) - return p - a * np.dot(p, a) - - def check_angle(state: CameraState): - p0 = proj(initial_state.focal_point) - p1 = proj(initial_state.position) - p2 = proj(state.position) - return angle_between_vectors(p1 - p0, p2 - p0, ref=np.array(axis)) - states = list(map(f, linspace(0, 1, sample_count))) assert all( approx(state.focal_point) == initial_state.focal_point for state in states ) assert all( - abs(check_angle(state) % 360 - angle % 360) % 360 < 0.01 + abs(turntable_angle(initial_state, state, axis) % 360 - angle % 360) % 360 + < 0.01 + for state, angle in zip(states, expected_angles) + ) + + +@pytest.mark.parametrize( + "foc, pos, turns, axis, sample_count, expected_angles", TEST_PARAMS +) +def test_turntable_interpolator( + foc: tuple[float, float, float], + pos: tuple[float, float, float], + turns: float, + axis: tuple[float, float, float], + sample_count: int, + expected_angles: Sequence[float], +): + engine = Engine.create(offscreen=True) + engine.window.camera.focal_point = foc + engine.window.camera.position = pos + engine.options["scene.up_direction"] = list(axis) + initial_state = engine.window.camera.state + f = turntable_interpolator(engine, turns=turns) + + def collect_states(): + for t in linspace(0, 1, sample_count): + f(t) + yield engine.window.camera.state + + states = list(collect_states()) + + assert all(approx(state.focal_point) == foc for state in states) + assert all( + abs(turntable_angle(initial_state, state, axis) % 360 - angle % 360) % 360 + < 0.01 for state, angle in zip(states, expected_angles) ) +def turntable_angle( + state1: CameraState, state2: CameraState, axis: tuple[float, float, float] +): + def proj(xyz: tuple[float, float, float]): + p = np.array(xyz, dtype=np.float64) + a = np.array(axis, dtype=np.float64) + a /= np.linalg.norm(a) + return p - a * np.dot(p, a) + + p0 = proj(state1.focal_point) + p1 = proj(state1.position) + p2 = proj(state2.position) + return angle_between_vectors(p1 - p0, p2 - p0, ref=np.array(axis)) + + def angle_between_vectors( v1: NDArray[np.floating], v2: NDArray[np.floating], ref: NDArray[np.floating] ):