from __future__ import annotations
from typing import Tuple
import numpy as np
import pygame
from electrosim import config
_GLOW_CACHE = {}
[docs]
def world_vector_to_screen(pos_meters: np.ndarray, pixels_per_meter: float) -> Tuple[int, int]:
"""Convert world coordinates (m) to integer screen pixels.
Parameters
----------
pos_meters : numpy.ndarray shape (2,)
World position in meters.
pixels_per_meter : float
Pixels per meter scale.
Returns
-------
tuple[int, int]
Pixel coordinates.
"""
x = int(round(pos_meters[0] * pixels_per_meter))
y = int(round(pos_meters[1] * pixels_per_meter))
return x, y
[docs]
def screen_vector_to_world(pos_pixels: Tuple[int, int], pixels_per_meter: float) -> np.ndarray:
"""Convert integer screen pixels to world meters as a float array.
Parameters
----------
pos_pixels : tuple[int, int]
Pixel coordinates.
pixels_per_meter : float
Pixels per meter scale.
Returns
-------
numpy.ndarray shape (2,)
World position in meters.
"""
return np.array([pos_pixels[0] / pixels_per_meter, pos_pixels[1] / pixels_per_meter], dtype=float)
def _draw_arrow(surface: pygame.Surface, color: Tuple[int, int, int] | Tuple[int, int, int, int], start_pixel: Tuple[int, int], vec_pixel: Tuple[float, float], max_len_pixel: float) -> None:
"""Draw a clamped arrow vector from a starting pixel with a small head.
Clamps the vector to `max_len_pixel`, draws a line, then a triangular head.
"""
x0, y0 = start_pixel
vx, vy = vec_pixel
length = float((vx * vx + vy * vy) ** 0.5)
if length <= 1e-6:
return
factor = min(1.0, max_len_pixel / length)
vx *= factor
vy *= factor
end = (int(x0 + vx), int(y0 + vy))
pygame.draw.line(surface, color, (x0, y0), end, 2)
# Arrow head
import math as _math
angle = _math.atan2(vy, vx)
head_len = 8
head_ang = _math.radians(25)
p1 = (int(end[0] - head_len * _math.cos(angle - head_ang)), int(end[1] - head_len * _math.sin(angle - head_ang)))
p2 = (int(end[0] - head_len * _math.cos(angle + head_ang)), int(end[1] - head_len * _math.sin(angle + head_ang)))
pygame.draw.polygon(surface, color, [end, p1, p2])
[docs]
def draw_meter_grid(screen: pygame.Surface, world_size_m: np.ndarray, pixels_per_meter: float) -> None:
"""Draw a metric grid in meters over the entire world size.
Major lines and colors are controlled via `config` constants.
"""
step_m = config.GRID_METER_STEP
major_every = config.GRID_MAJOR_EVERY
width_px = int(round(world_size_m[0] * pixels_per_meter))
height_px = int(round(world_size_m[1] * pixels_per_meter))
# Vertical lines
x_m = 0.0
idx = 0
while x_m <= world_size_m[0] + 1e-9:
x_px = int(round(x_m * pixels_per_meter))
is_major = (idx % major_every == 0)
color = config.COLOR_GRID_MAJOR if is_major else config.COLOR_GRID
thickness = config.GRID_MAJOR_LINE_WIDTH if is_major else config.GRID_LINE_WIDTH
pygame.draw.line(screen, color, (x_px, 0), (x_px, height_px), thickness)
x_m += step_m
idx += 1
# Horizontal lines
y_m = 0.0
idx = 0
while y_m <= world_size_m[1] + 1e-9:
y_px = int(round(y_m * pixels_per_meter))
is_major = (idx % major_every == 0)
color = config.COLOR_GRID_MAJOR if is_major else config.COLOR_GRID
thickness = config.GRID_MAJOR_LINE_WIDTH if is_major else config.GRID_LINE_WIDTH
pygame.draw.line(screen, color, (0, y_px), (width_px, y_px), thickness)
y_m += step_m
idx += 1
[docs]
def draw_glow_at_screen_pos(
screen: pygame.Surface,
center_px: Tuple[int, int],
base_radius_px: int,
color_rgb: Tuple[int, int, int],
intensity: float,
) -> None:
"""Draw a blurred radial glow using concentric alpha circles.
Parameters
----------
screen : pygame.Surface
Target surface.
center_px : tuple[int, int]
Center pixel.
base_radius_px : int
Base radius for the core circle.
color_rgb : tuple[int,int,int]
Glow color.
intensity : float
[0,1] normalized intensity, typically |q|/MAX_CHARGE_C.
"""
if not config.GLOW_ENABLED:
return
if intensity <= 0.0:
return
outer_radius_px = max(base_radius_px, int(round(base_radius_px * (1.0 + intensity * config.GLOW_RADIUS_SCALE))))
size = (outer_radius_px * 2 + 2, outer_radius_px * 2 + 2)
q_steps = max(1, int(getattr(config, "GLOW_CACHE_INTENSITY_STEPS", 32)))
q_int = int(round(min(max(intensity, 0.0), 1.0) * q_steps))
key = (size[0], size[1], color_rgb[0], color_rgb[1], color_rgb[2], q_int, base_radius_px)
glow_surface = _GLOW_CACHE.get(key)
if glow_surface is None:
glow_surface = pygame.Surface(size, pygame.SRCALPHA)
alpha_center = max(0, min(255, int(round((q_int / float(q_steps)) * config.GLOW_ALPHA_AT_MAX))))
glow_surface.fill((0, 0, 0, 0))
w = size[0]
h = size[1]
cx = outer_radius_px + 1
cy = outer_radius_px + 1
x = np.arange(w, dtype=float) - float(cx)
y = np.arange(h, dtype=float) - float(cy)
xx, yy = np.meshgrid(x, y, indexing="xy")
r = np.hypot(xx, yy)
rnorm = r / float(outer_radius_px)
falloff = np.clip(1.0 - rnorm, 0.0, 1.0) ** 3
rgb_view = pygame.surfarray.pixels3d(glow_surface)
falloff_T = falloff.T
rgb_view[:, :, 0] = (color_rgb[0] * falloff_T).astype(np.uint8)
rgb_view[:, :, 1] = (color_rgb[1] * falloff_T).astype(np.uint8)
rgb_view[:, :, 2] = (color_rgb[2] * falloff_T).astype(np.uint8)
del rgb_view
alpha = (alpha_center * falloff).astype(np.uint8)
alpha_view = pygame.surfarray.pixels_alpha(glow_surface)
alpha_view[:, :] = alpha.T
del alpha_view
# bound cache
max_cache = int(getattr(config, "GLOW_CACHE_MAX_SURFACES", 128))
if len(_GLOW_CACHE) >= max_cache:
_GLOW_CACHE.pop(next(iter(_GLOW_CACHE)))
_GLOW_CACHE[key] = glow_surface
screen.blit(glow_surface, (center_px[0] - outer_radius_px - 1, center_px[1] - outer_radius_px - 1), special_flags=pygame.BLEND_ADD)