Source code for simvx.editor.panels.viewport_math

"""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)