feat: dominium standalone — simulador de campo medio sobre Llimphi

Front-door publicable de dominium: los 9 crates propios como path
members; Llimphi, app-bus, rimay-localize, wawa-config y pluma-notebook
por git-dep al monorepo tawasuyu.git (branch=main). cargo check
--workspace --all-targets pasa exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-16 23:22:40 +00:00
commit 1860b51f70
70 changed files with 19902 additions and 0 deletions
@@ -0,0 +1,22 @@
[package]
name = "dominium-canvas-llimphi"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "dominium-canvas-llimphi — backend Llimphi del simulador. Recibe un `RenderPlan` (cadena `core → physics → iso → render-plan` agnóstica intacta) y devuelve un `View<Msg>` con `paint_with` que pinta los quads centrados en sus bounds usando vello."
[dependencies]
dominium-render-plan = { path = "../dominium-render-plan" }
llimphi-ui = { workspace = true }
[dev-dependencies]
# Cadena completa para el example: arma un World + un IsoProjector y
# llama a `build_plan` para que el canvas reciba un plan realista.
dominium-core = { path = "../dominium-core" }
dominium-iso = { path = "../dominium-iso" }
[[example]]
name = "canvas_demo"
path = "examples/canvas_demo.rs"
@@ -0,0 +1,10 @@
# dominium-canvas-llimphi
> Backend Llimphi (vello) para [dominium](../README.md).
Convierte el `Vec<Quad>` que produce [`dominium-render-plan`](../dominium-render-plan/README.md) en operaciones `vello::Scene` adentro de un `View::paint_with(...)` de Llimphi. Single-pass; cero allocs por frame (re-usa el buffer de quads).
## Deps
- [`dominium-render-plan`](../dominium-render-plan/README.md)
- [`llimphi-ui`](../../../02_ruway/llimphi/) (vello)
@@ -0,0 +1,10 @@
# dominium-canvas-llimphi
> Llimphi (vello) backend for [dominium](../README.md).
Converts the `Vec<Quad>` from [`dominium-render-plan`](../dominium-render-plan/README.md) into `vello::Scene` operations inside a Llimphi `View::paint_with(...)`. Single-pass; zero allocations per frame (reuses the quad buffer).
## Deps
- [`dominium-render-plan`](../dominium-render-plan/README.md)
- [`llimphi-ui`](../../../02_ruway/llimphi/) (vello)
@@ -0,0 +1,105 @@
//! Showcase de `dominium-canvas-llimphi`: arma un mundo pequeño con
//! patrones manuales (vetas de oro, parches de materia, niebla de
//! psique), construye el `RenderPlan` con `build_plan` y lo pinta
//! centrado en la ventana.
//!
//! Sin loop de simulación — la app Llimphi completa con tick vivo
//! va en `dominium-app-llimphi` (próximo bloque).
//!
//! Corré con: `cargo run -p dominium-canvas-llimphi --example canvas_demo --release`.
use dominium_canvas_llimphi::canvas_view;
use dominium_core::World;
use dominium_iso::{IsoProjector, ZWeights};
use dominium_render_plan::{build_plan, PlanConfig};
use llimphi_ui::llimphi_layout::taffy::prelude::{percent, Size, Style};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::{App, Handle, View};
const GRID: usize = 32;
#[derive(Clone)]
enum Msg {}
struct Model {
world: World,
}
struct Showcase;
impl App for Showcase {
type Model = Model;
type Msg = Msg;
fn title() -> &'static str {
"dominium · canvas showcase"
}
fn initial_size() -> (u32, u32) {
(1000, 720)
}
fn init(_: &Handle<Msg>) -> Model {
Model { world: seed() }
}
fn update(model: Model, _: Msg, _: &Handle<Msg>) -> Model {
model
}
fn view(model: &Model) -> View<Msg> {
let iso = IsoProjector::new(1.0, 4.0);
let weights = ZWeights::default();
let cfg = PlanConfig::default();
let plan = build_plan(&model.world, &iso, &weights, &cfg);
let canvas = canvas_view::<Msg>(plan, Some(Color::from_rgba8(14, 16, 22, 255)));
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
..Default::default()
})
.children(vec![canvas])
}
}
/// Mundo sembrado a mano: continentes de materia en el centro, vetas
/// de oro en una diagonal y un parche de psique en una esquina. Sin
/// PRNG — siempre la misma escena entre runs.
fn seed() -> World {
let mut w = World::new(GRID, GRID);
for cy in 0..GRID {
for cx in 0..GRID {
let idx = w.grid.idx(cx, cy);
// Continente: gauss centrado.
let dx = cx as f32 - (GRID as f32 * 0.5);
let dy = cy as f32 - (GRID as f32 * 0.5);
let d2 = dx * dx + dy * dy;
let materia = (40.0 - d2 * 0.15).max(0.0);
w.grid.materia[idx] = materia;
// Veta de oro en la diagonal cx == cy.
if cx == cy && cx > 4 && cx < GRID - 4 {
w.grid.oro[idx] = 35.0;
}
// Psique en el cuadrante inferior derecho.
if cx > GRID * 2 / 3 && cy > GRID * 2 / 3 {
w.grid.psique[idx] = 18.0;
}
// Borde de degradación.
if cx == 0 || cy == 0 || cx == GRID - 1 || cy == GRID - 1 {
w.grid.degradacion[idx] = 25.0;
}
}
}
w
}
fn main() {
llimphi_ui::run::<Showcase>();
}
@@ -0,0 +1,192 @@
//! `dominium-canvas-llimphi` — el único crate de dominium que importa
//! `llimphi-ui`.
//!
//! Toda la cadena `dominium-core → physics → iso → render-plan` es
//! agnóstica de backend. Este crate cierra el circuito: una función
//! [`canvas_view`] que recibe un [`RenderPlan`] ya resuelto y devuelve
//! un `View<Msg>` con `paint_with` que pinta los quads vía vello,
//! centrando la maqueta en los bounds asignados por taffy.
//!
//! Reemplazo Llimphi del `dominium-canvas-gpui`. Igual contrato:
//! el `Element` (acá `View`) no guarda estado entre frames — el host
//! reconstruye el View con el `RenderPlan` del frame actual.
#![forbid(unsafe_code)]
use dominium_render_plan::{Color as PlanColor, RenderPlan, SpritePrim};
use llimphi_ui::llimphi_layout::taffy::prelude::{percent, Size, Style};
use llimphi_ui::llimphi_raster::kurbo::{Affine, BezPath, Circle, Point, Rect as KurboRect, Stroke};
use llimphi_ui::llimphi_raster::peniko::{Color, Fill};
use llimphi_ui::llimphi_text::{draw_block, TextBlock};
use llimphi_ui::{PaintRect, View};
/// Convierte el RGBA lineal del plan (`[f32;4]` en [0,1]) al `Color`
/// de peniko. Mantiene la convención sin gamma del backend GPUI.
fn plan_color(c: PlanColor) -> Color {
let to_byte = |x: f32| (x.clamp(0.0, 1.0) * 255.0).round() as u8;
Color::from_rgba8(to_byte(c[0]), to_byte(c[1]), to_byte(c[2]), to_byte(c[3]))
}
/// Construye un View que pinta `plan` en su rect. Si `background` está
/// presente, se pinta como fondo sólido antes de los quads (el `fill`
/// del View ya lo cubriría — pero esta API mantiene el shape del
/// `DominiumCanvas::background` del backend GPUI).
pub fn canvas_view<Msg>(plan: RenderPlan, background: Option<Color>) -> View<Msg>
where
Msg: Clone + 'static,
{
// El plan es Send + Sync (Vec<Quad> con Copy). Lo movemos a la
// closure de paint; el runtime la invoca por frame.
let view = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
..Default::default()
});
let view = if let Some(bg) = background {
view.fill(bg)
} else {
view
};
view.paint_with(move |scene, ts, rect: PaintRect| {
if plan.quads.is_empty()
&& plan.polygons.is_empty()
&& plan.glyphs.is_empty()
&& plan.sprites.is_empty()
{
return;
}
// Centra la maqueta: el centro de la caja envolvente del plan
// se alinea con el centro del rect del nodo.
let plan_cx = (plan.min_x + plan.max_x) * 0.5;
let plan_cy = (plan.min_y + plan.max_y) * 0.5;
let off_x = (rect.x + rect.w * 0.5 - plan_cx) as f64;
let off_y = (rect.y + rect.h * 0.5 - plan_cy) as f64;
// Intercala quads + polygons por depth, atrás → adelante. Cada
// input ya está ordenado por su propio depth, así que un merge
// lineal alcanza — sin re-ordenar.
let mut qi = 0usize;
let mut pi = 0usize;
while qi < plan.quads.len() || pi < plan.polygons.len() {
let q_d = plan.quads.get(qi).map(|q| q.depth);
let p_d = plan.polygons.get(pi).map(|p| p.depth);
let take_quad = match (q_d, p_d) {
(Some(q), Some(p)) => q <= p,
(Some(_), None) => true,
(None, Some(_)) => false,
(None, None) => break,
};
if take_quad {
let q = &plan.quads[qi];
let x0 = q.x as f64 + off_x;
let y0 = q.y as f64 + off_y;
let x1 = x0 + q.w as f64;
let y1 = y0 + q.h as f64;
let r = KurboRect::new(x0, y0, x1, y1);
scene.fill(
Fill::NonZero,
Affine::IDENTITY,
plan_color(q.color),
None,
&r,
);
qi += 1;
} else {
let p = &plan.polygons[pi];
let mut path = BezPath::new();
let v = &p.vertices;
path.move_to(Point::new(v[0].0 as f64 + off_x, v[0].1 as f64 + off_y));
path.line_to(Point::new(v[1].0 as f64 + off_x, v[1].1 as f64 + off_y));
path.line_to(Point::new(v[2].0 as f64 + off_x, v[2].1 as f64 + off_y));
path.line_to(Point::new(v[3].0 as f64 + off_x, v[3].1 as f64 + off_y));
path.close_path();
scene.fill(
Fill::NonZero,
Affine::IDENTITY,
plan_color(p.color),
None,
&path,
);
pi += 1;
}
}
// Sprites vectoriales de los Conceptos, por encima de los quads.
// Cada primitiva es relleno (polígono cerrado), trazo (polilínea
// con grosor) o disco. Coordenadas ya en pantalla → sólo offset.
for prim in &plan.sprites {
match prim {
SpritePrim::Fill { points, color } => {
if points.len() < 3 {
continue;
}
let mut path = BezPath::new();
path.move_to(Point::new(points[0].0 as f64 + off_x, points[0].1 as f64 + off_y));
for pt in &points[1..] {
path.line_to(Point::new(pt.0 as f64 + off_x, pt.1 as f64 + off_y));
}
path.close_path();
scene.fill(Fill::NonZero, Affine::IDENTITY, plan_color(*color), None, &path);
}
SpritePrim::Stroke { points, width, color } => {
if points.len() < 2 {
continue;
}
let mut path = BezPath::new();
path.move_to(Point::new(points[0].0 as f64 + off_x, points[0].1 as f64 + off_y));
for pt in &points[1..] {
path.line_to(Point::new(pt.0 as f64 + off_x, pt.1 as f64 + off_y));
}
scene.stroke(
&Stroke::new(*width as f64),
Affine::IDENTITY,
plan_color(*color),
None,
&path,
);
}
SpritePrim::Disc { cx, cy, r, color } => {
let circle =
Circle::new(Point::new(*cx as f64 + off_x, *cy as f64 + off_y), *r as f64);
scene.fill(Fill::NonZero, Affine::IDENTITY, plan_color(*color), None, &circle);
}
}
}
// Glifos por encima de todo, sin re-shaping cacheado.
for gl in &plan.glyphs {
let s = gl.ch.to_string();
let block = TextBlock::simple(
&s,
gl.size_px,
plan_color(gl.color),
(gl.x as f64 + off_x, gl.y as f64 + off_y),
);
draw_block(scene, ts, &block);
}
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pure_red_round_trips() {
let c = plan_color([1.0, 0.0, 0.0, 1.0]).to_rgba8();
assert_eq!((c.r, c.g, c.b, c.a), (255, 0, 0, 255));
}
#[test]
fn alpha_passes_through() {
let c = plan_color([0.0, 0.0, 1.0, 0.25]).to_rgba8();
assert_eq!(c.b, 255);
assert_eq!(c.a, 64); // 0.25 * 255 = 63.75 ~> 64
}
#[test]
fn out_of_range_clamps() {
let c = plan_color([1.5, -0.2, 0.5, 1.0]).to_rgba8();
assert_eq!((c.r, c.g, c.b), (255, 0, 128));
}
}