| … | … | @@ -0,0 +1,117 @@ |
| 1 | """ |
| 2 | Tests for the viewport camera basis. |
| 3 | |
| 4 | These exist because a mirror in the view basis is invisible in a still image of a |
| 5 | symmetric fractal and invisible at the centre of the screen, so it survived every |
| 6 | check until someone orbited and said it "doesn't rotate properly". The previous |
| 7 | camera test asserted the mirror as CORRECT. |
| 8 | |
| 9 | Ground truth here is Blender's own projection, not my reasoning about handedness: |
| 10 | the viewport overlay has to land where Blender would put the same world point, or |
| 11 | it does not track the scene. |
| 12 | |
| 13 | These need mathutils, so they run inside Blender: |
| 14 | blender -b --python-expr "import unittest,sys; sys.path.insert(0,'blender'); \\ |
| 15 | unittest.main(module='test_viewport_camera', argv=['x'])" |
| 16 | """ |
| 17 | |
| 18 | import math |
| 19 | import unittest |
| 20 | |
| 21 | try: |
| 22 | from mathutils import Matrix, Vector |
| 23 | except ImportError: # pragma: no cover - only meaningful inside Blender |
| 24 | Matrix = Vector = None |
| 25 | |
| 26 | import ifs_render |
| 27 | |
| 28 | W = H = 512 |
| 29 | LENS = 50.0 |
| 30 | |
| 31 | |
| 32 | def _orbit_view(degrees, radius=3.0): |
| 33 | """A camera orbiting the origin at `degrees`, world +Z up, looking inward.""" |
| 34 | a = math.radians(degrees) |
| 35 | eye = Vector((radius * math.sin(a), -radius * math.cos(a), 0.0)) |
| 36 | forward = (Vector((0, 0, 0)) - eye).normalized() |
| 37 | right = forward.cross(Vector((0, 0, 1))).normalized() |
| 38 | up = right.cross(forward) |
| 39 | world = Matrix(((right.x, up.x, -forward.x, eye.x), |
| 40 | (right.y, up.y, -forward.y, eye.y), |
| 41 | (right.z, up.z, -forward.z, eye.z), |
| 42 | (0, 0, 0, 1))) |
| 43 | return world.inverted() |
| 44 | |
| 45 | |
| 46 | def _blender_screen(view, point): |
| 47 | fov = 2.0 * math.atan(18.0 / LENS) |
| 48 | f = 1.0 / math.tan(fov / 2) |
| 49 | n, fr = 0.1, 100.0 |
| 50 | proj = Matrix(((f, 0, 0, 0), (0, f, 0, 0), |
| 51 | (0, 0, (fr + n) / (n - fr), 2 * fr * n / (n - fr)), |
| 52 | (0, 0, -1, 0))) |
| 53 | v = proj @ view @ Vector((point[0], point[1], point[2], 1.0)) |
| 54 | if v.w <= 0: |
| 55 | return None |
| 56 | return ((v.x / v.w + 1) * 0.5 * W, (v.y / v.w + 1) * 0.5 * H) |
| 57 | |
| 58 | |
| 59 | def _our_screen(view, point): |
| 60 | """Exactly what ifs_viewport._view_projection does, including the negated up.""" |
| 61 | world = view.inverted() |
| 62 | eye = world.translation |
| 63 | forward = -Vector(world.col[2][:3]) |
| 64 | up = Vector(world.col[1][:3]) |
| 65 | fov = math.degrees(2.0 * math.atan(18.0 / LENS)) |
| 66 | vp = ifs_render.view_projection( |
| 67 | (eye.x, eye.y, eye.z), (forward.x, forward.y, forward.z), |
| 68 | (up.x, up.y, up.z), fov_degrees=fov, engine_fov_quirk=False, flip_x=True) |
| 69 | return ifs_render.project_point(vp, point, W, H) |
| 70 | |
| 71 | |
| 72 | @unittest.skipIf(Matrix is None, "needs mathutils (run inside Blender)") |
| 73 | class ViewportTracksBlender(unittest.TestCase): |
| 74 | # project_point subtracts half a pixel, as the kernel does. |
| 75 | TOLERANCE = 1.0 |
| 76 | |
| 77 | def test_matches_blenders_projection_around_a_full_orbit(self): |
| 78 | probe = (1.0, 0.0, 0.0) # asymmetric, or a mirror is invisible |
| 79 | for degrees in range(0, 360, 15): |
| 80 | view = _orbit_view(degrees) |
| 81 | expected = _blender_screen(view, probe) |
| 82 | actual = _our_screen(view, probe) |
| 83 | with self.subTest(orbit=degrees): |
| 84 | self.assertIsNotNone(actual, "point was discarded") |
| 85 | self.assertIsNotNone(expected) |
| 86 | self.assertLess(abs(expected[0] - actual[0]), self.TOLERANCE) |
| 87 | self.assertLess(abs(expected[1] - actual[1]), self.TOLERANCE) |
| 88 | |
| 89 | def test_a_mirrored_basis_would_fail_this(self): |
| 90 | # Guards the guard: with the up negation removed the test must break, or |
| 91 | # it is not actually testing for the mirror. |
| 92 | view = _orbit_view(0) |
| 93 | world = view.inverted() |
| 94 | eye = world.translation |
| 95 | forward = -Vector(world.col[2][:3]) |
| 96 | up = Vector(world.col[1][:3]) |
| 97 | fov = math.degrees(2.0 * math.atan(18.0 / LENS)) |
| 98 | vp = ifs_render.view_projection( # flip_x omitted - the old bug |
| 99 | (eye.x, eye.y, eye.z), (forward.x, forward.y, forward.z), |
| 100 | (up.x, up.y, up.z), fov_degrees=fov, engine_fov_quirk=False) |
| 101 | mirrored = ifs_render.project_point(vp, (1.0, 0.0, 0.0), W, H) |
| 102 | correct = _our_screen(view, (1.0, 0.0, 0.0)) |
| 103 | |
| 104 | self.assertGreater(abs(mirrored[0] - correct[0]), 100.0, |
| 105 | "the two bases are indistinguishable, so this proves nothing") |
| 106 | |
| 107 | def test_vertical_axis_is_not_flipped(self): |
| 108 | # The orbit probe sits at z=0, so its screen y never moves and a vertical |
| 109 | # flip hides from that test completely. This is the one that catches it. |
| 110 | view = _orbit_view(0) |
| 111 | high = _our_screen(view, (0.0, 0.0, 1.0)) |
| 112 | low = _our_screen(view, (0.0, 0.0, -1.0)) |
| 113 | self.assertGreater(high[1], low[1], "world +Z must be up on screen") |
| 114 | |
| 115 | |
| 116 | if __name__ == "__main__": |
| 117 | unittest.main() |