From 4619ba3a2bd7323c62bd620a7a8c036b1a3b2966 Mon Sep 17 00:00:00 2001 From: sergio Date: Tue, 19 May 2026 01:25:48 +0000 Subject: [PATCH] feat(cosmobiologia): crate WASM + fallback inteligente + DEPLOY.md (fase 3b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ` (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 --- Cargo.lock | 11 + Cargo.toml | 1 + crates/apps/cosmobiologia-server/DEPLOY.md | 298 ++++++++++++++++++ crates/apps/cosmobiologia-server/src/main.rs | 11 +- .../cosmobiologia-server/static/index.html | 57 +++- .../cosmobiologia-web/.cargo/config.toml | 7 + .../cosmobiologia-web/Cargo.toml | 23 ++ .../cosmobiologia-web/src/lib.rs | 73 +++++ 8 files changed, 467 insertions(+), 14 deletions(-) create mode 100644 crates/apps/cosmobiologia-server/DEPLOY.md create mode 100644 crates/modules/cosmobiologia/cosmobiologia-web/.cargo/config.toml create mode 100644 crates/modules/cosmobiologia/cosmobiologia-web/Cargo.toml create mode 100644 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 +api.cosmobiologia.gioser.net. A +``` + +--- + +## 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=` — time scrubbing (minutos desde el natal). +- `transit=1` — activa overlay de tránsito al `now` del server. +- `prog_age=` — progresión secundaria a edad N. +- `sa_age=` — solar arc a edad N. +- `pd_age=` — 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, + /// Directorio con los assets estáticos del cliente WASM + /// (output de `wasm-pack build --out-dir `). 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> { 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 = `${escapeHtml(render.title)} · ` + `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(); + })(); 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 { + 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::(); +}