tawasuyu

tawasuyu/brahman

Rust 326TOML 123JSON 17Markdown 144606 Definitions

⎇ d3649bfd1a80153652c2a028315369bb4b184024 crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/svg_export.rs Rust · 319 lines · 10.2 KB History Who touched what raw

feat(tahuantinsuyu): fase 18 — aspect list + hover tooltips + dignidades + export SVG 2cd34c82d · sergio ·

1//! Export del `RenderModel` a SVG.
2//!
3//! Genera un documento SVG standalone con la misma geometría que pinta
4//! el canvas: anillos zodiacales, cusps, planetas, aspectos. El
5//! resultado es escalable (imprimible a cualquier tamaño) y no requiere
6//! la app GPUI para verse — cualquier visor de SVG sirve.
7//!
8//! Convención de coordenadas idéntica al canvas:
9//! `screen_angle_deg = 180 - (longitude - ascendant)` con +y para abajo.
10
11use std::f64::consts::PI;
12use std::fmt::Write;
13
14use crate::{Geometry, LayerKind, RenderModel};
15
16/// Dimensiones default del viewport. Aspect ratio cuadrada.
17const VIEWBOX: f64 = 800.0;
18const MARGIN: f64 = 40.0;
19
20/// Radios normalizados — espejan los de `tahuantinsuyu-canvas`.
21const R_SIGN_OUTER: f64 = 1.00;
22const R_SIGN_INNER: f64 = 0.88;
23const R_TRANSITS: f64 = 0.82;
24const R_HOUSES_OUTER: f64 = 0.78;
25const R_HOUSES_INNER: f64 = 0.66;
26const R_BODIES: f64 = 0.58;
27const R_PROGRESSION: f64 = 0.48;
28const R_SOLAR_ARC: f64 = 0.40;
29const R_ASPECTS: f64 = 0.32;
30
31/// Convierte el `RenderModel` a un documento SVG completo.
32pub fn render_to_svg(render: &RenderModel) -> String {
33    let mut out = String::with_capacity(8192);
34    let r_outer = (VIEWBOX - MARGIN * 2.0) / 2.0;
35    let cx = VIEWBOX / 2.0;
36    let cy = VIEWBOX / 2.0;
37    let asc = render.ascendant_deg as f64;
38
39    writeln!(
40        out,
41        r#"<?xml version="1.0" encoding="UTF-8"?>
42<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {0} {1}" width="{0}" height="{1}" font-family="serif" text-anchor="middle" dominant-baseline="central">"#,
43        VIEWBOX, VIEWBOX
44    )
45    .unwrap();
46
47    // Fondo + título.
48    writeln!(
49        out,
50        r##"  <rect x="0" y="0" width="{0}" height="{0}" fill="#fdfaf3"/>
51  <text x="{cx}" y="20" font-size="14" fill="#2a2620">{title}</text>"##,
52        VIEWBOX,
53        cx = cx,
54        title = escape_xml(&render.title)
55    )
56    .unwrap();
57
58    // Anillos base.
59    for r in [R_SIGN_OUTER, R_SIGN_INNER, R_HOUSES_OUTER, R_HOUSES_INNER] {
60        writeln!(
61            out,
62            r##"  <circle cx="{cx}" cy="{cy}" r="{r}" fill="none" stroke="#a89572" stroke-width="0.6"/>"##,
63            r = r * r_outer
64        )
65        .unwrap();
66    }
67
68    // Cusps del zodíaco cada 30°.
69    for i in 0..12 {
70        let lon = (i as f64) * 30.0;
71        let (x1, y1) = polar(lon, asc, R_SIGN_INNER * r_outer, cx, cy);
72        let (x2, y2) = polar(lon, asc, R_SIGN_OUTER * r_outer, cx, cy);
73        writeln!(
74            out,
75            r##"  <line x1="{x1:.2}" y1="{y1:.2}" x2="{x2:.2}" y2="{y2:.2}" stroke="#a89572" stroke-width="0.5"/>"##,
76        )
77        .unwrap();
78    }
79
80    // Glifos de signos a media-altura del dial.
81    let sign_mid = (R_SIGN_OUTER + R_SIGN_INNER) / 2.0;
82    for layer in &render.layers {
83        if matches!(layer.kind, LayerKind::SignDial) {
84            for g in &layer.glyphs {
85                let (x, y) = polar(g.deg as f64, asc, sign_mid * r_outer, cx, cy);
86                writeln!(
87                    out,
88                    r##"  <text x="{x:.2}" y="{y:.2}" font-size="16" fill="#5a4830">{}</text>"##,
89                    sign_unicode(&g.symbol)
90                )
91                .unwrap();
92            }
93        }
94    }
95
96    // Cusps de casas + énfasis Asc/IC/Desc/MC.
97    for layer in &render.layers {
98        if matches!(layer.kind, LayerKind::Houses) {
99            if let Geometry::Ring { cusps_deg } = &layer.geometry {
100                for (i, c) in cusps_deg.iter().enumerate() {
101                    let is_angle = i == 0 || i == 3 || i == 6 || i == 9;
102                    let (color, w) = if is_angle {
103                        ("#b8862e", 1.6)
104                    } else {
105                        ("#9b8460", 0.5)
106                    };
107                    let (x1, y1) =
108                        polar(*c as f64, asc, R_HOUSES_INNER * r_outer, cx, cy);
109                    let (x2, y2) =
110                        polar(*c as f64, asc, R_HOUSES_OUTER * r_outer, cx, cy);
111                    writeln!(
112                        out,
113                        r##"  <line x1="{x1:.2}" y1="{y1:.2}" x2="{x2:.2}" y2="{y2:.2}" stroke="{color}" stroke-width="{w}"/>"##,
114                    )
115                    .unwrap();
116                }
117            }
118        }
119    }
120
121    // Líneas de aspectos. Para natal usamos un solo ring; para
122    // cross-aspects (transit/synastry/progression/solar_arc/...) los
123    // extremos van en rings distintos según el `module_id`.
124    for layer in &render.layers {
125        if !matches!(layer.kind, LayerKind::Aspects) {
126            continue;
127        }
128        if let Geometry::Lines(segs) = &layer.geometry {
129            let (r_from, r_to) = aspect_radii(&layer.module_id);
130            for seg in segs {
131                let color = aspect_color_hex(&seg.kind);
132                let (x1, y1) = polar(seg.from_deg as f64, asc, r_from * r_outer, cx, cy);
133                let (x2, y2) = polar(seg.to_deg as f64, asc, r_to * r_outer, cx, cy);
134                writeln!(
135                    out,
136                    r##"  <line x1="{x1:.2}" y1="{y1:.2}" x2="{x2:.2}" y2="{y2:.2}" stroke="{color}" stroke-width="0.6" stroke-opacity="{op:.2}"/>"##,
137                    op = seg.opacity
138                )
139                .unwrap();
140            }
141        }
142    }
143
144    // Glifos planetarios (natal + overlays). Cada uno en su ring.
145    for layer in &render.layers {
146        if !matches!(layer.kind, LayerKind::Bodies | LayerKind::Outer) {
147            continue;
148        }
149        let ring = body_ring_radius(&layer.module_id);
150        let size = if layer.module_id == "natal" { 18 } else { 14 };
151        for g in &layer.glyphs {
152            let (x, y) = polar(g.deg as f64, asc, ring * r_outer, cx, cy);
153            let glyph = planet_unicode(&g.symbol);
154            let suffix = match (g.retrograde, g.dignity_marker.as_deref()) {
155                (true, Some(m)) => format!("ᴿ{}", m),
156                (true, None) => "ᴿ".into(),
157                (false, Some(m)) => m.to_string(),
158                (false, None) => String::new(),
159            };
160            writeln!(
161                out,
162                r##"  <text x="{x:.2}" y="{y:.2}" font-size="{size}" fill="#1f1812">{glyph}{suffix}</text>"##
163            )
164            .unwrap();
165        }
166    }
167
168    // Etiquetas ASC / MC / DESC / IC en el perímetro.
169    for (deg, label) in [
170        (asc, "ASC"),
171        (render.midheaven_deg as f64, "MC"),
172        (render.descendant_deg as f64, "DESC"),
173        (render.imum_coeli_deg as f64, "IC"),
174    ] {
175        let (x, y) = polar(deg, asc, 1.06 * r_outer, cx, cy);
176        writeln!(
177            out,
178            r##"  <text x="{x:.2}" y="{y:.2}" font-size="10" fill="#b8862e">{label}</text>"##
179        )
180        .unwrap();
181    }
182
183    writeln!(out, "</svg>").unwrap();
184    out
185}
186
187fn polar(longitude_deg: f64, ascendant_deg: f64, radius: f64, cx: f64, cy: f64) -> (f64, f64) {
188    let deg = 180.0 - (longitude_deg - ascendant_deg);
189    let rad = deg * PI / 180.0;
190    (cx + radius * rad.cos(), cy + radius * rad.sin())
191}
192
193fn aspect_radii(module_id: &str) -> (f64, f64) {
194    if crate::OUTER_RING_MODULES.contains(&module_id) {
195        return (R_BODIES, R_TRANSITS);
196    }
197    match module_id {
198        "progression" => (R_BODIES, R_PROGRESSION),
199        "solar_arc" => (R_BODIES, R_SOLAR_ARC),
200        _ => (R_ASPECTS, R_ASPECTS),
201    }
202}
203
204fn body_ring_radius(module_id: &str) -> f64 {
205    if crate::OUTER_RING_MODULES.contains(&module_id) {
206        return R_TRANSITS;
207    }
208    match module_id {
209        "progression" => R_PROGRESSION,
210        "solar_arc" => R_SOLAR_ARC,
211        _ => R_BODIES,
212    }
213}
214
215fn sign_unicode(name: &str) -> &'static str {
216    match name {
217        "aries" => "♈",
218        "taurus" => "♉",
219        "gemini" => "♊",
220        "cancer" => "♋",
221        "leo" => "♌",
222        "virgo" => "♍",
223        "libra" => "♎",
224        "scorpio" => "♏",
225        "sagittarius" => "♐",
226        "capricorn" => "♑",
227        "aquarius" => "♒",
228        "pisces" => "♓",
229        _ => "?",
230    }
231}
232
233fn planet_unicode(name: &str) -> &'static str {
234    match name {
235        "sun" => "☉",
236        "moon" => "☽",
237        "mercury" => "☿",
238        "venus" => "♀",
239        "mars" => "♂",
240        "jupiter" => "♃",
241        "saturn" => "♄",
242        "uranus" => "♅",
243        "neptune" => "♆",
244        "pluto" => "♇",
245        "north_node" => "☊",
246        "south_node" => "☋",
247        "chiron" => "⚷",
248        "lilith" => "⚸",
249        "ceres" => "⚳",
250        "pallas" => "⚴",
251        "juno" => "⚵",
252        "vesta" => "⚶",
253        _ => "•",
254    }
255}
256
257fn aspect_color_hex(kind: &str) -> &'static str {
258    match kind {
259        "conjunction" => "#b8862e",
260        "opposition" => "#a64a8a",
261        "trine" => "#3f7d57",
262        "square" => "#c64b2a",
263        "sextile" => "#3a6db5",
264        _ => "#8a7660",
265    }
266}
267
268fn escape_xml(s: &str) -> String {
269    s.replace('&', "&amp;")
270        .replace('<', "&lt;")
271        .replace('>', "&gt;")
272        .replace('"', "&quot;")
273        .replace('\'', "&apos;")
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279    use crate::{compute_mock, ChartKind};
280    use tahuantinsuyu_model::{Chart, ContactId, StoredBirthData, StoredChartConfig};
281
282    fn sample_chart() -> Chart {
283        Chart {
284            id: tahuantinsuyu_model::ChartId::new(),
285            contact_id: ContactId::new(),
286            kind: ChartKind::Natal,
287            label: "Test".into(),
288            birth_data: StoredBirthData {
289                year: 1987,
290                month: 3,
291                day: 14,
292                hour: 5,
293                minute: 22,
294                second: 0.0,
295                tz_offset_minutes: -240,
296                latitude_deg: 10.0,
297                longitude_deg: -66.0,
298                altitude_m: 0.0,
299                time_certainty: Default::default(),
300                subject_name: None,
301                birthplace_label: None,
302            },
303            config: StoredChartConfig::default(),
304            related_chart_id: None,
305            created_at_ms: 0,
306        }
307    }
308
309    #[test]
310    fn svg_well_formed_minimal() {
311        let render = compute_mock(&sample_chart());
312        let svg = render_to_svg(&render);
313        assert!(svg.starts_with("<?xml"));
314        assert!(svg.contains("<svg"));
315        assert!(svg.ends_with("</svg>\n"));
316        // Debe traer al menos un círculo de los rings base.
317        assert!(svg.contains("<circle "));
318    }
319}