//! Export del `RenderModel` a SVG.
//!
//! Genera un documento SVG standalone con la misma geometría que pinta
//! el canvas: anillos zodiacales, cusps, planetas, aspectos. El
//! resultado es escalable (imprimible a cualquier tamaño) y no requiere
//! la app GPUI para verse — cualquier visor de SVG sirve.
//!
//! Convención de coordenadas idéntica al canvas:
//! `screen_angle_deg = 180 - (longitude - ascendant)` con +y para abajo.
use std::f64::consts::PI;
use std::fmt::Write;
use crate::{Geometry, LayerKind, RenderModel};
/// Dimensiones default del viewport. Aspect ratio cuadrada.
const VIEWBOX: f64 = 800.0;
const MARGIN: f64 = 40.0;
/// Radios normalizados — espejan los de `cosmobiologia-canvas`.
const R_SIGN_OUTER: f64 = 1.00;
const R_SIGN_INNER: f64 = 0.88;
const R_TRANSITS: f64 = 0.82;
const R_HOUSES_OUTER: f64 = 0.78;
const R_HOUSES_INNER: f64 = 0.66;
const R_BODIES: f64 = 0.58;
const R_PROGRESSION: f64 = 0.48;
const R_SOLAR_ARC: f64 = 0.40;
const R_ASPECTS: f64 = 0.32;
/// Convierte el `RenderModel` a un documento SVG completo.
pub fn render_to_svg(render: &RenderModel) -> String {
let mut out = String::with_capacity(8192);
let r_outer = (VIEWBOX - MARGIN * 2.0) / 2.0;
let cx = VIEWBOX / 2.0;
let cy = VIEWBOX / 2.0;
let asc = render.ascendant_deg as f64;
writeln!(
out,
r#"
").unwrap();
out
}
fn polar(longitude_deg: f64, ascendant_deg: f64, radius: f64, cx: f64, cy: f64) -> (f64, f64) {
let deg = 180.0 - (longitude_deg - ascendant_deg);
let rad = deg * PI / 180.0;
(cx + radius * rad.cos(), cy + radius * rad.sin())
}
fn aspect_radii(module_id: &str) -> (f64, f64) {
if crate::OUTER_RING_MODULES.contains(&module_id) {
return (R_BODIES, R_TRANSITS);
}
match module_id {
"progression" => (R_BODIES, R_PROGRESSION),
"solar_arc" => (R_BODIES, R_SOLAR_ARC),
_ => (R_ASPECTS, R_ASPECTS),
}
}
fn body_ring_radius(module_id: &str) -> f64 {
if crate::OUTER_RING_MODULES.contains(&module_id) {
return R_TRANSITS;
}
match module_id {
"progression" => R_PROGRESSION,
"solar_arc" => R_SOLAR_ARC,
_ => R_BODIES,
}
}
fn sign_unicode(name: &str) -> &'static str {
match name {
"aries" => "♈",
"taurus" => "♉",
"gemini" => "♊",
"cancer" => "♋",
"leo" => "♌",
"virgo" => "♍",
"libra" => "♎",
"scorpio" => "♏",
"sagittarius" => "♐",
"capricorn" => "♑",
"aquarius" => "♒",
"pisces" => "♓",
_ => "?",
}
}
fn planet_unicode(name: &str) -> &'static str {
match name {
"sun" => "☉",
"moon" => "☽",
"mercury" => "☿",
"venus" => "♀",
"mars" => "♂",
"jupiter" => "♃",
"saturn" => "♄",
"uranus" => "♅",
"neptune" => "♆",
"pluto" => "♇",
"north_node" => "☊",
"south_node" => "☋",
"chiron" => "⚷",
"lilith" => "⚸",
"ceres" => "⚳",
"pallas" => "⚴",
"juno" => "⚵",
"vesta" => "⚶",
_ => "•",
}
}
fn aspect_color_hex(kind: &str) -> &'static str {
match kind {
"conjunction" => "#b8862e",
"opposition" => "#a64a8a",
"trine" => "#3f7d57",
"square" => "#c64b2a",
"sextile" => "#3a6db5",
_ => "#8a7660",
}
}
fn escape_xml(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{compute_mock, ChartKind};
use cosmobiologia_model::{Chart, ContactId, StoredBirthData, StoredChartConfig};
fn sample_chart() -> Chart {
Chart {
id: cosmobiologia_model::ChartId::new(),
contact_id: ContactId::new(),
kind: ChartKind::Natal,
label: "Test".into(),
birth_data: StoredBirthData {
year: 1987,
month: 3,
day: 14,
hour: 5,
minute: 22,
second: 0.0,
tz_offset_minutes: -240,
latitude_deg: 10.0,
longitude_deg: -66.0,
altitude_m: 0.0,
time_certainty: Default::default(),
subject_name: None,
birthplace_label: None,
},
config: StoredChartConfig::default(),
related_chart_id: None,
created_at_ms: 0,
}
}
#[test]
fn svg_well_formed_minimal() {
let render = compute_mock(&sample_chart());
let svg = render_to_svg(&render);
assert!(svg.starts_with("\n"));
// Debe traer al menos un círculo de los rings base.
assert!(svg.contains("