feat(cosmobiologia): crate WASM + fallback inteligente + DEPLOY.md (fase 3b)
Cierra el requerimiento del módulo web. El cliente puede correr en
modo WASM (render local, scrubbing instantáneo, sin round-trip) o
caer al SSR (server compone el SVG) si el bundle WASM no está
desplegado. Switch automático sin configuración.
cosmobiologia-web (crate nuevo, cdylib + rlib):
- `lib.rs` con un único export wasm-bindgen
`render_model_to_svg(json, size, rot_offset_deg) -> String` que
deserializa un `RenderModel`, llama `compose_wheel` +
`draw_commands_to_svg` de cosmobiologia-render, y devuelve el
SVG inline listo para `wheel.innerHTML = svg`.
- Cargo.toml con `wasm-bindgen` + `getrandom` con feature
`wasm_js` solo bajo `target_arch = "wasm32"` (en nativo no se
arrastran).
- `.cargo/config.toml` con `--cfg getrandom_backend="wasm_js"`
para que la transitividad
`uuid → cosmobiologia-model → cosmobiologia-render` compile a
wasm32-unknown-unknown.
- `cargo check -p cosmobiologia-web` pasa en nativo (valida la
signature). Build WASM real lo dispara el usuario con
`wasm-pack build --target web --out-dir ../../../apps/
cosmobiologia-server/static/wasm` — comando documentado en
DEPLOY.md y en doc del crate.
cosmobiologia-server — soporte cliente WASM:
- Nuevo flag `--static-wasm <dir>` (default = static/wasm relativo
al cwd). Si el directorio existe, los archivos WASM se sirven
en `/static/wasm/*`. Si no existe, devuelve 404 y el cliente
cae al SSR.
- ServeDir de `tower-http` para fileserver simple.
index.html:
- Nueva función `tryLoadWasm()` que hace `import dinámico` del
módulo WASM al boot. Si carga OK, `wasm` global queda set; si
falla (archivo no existe o error de WASM), se loguea info y
sigue.
- `refreshSelected()` ahora hace fetch del RenderModel JSON
(`/api/sky` o `/api/charts/:id/render`); si hay WASM, llama
`wasm.render_model_to_svg(json)` localmente; si no hay WASM o
el render WASM falla, hace fetch del SVG SSR como fallback.
- Info row muestra "WASM" o "SSR" según el modo activo —
visualmente claro qué pipeline está corriendo.
cosmobiologia-server/DEPLOY.md (nuevo):
- Build del binario + build del WASM (con wasm-pack).
- systemd service template (sandboxing básico: ProtectSystem
strict, ProtectHome, PrivateTmp, NoNewPrivileges).
- Caddyfile y nginx para reverse proxy con TLS.
- DNS: A records para cosmobiologia.gioser.net + api.*.
- CORS: warnings sobre permissive vs producción multi-usuario.
- Separación demo público (DB vacía en VPS) vs desktop personal
(DB compartida en `~/.local/share/cosmobiologia/`).
- Backup con SQLite `.backup`.
- Smoke test post-deploy con curl.
- Tabla de referencia de TODOS los endpoints.
Tests: 10 verdes (cosmobiologia-render::math). El cliente WASM
no agrega tests propios — la lógica testeable vive en render.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
4619ba3a2bd7323c62bd620a7a8c036b1a3b2966 · by sergio · 2026-05-19T01:25:48Z (5 months ago) · parent eac8c5897 · browse the tree at this commit
Semantic delta · 6
crates/apps/cosmobiologia-server/src/main.rs
signature changed struct Cli body changed fn main
crates/modules/cosmobiologia/cosmobiologia-web/src/lib.rs
diff --git a/Cargo.lock b/Cargo.lock
index 8cda503..4993f3f 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2355,6 +2355,17 @@ dependencies = [
"yahweh-widget-tree",
]
+[[package]]
+name = "cosmobiologia-web"
+version = "0.1.0"
+dependencies = [
+ "cosmobiologia-render",
+ "getrandom 0.3.4",
+ "serde",
+ "serde_json",
+ "wasm-bindgen",
+]
+
[[package]]
name = "cpufeatures"
version = "0.2.17"
diff --git a/Cargo.toml b/Cargo.toml
index 8f97c75..84d5f0a 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -146,6 +146,7 @@ members = [
"crates/modules/cosmobiologia/cosmobiologia-canvas",
"crates/modules/cosmobiologia/cosmobiologia-tree",
"crates/modules/cosmobiologia/cosmobiologia-panel",
+ "crates/modules/cosmobiologia/cosmobiologia-web",
# ============================================================
# apps/ — apps que consumen el protocolo (yahweh modules+shell)
diff --git a/crates/apps/cosmobiologia-server/DEPLOY.md b/crates/apps/cosmobiologia-server/DEPLOY.md
new file mode 100644
index 0000000..6f35a31
--- /dev/null
+++ b/crates/apps/cosmobiologia-server/DEPLOY.md
@@ -0,0 +1,298 @@
+# Cosmobiología — guía de deploy
+
+Server HTTP single-user, escrito en Rust + axum. Sirve cartas
+astrológicas computadas con `cosmobiologia-engine` (VSOP2013 en Rust
+puro) y la página web HTML/JS del cliente. Diseñado para correr
+**local** o detrás de un reverse proxy con TLS.
+
+---
+
+## 1. Build
+
+### Binario del server
+
+```bash
+cargo build --release -p cosmobiologia-server
+# ./target/release/cosmobiologia-server
+```
+
+### Cliente WASM (opcional pero recomendado)
+
+Sin esto, el cliente cae al **SSR**: cada interacción pide al server
+el SVG recompuesto (~12 KB por click). Con WASM, el cliente compone
+localmente — primera carga ~150 KB, después scrubbing instantáneo
+sin round-trip.
+
+```bash
+# Una sola vez:
+cargo install wasm-pack
+
+# Cada vez que cambie cosmobiologia-render o cosmobiologia-web:
+cd crates/modules/cosmobiologia/cosmobiologia-web
+wasm-pack build --release --target web \
+ --out-dir ../../../../apps/cosmobiologia-server/static/wasm
+```
+
+`wasm-pack` produce `cosmobiologia_web.js` +
+`cosmobiologia_web_bg.wasm` en
+`crates/apps/cosmobiologia-server/static/wasm/`. El server los sirve
+en `/static/wasm/*` y el `index.html` los importa con
+`import init, { render_model_to_svg } from
+'/static/wasm/cosmobiologia_web.js'`.
+
+Si el directorio NO existe (build incompleto), el server devuelve
+404 y el cliente cae al SSR automáticamente — sin error visible.
+
+---
+
+## 2. Levantar el server
+
+### Local (single-user, sin reverse proxy)
+
+```bash
+./target/release/cosmobiologia-server \
+ --port 8787 \
+ --bind 127.0.0.1 \
+ --db ~/.local/share/cosmobiologia/charts.db
+```
+
+Abrí `http://127.0.0.1:8787/`. La DB es la misma que usa la app
+desktop — cualquier carta creada en la app aparece en el browser
+y viceversa.
+
+### systemd (server público vía VPS)
+
+```ini
+# /etc/systemd/system/cosmobiologia.service
+[Unit]
+Description=Cosmobiología (server astrológico)
+After=network.target
+
+[Service]
+Type=simple
+User=cosmobio
+Group=cosmobio
+WorkingDirectory=/opt/cosmobiologia
+ExecStart=/opt/cosmobiologia/cosmobiologia-server \
+ --port 8787 \
+ --bind 127.0.0.1 \
+ --db /var/lib/cosmobiologia/charts.db \
+ --static-wasm /opt/cosmobiologia/static/wasm
+Environment=RUST_LOG=cosmobiologia_server=info,tower_http=warn
+Restart=on-failure
+RestartSec=3
+# Sandboxing básico
+ProtectSystem=strict
+ProtectHome=true
+PrivateTmp=true
+ReadWritePaths=/var/lib/cosmobiologia
+NoNewPrivileges=true
+
+[Install]
+WantedBy=multi-user.target
+```
+
+```bash
+sudo useradd -r -s /usr/sbin/nologin cosmobio
+sudo mkdir -p /opt/cosmobiologia/static/wasm /var/lib/cosmobiologia
+sudo cp target/release/cosmobiologia-server /opt/cosmobiologia/
+sudo cp -r crates/apps/cosmobiologia-server/static/wasm/* \
+ /opt/cosmobiologia/static/wasm/
+sudo chown -R cosmobio:cosmobio /opt/cosmobiologia /var/lib/cosmobiologia
+sudo systemctl daemon-reload
+sudo systemctl enable --now cosmobiologia
+sudo systemctl status cosmobiologia
+```
+
+---
+
+## 3. Reverse proxy (HTTPS + DNS bonito)
+
+Con dos subdominios apuntando al host:
+
+| DNS | Función |
+|-----|---------|
+| `cosmobiologia.gioser.net` | página web (HTML + WASM) |
+| `api.cosmobiologia.gioser.net` | endpoints `/api/*` (JSON / SVG) |
+
+Hoy el server sirve los dos roles en el mismo puerto — el split por
+subdominio lo hace el proxy, **sin cambiar nada del Rust**.
+
+### Caddyfile (recomendado — TLS automático con Let's Encrypt)
+
+```Caddyfile
+cosmobiologia.gioser.net {
+ encode gzip zstd
+ # Página web + estáticos + WASM
+ @api path /api/*
+ handle @api {
+ # Si el cliente pega un /api/ directo al subdominio principal,
+ # lo dejamos pasar (más amigable que 404).
+ reverse_proxy 127.0.0.1:8787
+ }
+ handle {
+ reverse_proxy 127.0.0.1:8787
+ }
+}
+
+api.cosmobiologia.gioser.net {
+ encode gzip zstd
+ # Solo los endpoints /api/*; rechaza el resto.
+ @api path /api/*
+ handle @api {
+ reverse_proxy 127.0.0.1:8787
+ }
+ handle {
+ respond "Use cosmobiologia.gioser.net para la página" 404
+ }
+}
+```
+
+### nginx (alternativa)
+
+```nginx
+# /etc/nginx/sites-available/cosmobiologia
+server {
+ server_name cosmobiologia.gioser.net;
+ listen 443 ssl http2;
+ # ssl_certificate / ssl_certificate_key — vía certbot
+ location / {
+ proxy_pass http://127.0.0.1:8787;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ }
+ gzip on;
+ gzip_types application/javascript application/wasm image/svg+xml application/json text/css text/html;
+}
+
+server {
+ server_name api.cosmobiologia.gioser.net;
+ listen 443 ssl http2;
+ location /api/ {
+ proxy_pass http://127.0.0.1:8787;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ }
+ location / { return 404; }
+ gzip on;
+ gzip_types application/json image/svg+xml;
+}
+```
+
+### DNS
+
+A records (o AAAA si IPv6) hacia tu VPS:
+
+```
+cosmobiologia.gioser.net. A <ip-del-VPS>
+api.cosmobiologia.gioser.net. A <ip-del-VPS>
+```
+
+---
+
+## 4. CORS y separación cliente↔API
+
+Hoy el server tiene `CorsLayer::permissive()` — cualquier origen
+puede hacer fetch contra `/api/*`. Eso es OK para:
+
+- **Single-user local**: nadie más alcanza al server.
+- **Demo público single-tenant**: misma DB para todos los visitantes,
+ sin datos sensibles. Los visitantes pueden leer y crear cartas
+ públicamente (es la naturaleza del demo).
+
+**No use CorsLayer::permissive en producción multi-usuario**. Para
+eso hay que:
+1. Agregar auth (sesiones / JWT / API key).
+2. Reemplazar con `CorsLayer::new().allow_origin(["https://cosmobiologia.gioser.net".parse().unwrap()])`.
+3. Volverte el `AllowCredentials::yes()` si vas a usar cookies.
+
+---
+
+## 5. Separación demo público ↔ desktop personal
+
+El path por default de la DB (`~/.local/share/cosmobiologia/charts.db`)
+es **compartido entre el server y la app desktop**. Eso es lo que
+querés en tu máquina local — abrís el browser y ves las mismas
+cartas que tenés en la app gpui.
+
+**Pero NO querés que el server público en
+`cosmobiologia.gioser.net` exponga TUS cartas privadas**. Para el
+demo público:
+
+```bash
+# En tu VPS:
+mkdir -p /var/lib/cosmobiologia
+# Empezás con DB vacía (la app crea las tablas al primer arranque).
+cosmobiologia-server --db /var/lib/cosmobiologia/charts.db
+```
+
+Si querés precargar cartas demo (Einstein, una carta natal pública),
+podés copiarlas desde tu DB local con la app, exportarlas como JSON
+via `/api/charts/:id`, y postearlas al server público con POST
+`/api/charts`. O simplemente abrir el browser, ir a "Nuevo
+contacto" → "Nueva carta…" y cargarlas a mano.
+
+---
+
+## 6. Backup
+
+La DB SQLite es **un solo archivo**. Backup = `cp` (mientras el
+server está parado, o usá `sqlite3 charts.db ".backup
+charts.bak"` con el server corriendo).
+
+```bash
+# Snapshot diario sin parar el server
+sqlite3 /var/lib/cosmobiologia/charts.db ".backup /var/backups/cosmobiologia-$(date +%F).db"
+```
+
+---
+
+## 7. Smoke test post-deploy
+
+```bash
+# Desde tu máquina:
+curl https://cosmobiologia.gioser.net/api/health
+# → {"status":"ok","service":"cosmobiologia-server"}
+
+curl https://cosmobiologia.gioser.net/api/sky | jq .title
+# → "Cielo 2026-05-19 00:55 UTC"
+
+# Abrí la página:
+open https://cosmobiologia.gioser.net/
+# (deberías ver la rueda del cielo + sidebar con "Cielo ahora")
+```
+
+Si el cliente WASM cargó, en la barra inferior verás "WASM".
+Si cayó al SSR, verás "SSR". Ambos modos son funcionales.
+
+---
+
+## 8. Endpoints públicos (referencia)
+
+| Método | Path | Función |
+|--------|------|---------|
+| GET | `/api/health` | healthcheck |
+| GET | `/api/tree` | árbol completo (groups/contacts/charts) |
+| GET | `/api/sky` | RenderModel "Cielo ahora" |
+| GET | `/api/sky.svg` | SVG agnóstico del cielo (server-side) |
+| GET | `/api/charts/:id` | Chart JSON |
+| GET | `/api/charts/:id/render?...` | RenderModel con overlays |
+| GET | `/api/charts/:id/svg?...` | SVG vía engine (svg_export) |
+| GET | `/api/charts/:id/wheel.svg?...` | SVG vía render agnóstico |
+| POST | `/api/charts` | crear carta |
+| PATCH | `/api/charts/:id` | editar label/birth/config |
+| DELETE | `/api/charts/:id` | borrar |
+| POST | `/api/groups` | crear grupo |
+| PATCH | `/api/groups/:id` | renombrar |
+| DELETE | `/api/groups/:id` | borrar |
+| POST | `/api/contacts` | crear contacto |
+| PATCH | `/api/contacts/:id` | renombrar |
+| DELETE | `/api/contacts/:id` | borrar |
+
+Query params del render (`?...`):
+
+- `offset_min=<i64>` — time scrubbing (minutos desde el natal).
+- `transit=1` — activa overlay de tránsito al `now` del server.
+- `prog_age=<f64>` — progresión secundaria a edad N.
+- `sa_age=<f64>` — solar arc a edad N.
+- `pd_age=<f64>` — primary directions GR (Naibod).
diff --git a/crates/apps/cosmobiologia-server/src/main.rs b/crates/apps/cosmobiologia-server/src/main.rs
index 844b3cf..e59ea13 100644
--- a/crates/apps/cosmobiologia-server/src/main.rs
+++ b/crates/apps/cosmobiologia-server/src/main.rs
@@ -53,6 +53,7 @@ use cosmobiologia_model::{
use cosmobiologia_store::Store;
use serde::{Deserialize, Serialize};
use tower_http::cors::CorsLayer;
+use tower_http::services::ServeDir;
use tower_http::trace::TraceLayer;
use tracing::info;
@@ -73,6 +74,12 @@ struct Cli {
/// (`$XDG_DATA_HOME/cosmobiologia/charts.db`).
#[arg(long)]
db: Option<PathBuf>,
+ /// Directorio con los assets estáticos del cliente WASM
+ /// (output de `wasm-pack build --out-dir <este path>`). Si el
+ /// directorio no existe, el endpoint `/static/wasm/*` devuelve
+ /// 404 y el cliente cae al SSR.
+ #[arg(long, default_value = "crates/apps/cosmobiologia-server/static/wasm")]
+ static_wasm: PathBuf,
}
#[derive(Clone)]
@@ -102,7 +109,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let store = Arc::new(Store::open(&db_path)?);
let state = AppState { store };
- let app = router().with_state(state);
+ let app = router()
+ .nest_service("/static/wasm", ServeDir::new(&cli.static_wasm))
+ .with_state(state);
let addr: SocketAddr = format!("{}:{}", cli.bind, cli.port).parse()?;
info!("listening on http://{}", addr);
diff --git a/crates/apps/cosmobiologia-server/static/index.html b/crates/apps/cosmobiologia-server/static/index.html
index 5e355d5..4d7f481 100644
--- a/crates/apps/cosmobiologia-server/static/index.html
+++ b/crates/apps/cosmobiologia-server/static/index.html
@@ -171,30 +171,58 @@
await refreshSelected();
}
+ // Cliente WASM opcional. Si `/static/wasm/cosmobiologia_web.js`
+ // existe (= el usuario corrió `wasm-pack build` después de
+ // `cargo build`), el rendering se hace localmente sin pedirle
+ // el SVG al server por cada interacción. Si NO existe (deploy
+ // mínimo), caemos al SSR de `/api/*.svg`. Toggle automático,
+ // sin configuración.
+ let wasm = null;
+ async function tryLoadWasm() {
+ try {
+ const mod = await import('/static/wasm/cosmobiologia_web.js');
+ await mod.default(); // wasm-pack: inicializa
+ wasm = mod;
+ document.getElementById('info').textContent = 'WASM cargado — render local';
+ } catch (e) {
+ console.info('WASM no disponible, usando SSR:', e.message);
+ }
+ }
+
async function refreshSelected() {
const offset = document.getElementById('offset').value || 0;
const transit = document.getElementById('transit').checked ? '1' : '0';
const params = new URLSearchParams({ offset_min: offset, transit });
- let svgUrl, infoUrl;
- if (mode === 'sky') {
- svgUrl = `/api/sky.svg`;
- infoUrl = `/api/sky`;
+ const jsonUrl = mode === 'sky'
+ ? `/api/sky`
+ : `/api/charts/${selectedChartId}/render?${params}`;
+ const ssrUrl = mode === 'sky'
+ ? `/api/sky.svg`
+ : `/api/charts/${selectedChartId}/wheel.svg?${params}`;
+
+ const render = await fetch(jsonUrl).then(r => r.json()).catch(() => null);
+ let svg;
+ if (wasm && render) {
+ // Render local — WASM compose_wheel + draw_commands_to_svg.
+ try {
+ svg = wasm.render_model_to_svg(JSON.stringify(render), 600, 0);
+ } catch (e) {
+ console.warn('WASM render falló, fallback SSR:', e);
+ svg = await fetch(ssrUrl).then(r => r.text());
+ }
} else {
- svgUrl = `/api/charts/${selectedChartId}/wheel.svg?${params}`;
- infoUrl = `/api/charts/${selectedChartId}/render?${params}`;
+ // Fallback: SSR — el server devuelve el SVG ya compuesto.
+ svg = await fetch(ssrUrl).then(r => r.text());
}
- const [svg, render] = await Promise.all([
- fetch(svgUrl).then(r => r.text()),
- fetch(infoUrl).then(r => r.json()).catch(() => null),
- ]);
document.getElementById('wheel-container').innerHTML = svg;
const info = document.getElementById('info');
if (render) {
+ const mode_label = wasm ? 'WASM' : 'SSR';
info.innerHTML =
`<b>${escapeHtml(render.title)}</b> · ` +
`Asc ${render.ascendant_deg.toFixed(2)}° · ` +
`MC ${render.midheaven_deg.toFixed(2)}° · ` +
- `${render.compute_ms} ms`;
+ `${render.compute_ms} ms · ${mode_label}`;
} else {
info.textContent = '';
}
@@ -238,8 +266,11 @@
}[c]));
}
- loadTree();
- loadSky();
+ (async () => {
+ await tryLoadWasm();
+ await loadTree();
+ await loadSky();
+ })();
</script>
</body>
</html>
diff --git a/crates/modules/cosmobiologia/cosmobiologia-web/.cargo/config.toml b/crates/modules/cosmobiologia/cosmobiologia-web/.cargo/config.toml
new file mode 100644
index 0000000..97520ab
--- /dev/null
+++ b/crates/modules/cosmobiologia/cosmobiologia-web/.cargo/config.toml
@@ -0,0 +1,7 @@
+# Forzamos el backend `wasm_js` de getrandom para el target
+# wasm32-unknown-unknown — sin esta flag, el getrandom transitivo
+# vía `uuid → cosmobiologia-model → cosmobiologia-render` falla a
+# compilar a WASM con "wasm32-unknown-unknown targets are not
+# supported by default".
+[target.wasm32-unknown-unknown]
+rustflags = ['--cfg', 'getrandom_backend="wasm_js"']
diff --git a/crates/modules/cosmobiologia/cosmobiologia-web/Cargo.toml b/crates/modules/cosmobiologia/cosmobiologia-web/Cargo.toml
new file mode 100644
index 0000000..cb30d70
--- /dev/null
+++ b/crates/modules/cosmobiologia/cosmobiologia-web/Cargo.toml
@@ -0,0 +1,23 @@
+[package]
+name = "cosmobiologia-web"
+version = { workspace = true }
+edition = { workspace = true }
+license = { workspace = true }
+description = "Cosmobiología — cliente WASM. Reusa cosmobiologia-render para componer la rueda en SVG localmente, sin round-trip al server por cada interacción."
+
+[dependencies]
+cosmobiologia-render = { path = "../cosmobiologia-render" }
+serde = { workspace = true }
+serde_json = { workspace = true }
+
+# wasm-bindgen solo se compila para target wasm32; en nativo el
+# crate igual compila (rlib), pero esta API no se expone.
+[target.'cfg(target_arch = "wasm32")'.dependencies]
+wasm-bindgen = { workspace = true }
+# Backend de getrandom para WASM: pide al embedder (browser) que
+# provea la randomness vía Web Crypto. Se activa con el cfg
+# `getrandom_backend = "wasm_js"` desde .cargo/config.toml.
+getrandom = { version = "0.3", features = ["wasm_js"] }
+
+[lib]
+crate-type = ["cdylib", "rlib"]
diff --git a/crates/modules/cosmobiologia/cosmobiologia-web/src/lib.rs b/crates/modules/cosmobiologia/cosmobiologia-web/src/lib.rs
new file mode 100644
index 0000000..15acdf5
--- /dev/null
+++ b/crates/modules/cosmobiologia/cosmobiologia-web/src/lib.rs
@@ -0,0 +1,73 @@
+//! `cosmobiologia-web` — cdylib WASM que renderiza la rueda
+//! astrológica desde el browser, sin round-trip al server por cada
+//! interacción.
+//!
+//! ## Flujo
+//!
+//! 1. El cliente JS hace `await fetch('/api/sky')` o
+//! `/api/charts/:id/render?...` y recibe un `RenderModel` JSON.
+//! 2. JS llama `render_model_to_svg(json)` (exportado desde WASM) que
+//! deserializa + corre `cosmobiologia_render::compose_wheel` +
+//! serializa SVG.
+//! 3. JS hace `wheelContainer.innerHTML = svg`.
+//!
+//! ## Build
+//!
+//! ```bash
+//! cargo install wasm-pack # una vez
+//! cd crates/modules/cosmobiologia/cosmobiologia-web
+//! wasm-pack build --target web --out-dir ../../../../apps/cosmobiologia-server/static/wasm
+//! ```
+//!
+//! Esto produce un módulo ES6 (`cosmobiologia_web.js` +
+//! `cosmobiologia_web_bg.wasm`) que el `index.html` del server
+//! importa con `import init, { render_model_to_svg } from
+//! '/static/wasm/cosmobiologia_web.js';`.
+
+#![forbid(unsafe_code)]
+#![warn(rust_2018_idioms)]
+
+// La API pública SOLO se expone con `wasm-bindgen` en target
+// wasm32. En nativo (rlib) el crate compila para validar la
+// signature pero no exporta nada — los tests del render ya viven
+// en `cosmobiologia-render::math`.
+#[cfg(target_arch = "wasm32")]
+mod wasm {
+ use cosmobiologia_render::{
+ compose_wheel, draw_commands_to_svg, CompositionOpts, RenderModel,
+ };
+ use wasm_bindgen::prelude::*;
+
+ /// Renderea un `RenderModel` (JSON string) como SVG. El JSON sale
+ /// de `/api/sky` o `/api/charts/:id/render` del server.
+ ///
+ /// `size` es el lado del cuadrado contenedor en px (default 600).
+ /// `rot_offset_deg` permite rotar la vista (jog-dial / preview).
+ #[wasm_bindgen]
+ pub fn render_model_to_svg(json: &str, size: f32, rot_offset_deg: f32) -> Result<String, JsValue> {
+ let model: RenderModel = serde_json::from_str(json)
+ .map_err(|e| JsValue::from_str(&format!("parse RenderModel: {}", e)))?;
+ let opts = CompositionOpts {
+ size: if size > 0.0 { size } else { 600.0 },
+ rot_offset_deg,
+ include_bodies: true,
+ };
+ let cmds = compose_wheel(&model, &opts);
+ Ok(draw_commands_to_svg(&cmds, opts.size))
+ }
+
+ /// Hook de inicialización opcional — wasm_pack lo invoca al
+ /// cargar el módulo. Útil para instalar un panic hook hacia
+ /// `console.error`. Por ahora no-op.
+ #[wasm_bindgen(start)]
+ pub fn main_js() {}
+}
+
+#[cfg(not(target_arch = "wasm32"))]
+pub fn _native_marker() {
+ // Sin target wasm32, el crate solo expone el render como
+ // transitivo. Esta función vive para que `cargo check -p
+ // cosmobiologia-web` valide la compilación nativa sin
+ // wasm-bindgen — útil en CI y en desarrollo desktop.
+ let _ = std::any::type_name::<cosmobiologia_render::RenderModel>();
+}