Source code for glyphx.projection3d

"""
GlyphX 3D projection utilities.

Shared orthographic projection engine used by both the SVG static
renderer and as a data-normalisation layer for the Three.js HTML renderer.
"""
from __future__ import annotations

import math
from typing import NamedTuple


class Projected(NamedTuple):
    px: float    # screen X pixel
    py: float    # screen Y pixel (SVG convention: positive = down)
    depth: float # depth value for painter's-algorithm sorting (larger = farther)


[docs] def normalize(values: list[float]) -> tuple[list[float], float, float]: """Return (normalised_to_minus1_plus1, min_val, max_val).""" lo, hi = min(values), max(values) span = hi - lo or 1.0 return [(v - lo) / span * 2 - 1 for v in values], lo, hi
[docs] class Camera3D: """ Orthographic camera for projecting 3D data to 2D SVG. Angles follow the geographic convention: azimuth — rotation around the vertical (Z) axis, degrees. 0° = looking from +Y; 90° = looking from +X. elevation — tilt above the horizontal plane, degrees. 0° = side view; 90° = top-down. The right-hand coordinate system uses: X → right, Y → into screen (depth), Z → up. """ def __init__( self, azimuth: float = 45.0, elevation: float = 30.0, cx: float = 0.0, # canvas centre x cy: float = 0.0, # canvas centre y scale: float = 1.0, # pixels per normalised unit ) -> None: self.azimuth = azimuth self.elevation = elevation self.cx = cx self.cy = cy self.scale = scale self._update() def _update(self) -> None: az = math.radians(self.azimuth) el = math.radians(self.elevation) self._cos_az = math.cos(az) self._sin_az = math.sin(az) self._cos_el = math.cos(el) self._sin_el = math.sin(el)
[docs] def project(self, x: float, y: float, z: float) -> Projected: """Project a normalised 3D point to 2D screen coordinates.""" # Rotate around Z (azimuth) rx = x * self._cos_az + y * self._sin_az ry = -x * self._sin_az + y * self._cos_az rz = z # Tilt by elevation fx = rx fy = ry * self._cos_el - rz * self._sin_el # depth axis fz = -ry * self._sin_el - rz * self._cos_el # screen vertical (up=negative SVG) # Orthographic projection → pixel px = self.cx + fx * self.scale py = self.cy - fz * self.scale # SVG Y is inverted return Projected(px, py, fy) # fy = depth
[docs] def project_all( self, xs: list[float], ys: list[float], zs: list[float], ) -> list[Projected]: """Project a batch of points.""" return [self.project(x, y, z) for x, y, z in zip(xs, ys, zs)]
def axis_ticks(lo: float, hi: float, n: int = 5) -> list[float]: """Evenly spaced tick values in [lo, hi].""" return [lo + i * (hi - lo) / n for i in range(n + 1)] def norm_to_data(norm: float, lo: float, hi: float) -> float: """Convert a [-1, 1] normalised value back to data space.""" return lo + (norm + 1) / 2 * (hi - lo) def _format_3d_tick(v: float) -> str: """Compact tick label for 3D axes.""" if v == 0: return "0" abs_v = abs(v) if abs_v >= 1e6: return f"{v/1e6:.1f}M" if abs_v >= 1e3: return f"{v/1e3:.1f}k" if v == int(v): return str(int(v)) if abs_v >= 100: return f"{v:.0f}" if abs_v >= 10: return f"{v:.1f}" return f"{v:.2f}"