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:
sergio
2026-05-22 13:54:02 +00:00
parent ed4d5ffe4c
commit 968255f4cd
5 changed files with 326 additions and 2 deletions
@@ -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 => {
@@ -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<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");
}
}