⎇ 6a0781c0a811c29aa592c34c97351dc166916cc6
crates/modules/cosmobiologia/cosmobiologia-render/src/draw.rs
Historia
Quién tocó qué
crudo
feat(cosmobiologia): esfera celeste 3D — la carta como objeto rotable
1//! Primitivas agnósticas de pintura — el `DrawCommand` que cada
2//! surface (gpui canvas o SVG/Canvas2D del WASM) traduce a su API.
3
4use serde::{Deserialize, Serialize};
5
6/// Color RGBA en `[0.0, 1.0]^4`. Independiente del color-space del
7/// surface (no es Hsla de gpui ni hex de CSS). El traductor de surface
8/// hace la conversión final.
9#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
10pub struct Rgba {
11 pub r: f32,
12 pub g: f32,
13 pub b: f32,
14 pub a: f32,
15}
16
17impl Rgba {
18 pub const TRANSPARENT: Rgba = Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.0 };
19 pub fn opaque(r: f32, g: f32, b: f32) -> Self {
20 Self { r, g, b, a: 1.0 }
21 }
22 pub fn with_alpha(mut self, a: f32) -> Self {
23 self.a = a;
24 self
25 }
26 /// Helper para serializar como CSS rgba(...).
27 pub fn to_css(&self) -> String {
28 format!(
29 "rgba({},{},{},{})",
30 (self.r * 255.0).round() as u8,
31 (self.g * 255.0).round() as u8,
32 (self.b * 255.0).round() as u8,
33 self.a
34 )
35 }
36}
37
38/// Anchor horizontal del texto. Vertical siempre es `middle` para
39/// que el texto se centre verticalmente en `(x, y)`.
40#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
41#[serde(rename_all = "snake_case")]
42pub enum TextAnchor {
43 Start,
44 Middle,
45 End,
46}
47
48/// Primitiva de pintura agnóstica. La lista de comandos describe
49/// **qué** dibujar, no **cómo** — cada surface traduce a su API.
50#[derive(Debug, Clone, Serialize, Deserialize)]
51#[serde(tag = "kind", rename_all = "snake_case")]
52pub enum DrawCommand {
53 /// Círculo (stroke + fill opcional).
54 Circle {
55 cx: f32,
56 cy: f32,
57 r: f32,
58 #[serde(default)]
59 stroke: Option<Rgba>,
60 #[serde(default)]
61 fill: Option<Rgba>,
62 #[serde(default = "default_stroke_width")]
63 stroke_w: f32,
64 },
65 /// Segmento de línea con dash opcional.
66 Line {
67 x1: f32,
68 y1: f32,
69 x2: f32,
70 y2: f32,
71 color: Rgba,
72 #[serde(default = "default_stroke_width")]
73 width: f32,
74 /// `Some((on, off))` para dash. None = sólido.
75 #[serde(default)]
76 dash: Option<(f32, f32)>,
77 },
78 /// Texto en `(x, y)`, anchor horizontal configurable.
79 Text {
80 x: f32,
81 y: f32,
82 content: String,
83 color: Rgba,
84 size: f32,
85 #[serde(default = "default_anchor")]
86 anchor: TextAnchor,
87 },
88}
89
90fn default_stroke_width() -> f32 {
91 1.0
92}
93fn default_anchor() -> TextAnchor {
94 TextAnchor::Middle
95}
96
97/// Opciones para `compose_wheel` — el caller decide tamaño total,
98/// rotación visual, palette (dark/light) y qué overlays acompañar.
99#[derive(Debug, Clone)]
100pub struct CompositionOpts {
101 /// Tamaño total del wheel en px (lado del cuadrado contenedor).
102 pub size: f32,
103 /// Rotación adicional visual (para jog-dial / transformaciones).
104 pub rot_offset_deg: f32,
105 /// Si `false`, la lista no incluye los glyphs de cuerpos (útil
106 /// para previews compactos).
107 pub include_bodies: bool,
108 /// Paleta — controla todos los colores del lienzo. Default `dark()`.
109 pub palette: crate::Palette,
110 /// Si `true`, dibuja la cruz ascensional (líneas ASC↔DESC e
111 /// IC↔MC a través del centro) + pills con etiquetas.
112 pub draw_ascensional_cross: bool,
113 /// Si `true`, dibuja coord labels ("DD°MM'♈") al lado de cada
114 /// cuerpo natal.
115 pub show_coord_labels: bool,
116 /// Mostrar líneas de aspectos menores (semisextile, quincunx, etc.).
117 pub show_minor_aspects: bool,
118 /// Activa relieve 3D del dial (varios strokes concéntricos con
119 /// alpha decreciente para emular bevel).
120 pub dial_3d: bool,
121}
122
123impl Default for CompositionOpts {
124 fn default() -> Self {
125 Self {
126 size: 600.0,
127 rot_offset_deg: 0.0,
128 include_bodies: true,
129 palette: crate::Palette::dark(),
130 draw_ascensional_cross: true,
131 show_coord_labels: true,
132 show_minor_aspects: false,
133 dial_3d: true,
134 }
135 }
136}
137
138/// Compone una lista de `DrawCommand`s a partir de un `RenderModel`.
139/// Incluye:
140/// - Background panel + dial 3D (bevel via strokes concéntricos)
141/// - Anillos A/B/C/D/E con sus cusps
142/// - Casas topocéntricas (ring B→C) + geocéntricas (ring C→D) cuando
143/// están en el modelo
144/// - Glyphs zodiacales con color de su elemento
145/// - Cuerpos natales con disco coloreado + spread anti-solapamiento
146/// + coord labels en pills
147/// - Aspectos: width inversa al orbe, filtrado opcional de minors
148/// - Cruz ascensional ASC↔DESC + IC↔MC + pills con etiquetas
149pub fn compose_wheel(
150 model: &crate::RenderModel,
151 opts: &CompositionOpts,
152) -> Vec<DrawCommand> {
153 use crate::math::{find_clusters, format_coord_compact, polar_to_screen, spread_angles, Radii};
154 let mut out = Vec::new();
155
156 let cx = opts.size / 2.0;
157 let cy = opts.size / 2.0;
158 let margin = opts.size * 0.05;
159 let r_outer = (opts.size / 2.0) - margin;
160 let radii = Radii::from_outer(r_outer);
161
162 let asc = model.ascendant_deg;
163 let rot = opts.rot_offset_deg;
164 let pal = &opts.palette;
165
166 // === Background panel ===
167 out.push(DrawCommand::Circle {
168 cx,
169 cy,
170 r: radii.sign_outer + opts.size * 0.02,
171 stroke: None,
172 fill: Some(pal.bg_panel),
173 stroke_w: 0.0,
174 });
175
176 // === Dial 3D — relieve via strokes concéntricos cerca de aro A ===
177 if opts.dial_3d {
178 let bevel_steps: [(f32, f32, f32); 4] = [
179 (1.012, 0.18, 0.6), // halo externo
180 (1.006, 0.32, 0.9),
181 (0.994, 0.40, 1.0),
182 (0.988, 0.20, 0.7), // halo interno
183 ];
184 for (factor, alpha, w) in bevel_steps {
185 out.push(DrawCommand::Circle {
186 cx,
187 cy,
188 r: radii.sign_outer * factor,
189 stroke: Some(pal.dial_ring.with_alpha(alpha)),
190 fill: None,
191 stroke_w: w,
192 });
193 }
194 }
195
196 // === Aro A (externo zodiaco) + B (interno) ===
197 out.push(DrawCommand::Circle {
198 cx,
199 cy,
200 r: radii.sign_outer,
201 stroke: Some(pal.dial_ring),
202 fill: None,
203 stroke_w: 1.6,
204 });
205 out.push(DrawCommand::Circle {
206 cx,
207 cy,
208 r: radii.sign_inner,
209 stroke: Some(pal.dial_ring.with_alpha(0.7)),
210 fill: None,
211 stroke_w: 1.0,
212 });
213
214 // === Cusps zodiacales cada 30°, sub-divisiones cada 10° (más sutiles) ===
215 for i in 0..36 {
216 let lon = (i as f32) * 10.0;
217 let is_sign_boundary = i % 3 == 0;
218 let color = if is_sign_boundary {
219 pal.dial_ring
220 } else {
221 pal.dial_ring.with_alpha(0.30)
222 };
223 let width = if is_sign_boundary { 1.0 } else { 0.4 };
224 let (xi, yi) = polar_to_screen(lon, asc, rot, radii.sign_inner);
225 let (xo, yo) = polar_to_screen(lon, asc, rot, radii.sign_outer);
226 out.push(DrawCommand::Line {
227 x1: cx + xi,
228 y1: cy + yi,
229 x2: cx + xo,
230 y2: cy + yo,
231 color,
232 width,
233 dash: None,
234 });
235 }
236
237 // === Glyphs zodiacales con color elemental ===
238 let sign_ring_mid = (radii.sign_outer + radii.sign_inner) / 2.0;
239 for layer in &model.layers {
240 if !matches!(layer.kind, crate::LayerKind::SignDial) {
241 continue;
242 }
243 for g in &layer.glyphs {
244 let (gx, gy) = polar_to_screen(g.deg, asc, rot, sign_ring_mid);
245 out.push(DrawCommand::Text {
246 x: cx + gx,
247 y: cy + gy,
248 content: sign_unicode(&g.symbol).into(),
249 color: pal.sign(&g.symbol),
250 size: opts.size * 0.032,
251 anchor: TextAnchor::Middle,
252 });
253 }
254 }
255
256 // === Casas topocéntricas (ring B→C) — si están en el modelo ===
257 let topo_outer = radii.topo_houses_outer;
258 let topo_inner = radii.topo_houses_inner;
259 let topo_ring_color = pal.house_ring();
260 let has_topo = model
261 .layers
262 .iter()
263 .any(|l| matches!(l.kind, crate::LayerKind::Houses) && l.module_id == "topocentric");
264 if has_topo {
265 out.push(DrawCommand::Circle {
266 cx,
267 cy,
268 r: topo_inner,
269 stroke: Some(topo_ring_color.with_alpha(0.55)),
270 fill: None,
271 stroke_w: 0.8,
272 });
273 }
274
275 // === Casas geocéntricas (ring C→D) ===
276 let house_outer_r = radii.houses_outer;
277 let house_inner_r = radii.houses_inner;
278 out.push(DrawCommand::Circle {
279 cx,
280 cy,
281 r: house_outer_r,
282 stroke: Some(pal.house_cusp),
283 fill: None,
284 stroke_w: 1.0,
285 });
286 out.push(DrawCommand::Circle {
287 cx,
288 cy,
289 r: house_inner_r,
290 stroke: Some(pal.house_cusp),
291 fill: None,
292 stroke_w: 1.0,
293 });
294
295 // Draws cusps + numbers for both house systems (topo + geo) in their respective rings.
296 for layer in &model.layers {
297 if !matches!(layer.kind, crate::LayerKind::Houses) {
298 continue;
299 }
300 let (ring_outer, ring_inner, base_color, label_color) = match layer.module_id.as_str() {
301 "topocentric" => (topo_outer, topo_inner, topo_ring_color, topo_ring_color),
302 _ => (house_outer_r, house_inner_r, pal.house_cusp, pal.fg_muted),
303 };
304 if let crate::Geometry::Ring { cusps_deg } = &layer.geometry {
305 for (i, c) in cusps_deg.iter().enumerate() {
306 let is_angle = i == 0 || i == 3 || i == 6 || i == 9;
307 let color = if is_angle {
308 pal.angle_highlight
309 } else {
310 base_color
311 };
312 let width = if is_angle { 1.8 } else { 0.8 };
313 let (xi, yi) = polar_to_screen(*c, asc, rot, ring_inner);
314 let (xo, yo) = polar_to_screen(*c, asc, rot, ring_outer);
315 out.push(DrawCommand::Line {
316 x1: cx + xi,
317 y1: cy + yi,
318 x2: cx + xo,
319 y2: cy + yo,
320 color,
321 width,
322 dash: None,
323 });
324 }
325 }
326 // House numbers en el centro del ring
327 let label_r = (ring_outer + ring_inner) / 2.0;
328 for g in &layer.glyphs {
329 if let Some(h) = g.house {
330 let (gx, gy) = polar_to_screen(g.deg, asc, rot, label_r);
331 out.push(DrawCommand::Text {
332 x: cx + gx,
333 y: cy + gy,
334 content: format!("{}", h),
335 color: label_color,
336 size: opts.size * 0.018,
337 anchor: TextAnchor::Middle,
338 });
339 }
340 }
341 }
342
343 // === Cuerpos por módulo (natal, topocentric, transit, progresión…) ===
344 // Spread anti-solapamiento + clusters compartidos + coord labels.
345 // Cada body-layer se renderea en su ring canónico.
346 let mut natal_display_by_body: std::collections::HashMap<String, f32> =
347 std::collections::HashMap::new();
348 if opts.include_bodies {
349 for layer in &model.layers {
350 if !matches!(layer.kind, crate::LayerKind::Bodies) {
351 continue;
352 }
353 let ring = radii.body_ring(&layer.module_id);
354 let is_natal = layer.module_id == "natal";
355 // Spread: separación mínima ~10°, shift máximo ~12°.
356 let raw_degs: Vec<f32> = layer.glyphs.iter().map(|g| g.deg).collect();
357 let (display_degs, residual) = spread_angles(&raw_degs, 10.0, 12.0);
358 // Clusters para encoger discos
359 let clusters = find_clusters(&raw_degs, 9.0);
360 let mut cluster_size: Vec<usize> = vec![1; layer.glyphs.len()];
361 for c in &clusters {
362 for &i in c {
363 cluster_size[i] = c.len();
364 }
365 }
366 // Disco base y escala por cluster size
367 let base_disk = opts.size * 0.022;
368 let base_font = opts.size * 0.028;
369 for (i, g) in layer.glyphs.iter().enumerate() {
370 let disp_deg = display_degs[i];
371 if is_natal {
372 natal_display_by_body.insert(g.symbol.clone(), disp_deg);
373 }
374 // Encoge un poco si el cluster es denso o quedó presión residual
375 let dense_factor = if cluster_size[i] >= 3 {
376 0.78
377 } else if cluster_size[i] == 2 {
378 0.88
379 } else {
380 1.0
381 };
382 let stress_factor = (1.0 - residual * 0.5).max(0.6);
383 let disk = base_disk * dense_factor * stress_factor;
384 let font = (base_font * dense_factor * stress_factor).max(opts.size * 0.018);
385
386 let (gx, gy) = polar_to_screen(disp_deg, asc, rot, ring);
387
388 // Halo del disco — color del planeta, fill oscuro/claro según tema
389 let body_color = pal.planet(&g.symbol);
390 let halo_fill = if pal.is_dark {
391 pal.bg_panel.with_alpha(0.92)
392 } else {
393 Rgba::opaque(1.0, 1.0, 1.0).with_alpha(0.92)
394 };
395 out.push(DrawCommand::Circle {
396 cx: cx + gx,
397 cy: cy + gy,
398 r: disk,
399 stroke: Some(body_color),
400 fill: Some(halo_fill),
401 stroke_w: 1.2,
402 });
403 // Glyph
404 out.push(DrawCommand::Text {
405 x: cx + gx,
406 y: cy + gy,
407 content: planet_unicode_with_retro(&g.symbol, g.retrograde),
408 color: body_color,
409 size: font,
410 anchor: TextAnchor::Middle,
411 });
412
413 // Coord label en pill — solo natal por ahora (sino se ensucia)
414 if opts.show_coord_labels && is_natal {
415 let label_ring = ring - disk * 1.6;
416 let (lx, ly) = polar_to_screen(disp_deg, asc, rot, label_ring);
417 out.push(DrawCommand::Text {
418 x: cx + lx,
419 y: cy + ly,
420 content: format_coord_compact(g.deg),
421 color: pal.fg_muted,
422 size: opts.size * 0.0155,
423 anchor: TextAnchor::Middle,
424 });
425 }
426 }
427 }
428 }
429
430 // === Anillo de aspectos + líneas ===
431 out.push(DrawCommand::Circle {
432 cx,
433 cy,
434 r: radii.aspects,
435 stroke: Some(pal.fg_muted.with_alpha(0.35)),
436 fill: None,
437 stroke_w: 0.6,
438 });
439 for layer in &model.layers {
440 if !matches!(layer.kind, crate::LayerKind::Aspects) {
441 continue;
442 }
443 let (ring_a, ring_b) = radii.aspect_endpoints(&layer.module_id);
444 if let crate::Geometry::Lines(segs) = &layer.geometry {
445 for seg in segs {
446 // Filtrar menores si opt off
447 let is_minor = !matches!(
448 seg.kind.as_str(),
449 "conjunction" | "sextile" | "square" | "trine" | "opposition"
450 );
451 if is_minor && !opts.show_minor_aspects {
452 continue;
453 }
454 // Endpoints: si tenemos display_deg natal, usarlo para que la
455 // línea apunte al cuerpo "spread", no al "raw". Cae al raw
456 // si no hay match (overlays sin natal de un lado).
457 let from_deg = natal_display_by_body
458 .get(&seg.from_body)
459 .copied()
460 .unwrap_or(seg.from_deg);
461 let to_deg = natal_display_by_body
462 .get(&seg.to_body)
463 .copied()
464 .unwrap_or(seg.to_deg);
465 let (ax, ay) = polar_to_screen(from_deg, asc, rot, ring_a);
466 let (bx, by) = polar_to_screen(to_deg, asc, rot, ring_b);
467 let alpha = (seg.opacity).clamp(0.0, 1.0);
468 // Width inversa al orbe — orbe 0 → 1.6, orbe 10° → 0.5
469 let width = (1.6 - seg.orb_deg.abs() * 0.10).clamp(0.45, 1.8);
470 out.push(DrawCommand::Line {
471 x1: cx + ax,
472 y1: cy + ay,
473 x2: cx + bx,
474 y2: cy + by,
475 color: pal.aspect(&seg.kind).with_alpha(alpha),
476 width,
477 dash: None,
478 });
479 }
480 }
481 }
482
483 // === Cruz ascensional + pills ASC/MC/DESC/IC ===
484 if opts.draw_ascensional_cross {
485 let cross_r = radii.aspects * 0.96;
486 let angles: [(f32, &str); 4] = [
487 (model.ascendant_deg, "Asc"),
488 (model.descendant_deg, "Desc"),
489 (model.midheaven_deg, "MC"),
490 (model.imum_coeli_deg, "IC"),
491 ];
492 // Asc↔Desc + IC↔MC — dos líneas finas a través del centro
493 for (a, b) in [
494 (model.ascendant_deg, model.descendant_deg),
495 (model.imum_coeli_deg, model.midheaven_deg),
496 ] {
497 let (ax, ay) = polar_to_screen(a, asc, rot, cross_r);
498 let (bx, by) = polar_to_screen(b, asc, rot, cross_r);
499 out.push(DrawCommand::Line {
500 x1: cx + ax,
501 y1: cy + ay,
502 x2: cx + bx,
503 y2: cy + by,
504 color: pal.angle_highlight.with_alpha(0.35),
505 width: 0.8,
506 dash: Some((4.0, 4.0)),
507 });
508 }
509 // Pills — label justo afuera del sign_outer
510 let pill_r = radii.sign_outer + opts.size * 0.025;
511 for (deg, label) in angles {
512 let (gx, gy) = polar_to_screen(deg, asc, rot, pill_r);
513 out.push(DrawCommand::Text {
514 x: cx + gx,
515 y: cy + gy,
516 content: label.into(),
517 color: pal.angle_highlight,
518 size: opts.size * 0.022,
519 anchor: TextAnchor::Middle,
520 });
521 }
522 }
523
524 out
525}
526
527/// Sirve los `DrawCommand`s como un documento SVG completo.
528/// Devuelve un `String` listo para `innerHTML = ...` o file.
529pub fn draw_commands_to_svg(commands: &[DrawCommand], size: f32) -> String {
530 let mut s = String::with_capacity(8192);
531 s.push_str(&format!(
532 "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{0}\" height=\"{0}\" viewBox=\"0 0 {0} {0}\">",
533 size as i32
534 ));
535 for cmd in commands {
536 match cmd {
537 DrawCommand::Circle { cx, cy, r, stroke, fill, stroke_w } => {
538 let stroke_attr = stroke
539 .map(|c| format!(" stroke=\"{}\" stroke-width=\"{}\"", c.to_css(), stroke_w))
540 .unwrap_or_default();
541 let fill_attr = match fill {
542 Some(c) => format!(" fill=\"{}\"", c.to_css()),
543 None => " fill=\"none\"".into(),
544 };
545 s.push_str(&format!(
546 "<circle cx=\"{:.2}\" cy=\"{:.2}\" r=\"{:.2}\"{}{}/>",
547 cx, cy, r, stroke_attr, fill_attr
548 ));
549 }
550 DrawCommand::Line { x1, y1, x2, y2, color, width, dash } => {
551 let dash_attr = match dash {
552 Some((on, off)) => format!(" stroke-dasharray=\"{},{}\"", on, off),
553 None => String::new(),
554 };
555 s.push_str(&format!(
556 "<line x1=\"{:.2}\" y1=\"{:.2}\" x2=\"{:.2}\" y2=\"{:.2}\" stroke=\"{}\" stroke-width=\"{}\"{}/>",
557 x1, y1, x2, y2, color.to_css(), width, dash_attr
558 ));
559 }
560 DrawCommand::Text { x, y, content, color, size: sz, anchor } => {
561 let anchor_attr = match anchor {
562 TextAnchor::Start => "start",
563 TextAnchor::Middle => "middle",
564 TextAnchor::End => "end",
565 };
566 let escaped = svg_escape(content);
567 s.push_str(&format!(
568 "<text x=\"{:.2}\" y=\"{:.2}\" font-size=\"{:.2}\" fill=\"{}\" text-anchor=\"{}\" dominant-baseline=\"central\">{}</text>",
569 x, y, sz, color.to_css(), anchor_attr, escaped
570 ));
571 }
572 }
573 }
574 s.push_str("</svg>");
575 s
576}
577
578fn svg_escape(s: &str) -> String {
579 s.replace('&', "&")
580 .replace('<', "<")
581 .replace('>', ">")
582 .replace('"', """)
583}
584
585pub(crate) fn sign_unicode(name: &str) -> &'static str {
586 match name {
587 "aries" => "♈",
588 "taurus" => "♉",
589 "gemini" => "♊",
590 "cancer" => "♋",
591 "leo" => "♌",
592 "virgo" => "♍",
593 "libra" => "♎",
594 "scorpio" => "♏",
595 "sagittarius" => "♐",
596 "capricorn" => "♑",
597 "aquarius" => "♒",
598 "pisces" => "♓",
599 _ => "?",
600 }
601}
602
603fn planet_unicode(name: &str) -> &'static str {
604 match name {
605 "sun" => "☉",
606 "moon" => "☽",
607 "mercury" => "☿",
608 "venus" => "♀",
609 "mars" => "♂",
610 "jupiter" => "♃",
611 "saturn" => "♄",
612 "uranus" => "♅",
613 "neptune" => "♆",
614 "pluto" => "♇",
615 "north_node" => "☊",
616 "south_node" => "☋",
617 "chiron" => "⚷",
618 "lilith" => "⚸",
619 _ => "•",
620 }
621}
622
623/// Glyph del cuerpo con sufijo "℞" si está retrógrado — concatenación
624/// directa en el text para no agregar más comandos por planeta.
625pub(crate) fn planet_unicode_with_retro(name: &str, retrograde: bool) -> String {
626 if retrograde {
627 format!("{}℞", planet_unicode(name))
628 } else {
629 planet_unicode(name).to_string()
630 }
631}