Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/scenex/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
72 changes: 49 additions & 23 deletions src/scenex/utils/projections.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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)
Expand All @@ -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
46 changes: 43 additions & 3 deletions tests/utils/test_projections.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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
Expand Down Expand Up @@ -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)))
Expand Down
Loading