gioser

tawasuyu/brahman

Rust 496TOML 195Markdown 55JSON 197001 Definiciones

feat(dominium): backend GPUI + app — ventana viva del simulador

dominium-canvas-gpui: Element que pinta un RenderPlan como quads,
centrado en sus bounds (rgba→hsla, único crate que toca gpui).

app dominium: compone core→physics→iso→render-plan→canvas en una
ventana GPUI con bucle de simulación de fondo (~11 tps), panel de
estadísticas, controles play/pausa + re-sembrar, y re-siembra
automática al colapso poblacional.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

f46c7b435ff2913c45ab3e3170bc0fdab792cf35 · por sergio · (hace 5 meses) · padre cba61e354 · ver el árbol en este commit

Delta semántico · 35

crates/apps/dominium/src/main.rs

crates/modules/dominium/dominium-canvas-gpui/src/lib.rs

6 archivos · +543 −0

diff --git a/Cargo.lock b/Cargo.lock
index 86a29c0..c403b21 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3475,6 +3475,28 @@ dependencies = [
  "urlencoding",
 ]
 
+[[package]]
+name = "dominium"
+version = "0.1.0"
+dependencies = [
+ "dominium-canvas-gpui",
+ "dominium-core",
+ "dominium-iso",
+ "dominium-physics",
+ "dominium-render-plan",
+ "gpui",
+ "nahual-launcher",
+ "nahual-theme",
+]
+
+[[package]]
+name = "dominium-canvas-gpui"
+version = "0.1.0"
+dependencies = [
+ "dominium-render-plan",
+ "gpui",
+]
+
 [[package]]
 name = "dominium-core"
 version = "0.1.0"
diff --git a/Cargo.toml b/Cargo.toml
index bbd0998..fa87bfa 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -149,6 +149,7 @@ members = [
     "crates/modules/dominium/dominium-physics",
     "crates/modules/dominium/dominium-iso",
     "crates/modules/dominium/dominium-render-plan",
+    "crates/modules/dominium/dominium-canvas-gpui",
 
     # ============================================================
     # modules/gioser/ — Landing WASM (chacana + 4 elementos)
@@ -223,6 +224,7 @@ members = [
     "crates/apps/cosmobiologia",
     "crates/apps/cosmobiologia-cli",
     "crates/apps/cosmobiologia-server",
+    "crates/apps/dominium",
 ]
 
 [workspace.package]
diff --git a/crates/apps/dominium/Cargo.toml b/crates/apps/dominium/Cargo.toml
new file mode 100644
index 0000000..e61053b
--- /dev/null
+++ b/crates/apps/dominium/Cargo.toml
@@ -0,0 +1,23 @@
+[package]
+name = "dominium"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "dominium — simulador psicológico de campo medio: ventana GPUI con maqueta isométrica viva, panel de estadísticas y bucle de simulación."
+
+[[bin]]
+name = "dominium"
+path = "src/main.rs"
+
+[dependencies]
+dominium-core = { path = "../../modules/dominium/dominium-core" }
+dominium-physics = { path = "../../modules/dominium/dominium-physics" }
+dominium-iso = { path = "../../modules/dominium/dominium-iso" }
+dominium-render-plan = { path = "../../modules/dominium/dominium-render-plan" }
+dominium-canvas-gpui = { path = "../../modules/dominium/dominium-canvas-gpui" }
+nahual-theme = { path = "../../modules/nahual/libs/theme" }
+nahual-launcher = { path = "../../modules/nahual/libs/launcher" }
+gpui = { workspace = true }
diff --git a/crates/apps/dominium/src/main.rs b/crates/apps/dominium/src/main.rs
new file mode 100644
index 0000000..627b6b5
--- /dev/null
+++ b/crates/apps/dominium/src/main.rs
@@ -0,0 +1,310 @@
+//! `dominium` — la ventana viva del simulador de campo medio.
+//!
+//! Compone toda la cadena de dominium en un app GPUI:
+//!
+//! ```text
+//!   dominium-core ─► dominium-physics ─► dominium-iso ─►
+//!   dominium-render-plan ─► dominium-canvas-gpui ─► [esta ventana]
+//! ```
+//!
+//! Un bucle de fondo avanza la simulación ~11 veces por segundo; cada
+//! tick reconstruye la maqueta isométrica y la repinta. El panel
+//! derecho muestra las estadísticas agregadas y dos controles
+//! (play/pausa, re-sembrar). Cuando la población colapsa, el mundo se
+//! re-siembra solo: la demo nunca se queda en negro.
+
+use std::time::Duration;
+
+use dominium_canvas_gpui::DominiumCanvas;
+use dominium_core::{SimParams, World};
+use dominium_iso::{IsoProjector, ZWeights};
+use dominium_physics::tick;
+use dominium_render_plan::{build_plan, PlanConfig};
+use gpui::{
+    div, hsla, prelude::*, px, Context, IntoElement, Render, SharedString, Window,
+};
+use nahual_launcher::launch_app;
+use nahual_theme::Theme;
+
+/// Lado de la grilla cuadrada del mundo.
+const GRID: usize = 40;
+/// Población inicial de Lemmings.
+const LEMMINGS: usize = 50;
+/// Periodo del bucle de simulación.
+const TICK_MS: u64 = 90;
+
+/// PRNG mínimo (LCG de 64 bits) — siembra reproducible sin dependencias.
+struct Lcg(u64);
+
+impl Lcg {
+    fn new(seed: u64) -> Self {
+        Self(seed)
+    }
+    fn next_u32(&mut self) -> u32 {
+        // Constantes de Knuth (MMIX).
+        self.0 = self
+            .0
+            .wrapping_mul(6364136223846793005)
+            .wrapping_add(1442695040888963407);
+        (self.0 >> 33) as u32
+    }
+    /// Flotante uniforme en `[0, 1)`.
+    fn next_f32(&mut self) -> f32 {
+        (self.next_u32() >> 8) as f32 / (1u32 << 24) as f32
+    }
+}
+
+/// Siembra un mundo: continentes de `materia`, vetas de `oro`, niebla de
+/// `psique` y una población de Lemmings con sesgos y acciones variadas.
+fn seed(seed: u64) -> World {
+    let mut w = World::new(GRID, GRID);
+    let mut rng = Lcg::new(seed);
+    for cy in 0..GRID {
+        for cx in 0..GRID {
+            let idx = w.grid.idx(cx, cy);
+            // m² concentra la materia en parches → aspecto de continentes.
+            let m = rng.next_f32();
+            w.grid.materia[idx] = m * m * 60.0;
+            if rng.next_f32() > 0.92 {
+                w.grid.oro[idx] = rng.next_f32() * 40.0;
+            }
+            w.grid.psique[idx] = rng.next_f32() * 12.0;
+        }
+    }
+    for _ in 0..LEMMINGS {
+        let x = rng.next_f32() * (GRID as f32 - 1.0);
+        let y = rng.next_f32() * (GRID as f32 - 1.0);
+        let psi = [
+            rng.next_f32(),
+            rng.next_f32(),
+            rng.next_f32(),
+            rng.next_f32(),
+        ];
+        let i = w.lemmings.spawn(x, y, 30.0 + rng.next_f32() * 40.0, psi);
+        w.lemmings.accion[i] = (rng.next_u32() % 6) as u8;
+    }
+    w
+}
+
+/// Estadísticas agregadas de un instante de la simulación.
+struct Stats {
+    poblacion: usize,
+    materia: f32,
+    oro: f32,
+    energia: f32,
+}
+
+/// El estado del simulador y su presentación.
+struct Sim {
+    world: World,
+    params: SimParams,
+    iso: IsoProjector,
+    weights: ZWeights,
+    cfg: PlanConfig,
+    running: bool,
+    /// Ticks transcurridos en la época actual.
+    tick: u64,
+    /// Cuántas veces se re-sembró el mundo (colapso poblacional).
+    epoch: u64,
+    /// Semilla rodante para cada re-siembra.
+    rng_seed: u64,
+}
+
+impl Sim {
+    fn new(cx: &mut Context<Self>) -> Self {
+        let rng_seed = 0xD0_31_31_07;
+        let sim = Self {
+            world: seed(rng_seed),
+            params: SimParams::default(),
+            iso: IsoProjector::new(12.0, 0.05),
+            weights: ZWeights::default(),
+            cfg: PlanConfig {
+                tile: 15.0,
+                lemming_size: 8.0,
+                lemming_lift: 0.7,
+                palette: Default::default(),
+            },
+            running: true,
+            tick: 0,
+            epoch: 0,
+            rng_seed,
+        };
+        sim.start_loop(cx);
+        sim
+    }
+
+    /// Lanza el bucle de fondo que avanza la simulación.
+    fn start_loop(&self, cx: &mut Context<Self>) {
+        cx.spawn(async move |this, cx| loop {
+            cx.background_executor()
+                .timer(Duration::from_millis(TICK_MS))
+                .await;
+            let alive = this.update(cx, |sim, cx| {
+                if sim.running {
+                    sim.advance();
+                    cx.notify();
+                }
+            });
+            if alive.is_err() {
+                break; // la entidad murió → ventana cerrada.
+            }
+        })
+        .detach();
+    }
+
+    /// Un paso de simulación; re-siembra si la población colapsa.
+    fn advance(&mut self) {
+        tick(&mut self.world, &self.params);
+        self.tick += 1;
+        if self.world.lemmings.is_empty() {
+            self.epoch += 1;
+            self.rng_seed = self.rng_seed.wrapping_mul(2862933555777941757).wrapping_add(1);
+            self.world = seed(self.rng_seed);
+            self.tick = 0;
+        }
+    }
+
+    /// Re-siembra el mundo a mano (botón ↺).
+    fn reseed(&mut self) {
+        self.rng_seed = self.rng_seed.wrapping_add(0x9E3779B9);
+        self.world = seed(self.rng_seed);
+        self.tick = 0;
+        self.epoch += 1;
+    }
+
+    /// Calcula las estadísticas del instante actual.
+    fn stats(&self) -> Stats {
+        let g = &self.world.grid;
+        Stats {
+            poblacion: self.world.lemmings.len(),
+            materia: g.materia.iter().sum(),
+            oro: g.oro.iter().sum(),
+            energia: self.world.lemmings.energia.iter().sum(),
+        }
+    }
+}
+
+/// Fila etiqueta/valor del panel de estadísticas.
+fn stat_row(label: &str, value: String, theme: &Theme) -> impl IntoElement {
+    div()
+        .flex()
+        .flex_row()
+        .justify_between()
+        .child(div().text_color(theme.fg_muted).child(SharedString::from(label.to_string())))
+        .child(div().text_color(theme.fg_text).child(SharedString::from(value)))
+}
+
+impl Render for Sim {
+    fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        let theme = Theme::global(cx).clone();
+        let panel = hsla(220.0 / 360.0, 0.18, 0.10, 1.0);
+        let chip = hsla(220.0 / 360.0, 0.16, 0.16, 1.0);
+        let canvas_bg = hsla(220.0 / 360.0, 0.22, 0.06, 1.0);
+        let accent = theme.accent;
+        let stats = self.stats();
+
+        // --- Barra de estado ---
+        let estado = if self.running { "● corriendo" } else { "‖ en pausa" };
+        let status = div()
+            .h(px(34.))
+            .flex()
+            .flex_row()
+            .items_center()
+            .justify_between()
+            .px(px(14.))
+            .bg(panel)
+            .text_color(theme.fg_text)
+            .child(SharedString::from(format!(
+                "dominium · campo medio   ·   época {}   ·   tick {}",
+                self.epoch, self.tick
+            )))
+            .child(div().text_color(accent).child(SharedString::from(estado.to_string())));
+
+        // --- Maqueta isométrica ---
+        let plan = build_plan(&self.world, &self.iso, &self.weights, &self.cfg);
+        let canvas = div()
+            .flex_1()
+            .overflow_hidden()
+            .child(DominiumCanvas::new(plan).background(canvas_bg));
+
+        // --- Botones de control ---
+        let play_label = if self.running { "‖  Pausar" } else { "▶  Reanudar" };
+        let play = div()
+            .id("play")
+            .px(px(10.))
+            .py(px(7.))
+            .bg(chip)
+            .rounded(px(5.))
+            .text_color(theme.fg_text)
+            .cursor_pointer()
+            .hover(|s| s.bg(theme.bg_row_hover))
+            .child(SharedString::from(play_label.to_string()))
+            .on_click(cx.listener(|sim, _ev, _w, cx| {
+                sim.running = !sim.running;
+                cx.notify();
+            }));
+        let reset = div()
+            .id("reset")
+            .px(px(10.))
+            .py(px(7.))
+            .bg(chip)
+            .rounded(px(5.))
+            .text_color(theme.fg_text)
+            .cursor_pointer()
+            .hover(|s| s.bg(theme.bg_row_hover))
+            .child("↺  Re-sembrar")
+            .on_click(cx.listener(|sim, _ev, _w, cx| {
+                sim.reseed();
+                cx.notify();
+            }));
+
+        // --- Panel de estadísticas ---
+        let side = div()
+            .w(px(216.))
+            .flex()
+            .flex_col()
+            .gap(px(10.))
+            .p(px(12.))
+            .bg(panel)
+            .text_color(theme.fg_text)
+            .child(div().text_color(theme.fg_muted).child("[SIM]"))
+            .child(play)
+            .child(reset)
+            .child(div().h(px(1.)).bg(theme.border))
+            .child(stat_row("Población", format!("{}", stats.poblacion), &theme))
+            .child(stat_row("Materia", format!("{:.0}", stats.materia), &theme))
+            .child(stat_row("Oro", format!("{:.0}", stats.oro), &theme))
+            .child(stat_row("Energía", format!("{:.0}", stats.energia), &theme))
+            .child(div().h(px(1.)).bg(theme.border))
+            .child(
+                div()
+                    .text_color(theme.fg_muted)
+                    .child(SharedString::from(format!("grilla {GRID}×{GRID}"))),
+            )
+            .child(
+                div()
+                    .text_color(theme.fg_muted)
+                    .child("relieve = materia (Z)"),
+            );
+
+        // --- Composición ---
+        div()
+            .size_full()
+            .flex()
+            .flex_col()
+            .bg(theme.bg_app)
+            .child(status)
+            .child(
+                div()
+                    .flex()
+                    .flex_row()
+                    .flex_1()
+                    .child(canvas)
+                    .child(side),
+            )
+    }
+}
+
+fn main() {
+    launch_app("brahman · dominium", (1120., 720.), Sim::new);
+}
diff --git a/crates/modules/dominium/dominium-canvas-gpui/Cargo.toml b/crates/modules/dominium/dominium-canvas-gpui/Cargo.toml
new file mode 100644
index 0000000..31764c3
--- /dev/null
+++ b/crates/modules/dominium/dominium-canvas-gpui/Cargo.toml
@@ -0,0 +1,12 @@
+[package]
+name = "dominium-canvas-gpui"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "dominium — backend GPUI: un Element que pinta un RenderPlan isométrico como quads, centrado en sus bounds. El único crate de dominium que toca gpui."
+
+[dependencies]
+dominium-render-plan = { path = "../dominium-render-plan" }
+gpui = { workspace = true }
diff --git a/crates/modules/dominium/dominium-canvas-gpui/src/lib.rs b/crates/modules/dominium/dominium-canvas-gpui/src/lib.rs
new file mode 100644
index 0000000..7729170
--- /dev/null
+++ b/crates/modules/dominium/dominium-canvas-gpui/src/lib.rs
@@ -0,0 +1,174 @@
+//! `dominium-canvas-gpui` — el único crate de dominium que importa `gpui`.
+//!
+//! Toda la cadena `dominium-core → physics → iso → render-plan` es
+//! agnóstica de backend. Este crate cierra el circuito: un [`Element`]
+//! de GPUI que recibe un [`RenderPlan`] ya resuelto y lo vuelca a
+//! `paint_quad`, centrando la maqueta en los bounds disponibles.
+//!
+//! Si mañana el frontend fuera web o TUI, se escribe un
+//! `dominium-canvas-web` hermano sin tocar una línea del núcleo.
+
+#![forbid(unsafe_code)]
+
+use std::panic;
+
+use dominium_render_plan::{Color, RenderPlan};
+use gpui::{
+    fill, hsla, point, px, size, App, Bounds, Element, ElementId, GlobalElementId, Hsla,
+    InspectorElementId, IntoElement, LayoutId, Pixels, Style, Window,
+};
+
+/// Convierte un color RGBA lineal (`[f32;4]`) a `Hsla`, que es lo que
+/// GPUI consume. Misma convención de conversión que el backend de
+/// `pineal` — sin gamma.
+pub fn rgba_to_hsla(c: Color) -> Hsla {
+    let (r, g, b, a) = (c[0], c[1], c[2], c[3]);
+    let max = r.max(g).max(b);
+    let min = r.min(g).min(b);
+    let l = (max + min) * 0.5;
+    let delta = max - min;
+    if delta.abs() < 1e-6 {
+        return hsla(0.0, 0.0, l, a);
+    }
+    let s = if l < 0.5 {
+        delta / (max + min)
+    } else {
+        delta / (2.0 - max - min)
+    };
+    let h = if max == r {
+        ((g - b) / delta).rem_euclid(6.0)
+    } else if max == g {
+        (b - r) / delta + 2.0
+    } else {
+        (r - g) / delta + 4.0
+    };
+    hsla(h / 6.0, s, l, a)
+}
+
+/// `Element` GPUI que pinta una maqueta isométrica.
+///
+/// Construir uno nuevo en cada `render()` del host con el `RenderPlan`
+/// del frame actual — el Element no guarda estado entre frames.
+pub struct DominiumCanvas {
+    plan: RenderPlan,
+    background: Option<Hsla>,
+}
+
+impl DominiumCanvas {
+    /// Envuelve un `RenderPlan` listo para pintar.
+    pub fn new(plan: RenderPlan) -> Self {
+        Self { plan, background: None }
+    }
+
+    /// Pinta un fondo sólido antes de los quads.
+    pub fn background(mut self, color: Hsla) -> Self {
+        self.background = Some(color);
+        self
+    }
+}
+
+impl IntoElement for DominiumCanvas {
+    type Element = Self;
+    fn into_element(self) -> Self::Element {
+        self
+    }
+}
+
+impl Element for DominiumCanvas {
+    type RequestLayoutState = ();
+    type PrepaintState = ();
+
+    fn id(&self) -> Option<ElementId> {
+        None
+    }
+
+    fn source_location(&self) -> Option<&'static panic::Location<'static>> {
+        None
+    }
+
+    fn request_layout(
+        &mut self,
+        _id: Option<&GlobalElementId>,
+        _inspector_id: Option<&InspectorElementId>,
+        window: &mut Window,
+        cx: &mut App,
+    ) -> (LayoutId, Self::RequestLayoutState) {
+        let mut style = Style::default();
+        style.size.width = gpui::Length::Definite(gpui::DefiniteLength::Fraction(1.0));
+        style.size.height = gpui::Length::Definite(gpui::DefiniteLength::Fraction(1.0));
+        (window.request_layout(style, [], cx), ())
+    }
+
+    fn prepaint(
+        &mut self,
+        _id: Option<&GlobalElementId>,
+        _inspector_id: Option<&InspectorElementId>,
+        _bounds: Bounds<Pixels>,
+        _layout: &mut Self::RequestLayoutState,
+        _window: &mut Window,
+        _cx: &mut App,
+    ) -> Self::PrepaintState {
+    }
+
+    fn paint(
+        &mut self,
+        _id: Option<&GlobalElementId>,
+        _inspector_id: Option<&InspectorElementId>,
+        bounds: Bounds<Pixels>,
+        _layout: &mut Self::RequestLayoutState,
+        _prepaint: &mut Self::PrepaintState,
+        window: &mut Window,
+        _cx: &mut App,
+    ) {
+        let ox: f32 = bounds.origin.x.into();
+        let oy: f32 = bounds.origin.y.into();
+        let bw: f32 = bounds.size.width.into();
+        let bh: f32 = bounds.size.height.into();
+
+        if let Some(bg) = self.background {
+            window.paint_quad(fill(bounds, bg));
+        }
+
+        // Centra la maqueta: el centro de la caja envolvente del plan
+        // se alinea con el centro de los bounds del Element.
+        let plan_cx = (self.plan.min_x + self.plan.max_x) * 0.5;
+        let plan_cy = (self.plan.min_y + self.plan.max_y) * 0.5;
+        let off_x = ox + bw * 0.5 - plan_cx;
+        let off_y = oy + bh * 0.5 - plan_cy;
+
+        // Los quads ya vienen ordenados de atrás hacia adelante.
+        for q in &self.plan.quads {
+            let rect = Bounds {
+                origin: point(px(q.x + off_x), px(q.y + off_y)),
+                size: size(px(q.w), px(q.h)),
+            };
+            window.paint_quad(fill(rect, rgba_to_hsla(q.color)));
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn pure_red_maps_to_hue_zero() {
+        let h = rgba_to_hsla([1.0, 0.0, 0.0, 1.0]);
+        assert!((h.h - 0.0).abs() < 1e-6);
+        assert!((h.s - 1.0).abs() < 1e-6);
+        assert!((h.l - 0.5).abs() < 1e-6);
+    }
+
+    #[test]
+    fn grey_has_zero_saturation() {
+        let h = rgba_to_hsla([0.4, 0.4, 0.4, 0.8]);
+        assert!((h.s - 0.0).abs() < 1e-6);
+        assert!((h.a - 0.8).abs() < 1e-6);
+    }
+
+    #[test]
+    fn alpha_passes_through() {
+        let h = rgba_to_hsla([0.0, 0.0, 1.0, 0.25]);
+        assert!((h.a - 0.25).abs() < 1e-6);
+    }
+}