chore: rename tahuantinsuyu → cosmobiologia

Rename clean del proyecto astrológico antes de empezar el módulo
web (fase 2 = server axum, fase 3 = cliente WASM). Hacerlo ahora
ahorra refactor de URLs, package.json, paths de assets HTML y
deploy configs que aparecerían con el nombre en cuanto exista el
server.

Mecánica:
- `git mv` de los 10 crates de módulo + 2 apps:
  * `crates/modules/tahuantinsuyu/` → `cosmobiologia/`
  * `crates/modules/tahuantinsuyu/tahuantinsuyu-*` →
    `cosmobiologia/cosmobiologia-*`
  * `crates/apps/tahuantinsuyu` y `tahuantinsuyu-cli` análogos.
- Sed sobre todos los `.rs` y `.toml`: `tahuantinsuyu` →
  `cosmobiologia` (cubre crate names, deps paths, use
  statements, ProjectDirs literals, binary names).
- Workspace `Cargo.toml`: members con paths nuevos.
- Memoria del proyecto (`~/.claude/.../memory/project_*.md`)
  actualizada.

Cero leftovers: `grep -rn tahuantinsuyu --include="*.rs"
--include="*.toml" crates/` devuelve vacío.

DB & XDG: clean slate. La nueva app arranca con DB vacía en
`$XDG_DATA_HOME/cosmobiologia/charts.db`. Si tenías cartas
guardadas, viven todavía en `~/.local/share/tahuantinsuyu/` —
las podés migrar manualmente con un `cp`.

IDs UI inalterados: el prefijo `tts-` de gpui ElementIds queda
igual (cosmético, no afecta funcionalidad). Cambiarlo a `cb-`
ahora sería 3-4 líneas más de sed pero ningún beneficio
operativo.

Tests: 20 verdes (10 shell + 10 render math). Compila full:
`cargo check -p cosmobiologia` OK.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
sergio
2026-05-19 00:45:48 +00:00
parent 9084cf4b79
commit 06a1ca11ce
34 changed files with 325 additions and 315 deletions
@@ -0,0 +1,220 @@
//! `cosmobiologia-render` — modelo y matemática de render
//! **agnósticos de surface**. Lo consumen tanto el canvas gpui
//! (nativo, render Vulkan/Metal) como el cliente web (WASM, render
//! SVG / Canvas2D). Cualquier mejora del layout / spread / cluster /
//! coords vive acá una sola vez y aparece en ambos clientes.
//!
//! ## Por qué un crate aparte
//!
//! `cosmobiologia-engine` arrastra `eternal-sky` (VSOP2013 + I/O de
//! tablas) que **no compila a WASM** sin empaquetar 30+ MB de
//! efemérides. Los tipos del `RenderModel` en sí son serde puro y
//! sí compilan a WASM — extraerlos a este crate libera al cliente
//! web de la dependencia transitiva.
//!
//! ## Capas
//!
//! 1. **Modelo de render** — `RenderModel`, `Layer`, `Glyph`,
//! `LineSeg`, `Geometry`, `LayerKind`. Estructuras serde-friendly
//! que el engine emite y los clients consumen.
//! 2. **Matemática agnóstica** *(módulos siguientes, no en esta primera
//! versión)* — `polar_to_screen`, `spread_angles`, `find_clusters`,
//! `format_coord_compact`, `Radii`. Migran desde el canvas gpui.
//! 3. **`DrawCommand`** *(módulo siguiente)* — primitivas de pintura
//! (line, circle, glyph, pill) que cada surface traduce a su API.
#![forbid(unsafe_code)]
#![warn(rust_2018_idioms)]
use serde::{Deserialize, Serialize};
pub use cosmobiologia_model::{Chart, ChartId, ChartKind};
pub mod math;
pub use math::{
find_clusters, format_coord_compact, polar_to_screen, spread_angles, Radii,
};
// =====================================================================
// RenderModel — lo que el client renderea
// =====================================================================
/// Resultado agnóstico de un cómputo astrológico, listo para renderizar.
/// El canvas gpui y el cliente web lo consumen idénticamente: el engine
/// computa (en nativo, con eternal) y publica este struct.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RenderModel {
pub chart_id: ChartId,
pub chart_kind: ChartKind,
pub title: String,
#[serde(default)]
pub subtitle: Option<String>,
pub compute_ms: u64,
// ─── Ángulos del chart (grados eclípticos, 0..360) ───────────────
/// Ascendente — punto fijo de rotación del lienzo. La rueda se gira
/// de modo que el Asc cae a las 9 (lado izquierdo).
pub ascendant_deg: f32,
pub midheaven_deg: f32,
pub descendant_deg: f32,
pub imum_coeli_deg: f32,
/// Capas a pintar. Orden = z-order ascendente.
pub layers: Vec<Layer>,
/// Metadata humana por overlay activo (transit, progresión,
/// sinastría, retorno...). Vacío para una carta natal pura. La UI
/// la pinta como badges en el footer.
#[serde(default)]
pub overlays: Vec<OverlayMeta>,
/// Lista paralela a las LineSeg de aspectos — uno por aspecto
/// natal o cross. Ordenado por `orb_deg` ascendente (los más
/// cerrados primero). La UI lo usa para la lista textual.
#[serde(default)]
pub aspect_summary: Vec<AspectSummary>,
/// Grupos uranianos detectados (cuerpos en la misma posición mod 90).
/// Vacío sino se activó el módulo Uranian.
#[serde(default)]
pub uranian_groups: Vec<UranianGroup>,
}
/// Etiqueta legible de un overlay para el footer del canvas. La engine
/// la pushea desde cada `build_*_overlay`; el canvas solo lee y pinta.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OverlayMeta {
pub module_id: String,
/// Etiqueta corta — ej. "Tránsito ahora", "Progresión 38.2a",
/// "Sinastría · Ana", "Saturn return 29a".
pub label: String,
}
/// Grupo de cuerpos natales que caen en la misma posición del
/// dial uraniano de 90° (su longitud zodiacal módulo 90 es igual o
/// muy cercana). En la astrología uraniana esto es una "fórmula" o
/// "axis" — los cuerpos están en correspondencia simbólica directa
/// porque comparten un cuadrante simétrico.
///
/// Solo se emiten grupos con 2+ miembros (los singletons no son
/// fórmulas). La engine los ordena por proximidad al ε de tolerancia.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UranianGroup {
/// Identificadores agnósticos de los cuerpos en el grupo
/// (ej. `["sun", "jupiter", "saturn"]`).
pub bodies: Vec<String>,
/// Posición en el dial de 90° (la longitud módulo 90).
pub mod90_deg: f64,
}
/// Resumen textual de un aspecto para listas legibles. La engine lo
/// emite en paralelo con las `LineSeg` de la capa de aspectos, así
/// el canvas no tiene que re-derivar nombres de cuerpos desde grados.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AspectSummary {
/// Module al que pertenece — "natal", "transit", "synastry",
/// "progression", "solar_arc", "planetary_return".
pub module_id: String,
/// Identificador agnóstico del cuerpo "a" — "sun", "moon", etc.
pub from_body: String,
pub to_body: String,
/// Identificador del aspecto — "conjunction", "trine", etc.
pub kind: String,
pub orb_deg: f64,
/// `Some(true)` = applying, `Some(false)` = separating. `None` para
/// cross-aspects (sinastría/return) donde no se computa.
#[serde(default)]
pub applying: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Layer {
pub module_id: String,
pub kind: LayerKind,
/// Radio normalizado [0, 1] sobre el lienzo — el canvas lo convierte
/// a píxeles. Permite stack de anillos.
pub ring: f32,
#[serde(default)]
pub z: i32,
pub geometry: Geometry,
#[serde(default)]
pub glyphs: Vec<Glyph>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LayerKind {
SignDial,
Houses,
Bodies,
Aspects,
Lots,
FixedStars,
Midpoints,
Outer,
Custom,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum Geometry {
GlyphsOnly,
/// Anillo dividido en sectores. `cusps_deg` son los grados
/// zodiacales donde van las divisiones radiales.
Ring { cusps_deg: Vec<f32> },
Lines(Vec<LineSeg>),
Points(Vec<PointMark>),
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct LineSeg {
/// Grados zodiacales del extremo "a".
pub from_deg: f32,
/// Grados zodiacales del extremo "b".
pub to_deg: f32,
/// Categoría simbólica (`"conjunction"`, `"trine"`, …) — el theme la
/// resuelve a color.
pub kind: String,
pub opacity: f32,
/// Cuerpo en el extremo "a" — populado para LineSegs de aspectos
/// (natal × natal, cross con overlays). Vacío en `Default::default`
/// para serde back-compat.
#[serde(default)]
pub from_body: String,
/// Cuerpo en el extremo "b".
#[serde(default)]
pub to_body: String,
/// Orb absoluto en grados (para tooltips).
#[serde(default)]
pub orb_deg: f32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PointMark {
pub deg: f32,
pub label: String,
pub tag: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Glyph {
/// Grado eclíptico [0, 360).
pub deg: f32,
/// Glyph simbólico — el theme/canvas lo mapea a unicode o imagen.
/// Ej: `"sun"`, `"moon"`, `"aries"`, `"asc"`, `"mc"`.
pub symbol: String,
#[serde(default)]
pub annotation: Option<String>,
#[serde(default)]
pub retrograde: bool,
#[serde(default)]
pub house: Option<u8>,
/// Marker de dignidad esencial, set solo cuando
/// `NatalOptions::show_dignities` está activo: `"+"` (domicilio),
/// `"·"` (exaltación), `""` (exilio), `"*"` (caída).
#[serde(default)]
pub dignity_marker: Option<String>,
}
/// Módulos overlay que pintan en el mismo slot (outer ring del wheel)
/// y por lo tanto son **mutuamente excluyentes** a nivel de UI: al
/// prender uno, el shell debe apagar los otros. Single source of truth
/// — el shell y el canvas leen de acá en vez de hardcodear listas.
pub const OUTER_RING_MODULES: &[&str] = &["transit", "synastry", "planetary_return"];
@@ -0,0 +1,396 @@
//! Matemática agnóstica de surface — radios canónicos del wheel,
//! conversión polar → pantalla, spread anti-solapamiento, detección
//! de clusters, formato de coordenadas.
//!
//! Vive aquí (no en el canvas gpui) porque exactamente la misma
//! lógica corre en el cliente web (WASM) y en la app desktop. Cualquier
//! ajuste de geometría aparece en ambos a la vez.
use core::f32::consts::PI;
use crate::OUTER_RING_MODULES;
// =====================================================================
// Radii — geometría radial canónica de la rueda
// =====================================================================
/// Geometría radial canónica del wheel. Aros nombrados según convención
/// de Sergio, de afuera hacia adentro:
///
/// * **Aro A** (`sign_outer`) — externo del zodiaco.
/// * **Zona AB** — sign dial: glyphs de signos zodiacales.
/// * **Aro B** (`sign_inner` = `topo_houses_outer`) — interno del
/// zodiaco / externo del bloque ascensional.
/// * **Zona BC** — casas topocéntricas (cusps b→c) + planetas
/// topocéntricos, ambos con sus coordenadas.
/// * **Aro C** (`topo_houses_inner` = `houses_outer`) — separador
/// ascensional / casas geo.
/// * **Zona CD** — casas geocéntricas (cusps c→d) + sus coordenadas.
/// * **Aro D** (`houses_inner`) — externo de los planetas natales.
/// Junto a D, hacia adentro, se posan los planetas natales y sus
/// coordenadas.
/// * **Aro E** (`aspects`) — el más interno. Desde aquí nacen las
/// líneas de aspecto / relaciones / overlays opcionales.
///
/// Los overlays adicionales (transits, midpoints, progression, solar
/// arc, composite) viven INTERIORES al aro E — solo se pintan
/// cuando el módulo correspondiente está activo, así no compiten
/// con el layout base.
#[derive(Clone, Copy, Debug)]
pub struct Radii {
pub sign_outer: f32, // Aro A
pub sign_inner: f32, // Aro B
pub topo_houses_outer: f32, // = Aro B
pub topocentric: f32, // Zona BC: planetas topo
pub topo_houses_inner: f32, // Aro C
pub houses_outer: f32, // = Aro C
pub houses_inner: f32, // Aro D
pub bodies: f32, // Zona D-E: planetas natales (junto a D)
pub pd_direct: f32, // GR (cuando activo): exterior al cinturón natal
pub pd_converse: f32, // GR (cuando activo): interior al cinturón natal
pub aspects: f32, // Aro E (invisible, ancla de líneas)
// Overlays adicionales — todos interiores a E.
pub transits: f32,
pub midpoints: f32,
pub progression: f32,
pub solar_arc: f32,
pub composite: f32,
}
impl Radii {
pub fn from_outer(r: f32) -> Self {
Self {
sign_outer: r,
sign_inner: r * 0.92,
topo_houses_outer: r * 0.92,
topocentric: r * 0.85,
topo_houses_inner: r * 0.78,
houses_outer: r * 0.78,
houses_inner: r * 0.62,
bodies: r * 0.57,
pd_direct: r * 0.545,
pd_converse: r * 0.515,
aspects: r * 0.49,
transits: r * 0.43,
midpoints: r * 0.39,
progression: r * 0.33,
solar_arc: r * 0.27,
composite: r * 0.21,
}
}
/// Radio del ring de cuerpos según el `module_id` del Layer.
pub fn body_ring(&self, module_id: &str) -> f32 {
match module_id {
"progression" => self.progression,
"solar_arc" => self.solar_arc,
"composite" => self.composite,
"midpoints" => self.midpoints,
"topocentric" => self.topocentric,
"pd_direct" => self.pd_direct,
"pd_converse" => self.pd_converse,
_ => self.bodies,
}
}
/// Resuelve qué radios corresponden a una capa de aspectos según el
/// `module_id`: natal-natal en `aspects`, cross con cada overlay
/// desde `bodies` (extremo natal) al ring del módulo. Los módulos
/// del outer ring (OUTER_RING_MODULES) comparten el slot de
/// tránsito (son mutuamente excluyentes a nivel de Shell).
pub fn aspect_endpoints(&self, module_id: &str) -> (f32, f32) {
if OUTER_RING_MODULES.contains(&module_id) {
return (self.bodies, self.transits);
}
match module_id {
"progression" => (self.bodies, self.progression),
"solar_arc" => (self.bodies, self.solar_arc),
"composite" => (self.bodies, self.composite),
_ => (self.aspects, self.aspects),
}
}
}
// =====================================================================
// polar_to_screen — convención de rotación del wheel
// =====================================================================
/// Convierte una longitud eclíptica a coords cartesianas relativas al
/// centro del wheel. Convención: el Ascendente cae a las 9 (lado
/// izquierdo). `rot_offset_deg` permite rotar la vista (jog-dial).
pub fn polar_to_screen(
longitude_deg: f32,
ascendant_deg: f32,
rot_offset_deg: f32,
radius: f32,
) -> (f32, f32) {
let deg = 180.0 - (longitude_deg - ascendant_deg + rot_offset_deg);
let rad = deg * PI / 180.0;
(radius * rad.cos(), radius * rad.sin())
}
// =====================================================================
// Spread anti-solapamiento de glyphs
// =====================================================================
/// Reposiciona angularmente un conjunto de longitudes para que pares
/// adyacentes mantengan al menos `min_sep_deg` de separación, **sin
/// que ningún glyph se aleje más de `max_shift_deg` de su posición
/// real**. La acotación es clave para evitar que un cluster denso
/// "empuje" a planetas que estaban lejos.
///
/// Algoritmo: iteramos hasta 80 veces; en cada pasada re-ordenamos
/// los displays para mantener el orden circular, y en cada par
/// adyacente que esté muy cerca acumulamos fuerzas en sentidos
/// opuestos. Aplicamos las fuerzas con `damping = 0.6` y clampeamos
/// cada display al rango `[raw[i] - max_shift, raw[i] + max_shift]`.
/// Si el cluster es tan denso que el clamp impide alcanzar el
/// `min_sep`, el residual queda alto y el caller encoge los discos.
///
/// Devuelve `(displays, residual)` con `residual ∈ [0, 1]` =
/// fracción de presión no resuelta tras el clamp.
pub fn spread_angles(
angles_deg: &[f32],
min_sep_deg: f32,
max_shift_deg: f32,
) -> (Vec<f32>, f32) {
let n = angles_deg.len();
if n <= 1 {
return (angles_deg.to_vec(), 0.0);
}
if (n as f32) * min_sep_deg >= 360.0 {
return (angles_deg.to_vec(), 1.0);
}
let raw: Vec<f32> = angles_deg.iter().map(|a| a.rem_euclid(360.0)).collect();
let mut displays: Vec<f32> = raw.clone();
let mut last_residual = 0.0_f32;
let clamp_to_raw = |display: f32, raw: f32, max_shift: f32| -> f32 {
let mut delta = display - raw;
if delta > 180.0 {
delta -= 360.0;
}
if delta < -180.0 {
delta += 360.0;
}
let clamped = delta.clamp(-max_shift, max_shift);
(raw + clamped).rem_euclid(360.0)
};
let damping: f32 = 0.6;
for _ in 0..80 {
let mut order: Vec<usize> = (0..n).collect();
order.sort_by(|&a, &b| {
displays[a]
.partial_cmp(&displays[b])
.unwrap_or(core::cmp::Ordering::Equal)
});
let mut forces = vec![0.0_f32; n];
let mut max_residual: f32 = 0.0;
for k in 0..n {
let i = order[k];
let j = order[(k + 1) % n];
let diff = (displays[j] - displays[i]).rem_euclid(360.0);
if diff < min_sep_deg {
let push = (min_sep_deg - diff) / 2.0;
forces[i] -= push;
forces[j] += push;
let r = (min_sep_deg - diff) / min_sep_deg;
if r > max_residual {
max_residual = r;
}
}
}
for i in 0..n {
let stepped = (displays[i] + forces[i] * damping).rem_euclid(360.0);
displays[i] = clamp_to_raw(stepped, raw[i], max_shift_deg);
}
last_residual = max_residual;
if max_residual < 0.001 {
break;
}
}
(displays, last_residual)
}
/// Detecta clusters de longitudes angularmente cercanas. Dos
/// elementos están en el mismo cluster si su separación circular es
/// menor a `threshold_deg`. Devuelve los índices originales
/// agrupados; cada Vec interno representa un cluster (incluso si
/// es de tamaño 1). Cluster con wrap-around (último→primero) se
/// fusionan correctamente.
pub fn find_clusters(angles_deg: &[f32], threshold_deg: f32) -> Vec<Vec<usize>> {
let n = angles_deg.len();
if n == 0 {
return Vec::new();
}
let mut idxed: Vec<(usize, f32)> = angles_deg
.iter()
.copied()
.map(|a| a.rem_euclid(360.0))
.enumerate()
.collect();
idxed.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(core::cmp::Ordering::Equal));
let mut clusters: Vec<Vec<usize>> = Vec::new();
let mut cur: Vec<usize> = vec![idxed[0].0];
let mut last = idxed[0].1;
for (idx, a) in idxed.iter().skip(1).copied() {
if (a - last) < threshold_deg {
cur.push(idx);
} else {
clusters.push(core::mem::take(&mut cur));
cur.push(idx);
}
last = a;
}
clusters.push(cur);
if clusters.len() >= 2 {
let first_a = angles_deg[clusters[0][0]].rem_euclid(360.0);
let last_a = angles_deg[*clusters.last().unwrap().last().unwrap()].rem_euclid(360.0);
let wrap_diff = 360.0 - last_a + first_a;
if wrap_diff < threshold_deg {
let mut tail = clusters.pop().unwrap();
tail.extend(clusters[0].iter().copied());
clusters[0] = tail;
}
}
clusters
}
// =====================================================================
// Coord formatter
// =====================================================================
/// Formato compacto con precisión de minutos: "DD°MM'{signo}" donde
/// el signo es el glyph zodiacal (♈♉♊…). Ej: 14.93° → "14°56'♈".
/// Los minutos se redondean al entero más cercano; carry-overs entre
/// signos están cubiertos por trabajar en minutos enteros absolutos.
pub fn format_coord_compact(deg: f32) -> String {
let normalized = deg.rem_euclid(360.0);
let total_minutes = (normalized * 60.0).round() as i64;
let total_minutes = total_minutes.rem_euclid(360 * 60);
let sign_idx = (total_minutes / (30 * 60)) as usize % 12;
let within_sign = total_minutes - (sign_idx as i64) * 30 * 60;
let deg_int = (within_sign / 60) as i32;
let minutes = (within_sign % 60) as i32;
let sign_glyph = match sign_idx {
0 => "",
1 => "",
2 => "",
3 => "",
4 => "",
5 => "",
6 => "",
7 => "",
8 => "",
9 => "",
10 => "",
_ => "",
};
format!("{}°{:02}'{}", deg_int, minutes, sign_glyph)
}
#[cfg(test)]
mod tests {
use super::*;
fn assert_min_sep(displays: &[f32], min_sep: f32) {
let n = displays.len();
let mut sorted = displays.to_vec();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
let tol = min_sep * 0.02;
for i in 0..n {
let nxt = (i + 1) % n;
let diff = (sorted[nxt] - sorted[i]).rem_euclid(360.0);
assert!(
diff + tol >= min_sep,
"vecinos {} y {} a {}° (mínimo {})",
sorted[i],
sorted[nxt],
diff,
min_sep
);
}
}
#[test]
fn spread_empty_and_single_unchanged() {
let (r, residual) = spread_angles(&[], 10.0, 30.0);
assert!(r.is_empty());
assert_eq!(residual, 0.0);
let (r, residual) = spread_angles(&[42.0], 10.0, 30.0);
assert_eq!(r, vec![42.0]);
assert_eq!(residual, 0.0);
}
#[test]
fn spread_spaced_input_left_alone() {
let input = vec![0.0, 30.0, 90.0, 200.0];
let (out, residual) = spread_angles(&input, 10.0, 30.0);
assert!(residual < 0.001);
for (a, b) in input.iter().zip(out.iter()) {
assert!((a - b).abs() < 1e-3, "{} vs {}", a, b);
}
}
#[test]
fn spread_tight_cluster_gets_spread() {
let input = vec![100.0, 101.0, 102.0];
let (out, residual) = spread_angles(&input, 10.0, 30.0);
assert!(residual < 0.05, "residual {}", residual);
assert_min_sep(&out, 10.0);
}
#[test]
fn spread_shift_is_bounded() {
let input = vec![100.0, 101.0];
let (out, _) = spread_angles(&input, 10.0, 2.0);
for (raw, disp) in input.iter().zip(out.iter()) {
let mut delta = (disp - raw).abs();
if delta > 180.0 {
delta = 360.0 - delta;
}
assert!(delta <= 2.0 + 0.01, "shift {} > 2°", delta);
}
}
#[test]
fn spread_distant_planet_unaffected_by_dense_cluster() {
let input = vec![100.0, 100.5, 101.0, 200.0];
let (out, _) = spread_angles(&input, 10.0, 10.0);
let mut delta = (out[3] - 200.0).abs();
if delta > 180.0 {
delta = 360.0 - delta;
}
assert!(delta < 5.0, "planeta lejano se movió {}°", delta);
}
#[test]
fn coord_zero_aries() {
assert_eq!(format_coord_compact(0.0), "0°00'♈");
}
#[test]
fn coord_fourteen_fiftysix_aries() {
assert_eq!(format_coord_compact(14.933_3), "14°56'♈");
}
#[test]
fn coord_rollover_to_taurus() {
assert_eq!(format_coord_compact(29.9995), "0°00'♉");
}
#[test]
fn coord_negative_wraps() {
assert_eq!(format_coord_compact(-10.0), "20°00'♓");
}
#[test]
fn polar_to_screen_asc_on_left() {
// Si la longitud = asc, el punto cae a las 9 (x = -radius, y = 0).
let (x, y) = polar_to_screen(120.0, 120.0, 0.0, 100.0);
assert!((x - (-100.0)).abs() < 1e-3, "x={}", x);
assert!(y.abs() < 1e-3, "y={}", y);
}
}