diff --git a/crates/modules/cosmobiologia/cosmobiologia-canvas/src/lib.rs b/crates/modules/cosmobiologia/cosmobiologia-canvas/src/lib.rs index 0c687a6..b5d8c33 100644 --- a/crates/modules/cosmobiologia/cosmobiologia-canvas/src/lib.rs +++ b/crates/modules/cosmobiologia/cosmobiologia-canvas/src/lib.rs @@ -41,8 +41,9 @@ use gpui::{ }; use cosmobiologia_engine::{ - corpus_inputs, Corpus, Dominio, Geometry, GrTrigger, Layer, LayerKind, Pasaje, - Rectificacion, RenderModel, UranianGroup, OUTER_RING_MODULES, + combinaciones_de_carta, corpus_inputs, rebanar_por_dominio, CombinacionId, Corpus, + Dominio, EvidenciaVecina, Geometry, GrTrigger, Layer, LayerKind, Pasaje, Rectificacion, + RenderModel, UranianGroup, OUTER_RING_MODULES, }; use cosmobiologia_model::{ChartId, ContactId, GroupId}; use cosmobiologia_render::{compose_sphere, DrawCommand, Palette, SphereOpts, SphereView}; @@ -986,6 +987,17 @@ fn domain_label(d: Dominio) -> &'static str { } } +/// Lo computado del corpus para la tajada activa: los pasajes con texto +/// propio (`directos`), las combinaciones sin texto con su evidencia +/// vecina (`compuestos` — la capa de composición), y los grados de los +/// cuerpos a resaltar en la rueda. +struct CorpusView<'a> { + dominio: Dominio, + directos: Vec<&'a Pasaje>, + compuestos: Vec<(CombinacionId, Vec>)>, + degs: Vec, +} + /// Capa transparente sobre la rueda que dibuja un anillo de resalte en /// cada cuerpo de la tajada activa. fn corpus_highlight_canvas(degs: Vec, asc: f32, rot: f32, color: Hsla) -> impl IntoElement { @@ -1005,8 +1017,41 @@ fn corpus_highlight_canvas(degs: Vec, asc: f32, rot: f32, color: Hsla) -> i .size_full() } -/// El panel lateral con los pasajes de la tajada activa. -fn corpus_panel(theme: &Theme, dom: Dominio, pasajes: &[&Pasaje]) -> impl IntoElement { +/// Una tarjeta de pasaje: combinación, texto citado y fuente. +fn passage_card(theme: &Theme, p: &Pasaje) -> gpui::Div { + div() + .flex() + .flex_col() + .gap(px(3.0)) + .p(px(8.0)) + .rounded(px(5.0)) + .bg(theme.bg_panel.clone()) + .border_1() + .border_color(theme.border) + .child( + div() + .text_size(px(9.0)) + .text_color(theme.fg_muted) + .child(SharedString::from(p.combinacion.to_string())), + ) + .child( + div() + .text_size(px(11.0)) + .text_color(theme.fg_text) + .child(SharedString::from(p.texto.clone())), + ) + .child( + div() + .text_size(px(9.0)) + .text_color(theme.fg_muted) + .child(SharedString::from(format!("— {}", p.fuente))), + ) +} + +/// El panel lateral de la tajada activa: los pasajes con texto propio +/// y, debajo, la composición — evidencia vecina de las combinaciones +/// sin pasaje. +fn corpus_panel(theme: &Theme, cv: &CorpusView<'_>) -> impl IntoElement { let mut list = div() .id("corpus-panel") .flex() @@ -1025,59 +1070,87 @@ fn corpus_panel(theme: &Theme, dom: Dominio, pasajes: &[&Pasaje]) -> impl IntoEl .flex_row() .items_center() .gap(px(6.0)) - .child(div().w(px(10.0)).h(px(10.0)).rounded_full().bg(domain_color(dom))) + .child( + div() + .w(px(10.0)) + .h(px(10.0)) + .rounded_full() + .bg(domain_color(cv.dominio)), + ) .child( div() .text_size(px(13.0)) .text_color(theme.fg_text) .child(SharedString::from(format!( "Tajada {} · {} pasajes", - domain_label(dom), - pasajes.len() + domain_label(cv.dominio), + cv.directos.len() ))), ), ); - if pasajes.is_empty() { + + if cv.directos.is_empty() && cv.compuestos.is_empty() { list = list.child( div() .text_size(px(11.0)) .text_color(theme.fg_muted) .child( - "Sin pasajes para esta tajada todavía. Escribilos en \ + "Sin nada del corpus para esta tajada. Escribí pasajes en \ corpus.ron — ver la GUIA del corpus.", ), ); } - for p in pasajes { - list = list.child( - div() - .flex() - .flex_col() - .gap(px(3.0)) - .p(px(8.0)) - .rounded(px(5.0)) - .bg(theme.bg_panel.clone()) - .border_1() - .border_color(theme.border) - .child( + for p in &cv.directos { + list = list.child(passage_card(theme, p)); + } + + // Capa de composición: evidencia vecina, citada. NO sintetizada. + if !cv.compuestos.is_empty() { + list = list + .child( + div() + .mt(px(6.0)) + .text_size(px(11.0)) + .text_color(theme.fg_text) + .child("Composición — combinaciones sin pasaje propio"), + ) + .child( + div() + .text_size(px(9.0)) + .text_color(theme.fg_muted) + .child("Evidencia vecina, citada — el corpus no sintetiza; componés vos."), + ); + for (combo, evs) in &cv.compuestos { + list = list.child( + div() + .mt(px(3.0)) + .text_size(px(10.0)) + .text_color(theme.fg_text) + .child(SharedString::from(combo.to_string())), + ); + for ev in evs { + list = list.child( div() .text_size(px(9.0)) .text_color(theme.fg_muted) - .child(SharedString::from(p.combinacion.to_string())), - ) - .child( - div() - .text_size(px(11.0)) - .text_color(theme.fg_text) - .child(SharedString::from(p.texto.clone())), - ) - .child( - div() - .text_size(px(9.0)) - .text_color(theme.fg_muted) - .child(SharedString::from(format!("— {}", p.fuente))), - ), - ); + .child(SharedString::from(format!("vecinos · comparte {}", ev.comparte))), + ); + for p in ev.pasajes.iter().take(3) { + list = list.child(passage_card(theme, p)); + } + if ev.pasajes.len() > 3 { + list = list.child( + div() + .text_size(px(9.0)) + .text_color(theme.fg_muted) + .child(SharedString::from(format!( + "… +{} más", + ev.pasajes.len() - 3 + ))), + ); + } + } + } } list } @@ -1094,32 +1167,45 @@ impl Render for AstrologyCanvas { let focus = self.focus_handle.clone(); // Vista del corpus: con una tajada elegida sobre la rueda 2D, se - // calcula la interpretación por dominio (pasajes + cuerpos a - // resaltar). Los `&Pasaje` toman prestado de `state.corpus`. - let corpus_view: Option<(Dominio, Vec<&Pasaje>, Vec)> = - match &self.state.mode { - CanvasMode::Wheel { render } if !self.state.sphere_3d => { - self.state.corpus_domain.and_then(|dom| { - let corpus = self.state.corpus.as_ref()?; - let (col, asp) = corpus_inputs(render); - let por_dom = corpus.interpretar_por_dominio(&col, &asp); - let pasajes = por_dom.get(&dom).cloned().unwrap_or_default(); - let degs: Vec = render - .layers - .iter() - .filter(|l| { - matches!(l.kind, LayerKind::Bodies) - && l.module_id == "natal" - }) - .flat_map(|l| l.glyphs.iter()) - .filter(|g| g.house.and_then(Dominio::de_casa) == Some(dom)) - .map(|g| g.deg) - .collect(); - Some((dom, pasajes, degs)) - }) - } - _ => None, - }; + // separan las combinaciones del dominio en las que tienen pasaje + // propio y las que sólo tienen evidencia vecina (composición). + // Todo toma prestado de `state.corpus`. + let corpus_view: Option> = match &self.state.mode { + CanvasMode::Wheel { render } if !self.state.sphere_3d => { + self.state.corpus_domain.and_then(|dom| { + let corpus = self.state.corpus.as_ref()?; + let (col, asp) = corpus_inputs(render); + let combos = combinaciones_de_carta(&col, &asp); + let tajadas = rebanar_por_dominio(&col, &combos); + let dom_combos = tajadas.get(&dom).cloned().unwrap_or_default(); + let mut directos = Vec::new(); + let mut compuestos = Vec::new(); + for c in &dom_combos { + let p = corpus.pasajes_de(c); + if p.is_empty() { + let ev = corpus.evidencia_relacionada(c); + if !ev.is_empty() { + compuestos.push((c.clone(), ev)); + } + } else { + directos.extend(p); + } + } + let degs: Vec = render + .layers + .iter() + .filter(|l| { + matches!(l.kind, LayerKind::Bodies) && l.module_id == "natal" + }) + .flat_map(|l| l.glyphs.iter()) + .filter(|g| g.house.and_then(Dominio::de_casa) == Some(dom)) + .map(|g| g.deg) + .collect(); + Some(CorpusView { dominio: dom, directos, compuestos, degs }) + }) + } + _ => None, + }; let body = match &self.state.mode { CanvasMode::Empty => render_empty(&theme), @@ -1151,11 +1237,11 @@ impl Render for AstrologyCanvas { ); // Capa de resalte de la tajada activa, encima de la rueda. match &corpus_view { - Some((dom, _, degs)) => wheel.child(corpus_highlight_canvas( - degs.clone(), + Some(cv) => wheel.child(corpus_highlight_canvas( + cv.degs.clone(), render.ascendant_deg, self.state.view_rotation_deg, - domain_color(*dom), + domain_color(cv.dominio), )), None => wheel, } @@ -1163,9 +1249,7 @@ impl Render for AstrologyCanvas { CanvasMode::Thumbnails { items, .. } => render_thumbnails(&theme, items), }; - let corpus_side = corpus_view - .as_ref() - .map(|(dom, pasajes, _)| corpus_panel(&theme, *dom, pasajes)); + let corpus_side = corpus_view.as_ref().map(|cv| corpus_panel(&theme, cv)); // Botón flotante 2D ⇄ 3D — visible solo con una carta cargada. // Muestra el modo al que se cambiará, no el activo. diff --git a/crates/modules/cosmobiologia/cosmobiologia-corpus/src/lib.rs b/crates/modules/cosmobiologia/cosmobiologia-corpus/src/lib.rs index 064c4f5..895f06c 100644 --- a/crates/modules/cosmobiologia/cosmobiologia-corpus/src/lib.rs +++ b/crates/modules/cosmobiologia/cosmobiologia-corpus/src/lib.rs @@ -319,6 +319,34 @@ pub struct Pasaje { pub dominio: Option, } +/// Evidencia **vecina** de una combinación que no tiene pasaje propio: +/// pasajes del corpus que comparten uno de sus componentes (el planeta, +/// el signo, la casa, o el tipo de aspecto). +/// +/// Es la respuesta honesta al problema de la «composición». El corpus +/// **no sintetiza** un texto para una combinación no escrita —eso sería +/// inventar—. Tampoco multiplica perfiles numéricos: el producto +/// Hadamard (y parientes) se descartó porque da falsos (una dimensión +/// en 0 nunca «se enciende») y, sobre todo, porque un perfil compuesto +/// es una conjetura, no evidencia. Lo que sí es honesto: traer las +/// citas reales de contextos parecidos y que el astrólogo componga él. +#[derive(Debug, Clone)] +pub struct EvidenciaVecina<'a> { + /// Qué componente comparten — `"planeta mars"`, `"signo virgo"`, + /// `"casa 6"`, `"aspecto square"`. + pub comparte: String, + pub pasajes: Vec<&'a Pasaje>, +} + +/// `true` si la combinación involucra a ese planeta, en cualquier rol. +fn combinacion_usa_planeta(c: &CombinacionId, planeta: &str) -> bool { + match c { + CombinacionId::PlanetaSigno { planeta: p, .. } => p == planeta, + CombinacionId::PlanetaCasa { planeta: p, .. } => p == planeta, + CombinacionId::Aspecto { a, b, .. } => a == planeta || b == planeta, + } +} + /// El corpus completo: la ontología de arquetipos + los pasajes. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct Corpus { @@ -395,6 +423,69 @@ impl Corpus { .cloned() .collect() } + + /// Pasajes cuya combinación cumple un predicado. + fn pasajes_donde(&self, pred: impl Fn(&CombinacionId) -> bool) -> Vec<&Pasaje> { + self.pasajes.iter().filter(|p| pred(&p.combinacion)).collect() + } + + /// La **capa de composición**, hecha con honestidad: para una + /// combinación SIN pasaje propio, junta la evidencia vecina — + /// pasajes que comparten uno de sus componentes—. No sintetiza un + /// texto ni compone perfiles; son citas reales de contextos + /// parecidos, agrupadas por el componente que comparten, para que + /// el astrólogo componga. Si la combinación SÍ tiene pasaje propio, + /// devuelve vacío — no hace falta. Ver [`EvidenciaVecina`]. + pub fn evidencia_relacionada(&self, id: &CombinacionId) -> Vec> { + if !self.pasajes_de(id).is_empty() { + return Vec::new(); + } + let mut grupos: Vec> = Vec::new(); + match id { + CombinacionId::PlanetaSigno { planeta, signo } => { + grupos.push(EvidenciaVecina { + comparte: format!("planeta {planeta}"), + pasajes: self.pasajes_donde(|c| combinacion_usa_planeta(c, planeta)), + }); + grupos.push(EvidenciaVecina { + comparte: format!("signo {signo}"), + pasajes: self.pasajes_donde(|c| { + matches!(c, CombinacionId::PlanetaSigno { signo: s, .. } if s == signo) + }), + }); + } + CombinacionId::PlanetaCasa { planeta, casa } => { + grupos.push(EvidenciaVecina { + comparte: format!("planeta {planeta}"), + pasajes: self.pasajes_donde(|c| combinacion_usa_planeta(c, planeta)), + }); + grupos.push(EvidenciaVecina { + comparte: format!("casa {casa}"), + pasajes: self.pasajes_donde(|c| { + matches!(c, CombinacionId::PlanetaCasa { casa: k, .. } if k == casa) + }), + }); + } + CombinacionId::Aspecto { a, kind, b } => { + grupos.push(EvidenciaVecina { + comparte: format!("aspecto {kind}"), + pasajes: self.pasajes_donde(|c| { + matches!(c, CombinacionId::Aspecto { kind: k, .. } if k == kind) + }), + }); + grupos.push(EvidenciaVecina { + comparte: format!("planeta {a}"), + pasajes: self.pasajes_donde(|c| combinacion_usa_planeta(c, a)), + }); + grupos.push(EvidenciaVecina { + comparte: format!("planeta {b}"), + pasajes: self.pasajes_donde(|c| combinacion_usa_planeta(c, b)), + }); + } + } + grupos.retain(|g| !g.pasajes.is_empty()); + grupos + } } #[cfg(test)] @@ -608,4 +699,32 @@ mod tests { assert_eq!(p.len(), 1); assert_eq!(p[0].dominio, Some(Dominio::Psiquico)); } + + #[test] + fn evidencia_relacionada_junta_vecinos_por_componente() { + let corpus = Corpus { + arquetipos: Vec::new(), + pasajes: vec![ + pasaje(CombinacionId::planeta_signo("mars", "virgo"), "marte cirujano"), + pasaje(CombinacionId::planeta_signo("mars", "aries"), "marte crudo"), + pasaje(CombinacionId::planeta_signo("venus", "gemini"), "venus locuaz"), + ], + }; + // mars·gemini no tiene pasaje propio → evidencia vecina. + let ev = corpus.evidencia_relacionada(&CombinacionId::planeta_signo("mars", "gemini")); + let mars = ev.iter().find(|g| g.comparte == "planeta mars").unwrap(); + assert_eq!(mars.pasajes.len(), 2, "marte en otros signos"); + let gem = ev.iter().find(|g| g.comparte == "signo gemini").unwrap(); + assert_eq!(gem.pasajes.len(), 1, "otros planetas en géminis"); + } + + #[test] + fn evidencia_relacionada_vacia_si_hay_pasaje_propio() { + let corpus = Corpus { + arquetipos: Vec::new(), + pasajes: vec![pasaje(CombinacionId::planeta_signo("mars", "virgo"), "x")], + }; + let ev = corpus.evidencia_relacionada(&CombinacionId::planeta_signo("mars", "virgo")); + assert!(ev.is_empty(), "con pasaje propio no se busca evidencia vecina"); + } } diff --git a/crates/modules/cosmobiologia/cosmobiologia-engine/src/lib.rs b/crates/modules/cosmobiologia/cosmobiologia-engine/src/lib.rs index 979bdc4..56023a7 100644 --- a/crates/modules/cosmobiologia/cosmobiologia-engine/src/lib.rs +++ b/crates/modules/cosmobiologia/cosmobiologia-engine/src/lib.rs @@ -45,7 +45,8 @@ pub use cosmobiologia_render::{ // El engine lo reexporta para que el shell y el canvas trabajen los // pasajes sin importar el crate aparte. pub use cosmobiologia_corpus::{ - AspectoEnCarta, Colocacion, CombinacionId, Corpus, Dominio, Pasaje, + combinaciones_de_carta, rebanar_por_dominio, AspectoEnCarta, Colocacion, CombinacionId, + Corpus, Dominio, EvidenciaVecina, Pasaje, }; // `Chart` reexportado arriba es lo que `PipelineRequest::Synastry`