"""Pure math helpers for 3D viewport projection.
Stateless functions for projecting 3D world coordinates to 2D screen
coordinates via a view-projection matrix. Used by Viewport3DPanel but
contain no panel state or rendering calls.
"""
from __future__ import annotations
import math
import numpy as np
from simvx.core import Vec3
# ---------------------------------------------------------------------------
# Projection
# ---------------------------------------------------------------------------
[docs]
def project_point(
vp_matrix: np.ndarray,
world_pos,
vx: float, vy: float, vw: float, vh: float,
) -> tuple[float, float, float] | None:
"""Project a 3D world point to 2D screen coordinates.
Args:
vp_matrix: Combined view-projection matrix (4x4).
world_pos: World-space position (Vec3, tuple, or array-like).
vx, vy, vw, vh: Viewport rectangle in screen pixels.
Returns:
(screen_x, screen_y, ndc_depth) or None if behind camera.
ndc_depth is in [-1, 1]; values outside indicate clipping.
"""
if isinstance(world_pos, Vec3):
wp = np.array([world_pos.x, world_pos.y, world_pos.z, 1.0],
dtype=np.float32)
elif isinstance(world_pos, tuple | list):
wp = np.array([world_pos[0], world_pos[1], world_pos[2], 1.0],
dtype=np.float32)
else:
wp = np.array([float(world_pos[0]), float(world_pos[1]),
float(world_pos[2]), 1.0], dtype=np.float32)
clip = vp_matrix @ wp
if abs(clip[3]) < 1e-7:
return None
ndc = clip[:3] / clip[3]
# Vulkan Y-flip: projection already flips Y, so ndc_y maps correctly
# when we treat positive ndc_y as screen-up.
sx = vx + (ndc[0] * 0.5 + 0.5) * vw
sy = vy + (1.0 - (ndc[1] * 0.5 + 0.5)) * vh
return (sx, sy, float(ndc[2]))
[docs]
def project_direction(
vp_matrix: np.ndarray,
origin: Vec3, direction: Vec3, axis_length: float,
vx: float, vy: float, vw: float, vh: float,
) -> tuple[tuple[float, float], tuple[float, float]] | None:
"""Project an axis segment (origin -> origin + direction * axis_length).
Args:
vp_matrix: Combined view-projection matrix (4x4).
origin: Segment start in world space.
direction: Unit direction vector.
axis_length: Length of the segment in world units.
vx, vy, vw, vh: Viewport rectangle in screen pixels.
Returns:
((sx0, sy0), (sx1, sy1)) or None if either endpoint fails.
"""
p0 = project_point(vp_matrix, origin, vx, vy, vw, vh)
end = origin + direction * axis_length
p1 = project_point(vp_matrix, end, vx, vy, vw, vh)
if p0 is None or p1 is None:
return None
return ((p0[0], p0[1]), (p1[0], p1[1]))
# ---------------------------------------------------------------------------
# Gizmo sizing
# ---------------------------------------------------------------------------
[docs]
def gizmo_screen_length(
vp_matrix: np.ndarray,
world_origin: Vec3,
vx: float, vy: float, vw: float, vh: float,
target_px: float = 100.0,
) -> float:
"""Compute a world-space axis length that appears *target_px* pixels on screen.
Uses a simple linear estimate: project a 1-unit test offset along X,
measure the screen-pixel distance, then scale to reach *target_px*.
Args:
vp_matrix: Combined view-projection matrix (4x4).
world_origin: Origin of the gizmo in world space.
vx, vy, vw, vh: Viewport rectangle in screen pixels.
target_px: Desired screen-pixel length.
Returns:
World-space axis length (fallback 1.5 if projection fails).
"""
p0 = project_point(vp_matrix, world_origin, vx, vy, vw, vh)
if p0 is None:
return 1.5
test_end = world_origin + Vec3(1, 0, 0)
p1 = project_point(vp_matrix, test_end, vx, vy, vw, vh)
if p1 is None:
return 1.5
px_per_unit = math.sqrt((p1[0] - p0[0]) ** 2 + (p1[1] - p0[1]) ** 2)
if px_per_unit < 1e-3:
return 1.5
return target_px / px_per_unit
# ---------------------------------------------------------------------------
# Axis indicator rotation
# ---------------------------------------------------------------------------
[docs]
def rotate_axis_to_screen(
world_dir: Vec3, yaw_rad: float, pitch_rad: float,
) -> tuple[float, float]:
"""Rotate a world-space axis direction by camera yaw/pitch for the axis indicator.
Returns (screen_x_factor, screen_y_factor) in [-1, 1] range, suitable
for multiplying by the indicator size to get pixel offsets from center.
"""
cos_y = math.cos(yaw_rad)
sin_y = math.sin(yaw_rad)
cos_p = math.cos(pitch_rad)
sin_p = math.sin(pitch_rad)
# Rotate by yaw around Y
rx = world_dir.x * cos_y + world_dir.z * sin_y
rz = -world_dir.x * sin_y + world_dir.z * cos_y
ry = world_dir.y
# Rotate by pitch around X (screen-relative)
final_x = rx
final_y = ry * cos_p - rz * sin_p
return (final_x, final_y)
# ---------------------------------------------------------------------------
# Grid math
# ---------------------------------------------------------------------------
[docs]
def grid_snap_origin(
pivot_x: float, pivot_z: float, major_step: float,
) -> tuple[float, float]:
"""Snap grid origin to major-step increments for visual stability."""
snap_x = math.floor(pivot_x / major_step) * major_step
snap_z = math.floor(pivot_z / major_step) * major_step
return (snap_x, snap_z)