feat(cosmobiologia): carta armónica — el slider de armónico ahora pinta
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 <noreply@anthropic.com>
This commit is contained in:
@@ -945,6 +945,7 @@ impl Shell {
|
|||||||
show_minors: read_bool("aspect_minors", false),
|
show_minors: read_bool("aspect_minors", false),
|
||||||
orb_multiplier: read_f64("orb_multiplier", 1.0),
|
orb_multiplier: read_f64("orb_multiplier", 1.0),
|
||||||
show_dignities: read_bool("show_dignities", false),
|
show_dignities: read_bool("show_dignities", false),
|
||||||
|
harmonic: read_f64("harmonic", 1.0).round().clamp(1.0, 64.0) as u32,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -282,6 +282,12 @@ pub fn compose(
|
|||||||
}
|
}
|
||||||
populate_natal_aspect_summary(&aspects, &mut render);
|
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 {
|
for req in requests {
|
||||||
match req {
|
match req {
|
||||||
crate::PipelineRequest::Transit => {
|
crate::PipelineRequest::Transit => {
|
||||||
|
|||||||
@@ -36,8 +36,9 @@ pub use cosmobiologia_model::{Chart, ChartId, ChartKind};
|
|||||||
// (`cosmobiologia_engine::Layer`, etc.) sin tener que cambiar
|
// (`cosmobiologia_engine::Layer`, etc.) sin tener que cambiar
|
||||||
// imports en el shell, canvas, modules, tree, panel...
|
// imports en el shell, canvas, modules, tree, panel...
|
||||||
pub use cosmobiologia_render::{
|
pub use cosmobiologia_render::{
|
||||||
compute_gr_triggers, AspectSummary, Geometry, Glyph, GrDirection, GrTrigger, Layer, LayerKind,
|
apply_harmonic, compute_gr_triggers, AspectSummary, Geometry, Glyph, GrDirection, GrTrigger,
|
||||||
LineSeg, OverlayMeta, PointMark, RenderModel, UranianGroup, OUTER_RING_MODULES,
|
Layer, LayerKind, LineSeg, OverlayMeta, PointMark, RenderModel, UranianGroup,
|
||||||
|
OUTER_RING_MODULES,
|
||||||
};
|
};
|
||||||
|
|
||||||
// `Chart` reexportado arriba es lo que `PipelineRequest::Synastry`
|
// `Chart` reexportado arriba es lo que `PipelineRequest::Synastry`
|
||||||
@@ -191,6 +192,10 @@ pub struct NatalOptions {
|
|||||||
/// (domicilio +, exaltación ·, exilio −, caída *). El canvas lo
|
/// (domicilio +, exaltación ·, exilio −, caída *). El canvas lo
|
||||||
/// renderea como sufijo del glifo.
|
/// renderea como sufijo del glifo.
|
||||||
pub show_dignities: bool,
|
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 {
|
impl Default for NatalOptions {
|
||||||
@@ -200,6 +205,7 @@ impl Default for NatalOptions {
|
|||||||
show_minors: false,
|
show_minors: false,
|
||||||
orb_multiplier: 1.0,
|
orb_multiplier: 1.0,
|
||||||
show_dignities: false,
|
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<f32> {
|
||||||
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<LineSeg> {
|
||||||
|
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<Glyph> = bodies.iter().map(|(s, d)| body(s, *d)).collect();
|
||||||
|
let points: Vec<PointMark> = 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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ pub use cosmobiologia_model::{Chart, ChartId, ChartKind};
|
|||||||
|
|
||||||
pub mod draw;
|
pub mod draw;
|
||||||
pub mod gr;
|
pub mod gr;
|
||||||
|
pub mod harmonic;
|
||||||
pub mod math;
|
pub mod math;
|
||||||
pub mod palette;
|
pub mod palette;
|
||||||
|
|
||||||
@@ -39,6 +40,7 @@ pub use draw::{
|
|||||||
compose_wheel, draw_commands_to_svg, CompositionOpts, DrawCommand, Rgba, TextAnchor,
|
compose_wheel, draw_commands_to_svg, CompositionOpts, DrawCommand, Rgba, TextAnchor,
|
||||||
};
|
};
|
||||||
pub use gr::{compute_gr_triggers, GrDirection, GrTrigger};
|
pub use gr::{compute_gr_triggers, GrDirection, GrTrigger};
|
||||||
|
pub use harmonic::apply_harmonic;
|
||||||
pub use math::{
|
pub use math::{
|
||||||
find_clusters, format_coord_compact, polar_to_screen, spread_angles, Radii,
|
find_clusters, format_coord_compact, polar_to_screen, spread_angles, Radii,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user