From 968255f4cd3458e655345ec70b0687ad703f3871 Mon Sep 17 00:00:00 2001 From: sergio Date: Fri, 22 May 2026 13:54:02 +0000 Subject: [PATCH] =?UTF-8?q?feat(cosmobiologia):=20carta=20arm=C3=B3nica=20?= =?UTF-8?q?=E2=80=94=20el=20slider=20de=20arm=C3=B3nico=20ahora=20pinta?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit El slider "Armónico" del NatalModule existía pero no hacía nada. Ahora re-renderiza la carta en el armónico de orden N. - cosmobiologia-render: módulo `harmonic` agnóstico — apply_harmonic transforma los cuerpos natales a (longitud·N) mod 360 y recomputa los aspectos sobre las posiciones armónicas (conjunción, oposición, trígono, cuadratura, sextil). Las casas se conservan como marco. 6 tests (incluye: quintil natal → conjunción en H5). - cosmobiologia-engine: NatalOptions.harmonic; compose lo aplica tras la pasada natal, antes de los overlays. Test end-to-end. - shell: build_natal_options lee el slider del módulo natal. El título anota "· HN". Falta: histograma de fuerza por armónico. Co-Authored-By: Claude Opus 4.7 --- crates/apps/cosmobiologia/src/shell.rs | 1 + .../cosmobiologia-engine/src/bridge.rs | 6 + .../cosmobiologia-engine/src/lib.rs | 48 +++- .../cosmobiologia-render/src/harmonic.rs | 271 ++++++++++++++++++ .../cosmobiologia-render/src/lib.rs | 2 + 5 files changed, 326 insertions(+), 2 deletions(-) create mode 100644 crates/modules/cosmobiologia/cosmobiologia-render/src/harmonic.rs diff --git a/crates/apps/cosmobiologia/src/shell.rs b/crates/apps/cosmobiologia/src/shell.rs index 52ce17f..ca2ec70 100644 --- a/crates/apps/cosmobiologia/src/shell.rs +++ b/crates/apps/cosmobiologia/src/shell.rs @@ -945,6 +945,7 @@ impl Shell { show_minors: read_bool("aspect_minors", false), orb_multiplier: read_f64("orb_multiplier", 1.0), show_dignities: read_bool("show_dignities", false), + harmonic: read_f64("harmonic", 1.0).round().clamp(1.0, 64.0) as u32, } } diff --git a/crates/modules/cosmobiologia/cosmobiologia-engine/src/bridge.rs b/crates/modules/cosmobiologia/cosmobiologia-engine/src/bridge.rs index 3702338..637aae4 100644 --- a/crates/modules/cosmobiologia/cosmobiologia-engine/src/bridge.rs +++ b/crates/modules/cosmobiologia/cosmobiologia-engine/src/bridge.rs @@ -282,6 +282,12 @@ pub fn compose( } populate_natal_aspect_summary(&aspects, &mut render); + // Carta armónica: re-renderiza los cuerpos natales en su armónico + // de orden N y recomputa sus aspectos. Se aplica antes de los + // overlays — éstos quedan en coordenadas natales (la armónica es + // un análisis de la carta natal pura). + crate::apply_harmonic(&mut render, natal_options.harmonic); + for req in requests { match req { crate::PipelineRequest::Transit => { diff --git a/crates/modules/cosmobiologia/cosmobiologia-engine/src/lib.rs b/crates/modules/cosmobiologia/cosmobiologia-engine/src/lib.rs index b79aa49..9e8c14e 100644 --- a/crates/modules/cosmobiologia/cosmobiologia-engine/src/lib.rs +++ b/crates/modules/cosmobiologia/cosmobiologia-engine/src/lib.rs @@ -36,8 +36,9 @@ pub use cosmobiologia_model::{Chart, ChartId, ChartKind}; // (`cosmobiologia_engine::Layer`, etc.) sin tener que cambiar // imports en el shell, canvas, modules, tree, panel... pub use cosmobiologia_render::{ - compute_gr_triggers, AspectSummary, Geometry, Glyph, GrDirection, GrTrigger, Layer, LayerKind, - LineSeg, OverlayMeta, PointMark, RenderModel, UranianGroup, OUTER_RING_MODULES, + apply_harmonic, compute_gr_triggers, AspectSummary, Geometry, Glyph, GrDirection, GrTrigger, + Layer, LayerKind, LineSeg, OverlayMeta, PointMark, RenderModel, UranianGroup, + OUTER_RING_MODULES, }; // `Chart` reexportado arriba es lo que `PipelineRequest::Synastry` @@ -191,6 +192,10 @@ pub struct NatalOptions { /// (domicilio +, exaltación ·, exilio −, caída *). El canvas lo /// renderea como sufijo del glifo. pub show_dignities: bool, + /// Orden de la carta armónica. `1` = carta natal sin transformar; + /// `N > 1` re-renderiza los cuerpos en `(longitud · N) mod 360` y + /// recomputa los aspectos sobre esas posiciones. + pub harmonic: u32, } impl Default for NatalOptions { @@ -200,6 +205,7 @@ impl Default for NatalOptions { show_minors: false, orb_multiplier: 1.0, show_dignities: false, + harmonic: 1, } } } @@ -489,4 +495,42 @@ mod tests { } } } + + /// La carta armónica debe mover los cuerpos respecto de la natal y + /// anotar el orden en el título. + #[cfg(feature = "eternal-bridge")] + #[test] + fn harmonic_chart_transforms_bodies_and_title() { + let chart = sample_chart(); + let natal = compose_with_options(&chart, 0, &[], &NatalOptions::default()) + .expect("compose natal"); + let h5 = compose_with_options( + &chart, + 0, + &[], + &NatalOptions { + harmonic: 5, + ..NatalOptions::default() + }, + ) + .expect("compose H5"); + + assert!(h5.title.ends_with("· H5"), "título anota el armónico"); + + let pick = |m: &RenderModel| -> Vec { + m.layers + .iter() + .find(|l| matches!(l.kind, LayerKind::Bodies)) + .map(|l| l.glyphs.iter().map(|g| g.deg).collect()) + .unwrap_or_default() + }; + let natal_degs = pick(&natal); + let h5_degs = pick(&h5); + assert_eq!(natal_degs.len(), h5_degs.len()); + let moved = natal_degs + .iter() + .zip(&h5_degs) + .any(|(a, b)| (a - b).abs() > 0.01); + assert!(moved, "el armónico debe mover los cuerpos"); + } } diff --git a/crates/modules/cosmobiologia/cosmobiologia-render/src/harmonic.rs b/crates/modules/cosmobiologia/cosmobiologia-render/src/harmonic.rs new file mode 100644 index 0000000..119d7a0 --- /dev/null +++ b/crates/modules/cosmobiologia/cosmobiologia-render/src/harmonic.rs @@ -0,0 +1,271 @@ +//! Carta armónica — transforma un `RenderModel` natal a su armónico +//! de orden N. +//! +//! La carta armónica multiplica cada longitud eclíptica por N (mod +//! 360). Es la herramienta de John Addey / David Cochrane para +//! revelar patrones de aspecto: dos cuerpos que forman el aspecto de +//! la N-ésima armónica natal (p. ej. un quintil en N=5) caen +//! conjuntos en la carta armónica N — los clusters armónicos saltan +//! a la vista. +//! +//! Lógica pura, agnóstica de surface: el engine produce el +//! `RenderModel` natal y delega aquí la transformación. Reutilizable +//! por el canvas gpui y por el cliente web. + +use crate::{AspectSummary, Geometry, LayerKind, LineSeg, RenderModel}; + +/// Aspectos que se buscan en la carta armónica: `(id, ángulo, orbe)`. +/// Conjunción y oposición llevan orbe más amplio, como es convención. +const HARMONIC_ASPECTS: &[(&str, f32, f32)] = &[ + ("conjunction", 0.0, 8.0), + ("opposition", 180.0, 7.0), + ("trine", 120.0, 6.0), + ("square", 90.0, 6.0), + ("sextile", 60.0, 4.0), +]; + +/// Transforma `model` —una carta natal ya compuesta— en su carta +/// armónica de orden `n`. `n <= 1` la deja intacta. +/// +/// Sólo afecta las capas `module_id == "natal"`: los cuerpos pasan a +/// `(lon · n) mod 360` y la capa de aspectos se recomputa sobre las +/// posiciones armónicas. Las casas y los ángulos natales se conservan +/// como marco espacial de referencia (variante "armónicos en casas +/// radicales"); los overlays, si los hubiera, quedan intactos. +pub fn apply_harmonic(model: &mut RenderModel, n: u32) { + if n <= 1 { + return; + } + let nf = n as f32; + + // 1. Transformar los cuerpos natales; recolectar `(símbolo, lon)`. + let mut bodies: Vec<(String, f32)> = Vec::new(); + for layer in &mut model.layers { + if layer.module_id != "natal" || layer.kind != LayerKind::Bodies { + continue; + } + for g in &mut layer.glyphs { + g.deg = (g.deg * nf).rem_euclid(360.0); + bodies.push((g.symbol.clone(), g.deg)); + } + if let Geometry::Points(points) = &mut layer.geometry { + for p in points { + p.deg = (p.deg * nf).rem_euclid(360.0); + } + } + } + + // 2. Recomputar la capa de aspectos natal sobre las posiciones + // armónicas. + let lines = harmonic_aspect_lines(&bodies); + for layer in &mut model.layers { + if layer.module_id == "natal" && layer.kind == LayerKind::Aspects { + layer.geometry = Geometry::Lines(lines.clone()); + } + } + + // 3. Rehacer el `aspect_summary`. En este punto del pipeline sólo + // contiene aspectos natales (los overlays agregan los suyos + // después de esta transformación). + model.aspect_summary = lines + .iter() + .map(|l| AspectSummary { + module_id: "natal".into(), + from_body: l.from_body.clone(), + to_body: l.to_body.clone(), + kind: l.kind.clone(), + orb_deg: l.orb_deg as f64, + applying: None, + }) + .collect(); + + // 4. Anotar el armónico en el título. + model.title = format!("{} · H{}", model.title, n); +} + +/// Separación circular mínima entre dos longitudes (rango `0..=180`). +fn circular_sep(a: f32, b: f32) -> f32 { + let d = (a - b).rem_euclid(360.0); + d.min(360.0 - d) +} + +/// Busca aspectos entre cada par de cuerpos por sus longitudes (ya +/// armónicas). Devuelve los segmentos ordenados por orbe ascendente. +fn harmonic_aspect_lines(bodies: &[(String, f32)]) -> Vec { + let mut lines = Vec::new(); + for i in 0..bodies.len() { + for j in (i + 1)..bodies.len() { + let (a_sym, a_deg) = &bodies[i]; + let (b_sym, b_deg) = &bodies[j]; + let sep = circular_sep(*a_deg, *b_deg); + for (id, angle, orb) in HARMONIC_ASPECTS { + let delta = (sep - angle).abs(); + if delta <= *orb { + lines.push(LineSeg { + from_deg: *a_deg, + to_deg: *b_deg, + kind: (*id).to_string(), + opacity: (1.0 - delta / orb * 0.65).clamp(0.30, 1.0), + from_body: a_sym.clone(), + to_body: b_sym.clone(), + orb_deg: delta, + }); + break; // un par no forma dos aspectos a la vez + } + } + } + } + lines.sort_by(|x, y| { + x.orb_deg + .partial_cmp(&y.orb_deg) + .unwrap_or(core::cmp::Ordering::Equal) + }); + lines +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ChartId, ChartKind, Geometry, Glyph, Layer, LayerKind, PointMark, RenderModel}; + + fn body(symbol: &str, deg: f32) -> Glyph { + Glyph { + deg, + symbol: symbol.to_string(), + ..Default::default() + } + } + + fn natal_model(bodies: &[(&str, f32)]) -> RenderModel { + let glyphs: Vec = bodies.iter().map(|(s, d)| body(s, *d)).collect(); + let points: Vec = bodies + .iter() + .map(|(s, d)| PointMark { + deg: *d, + label: s.to_string(), + tag: s.to_string(), + }) + .collect(); + RenderModel { + chart_id: ChartId::new(), + chart_kind: ChartKind::Natal, + title: "Test".to_string(), + subtitle: None, + compute_ms: 0, + ascendant_deg: 0.0, + midheaven_deg: 270.0, + descendant_deg: 180.0, + imum_coeli_deg: 90.0, + layers: vec![ + Layer { + module_id: "natal".into(), + kind: LayerKind::Houses, + ring: 0.86, + z: 1, + geometry: Geometry::Ring { + cusps_deg: (0..12).map(|i| i as f32 * 30.0).collect(), + }, + glyphs: Vec::new(), + }, + Layer { + module_id: "natal".into(), + kind: LayerKind::Bodies, + ring: 0.72, + z: 2, + geometry: Geometry::Points(points), + glyphs, + }, + Layer { + module_id: "natal".into(), + kind: LayerKind::Aspects, + ring: 0.58, + z: 3, + geometry: Geometry::Lines(Vec::new()), + glyphs: Vec::new(), + }, + ], + overlays: Vec::new(), + aspect_summary: Vec::new(), + uranian_groups: Vec::new(), + gr_triggers: Vec::new(), + } + } + + fn bodies_layer(model: &RenderModel) -> &Layer { + model + .layers + .iter() + .find(|l| l.module_id == "natal" && l.kind == LayerKind::Bodies) + .expect("capa de cuerpos") + } + + #[test] + fn harmonic_one_is_identity() { + let mut model = natal_model(&[("sun", 30.0), ("moon", 200.0)]); + let before = model.clone(); + apply_harmonic(&mut model, 1); + assert_eq!(bodies_layer(&model).glyphs[0].deg, before.layers[1].glyphs[0].deg); + assert_eq!(model.title, "Test"); + } + + #[test] + fn harmonic_two_doubles_longitudes_mod_360() { + let mut model = natal_model(&[("sun", 30.0), ("moon", 200.0)]); + apply_harmonic(&mut model, 2); + let g = &bodies_layer(&model).glyphs; + assert!((g[0].deg - 60.0).abs() < 1e-3, "30·2 = 60"); + assert!((g[1].deg - 40.0).abs() < 1e-3, "200·2 = 400 ≡ 40"); + } + + #[test] + fn harmonic_two_also_transforms_point_marks() { + let mut model = natal_model(&[("sun", 100.0)]); + apply_harmonic(&mut model, 2); + let Geometry::Points(points) = &bodies_layer(&model).geometry else { + panic!("la capa de cuerpos debe seguir siendo Points"); + }; + assert!((points[0].deg - 200.0).abs() < 1e-3); + } + + #[test] + fn quintile_natally_becomes_conjunction_in_h5() { + // 0° y 72° forman un quintil (72°). En H5: 0·5=0, 72·5=360≡0 + // → conjunción exacta. + let mut model = natal_model(&[("sun", 0.0), ("venus", 72.0)]); + apply_harmonic(&mut model, 5); + let Geometry::Lines(lines) = &model + .layers + .iter() + .find(|l| l.kind == LayerKind::Aspects) + .unwrap() + .geometry + else { + panic!("capa de aspectos"); + }; + assert_eq!(lines.len(), 1); + assert_eq!(lines[0].kind, "conjunction"); + assert!(lines[0].orb_deg < 0.01, "orbe ~0"); + } + + #[test] + fn harmonic_annotates_title_and_summary() { + let mut model = natal_model(&[("sun", 0.0), ("venus", 72.0)]); + apply_harmonic(&mut model, 5); + assert_eq!(model.title, "Test · H5"); + assert_eq!(model.aspect_summary.len(), 1); + assert_eq!(model.aspect_summary[0].kind, "conjunction"); + } + + #[test] + fn houses_layer_is_preserved() { + let mut model = natal_model(&[("sun", 10.0)]); + apply_harmonic(&mut model, 3); + assert!( + model + .layers + .iter() + .any(|l| l.kind == LayerKind::Houses), + "las casas se conservan como marco de referencia" + ); + } +} diff --git a/crates/modules/cosmobiologia/cosmobiologia-render/src/lib.rs b/crates/modules/cosmobiologia/cosmobiologia-render/src/lib.rs index 066058d..ab35fa0 100644 --- a/crates/modules/cosmobiologia/cosmobiologia-render/src/lib.rs +++ b/crates/modules/cosmobiologia/cosmobiologia-render/src/lib.rs @@ -32,6 +32,7 @@ pub use cosmobiologia_model::{Chart, ChartId, ChartKind}; pub mod draw; pub mod gr; +pub mod harmonic; pub mod math; pub mod palette; @@ -39,6 +40,7 @@ pub use draw::{ compose_wheel, draw_commands_to_svg, CompositionOpts, DrawCommand, Rgba, TextAnchor, }; pub use gr::{compute_gr_triggers, GrDirection, GrTrigger}; +pub use harmonic::apply_harmonic; pub use math::{ find_clusters, format_coord_compact, polar_to_screen, spread_angles, Radii, };