import os import math import pytest import numpy as np import naramotion from naramotion import Image, Context, Format, Path, VideoWriter from naramotion.motion import PerspectiveCamera, render_globe, Timeline, Easing def test_perspective_project_point(): cam = PerspectiveCamera(width=1920, height=1080, fov=60.0, pitch=0.0, distance=1000.0) # Point at origin (0, 0) should project directly to viewport center (960, 540) sx, sy, ds, ok = cam.project_point(0.0, 0.0) assert ok is True assert math.isclose(sx, 960.0, abs_tol=1e-2) assert math.isclose(sy, 540.0, abs_tol=1e-2) assert math.isclose(ds, 1.0, abs_tol=1e-2) def test_perspective_pitch_and_horizon_clipping(): # Camera tilted 60 degrees forward cam = PerspectiveCamera(width=1920, height=1080, fov=60.0, pitch=60.0, distance=500.0) # Point directly in front sx1, sy1, ds1, ok1 = cam.project_point(0.0, 200.0) assert ok1 is True assert sy1 < 540.0 # Appears higher towards horizon # Point far behind camera should be clipped by near-plane sx2, sy2, ds2, ok2 = cam.project_point(0.0, -5000.0) assert ok2 is False or ds2 <= 0.0 def test_perspective_transform_points_numpy(): cam = PerspectiveCamera(width=1920, height=1080, fov=60.0, pitch=45.0, distance=800.0) # (1000, 2) grid points grid_x, grid_y = np.meshgrid(np.linspace(-500, 500, 50), np.linspace(-500, 500, 20)) pts = np.column_stack([grid_x.ravel(), grid_y.ravel()]) proj_pts, depth_scales, valid_mask = cam.transform_points(pts) assert proj_pts.shape == (1000, 2) assert depth_scales.shape == (1000,) assert valid_mask.shape == (1000,) assert np.any(valid_mask) def test_perspective_transform_lines(): cam = PerspectiveCamera(width=1920, height=1080, fov=60.0, pitch=30.0, distance=600.0) lines = np.array([ [-100.0, -100.0, 100.0, -100.0], [100.0, -100.0, 100.0, 100.0], [100.0, 100.0, -100.0, 100.0], [-100.0, 100.0, -100.0, -100.0], ], dtype=np.float64) proj_lines, depth_scales, valid_mask = cam.transform_lines(lines) assert proj_lines.shape == (4, 4) assert len(depth_scales) == 4 assert np.all(valid_mask) def test_perspective_transform_path(): cam = PerspectiveCamera(width=1920, height=1080, fov=60.0, pitch=45.0, distance=500.0) path = Path() path.add_circle(0.0, 0.0, 200.0) proj_path, avg_ds = cam.transform_path(path) assert not proj_path.is_empty() assert proj_path.size >= 10 assert avg_ds > 0.0 def test_perspective_render_globe(tmp_path): # 1. Create dummy world texture image (e.g. 512x256) tex = Image(512, 256, Format.PRGB32) with Context(tex) as ctx: ctx.fill_all("#1E3A8A") # Ocean blue ctx.fill_circle(150, 100, 60, style="#10B981") # Land green ctx.fill_circle(350, 120, 70, style="#10B981") # 2. Render 3D Globe onto canvas canvas = Image(1000, 1000, Format.PRGB32) with Context(canvas) as ctx: ctx.fill_all("#030712") # Space black ok = render_globe( dst_image=canvas, texture_image=tex, cx=500.0, cy=500.0, radius=350.0, rot_lon=45.0, rot_lat=15.0, glow_color="#38BDF8FF" ) assert ok is True out_file = str(tmp_path / "globe_test.png") canvas.save(out_file) assert os.path.exists(out_file) assert os.path.getsize(out_file) > 1000 def test_perspective_drone_flythrough_timeline(tmp_path): video_out = str(tmp_path / "drone_perspective_flythrough.mp4") cam = PerspectiveCamera(width=640, height=360, fov=60.0) timeline = Timeline(duration=0.5, fps=30) # Animate pitch from 0 deg (flat) to 70 deg (drone perspective) timeline.animate(cam, "pitch", 0.0, 70.0, duration=0.5, easing=Easing.cubic_in_out) timeline.animate(cam, "target_y", 0.0, 300.0, duration=0.5) # Grid of road lines grid_lines = [] for i in range(-5, 6): grid_lines.append([-500.0, i * 100.0, 500.0, i * 100.0]) grid_lines.append([i * 100.0, -500.0, i * 100.0, 500.0]) lines_arr = np.array(grid_lines, dtype=np.float64) def draw_frame(ctx: Context, t: float, frame_idx: int): ctx.fill_all("#090D16") proj_lines, depth_scales, mask = cam.transform_lines(lines_arr) for line, ds, valid in zip(proj_lines, depth_scales, mask): if not valid: continue ctx.stroke_width = max(0.5, 2.0 * ds) ctx.stroke_line(line[0], line[1], line[2], line[3], style="#38BDF8") cam.render_fog(ctx, fog_color="#090D16", horizon_y_ratio=0.30) timeline.render_video(video_out, width=640, height=360, fps=30, draw_fn=draw_frame) assert os.path.exists(video_out) assert os.path.getsize(video_out) > 1000