From d890bd4b3a7aa8af918e854c1ae6754ca08673cd Mon Sep 17 00:00:00 2001 From: sergio Date: Sun, 17 May 2026 23:34:35 +0000 Subject: [PATCH] =?UTF-8?q?feat(tahuantinsuyu):=20fase=2021=20=E2=80=94=20?= =?UTF-8?q?background=20compute=20+=20UranianModule?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cierre del brief original — última pieza visual (Uraniano) + perf. ## #1 — Compute en background thread render_current() pasa de bloqueante a async. La pipeline corre en cx.background_executor().spawn (no UI thread), y al terminar el update vuelve al UI vía cx.spawn. Sin esto, un drag del slider con muchos overlays bloquea el frame por hasta 200ms. Cancelación: Shell gana `render_seq: u64`. Cada render_current() incrementa el counter y captura su número; el closure async compara antes de aplicar. Si llegó un compute más nuevo en el medio (drag rápido), el viejo se descarta — evita el race donde un cómputo lento sobrescribe uno reciente y rápido. Inputs al background: Chart clonado + offset + Vec + NatalOptions. La sesión VSOP2013 sigue siendo `static OnceLock` read-only, accesible desde cualquier thread. ## #11 — UranianModule (versión textual) Cierra la última pieza del brief original. Toggle "Uraniano (90°)" en el panel; engine detecta cuerpos natales cuya longitud módulo 90 cae dentro de ε=2° y los agrupa como "ejes". Footer renderea cada grupo como pill con los unicodes (☉ ♃ · 14.3°) bajo el header "Ejes uranianos (90°)". El algoritmo: 1. mod90 = longitude.rem_euclid(90.0) para cada placement 2. Sort por mod90 ascendente 3. Walk lineal agrupando entradas con diff(mod90) ≤ ε 4. Wrap-around check: el primer y último grupo se mergean si abarcan el cierre del dial (88→2 = solo 4° de diff modular) 5. Solo emite grupos con 2+ miembros (singletons no son fórmulas) - engine: PipelineRequest::Uranian + UranianGroup struct + build_uranian_groups helper. RenderModel gana uranian_groups field. push_overlay_meta tipo "Uraniano · N ejes" o "sin ejes". - modules: uranian::UranianModule (toggle "Activar"). Registry pasa a 9 módulos para ChartKind::Natal. Test actualizado. - shell: build_requests detecta uranian.enabled, pushea PipelineRequest::Uranian (sin parámetros). - canvas: footer agrega sección "Ejes uranianos (90°)" con pills arriba de la lista de aspectos — border angle_highlight para invitar a la lectura. La visualización geométrica completa del dial de 90° con árbol de simetría al hover queda para una fase posterior — esta versión textual cubre el caso analítico (ver qué cuerpos están "en relación uraniana") sin requerir un canvas secundario. cargo check verde, 8 tests engine + 1 test modules (9 módulos aplicables a ChartKind::Natal) verdes. Co-Authored-By: Claude Opus 4.7 --- crates/apps/tahuantinsuyu/src/shell.rs | 90 +++++++++++++------ .../tahuantinsuyu-canvas/src/lib.rs | 45 ++++++++++ .../tahuantinsuyu-engine/src/bridge.rs | 85 +++++++++++++++++- .../tahuantinsuyu-engine/src/lib.rs | 29 ++++++ .../tahuantinsuyu-modules/src/lib.rs | 49 +++++++++- 5 files changed, 269 insertions(+), 29 deletions(-) diff --git a/crates/apps/tahuantinsuyu/src/shell.rs b/crates/apps/tahuantinsuyu/src/shell.rs index 4238a46..b783f5c 100644 --- a/crates/apps/tahuantinsuyu/src/shell.rs +++ b/crates/apps/tahuantinsuyu/src/shell.rs @@ -59,6 +59,11 @@ pub struct Shell { /// `module_id`. Las claves dentro del JSON dependen del módulo (la /// convención es `"enabled": bool` para el toggle principal). module_configs: HashMap, + /// Sequence counter para descartar resultados de cómputos + /// background que llegan después de uno más reciente. Cada + /// `render_current` lo incrementa y la closure async compara antes + /// de aplicar el render al canvas. + render_seq: u64, } impl Shell { @@ -94,6 +99,7 @@ impl Shell { current_chart: None, current_offset_minutes: 0, module_configs: HashMap::new(), + render_seq: 0, }; shell.refresh_chart_options(cx); shell @@ -256,6 +262,9 @@ impl Shell { if module_enabled(&self.module_configs, "midpoints") { requests.push(PipelineRequest::Midpoints); } + if module_enabled(&self.module_configs, "uranian") { + requests.push(PipelineRequest::Uranian); + } if module_enabled(&self.module_configs, "composite") { if let Some(partner) = self.resolve_composite_partner() { requests.push(PipelineRequest::Composite { @@ -464,34 +473,63 @@ impl Shell { let Some(chart) = self.current_chart.as_ref() else { return; }; + // Snapshot de inputs para mover al background. La sesión + // VSOP2013 vive en un static `OnceLock` adentro del bridge, así + // que es compartible read-only entre threads sin que ningún + // dato cruce más allá del Chart clonado + requests/options. + let chart = chart.clone(); + let offset = self.current_offset_minutes; let requests = self.build_requests(); let natal_options = self.build_natal_options(); - let render = match compose_with_options( - chart, - self.current_offset_minutes, - &requests, - &natal_options, - ) { - Ok(r) => r, - Err(e) => { - eprintln!( - "[shell] compose {} (+{}min, {} reqs): {}", - chart.id, - self.current_offset_minutes, - requests.len(), - e - ); - return; - } - }; - self.canvas.update(cx, |c, cx| { - c.set_mode( - CanvasMode::Wheel { - render: Box::new(render), - }, - cx, - ); - }); + self.render_seq = self.render_seq.wrapping_add(1); + let my_seq = self.render_seq; + + cx.spawn(async move |this, cx| { + // El compute corre en el background_executor — no bloquea + // el UI thread. Para una rueda completa con varios overlays + // puede tomar 100-200ms; sin esto, los drags del slider se + // sentirían atorados. + let chart_for_bg = chart.clone(); + let requests_for_bg = requests.clone(); + let opts_for_bg = natal_options.clone(); + let result = cx + .background_executor() + .spawn(async move { + compose_with_options(&chart_for_bg, offset, &requests_for_bg, &opts_for_bg) + }) + .await; + + let _ = this.update(cx, |this, cx| { + // Descartar si llegó un render más nuevo en el medio. + // Sin este check, durante un drag rápido un compute + // viejo podría sobrescribir el más reciente. + if this.render_seq != my_seq { + return; + } + match result { + Ok(render) => { + this.canvas.update(cx, |c, cx| { + c.set_mode( + CanvasMode::Wheel { + render: Box::new(render), + }, + cx, + ); + }); + } + Err(e) => { + eprintln!( + "[shell] compose {} (+{}min, {} reqs): {}", + chart.id, + offset, + requests.len(), + e + ); + } + } + }); + }) + .detach(); } fn on_canvas_event(&mut self, ev: &CanvasEvent, cx: &mut Context) { diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs index 7b22247..ca9861a 100644 --- a/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs +++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs @@ -1005,6 +1005,51 @@ fn render_wheel( footer = footer.child(b); } + // Ejes uranianos detectados (cuerpos en la misma posición mod 90). + // Aparece sólo cuando el módulo Uranian está activo y hay + // grupos. Cada grupo se muestra como pill con los unicode de los + // cuerpos + el grado dial-90. + if !render.uranian_groups.is_empty() { + let mut row = div().flex().flex_row().flex_wrap().gap(px(6.0)); + for group in &render.uranian_groups { + let bodies_text: String = group + .bodies + .iter() + .map(|b| planet_unicode(b)) + .collect::>() + .join(" "); + row = row.child( + div() + .px(px(8.0)) + .py(px(2.0)) + .rounded(px(10.0)) + .bg(theme.bg_panel_alt.clone()) + .border_1() + .border_color(with_alpha(palette.angle_highlight, 0.6)) + .text_size(px(11.0)) + .text_color(theme.fg_text) + .child(SharedString::from(format!( + "{} · {:.1}°", + bodies_text, group.mod90_deg + ))), + ); + } + footer = footer.child( + div() + .flex() + .flex_col() + .items_center() + .gap(px(3.0)) + .child( + div() + .text_size(px(10.0)) + .text_color(theme.fg_muted) + .child("Ejes uranianos (90°)"), + ) + .child(row), + ); + } + // Lista textual de aspectos (top 12 por orb). Compacta, en grid // de 3 columnas, fonts pequeños. Solo aparece cuando hay aspectos // computados. diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs index cab0f07..2b8c44a 100644 --- a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs +++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs @@ -20,7 +20,7 @@ use tahuantinsuyu_model::{Chart, HouseSystem, StoredChartConfig, Zodiac}; use crate::dignity::essential_dignity; use crate::{ AspectSummary, EngineError, Geometry, Glyph, Layer, LayerKind, LineSeg, OverlayMeta, - RenderModel, + RenderModel, UranianGroup, }; // ===================================================================== @@ -343,6 +343,19 @@ pub fn compose( format!("Composite · {}", partner_label), ); } + crate::PipelineRequest::Uranian => { + build_uranian_groups(&natal, &mut render); + let n = render.uranian_groups.len(); + push_overlay_meta( + &mut render, + "uranian", + if n == 0 { + "Uraniano · sin ejes".into() + } else { + format!("Uraniano · {} ejes", n) + }, + ); + } } } @@ -487,6 +500,75 @@ fn build_progression_overlay( Ok(()) } +/// Helper: detecta "ejes" del dial uraniano de 90° — cuerpos natales +/// cuya longitud módulo 90 cae dentro de una tolerancia ε (2° por +/// default). Llena `render.uranian_groups` con los grupos detectados. +fn build_uranian_groups(natal: &NatalChart, render: &mut RenderModel) { + const EPSILON: f64 = 2.0; + let mut entries: Vec<(String, f64)> = natal + .placements + .iter() + .map(|p| { + let lon = p.longitude.longitude_deg(); + let mod90 = lon.rem_euclid(90.0); + (body_symbol(p.body).to_string(), mod90) + }) + .collect(); + entries.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal)); + + let mut groups: Vec = Vec::new(); + let mut current: Vec<(String, f64)> = Vec::new(); + let mut anchor_mod90 = 0.0_f64; + for entry in entries { + if current.is_empty() { + anchor_mod90 = entry.1; + current.push(entry); + continue; + } + // Distancia circular módulo 90 entre el entry y el anchor. + let mut diff = (entry.1 - anchor_mod90).abs(); + if diff > 45.0 { + diff = 90.0 - diff; + } + if diff <= EPSILON { + current.push(entry); + } else { + if current.len() >= 2 { + let center = current.iter().map(|(_, m)| *m).sum::() / current.len() as f64; + groups.push(UranianGroup { + bodies: current.iter().map(|(s, _)| s.clone()).collect(), + mod90_deg: center, + }); + } + anchor_mod90 = entry.1; + current = vec![entry]; + } + } + if current.len() >= 2 { + let center = current.iter().map(|(_, m)| *m).sum::() / current.len() as f64; + groups.push(UranianGroup { + bodies: current.iter().map(|(s, _)| s.clone()).collect(), + mod90_deg: center, + }); + } + // Wrap-around check: el primer y último grupo podrían ser el mismo + // (si span >88° abarcando el wrap en 90/0). Si los anchors están a + // ≤EPSILON modulo 90, mergeamos. + if groups.len() >= 2 { + let first_mod = groups[0].mod90_deg; + let last_mod = groups[groups.len() - 1].mod90_deg; + let mut diff = (first_mod - last_mod).abs(); + if diff > 45.0 { + diff = 90.0 - diff; + } + if diff <= EPSILON { + let last = groups.pop().unwrap(); + groups[0].bodies.extend(last.bodies); + } + } + render.uranian_groups = groups; +} + /// Helper: agrega al `RenderModel` la carta compuesta (midpoint /// composite, Davison 1958) entre la natal del sujeto y la carta del /// partner. Cada planeta compuesto es el angular midpoint entre los @@ -972,6 +1054,7 @@ fn build_render_model( layers: vec![sign_dial, houses, bodies, aspects_layer], overlays: Vec::new(), aspect_summary: Vec::new(), + uranian_groups: Vec::new(), } } diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs index fdd5e88..7a51477 100644 --- a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs +++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs @@ -75,6 +75,10 @@ pub struct RenderModel { /// cerrados primero). La UI lo usa para la lista textual. #[serde(default)] pub aspect_summary: Vec, + /// Grupos uranianos detectados (cuerpos en la misma posición mod 90). + /// Vacío sino se activó el módulo Uranian. + #[serde(default)] + pub uranian_groups: Vec, } /// Etiqueta legible de un overlay para el footer del canvas. La engine @@ -87,6 +91,23 @@ pub struct OverlayMeta { pub label: String, } +/// Grupo de cuerpos natales que caen en la misma posición del +/// dial uraniano de 90° (su longitud zodiacal módulo 90 es igual o +/// muy cercana). En la astrología uraniana esto es una "fórmula" o +/// "axis" — los cuerpos están en correspondencia simbólica directa +/// porque comparten un cuadrante simétrico. +/// +/// Solo se emiten grupos con 2+ miembros (los singletons no son +/// fórmulas). La engine los ordena por proximidad al ε de tolerancia. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UranianGroup { + /// Identificadores agnósticos de los cuerpos en el grupo + /// (ej. `["sun", "jupiter", "saturn"]`). + pub bodies: Vec, + /// Posición en el dial de 90° (la longitud módulo 90). + pub mod90_deg: f64, +} + /// Resumen textual de un aspecto para listas legibles. La engine lo /// emite en paralelo con las `LineSeg` de la capa de aspectos, así /// el canvas no tiene que re-derivar nombres de cuerpos desde grados. @@ -274,6 +295,13 @@ pub enum PipelineRequest { Composite { partner_chart: Box, }, + /// `module_id = "uranian"` — calcula los "ejes" del dial uraniano + /// de 90°: agrupa los cuerpos natales cuya longitud módulo 90 cae + /// dentro de una tolerancia (~2°). El resultado se publica en + /// `RenderModel.uranian_groups` para que la UI lo liste como + /// fórmulas analíticas. La visualización geométrica completa del + /// dial de 90° queda pendiente para una fase posterior. + Uranian, } /// Opciones que afectan la pasada natal (qué aspectos pintar, qué @@ -400,6 +428,7 @@ pub fn compute_mock(chart: &Chart) -> RenderModel { layers: vec![sign_dial], overlays: Vec::new(), aspect_summary: Vec::new(), + uranian_groups: Vec::new(), } } diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs index c689170..fd35da8 100644 --- a/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs +++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs @@ -139,6 +139,7 @@ impl Registry { r.register(Box::new(planetary_return::PlanetaryReturnModule)); r.register(Box::new(midpoints::MidpointsModule)); r.register(Box::new(composite::CompositeModule)); + r.register(Box::new(uranian::UranianModule)); r } @@ -663,8 +664,52 @@ mod tests { assert!(r.find("planetary_return").is_some()); assert!(r.find("midpoints").is_some()); assert!(r.find("composite").is_some()); - // Natal kind tiene 8 módulos aplicables. - assert_eq!(r.for_kind(ChartKind::Natal).len(), 8); + assert!(r.find("uranian").is_some()); + // Natal kind tiene 9 módulos aplicables. + assert_eq!(r.for_kind(ChartKind::Natal).len(), 9); assert!(r.for_kind(ChartKind::Synastry).is_empty()); } } + +// ===================================================================== +// UranianModule — ejes del dial uraniano de 90° (versión textual) +// ===================================================================== + +pub mod uranian { + use super::*; + + /// Detecta "ejes" del dial uraniano: grupos de cuerpos natales cuya + /// longitud módulo 90 cae dentro de una tolerancia. Los grupos + /// resultantes se listan en el footer del canvas. La visualización + /// geométrica del dial completo de 90° queda para una fase futura. + pub struct UranianModule; + + impl Module for UranianModule { + fn id(&self) -> &'static str { + "uranian" + } + fn label(&self) -> &'static str { + "Uraniano (90°)" + } + fn description(&self) -> &'static str { + "Ejes del dial uraniano — cuerpos en la misma posición mod 90." + } + fn applies_to(&self, kind: ChartKind) -> bool { + matches!(kind, ChartKind::Natal) + } + fn enabled_by_default(&self) -> bool { + false + } + fn controls(&self) -> Vec { + vec![Control::Toggle { + key: "enabled".into(), + label: "Activar".into(), + default: false, + hotkey: None, + }] + } + fn compute_layers(&self, _chart: &Chart, _cfg: &serde_json::Value) -> Vec { + Vec::new() + } + } +}