diff --git a/src/scenex/util.py b/src/scenex/util.py index 3ca7cfcb..8fc8ddfa 100644 --- a/src/scenex/util.py +++ b/src/scenex/util.py @@ -182,7 +182,7 @@ def show( reg.get_adaptor(canvas, create=True) app().create_app() for view in canvas.views: - projections.zoom_to_fit(view, zoom_factor=0.9, preserve_aspect_ratio=True) + projections.zoom_to_fit(view, zoom_factor=0.9, letterbox=True) # logger.debug("SHOW MODEL %s", tree_repr(view.scene)) # native_scene = view.scene._get_native() diff --git a/src/scenex/utils/projections.py b/src/scenex/utils/projections.py index 9cd19df1..416515c0 100644 --- a/src/scenex/utils/projections.py +++ b/src/scenex/utils/projections.py @@ -102,7 +102,7 @@ def zoom_to_fit( view: View, type: Literal["perspective", "orthographic"] = "orthographic", zoom_factor: float = 1.0, - preserve_aspect_ratio: bool = False, + letterbox: bool = False, ) -> None: """Adjusts the Camera to fit the entire scene. @@ -119,12 +119,10 @@ def zoom_to_fit( approaches 0, the scene will linearly decrease in size. As the zoom factor increases beyond 1.0, the bounds of the scene will expand linearly beyond the view. - preserve_aspect_ratio: bool - Whether to apply aspect ratio correction to prevent distortion. When True, + letterbox: bool + Whether to letterbox/pillarbox to prevent anisotropic distortion. When True, squares will appear as squares regardless of view dimensions. When False, content may be stretched to fill the view. Default False. - - FIXME: Is this the correct name for this behavior? """ bb = view.scene.bounding_box center = np.mean(bb, axis=0) if bb else (0, 0, 0) @@ -133,32 +131,60 @@ def zoom_to_fit( # projection matrix calculations w, h, d = np.maximum(np.ptp(bb, axis=0) if bb else (1, 1, 1), 1e-6) - # Apply aspect ratio correction only if requested - if preserve_aspect_ratio: - if not (canvas := view._canvas): - raise Exception("Cannot preserve aspect ratio without a canvas.") - _, _, pw, ph = canvas.rect_for(view) - aspect_ratio = pw / ph if ph else None - if aspect_ratio is not None and h > 0: - if w / h > aspect_ratio: - h = w / aspect_ratio - else: - w = h * aspect_ratio if type == "orthographic": + if letterbox: + # The scene has aspect w:h. If that doesn't match the viewport's aspect + # ratio ar, setting the orthographic frustum to those values will distort + # world-space squares. We can correct this distortion by expanding whichever + # dimension is too small to make w:h == ar. This is kinda like letterboxing, + # except you'll see extra scene background instead of black bars. + if (ar := _aspect_ratio(view)) is not None: + if w / h > ar: # scene wider than view - expand height + h = w / ar + else: # scene taller than view - expand width + w = h * ar view.camera.transform = Transform().translated(center) view.camera.projection = orthographic(w, h, d).scaled([zoom_factor] * 3) elif type == "perspective": - # Compute the distance a to the near plane of the frustum using a default fov - o = max(w, h) / 2 fov = 70 + # First, we need to figure out how far away to place the camera so that the + # entire scene fits within the frustum defined by the FOV. Calculation borrowed + # from + # https://www.scratchapixel.com/lessons/3d-basic-rendering/perspective-and-orthographic-projection-matrix/building-basic-perspective-projection-matrix.html + if letterbox and (ar := _aspect_ratio(view)) is not None: + # perspective() produces a square frustum (equal x/y FOV). If the viewport + # isn't square, world-space squares will appear distorted. We correct this + # by adjusting two different camera parameters. First, if the scene is wider + # than the viewport, our distance will actually have to increased based on + # that view aspect ratio. + o = max(h, w / ar) / 2 + else: + o = max(w, h) / 2 a = o / tan(fov * pi / 360) / zoom_factor - # So that the bounding cube's front plane is mapped to the canvas, - # the camera must be a units away from the front plane (at z=(center[2] + d/2)) + # Place the camera so the bounding box's front face (z = center[2] + d/2) + # maps to the near plane of the frustum. z_bound = center[2] + (d / 2) + a view.camera.transform = Transform().translated((center[0], center[1], z_bound)) - # Note that the near and far planes are set to reasonable defaults. - # TODO: Consider making these parameters - view.camera.projection = perspective(fov, near=1, far=1_000_000) + proj = perspective(fov, near=1, far=1_000_000) + if letterbox and (ar := _aspect_ratio(view)) is not None: + # Second, if the viewport is non-square, we'll have to adjust our (square) + # projection matrix to reflect the non-square viewport. The result is kinda + # like letterboxing, except you'll see extra scene background instead of + # black bars. + proj = proj.scaled((1.0 / ar, 1.0, 1.0)) + view.camera.projection = proj else: raise TypeError(f"Unrecognized projection type: {type}") + + +def _aspect_ratio(view: View) -> float | None: + if not (canvas := view._canvas): + # If the view isn't attached to a canvas, we can't get viewport dimensions, and + # can't compute an aspect ratio. + return None + _, _, pw, ph = canvas.rect_for(view) + if pw <= 0 or ph <= 0: + # If the view has non-positive dimensions, we can't compute an aspect ratio. + return None + return pw / ph diff --git a/tests/utils/test_projections.py b/tests/utils/test_projections.py index 9d5197a1..cc46276e 100644 --- a/tests/utils/test_projections.py +++ b/tests/utils/test_projections.py @@ -141,7 +141,7 @@ def test_zoom_to_fit_orthographic() -> None: ) -def test_zoom_to_fit_orthographic_preserving_aspect_ratio() -> None: +def test_zoom_to_fit_orthographic_letterbox() -> None: # Put a view on a (square) canvas canvas = snx.Canvas(width=400, height=400) view = snx.View( @@ -161,9 +161,9 @@ def test_zoom_to_fit_orthographic_preserving_aspect_ratio() -> None: # ...and [100, 200, 0] to NDC coordinates [1, 1] assert np.array_equal((1, 1), tform.map((100, 200, 0))[:2]) - # Now with aspect preservation the longer axis (y) should fit exactly, and the + # Now with letterbox=True, the longer axis (y) should fit exactly, and the # shorter axis (x) should have some padding to maintain aspect ratio - zoom_to_fit(view, type="orthographic", preserve_aspect_ratio=True) + zoom_to_fit(view, type="orthographic", letterbox=True) # Assert the camera is moved to the center of the scene assert view.camera.transform == snx.Transform().translated((50, 100, 0.5)) # Projection that maps world space to canvas coordinates @@ -220,6 +220,46 @@ def test_zoom_to_fit_perspective() -> None: assert np.all(_project(tform, (100, 100, 0)) < _project(tform, (100, 100, 1))) +def test_zoom_to_fit_perspective_letterbox() -> None: + view = snx.View( + scene=snx.Scene( + children=[snx.Points(vertices=np.asarray([[0, 100, 1], [100, 0, 0]]))] + ) + ) + # Put the view on a non-square canvas + canvas = snx.Canvas(width=800, height=400, views=[view]) + zoom_to_fit(view, type="perspective", letterbox=True) + + # Assert the camera is moved to the center of the scene + # Depth isn't particularly important to test here. + assert np.array_equal( + view.camera.transform, + Transform().translated((50, 50, view.camera.transform.root[3, 2])), + ) + # Projection that maps world space to canvas coordinates + tform = view.camera.projection @ view.camera.transform.inv().T + # Assert the camera projects [0, 0, 0] to NDC coordinates [-1, -1] + assert np.allclose((-1, -1), _project(tform, (-50, 0, 1))[:2]) + # ...and [100, 100, 0] to NDC coordinates [1, 1] + assert np.allclose((1, 1), _project(tform, (150, 100, 1))[:2]) + + # Now, do the same thing but with height > width + canvas.size = (400, 800) + zoom_to_fit(view, type="perspective", letterbox=True) + # Assert the camera is moved to the center of the scene + # Depth isn't particularly important to test here. + assert np.array_equal( + view.camera.transform, + Transform().translated((50, 50, view.camera.transform.root[3, 2])), + ) + # Projection that maps world space to canvas coordinates + tform = view.camera.projection @ view.camera.transform.inv().T + # Assert the camera projects [0, 0, 0] to NDC coordinates [-1, -1] + assert np.allclose((-1, -1), _project(tform, (0, -50, 1))[:2]) + # ...and [100, 100, 0] to NDC coordinates [1, 1] + assert np.allclose((1, 1), _project(tform, (100, 150, 1))[:2]) + + def _project(mat: snx.Transform, world_pos: tuple[float, float, float]) -> np.ndarray: # Inverting the behavior of vec_unproject proj = np.dot(mat.root, np.asarray((*world_pos, 1)))