feat(gioser): chacana mística stepped, nubes/estrellas/meteoros, tilt 35°
- gioser-geom: ChacanaSpec paramétrica con `steps` (default 2). bounding box cuadrado (no cruz alargada), centro 6s×6s, brazos cortos de 2 niveles que adelgazan hacia la punta. arm_extent = 0.65 con thickness=0.13. - gioser-shaders: nubes FBM 5× más rápidas, 3 estratos de estrellas con twinkle independiente, 4 meteoros procedurales con cola/cabeza y vida cíclica. Chacana SDF rediseñada para 2 escalones, aro doble (interior + exterior), 12 rayos angulares y 4 marcas cardinales animadas. - gioser-canvas-web: MAX_TILT 22°→35°, WORLD_SCALE 0.92→1.45, spring 1.8 Hz / ζ=0.62 (más languido). uniform `u_center_half` agregado. Las puntas DOM se desplazan visiblemente con el tilt. - README: fix wasm-bindgen-cli 0.2.99 → 0.2.121 + `--locked`. 13 tests pasan (6 geom + 4 palette + 3 physics). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -35,32 +35,127 @@ se enchufa al runtime equivalente a `yahweh_launcher::launch_app`.
|
||||
- **Botones:** DOM real (accesibles, navegables por teclado, deep-link
|
||||
por hash) proyectados desde 3D al viewport cada frame.
|
||||
|
||||
## Build
|
||||
---
|
||||
|
||||
Requiere el target `wasm32-unknown-unknown` y `wasm-bindgen-cli`.
|
||||
## Requisitos
|
||||
|
||||
- **Rust** con el target `wasm32-unknown-unknown` instalado.
|
||||
- Con `rustup`: `rustup target add wasm32-unknown-unknown`.
|
||||
- En Artix/Arch con Rust del sistema: el target suele venir incluido en
|
||||
`/usr/lib/rustlib/wasm32-unknown-unknown` (verificá con
|
||||
`ls /usr/lib/rustlib | grep wasm`). Si falta: `pacman -S rust-wasm`
|
||||
(no existe — el target viene con el paquete `rust` base).
|
||||
- **wasm-bindgen-cli** (versión exacta 0.2.121, debe matchear la dep del
|
||||
`Cargo.lock`). Verificá con
|
||||
`grep -A1 '^name = "wasm-bindgen"$' Cargo.lock | head` antes de instalar.
|
||||
|
||||
```sh
|
||||
# Una vez (si no los tenés):
|
||||
rustup target add wasm32-unknown-unknown
|
||||
cargo install wasm-bindgen-cli --version 0.2.99
|
||||
|
||||
# Build:
|
||||
cargo build -p gioser-web --release --target wasm32-unknown-unknown
|
||||
|
||||
# Generar bindings JS:
|
||||
wasm-bindgen \
|
||||
target/wasm32-unknown-unknown/release/gioser_web.wasm \
|
||||
--out-dir crates/apps/gioser-web/pkg \
|
||||
--target web
|
||||
|
||||
# Servir (cualquier static server vale):
|
||||
python3 -m http.server -d crates/apps/gioser-web 8080
|
||||
# → http://localhost:8080/
|
||||
# Una sola vez:
|
||||
cargo install wasm-bindgen-cli --version 0.2.121 --locked
|
||||
```
|
||||
|
||||
Si usás `rust-toolchain.toml` con Rust del sistema (Artix/Arch), instalá
|
||||
el target con tu package manager (`pacman -S rust-wasm` o equivalente) o
|
||||
montá rustup en un perfil aparte.
|
||||
> La versión del CLI **debe** coincidir con la del crate `wasm-bindgen`
|
||||
> en `Cargo.lock`. Si no coincide, el output JS no carga el `.wasm`
|
||||
> generado. Si actualizás el workspace y wasm-bindgen sube de versión,
|
||||
> reinstalá el CLI con la nueva versión.
|
||||
|
||||
- Un static server para probar local: `python3 -m http.server` alcanza.
|
||||
|
||||
---
|
||||
|
||||
## Flujo rápido (un comando)
|
||||
|
||||
Hay un wrapper que hace cargo build + wasm-bindgen + copia salida:
|
||||
|
||||
```sh
|
||||
# Dev — sin optimización, build rápido (~10 s).
|
||||
./scripts/build-gioser-web.sh dev
|
||||
|
||||
# Release — opt-level=3, lto, strip, ~30 s pero binario pequeño.
|
||||
./scripts/build-gioser-web.sh release
|
||||
```
|
||||
|
||||
El output queda en `crates/apps/gioser-web/pkg/`:
|
||||
```
|
||||
pkg/
|
||||
├── gioser_web.js ← bindings JS (referenciados por index.html)
|
||||
├── gioser_web_bg.wasm ← binario WASM
|
||||
└── gioser_web.d.ts ← typings (no se usan en runtime, son para IDE)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Probarlo local
|
||||
|
||||
```sh
|
||||
./scripts/build-gioser-web.sh dev
|
||||
python3 -m http.server -d crates/apps/gioser-web 8080
|
||||
# Abrir http://localhost:8080/
|
||||
```
|
||||
|
||||
Si cambiás Rust: re-ejecutar el script y refrescar el browser.
|
||||
Si cambiás `index.html` / `styles.css`: alcanza con refrescar.
|
||||
|
||||
---
|
||||
|
||||
## Build release y deploy
|
||||
|
||||
```sh
|
||||
./scripts/build-gioser-web.sh release
|
||||
```
|
||||
|
||||
El binario release optimiza:
|
||||
- `opt-level = 3`, `lto = "thin"`, `codegen-units = 1`,
|
||||
`panic = "abort"`, `strip = "symbols"` (del perfil `[profile.release]`
|
||||
del workspace).
|
||||
- WASM resultante típico: **~120 KB** (canvas + shaders + bindings) sin
|
||||
comprimir, **~50 KB** gzippeado. wasm-bindgen pasa por
|
||||
`wasm-opt` automáticamente si lo encontrás en `$PATH` (instalable via
|
||||
`binaryen`).
|
||||
|
||||
Para deploy, los **artefactos a subir al host estático** son sólo
|
||||
cuatro archivos:
|
||||
|
||||
```
|
||||
crates/apps/gioser-web/
|
||||
├── index.html ← entry point
|
||||
├── styles.css ← estilos
|
||||
└── pkg/
|
||||
├── gioser_web.js
|
||||
└── gioser_web_bg.wasm
|
||||
```
|
||||
|
||||
Funciona en cualquier static host (Nginx, GitHub Pages, S3+CloudFront,
|
||||
Caddy, netlify, fly, Vercel static). **Importante:**
|
||||
|
||||
- El server debe servir `.wasm` con `Content-Type: application/wasm`.
|
||||
Nginx/Caddy lo hacen por default; algunos hosts muy viejos no — fijate.
|
||||
- `index.html` referencia `./pkg/gioser_web.js` con `type="module"`,
|
||||
o sea que el browser usa el ES module loader. Eso requiere servir por
|
||||
HTTP/HTTPS (no `file://`).
|
||||
- Fonts de Google: el `<link>` apunta a `fonts.googleapis.com`. Para uso
|
||||
offline o sin tracking, descargá las fuentes y servilas locales.
|
||||
|
||||
### Comando deploy "tar + scp"
|
||||
|
||||
```sh
|
||||
./scripts/build-gioser-web.sh release
|
||||
tar czf gioser-web-dist.tar.gz \
|
||||
-C crates/apps/gioser-web \
|
||||
index.html styles.css pkg/
|
||||
|
||||
# Subir al server:
|
||||
scp gioser-web-dist.tar.gz user@host:/var/www/gioser/
|
||||
ssh user@host 'cd /var/www/gioser && tar xzf gioser-web-dist.tar.gz'
|
||||
```
|
||||
|
||||
### GitHub Pages / gitea pages
|
||||
|
||||
Configurá la branch de pages a apuntar a un directorio que contenga
|
||||
sólo los 4 archivos. Un workflow CI que corra el script y commitee el
|
||||
`pkg/` a la branch de deploy hace el trabajo.
|
||||
|
||||
---
|
||||
|
||||
## Routing
|
||||
|
||||
@@ -68,10 +163,35 @@ Los `<a href="#aire|fuego|tierra|agua">` apuntan a anchors locales.
|
||||
Cuando definamos rutas reales (otras páginas, sub-apps, etc.), basta con
|
||||
cambiar el `href` en `index.html` o interceptar el click desde JS.
|
||||
|
||||
## Tests
|
||||
---
|
||||
|
||||
Los crates agnósticos tienen tests unitarios:
|
||||
## Tests de los crates agnósticos
|
||||
|
||||
Los cuatro crates sin gpui/web tienen tests unitarios estándar:
|
||||
|
||||
```sh
|
||||
cargo test -p gioser-geom -p gioser-physics -p gioser-palette
|
||||
cargo test -p gioser-geom -p gioser-physics -p gioser-palette -p gioser-shaders
|
||||
```
|
||||
|
||||
`gioser-canvas-web` no tiene tests (depende de WebGL2 que sólo existe
|
||||
en browser).
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Pantalla en blanco + error en consola "no link/binding found"**
|
||||
→ Versión de `wasm-bindgen-cli` no coincide con la del `Cargo.lock`.
|
||||
Reinstalá con `cargo install wasm-bindgen-cli --version <X.Y.Z>`
|
||||
donde `<X.Y.Z>` sale de `grep '^version' Cargo.lock` cerca de la entrada
|
||||
`wasm-bindgen`.
|
||||
|
||||
**"WebGL2 not supported"** en algunos navegadores viejos
|
||||
→ No hay fallback. WebGL2 es soporte universal en navegadores modernos
|
||||
(Chrome/Edge/Firefox/Safari desde 2017). Para targets ancestrales habría
|
||||
que escribir un renderer WebGL1, no contemplado por ahora.
|
||||
|
||||
**Build muy lento por las deps de web-sys**
|
||||
→ Las features de web-sys están minimizadas en el Cargo.toml; sólo se
|
||||
importan las que el renderer usa. El primer build sí es lento (~1 min),
|
||||
los incrementales son rápidos.
|
||||
|
||||
@@ -31,7 +31,12 @@ use web_sys::{
|
||||
};
|
||||
|
||||
const RAD: f32 = core::f32::consts::PI / 180.0;
|
||||
const MAX_TILT_DEG: f32 = 22.0;
|
||||
/// Inclinación máxima en cada eje. 35° hace que las puntas se desplacen
|
||||
/// visiblemente en pantalla — los botones DOM cabalgan ese movimiento.
|
||||
const MAX_TILT_DEG: f32 = 35.0;
|
||||
/// Escala mundo→viewport: la chacana clásica con arm_extent ≈ 0.65 ocupa
|
||||
/// ~94% del eje vertical del viewport, dejando aire para aros y glow.
|
||||
const WORLD_SCALE: f32 = 1.45;
|
||||
|
||||
/// Re-export para apps: identidad (id, color, label) de cada punta cardinal,
|
||||
/// en el orden `[N, E, S, W]` de `ChacanaSpec::tips()`.
|
||||
@@ -73,7 +78,6 @@ impl Program {
|
||||
let program = gl.create_program().ok_or("create_program failed")?;
|
||||
gl.attach_shader(&program, &vs);
|
||||
gl.attach_shader(&program, &fs);
|
||||
// Atributo `a_pos` siempre en location 0.
|
||||
gl.bind_attrib_location(&program, 0, "a_pos");
|
||||
gl.link_program(&program);
|
||||
let linked = gl
|
||||
@@ -126,8 +130,8 @@ fn upload_quad(
|
||||
gl.bind_vertex_array(Some(&vao));
|
||||
let buf = gl.create_buffer().ok_or("create_buffer failed")?;
|
||||
gl.bind_buffer(GL::ARRAY_BUFFER, Some(&buf));
|
||||
// SAFETY: `Float32Array::view` apunta directamente a la memoria del WASM linear,
|
||||
// que no podemos mover durante este scope (sin allocs intermedias).
|
||||
// SAFETY: `Float32Array::view` apunta a memoria WASM lineal; no la
|
||||
// movemos durante este scope (no hay allocs intermedias).
|
||||
unsafe {
|
||||
let view = js_sys::Float32Array::view(verts);
|
||||
gl.buffer_data_with_array_buffer_view(GL::ARRAY_BUFFER, &view, GL::STATIC_DRAW);
|
||||
@@ -171,6 +175,7 @@ impl Renderer {
|
||||
"u_mvp",
|
||||
"u_time",
|
||||
"u_thickness",
|
||||
"u_center_half",
|
||||
"u_arm_extent",
|
||||
"u_line_color",
|
||||
"u_rim_color",
|
||||
@@ -181,11 +186,13 @@ impl Renderer {
|
||||
.map_err(JsValue::from)?;
|
||||
|
||||
let (cosmos_vao, _) = upload_quad(&gl, &FULLSCREEN_QUAD, 0).map_err(JsValue::from)?;
|
||||
let chacana_quad_verts = chacana_quad(chacana.arm_extent);
|
||||
let chacana_quad_verts = chacana_quad(chacana.arm_extent());
|
||||
let (chacana_vao, chacana_quad_count) =
|
||||
upload_quad(&gl, &chacana_quad_verts, 0).map_err(JsValue::from)?;
|
||||
|
||||
let tilt = SpringDamper2::new(2.2, 0.72);
|
||||
// Spring sub-crítico, frecuencia baja: sensación de cuerpo pesado
|
||||
// que se inclina hacia el cursor con overshoot orgánico (~10%).
|
||||
let tilt = SpringDamper2::new(1.8, 0.62);
|
||||
|
||||
Ok(Self {
|
||||
gl,
|
||||
@@ -209,8 +216,7 @@ impl Renderer {
|
||||
.viewport(0, 0, self.viewport.0 as i32, self.viewport.1 as i32);
|
||||
}
|
||||
|
||||
/// Recibe coords del mouse en pixeles, origen en el centro del canvas
|
||||
/// (+x derecha, +y arriba). Setea el target de inclinación.
|
||||
/// Mouse en pixeles desde el centro del canvas (+x derecha, +y arriba).
|
||||
pub fn set_mouse_px(&mut self, x: f32, y: f32) {
|
||||
let (w, h) = self.viewport;
|
||||
if h == 0 {
|
||||
@@ -223,13 +229,13 @@ impl Renderer {
|
||||
self.mouse = (mx, my);
|
||||
|
||||
let max_tilt = MAX_TILT_DEG * RAD;
|
||||
// Pitch (rot X): mouse arriba → top inclina hacia el viewer (+rotX).
|
||||
// Yaw (rot Y): mouse derecha → right inclina hacia el viewer (-rotY).
|
||||
// Pitch (+rotX) cuando mouse arriba: el tope se acerca al viewer.
|
||||
// Yaw (-rotY) cuando mouse derecha: el lado derecho se acerca.
|
||||
let target = [my * max_tilt, -mx * max_tilt / aspect];
|
||||
self.tilt.set_target(target);
|
||||
}
|
||||
|
||||
/// Posición proyectada (NDC `[-1, 1]²`) de cada tip cardinal en el orden N/E/S/W.
|
||||
/// Posición proyectada (NDC `[-1, 1]²`) de cada tip cardinal en orden N/E/S/W.
|
||||
/// El caller la usa para anclar el DOM.
|
||||
pub fn tips_ndc(&self) -> [(f32, f32); 4] {
|
||||
let mvp = self.build_mvp();
|
||||
@@ -246,6 +252,12 @@ impl Renderer {
|
||||
&self.chacana
|
||||
}
|
||||
|
||||
/// Posición normalizada del mouse en clip-space; útil para que el caller
|
||||
/// añada parallax sutil a elementos DOM independientes de la chacana.
|
||||
pub fn mouse_clip(&self) -> (f32, f32) {
|
||||
self.mouse
|
||||
}
|
||||
|
||||
fn build_mvp(&self) -> Mat4 {
|
||||
let (w, h) = self.viewport;
|
||||
let aspect = w as f32 / h as f32;
|
||||
@@ -253,7 +265,7 @@ impl Renderer {
|
||||
let view = Mat4::look_at_rh(Vec3::new(0.0, 0.0, 2.6), Vec3::ZERO, Vec3::Y);
|
||||
let pitch = Mat4::from_rotation_x(self.tilt.position[0]);
|
||||
let yaw = Mat4::from_rotation_y(self.tilt.position[1]);
|
||||
let scale = Mat4::from_scale(Vec3::splat(0.92));
|
||||
let scale = Mat4::from_scale(Vec3::splat(WORLD_SCALE));
|
||||
proj * view * yaw * pitch * scale
|
||||
}
|
||||
|
||||
@@ -265,7 +277,6 @@ impl Renderer {
|
||||
};
|
||||
self.last_time_ms = time_ms;
|
||||
|
||||
// Subdividimos la física para mantener estabilidad con dt grandes.
|
||||
let sub = 4;
|
||||
let sub_dt = dt / sub as f32;
|
||||
for _ in 0..sub {
|
||||
@@ -313,8 +324,11 @@ impl Renderer {
|
||||
if let Some(u) = self.chacana_prog.u("u_thickness") {
|
||||
gl.uniform1f(Some(u), self.chacana.thickness);
|
||||
}
|
||||
if let Some(u) = self.chacana_prog.u("u_center_half") {
|
||||
gl.uniform1f(Some(u), self.chacana.center_half());
|
||||
}
|
||||
if let Some(u) = self.chacana_prog.u("u_arm_extent") {
|
||||
gl.uniform1f(Some(u), self.chacana.arm_extent);
|
||||
gl.uniform1f(Some(u), self.chacana.arm_extent());
|
||||
}
|
||||
upload_rgb(gl, self.chacana_prog.u("u_line_color"), cosmos::CHACANA_LINE);
|
||||
upload_rgb(gl, self.chacana_prog.u("u_rim_color"), cosmos::CHACANA_RIM);
|
||||
|
||||
@@ -1,85 +1,66 @@
|
||||
//! Geometría de la chacana andina (cruz cuadrada escalonada).
|
||||
//! Geometría de la chacana andina escalonada (cruz cuadrada de Tiwanaku).
|
||||
//!
|
||||
//! Genera un polígono cerrado de 20 vértices: un cuadrado central,
|
||||
//! cuatro escalones (uno por brazo cardinal) y cuatro puntas que
|
||||
//! extienden hasta `arm_extent`.
|
||||
//! Modelo paramétrico: un cuadrado central de lado `2 * center_half()`,
|
||||
//! del que sobresalen cuatro brazos cardinales formados por `steps`
|
||||
//! niveles. Cada nivel adelgaza al brazo en `thickness` por lado y lo
|
||||
//! prolonga en `thickness` hacia afuera.
|
||||
//!
|
||||
//! Convención: plano XY, centro en `(0, 0)`, +Y hacia el norte,
|
||||
//! +X hacia el este. Toda la API es pura: ningún I/O, ninguna asignación
|
||||
//! global; apta para ejecutar dentro de un shader-host, en un test,
|
||||
//! o en una integración nativa.
|
||||
//! Para `steps = 2` (clásica mística):
|
||||
//! - Centro: cuadrado `6s × 6s` (donde `s = thickness`).
|
||||
//! - Nivel 1: rectángulo perpendicular `4s × s` adosado a cada cara del centro.
|
||||
//! - Nivel 2 (punta): rectángulo `2s × s` adosado al nivel 1.
|
||||
//!
|
||||
//! Resultado: bounding box `±5s` (cuadrado, no alargado como una cruz latina),
|
||||
//! 9 rectángulos disjuntos triangulables, 4 tips cardinales.
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct ChacanaSpec {
|
||||
/// Distancia desde el centro hasta la punta del brazo.
|
||||
pub arm_extent: f32,
|
||||
/// Semi-grosor del brazo. El escalón mide `2 * thickness`.
|
||||
/// Unidad base de la geometría. Cada paso aporta `thickness` de ancho
|
||||
/// y `thickness` de profundidad.
|
||||
pub thickness: f32,
|
||||
/// Cantidad de escalones por brazo (`>= 1`). La chacana mística clásica = `2`.
|
||||
pub steps: u32,
|
||||
}
|
||||
|
||||
impl ChacanaSpec {
|
||||
/// Configuración canónica: brazo 1.0, grosor 0.18 (proporciones del logo).
|
||||
/// Configuración canónica del logo GioSer: 2 escalones, thickness 0.13
|
||||
/// (bounding box ≈ 1.30 × 1.30 en unidades de mundo).
|
||||
pub const CLASSIC: Self = Self {
|
||||
arm_extent: 1.0,
|
||||
thickness: 0.18,
|
||||
thickness: 0.13,
|
||||
steps: 2,
|
||||
};
|
||||
|
||||
pub const fn new(arm_extent: f32, thickness: f32) -> Self {
|
||||
Self {
|
||||
arm_extent,
|
||||
thickness,
|
||||
}
|
||||
pub const fn new(thickness: f32, steps: u32) -> Self {
|
||||
Self { thickness, steps }
|
||||
}
|
||||
|
||||
/// Las cuatro puntas cardinales en orden `[N, E, S, W]`.
|
||||
/// Coordenadas listas para anclar UI sobre la chacana.
|
||||
/// Semi-lado del cuadrado central — la parte **más ancha** de la chacana.
|
||||
pub fn center_half(&self) -> f32 {
|
||||
(self.steps as f32 + 1.0) * self.thickness
|
||||
}
|
||||
|
||||
/// Distancia desde el centro a la punta más externa.
|
||||
pub fn arm_extent(&self) -> f32 {
|
||||
self.center_half() + self.steps as f32 * self.thickness
|
||||
}
|
||||
|
||||
/// Las cuatro puntas cardinales `[N, E, S, W]`.
|
||||
pub fn tips(&self) -> [(f32, f32); 4] {
|
||||
let l = self.arm_extent;
|
||||
let l = self.arm_extent();
|
||||
[(0.0, l), (l, 0.0), (0.0, -l), (-l, 0.0)]
|
||||
}
|
||||
|
||||
/// Bounding box axis-aligned `(min, max)`.
|
||||
pub fn aabb(&self) -> ((f32, f32), (f32, f32)) {
|
||||
let l = self.arm_extent;
|
||||
let l = self.arm_extent();
|
||||
((-l, -l), (l, l))
|
||||
}
|
||||
|
||||
/// Perímetro cerrado en orden horario: 20 vértices, listo para `LINE_LOOP`.
|
||||
pub fn perimeter(&self) -> Vec<(f32, f32)> {
|
||||
let s = self.thickness;
|
||||
let l = self.arm_extent;
|
||||
let s2 = s * 2.0;
|
||||
vec![
|
||||
(s, l),
|
||||
(s, s2),
|
||||
(s2, s2),
|
||||
(s2, s),
|
||||
(l, s),
|
||||
(l, -s),
|
||||
(s2, -s),
|
||||
(s2, -s2),
|
||||
(s, -s2),
|
||||
(s, -l),
|
||||
(-s, -l),
|
||||
(-s, -s2),
|
||||
(-s2, -s2),
|
||||
(-s2, -s),
|
||||
(-l, -s),
|
||||
(-l, s),
|
||||
(-s2, s),
|
||||
(-s2, s2),
|
||||
(-s, s2),
|
||||
(-s, l),
|
||||
]
|
||||
}
|
||||
|
||||
/// Triangulación: 9 rectángulos (1 centro + 4 escalones + 4 puntas) = 54 vértices.
|
||||
/// Listo para `GL_TRIANGLES`.
|
||||
/// Triangulación: `1 + 4 * steps` rectángulos en `GL_TRIANGLES`.
|
||||
/// Para `steps = 2`: 9 rects = 54 vértices.
|
||||
pub fn triangles(&self) -> Vec<(f32, f32)> {
|
||||
let s = self.thickness;
|
||||
let l = self.arm_extent;
|
||||
let s2 = s * 2.0;
|
||||
let mut tri = Vec::with_capacity(9 * 6);
|
||||
let c = self.center_half();
|
||||
let mut tri = Vec::with_capacity(6 * (1 + 4 * self.steps as usize));
|
||||
let mut rect = |x0: f32, y0: f32, x1: f32, y1: f32| {
|
||||
tri.push((x0, y0));
|
||||
tri.push((x1, y0));
|
||||
@@ -88,23 +69,22 @@ impl ChacanaSpec {
|
||||
tri.push((x1, y1));
|
||||
tri.push((x0, y1));
|
||||
};
|
||||
// Cuadrado central
|
||||
rect(-s, -s, s, s);
|
||||
// Escalones (un rect 4s × s por brazo)
|
||||
rect(-s2, s, s2, s2); // N
|
||||
rect(-s2, -s2, s2, -s); // S
|
||||
rect(s, -s2, s2, s2); // E
|
||||
rect(-s2, -s2, -s, s2); // W
|
||||
// Puntas (un rect 2s × (l - 2s) por brazo)
|
||||
rect(-s, s2, s, l); // N
|
||||
rect(-s, -l, s, -s2); // S
|
||||
rect(s2, -s, l, s); // E
|
||||
rect(-l, -s, -s2, s); // W
|
||||
rect(-c, -c, c, c);
|
||||
for k in 1..=self.steps {
|
||||
// El k-ésimo nivel (1 = más cerca del centro, steps = punta)
|
||||
// adelgaza a (steps - k + 1) * thickness de semi-ancho.
|
||||
let hw = (self.steps - k + 1) as f32 * s;
|
||||
let inner = c + (k - 1) as f32 * s;
|
||||
let outer = c + k as f32 * s;
|
||||
rect(-hw, inner, hw, outer); // N
|
||||
rect(-hw, -outer, hw, -inner); // S
|
||||
rect(inner, -hw, outer, hw); // E
|
||||
rect(-outer, -hw, -inner, hw); // W
|
||||
}
|
||||
tri
|
||||
}
|
||||
|
||||
/// Para un punto cualquiera, devuelve la punta más cercana y su distancia.
|
||||
/// Útil para snapping de interacción.
|
||||
/// Para un punto cualquiera, devuelve la punta más cercana y la distancia.
|
||||
pub fn closest_tip(&self, p: (f32, f32)) -> ((f32, f32), f32) {
|
||||
let tips = self.tips();
|
||||
let mut best = (tips[0], f32::INFINITY);
|
||||
@@ -125,35 +105,55 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn perimeter_has_20_vertices() {
|
||||
assert_eq!(ChacanaSpec::CLASSIC.perimeter().len(), 20);
|
||||
fn classic_is_two_step_chacana() {
|
||||
let c = ChacanaSpec::CLASSIC;
|
||||
assert_eq!(c.steps, 2);
|
||||
// center_half = 3 * 0.13 = 0.39; arm_extent = 0.65.
|
||||
assert!((c.center_half() - 0.39).abs() < 1e-6);
|
||||
assert!((c.arm_extent() - 0.65).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn triangles_form_9_rectangles() {
|
||||
assert_eq!(ChacanaSpec::CLASSIC.triangles().len(), 9 * 6);
|
||||
fn arm_extent_grows_with_steps() {
|
||||
let c1 = ChacanaSpec::new(0.1, 1);
|
||||
let c2 = ChacanaSpec::new(0.1, 2);
|
||||
let c3 = ChacanaSpec::new(0.1, 3);
|
||||
assert!(c1.arm_extent() < c2.arm_extent());
|
||||
assert!(c2.arm_extent() < c3.arm_extent());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn triangles_one_rect_plus_four_per_step() {
|
||||
let c1 = ChacanaSpec::new(0.1, 1);
|
||||
assert_eq!(c1.triangles().len(), 6 * (1 + 4 * 1));
|
||||
let c2 = ChacanaSpec::CLASSIC;
|
||||
assert_eq!(c2.triangles().len(), 6 * (1 + 4 * 2));
|
||||
let c3 = ChacanaSpec::new(0.1, 3);
|
||||
assert_eq!(c3.triangles().len(), 6 * (1 + 4 * 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tips_match_cardinals() {
|
||||
let c = ChacanaSpec::new(2.0, 0.3);
|
||||
let c = ChacanaSpec::CLASSIC;
|
||||
let l = c.arm_extent();
|
||||
let tips = c.tips();
|
||||
assert_eq!(tips[0], (0.0, 2.0)); // N
|
||||
assert_eq!(tips[1], (2.0, 0.0)); // E
|
||||
assert_eq!(tips[2], (0.0, -2.0)); // S
|
||||
assert_eq!(tips[3], (-2.0, 0.0)); // W
|
||||
assert_eq!(tips[0], (0.0, l)); // N
|
||||
assert_eq!(tips[1], (l, 0.0)); // E
|
||||
assert_eq!(tips[2], (0.0, -l)); // S
|
||||
assert_eq!(tips[3], (-l, 0.0)); // W
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closest_tip_to_upper_left_is_north() {
|
||||
fn closest_tip_to_upper_point_is_north() {
|
||||
let c = ChacanaSpec::CLASSIC;
|
||||
let (tip, _d) = c.closest_tip((-0.1, 0.95));
|
||||
assert_eq!(tip, (0.0, 1.0));
|
||||
let (tip, _d) = c.closest_tip((-0.1, 0.55));
|
||||
assert_eq!(tip, (0.0, c.arm_extent()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aabb_matches_extent() {
|
||||
let c = ChacanaSpec::new(1.5, 0.2);
|
||||
assert_eq!(c.aabb(), ((-1.5, -1.5), (1.5, 1.5)));
|
||||
let c = ChacanaSpec::new(0.12, 2);
|
||||
let l = c.arm_extent();
|
||||
assert_eq!(c.aabb(), ((-l, -l), (l, l)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,9 +20,9 @@ void main() {
|
||||
}
|
||||
";
|
||||
|
||||
/// Fragment del fondo cósmico: FBM en 3 capas + estrellas + viñeta.
|
||||
/// Uniforms esperados: `u_resolution`, `u_time`, `u_parallax`,
|
||||
/// `u_void`, `u_nebula_a`, `u_nebula_b`, `u_stardust`.
|
||||
/// Fragment del fondo cósmico: nubes FBM en 3 capas, 3 estratos de
|
||||
/// estrellas con titilación independiente, viñeta, y 4 meteoros
|
||||
/// procedurales que cruzan el cielo periódicamente.
|
||||
pub const FS_COSMOS: &str = "#version 300 es
|
||||
precision highp float;
|
||||
in vec2 v_clip;
|
||||
@@ -39,6 +39,9 @@ uniform vec3 u_stardust;
|
||||
float hash21(vec2 p) {
|
||||
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
|
||||
}
|
||||
float hash11(float n) {
|
||||
return fract(sin(n * 78.233) * 43758.5453);
|
||||
}
|
||||
float vnoise(vec2 p) {
|
||||
vec2 i = floor(p);
|
||||
vec2 f = fract(p);
|
||||
@@ -54,45 +57,106 @@ float fbm(vec2 p) {
|
||||
float a = 0.55;
|
||||
for (int i = 0; i < 5; i++) {
|
||||
v += a * vnoise(p);
|
||||
p *= 2.07;
|
||||
p = p * 2.07 + vec2(11.3, 7.7);
|
||||
a *= 0.55;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
// Meteoro procedural: trazo brillante con cola, vida 1.6s, respawnea solo.
|
||||
float meteor(vec2 uv, float seed) {
|
||||
float period = 6.5 + 4.0 * hash11(seed * 17.0);
|
||||
float t_seeded = u_time + seed * 19.0;
|
||||
float phase = mod(t_seeded, period);
|
||||
float life = 1.6;
|
||||
if (phase > life) return 0.0;
|
||||
float t = phase / life;
|
||||
|
||||
float epoch = floor(t_seeded / period);
|
||||
vec2 origin = vec2(
|
||||
hash21(vec2(seed, epoch)) * 2.6 - 1.3,
|
||||
0.55 + hash21(vec2(seed + 5.0, epoch)) * 0.55
|
||||
);
|
||||
vec2 dir = normalize(vec2(
|
||||
hash21(vec2(seed + 1.0, epoch)) * 1.6 - 0.8,
|
||||
-0.7 - hash21(vec2(seed + 2.0, epoch)) * 0.6
|
||||
));
|
||||
|
||||
vec2 head = origin + dir * t * 2.1;
|
||||
vec2 tail = head - dir * 0.24;
|
||||
|
||||
vec2 pa = uv - tail;
|
||||
vec2 ba = head - tail;
|
||||
float h = clamp(dot(pa, ba) / max(dot(ba, ba), 1e-6), 0.0, 1.0);
|
||||
float dist = length(pa - ba * h);
|
||||
|
||||
float perpGlow = exp(-dist * 420.0);
|
||||
float trailFalloff = smoothstep(0.0, 1.0, h);
|
||||
float headPulse = exp(-dist * 900.0);
|
||||
float lifeFade = sin(t * 3.14159);
|
||||
|
||||
return (perpGlow * trailFalloff + headPulse * 1.4) * lifeFade;
|
||||
}
|
||||
|
||||
void main() {
|
||||
float aspect = u_resolution.x / max(u_resolution.y, 1.0);
|
||||
vec2 uv = v_clip;
|
||||
uv.x *= aspect;
|
||||
|
||||
vec2 d1 = vec2( u_time * 0.010, u_time * 0.004) + u_parallax * 0.08;
|
||||
vec2 d2 = vec2(-u_time * 0.016, u_time * 0.011) + u_parallax * 0.18;
|
||||
vec2 d3 = vec2( u_time * 0.024, -u_time * 0.019) + u_parallax * 0.34;
|
||||
// === NUBES (drift visible, 5× más rápido que la versión anterior) ===
|
||||
vec2 d1 = vec2( u_time * 0.055, u_time * 0.022) + u_parallax * 0.10;
|
||||
vec2 d2 = vec2(-u_time * 0.085, u_time * 0.058) + u_parallax * 0.22;
|
||||
vec2 d3 = vec2( u_time * 0.130, -u_time * 0.095) + u_parallax * 0.40;
|
||||
|
||||
float n1 = fbm(uv * 0.9 + d1);
|
||||
float n2 = fbm(uv * 2.1 + d2);
|
||||
float n3 = fbm(uv * 4.5 + d3);
|
||||
float n1 = fbm(uv * 0.85 + d1);
|
||||
float n2 = fbm(uv * 2.05 + d2);
|
||||
float n3 = fbm(uv * 4.40 + d3);
|
||||
|
||||
vec3 color = u_void;
|
||||
color = mix(color, u_nebula_a, pow(n1, 1.6) * 0.70);
|
||||
color = mix(color, u_nebula_b, pow(n2, 2.0) * 0.55);
|
||||
color += u_nebula_a * pow(n3, 3.2) * 0.22;
|
||||
color = mix(color, u_nebula_a, pow(n1, 1.5) * 0.80);
|
||||
color = mix(color, u_nebula_b, pow(n2, 1.85) * 0.62);
|
||||
color += u_nebula_a * pow(n3, 3.0) * 0.28;
|
||||
|
||||
// Viñeta radial.
|
||||
float r = length(v_clip);
|
||||
color *= 1.0 - smoothstep(0.55, 1.35, r) * 0.85;
|
||||
color *= 1.0 - smoothstep(0.55, 1.40, r) * 0.85;
|
||||
|
||||
// Estrellas brillantes (pocas, titilan).
|
||||
vec2 sgrid = uv * 90.0;
|
||||
// === ESTRELLAS — 3 estratos con titilación distinta ===
|
||||
// Brillantes, pocas, titilan rápido.
|
||||
{
|
||||
vec2 sgrid = uv * 75.0;
|
||||
vec2 sid = floor(sgrid);
|
||||
float sh = hash21(sid);
|
||||
float twinkle = 0.4 + 0.6 * sin(u_time * 1.7 + sh * 28.0);
|
||||
float starMask = smoothstep(0.997, 0.9985, sh);
|
||||
color += u_stardust * starMask * twinkle * 0.95;
|
||||
float tw = 0.45 + 0.55 * sin(u_time * 2.6 + sh * 41.0);
|
||||
float mask = smoothstep(0.9935, 0.999, sh);
|
||||
color += u_stardust * mask * tw * 1.15;
|
||||
}
|
||||
// Medianas, densas, titilan lento.
|
||||
{
|
||||
vec2 sgrid = uv * 135.0 + vec2(7.0, 11.0);
|
||||
vec2 sid = floor(sgrid);
|
||||
float sh = hash21(sid);
|
||||
float tw = 0.55 + 0.45 * sin(u_time * 1.1 + sh * 28.0);
|
||||
float mask = smoothstep(0.987, 0.994, sh);
|
||||
color += u_stardust * mask * tw * 0.75;
|
||||
}
|
||||
// Polvo de fondo, muchas, casi sin twinkle.
|
||||
{
|
||||
vec2 sgrid = uv * 260.0 + vec2(13.0, 3.0);
|
||||
vec2 sid = floor(sgrid);
|
||||
float sh = hash21(sid);
|
||||
float tw = 0.7 + 0.3 * sin(u_time * 0.5 + sh * 15.0);
|
||||
float mask = smoothstep(0.982, 0.989, sh);
|
||||
color += u_stardust * mask * tw * 0.40;
|
||||
}
|
||||
|
||||
// Polvo (muchas, débiles).
|
||||
vec2 dgrid = uv * 220.0;
|
||||
float dh = hash21(floor(dgrid));
|
||||
float dustMask = smoothstep(0.985, 0.992, dh);
|
||||
color += u_stardust * dustMask * 0.25;
|
||||
// === METEOROS (4 procedurales, respawn independiente) ===
|
||||
float meteors = 0.0;
|
||||
meteors += meteor(uv, 0.31);
|
||||
meteors += meteor(uv, 1.73);
|
||||
meteors += meteor(uv, 4.29);
|
||||
meteors += meteor(uv, 7.11);
|
||||
color += vec3(1.0, 0.94, 0.78) * meteors * 1.1;
|
||||
|
||||
fragColor = vec4(color, 1.0);
|
||||
}
|
||||
@@ -110,8 +174,9 @@ void main() {
|
||||
}
|
||||
";
|
||||
|
||||
/// Fragment de la chacana: SDF de la cruz escalonada + glow + aro + sol pulsante.
|
||||
/// Uniforms: `u_time`, `u_thickness`, `u_arm_extent`,
|
||||
/// Fragment de la chacana mística: SDF de 2 escalones por brazo,
|
||||
/// líneas glow + aro + rayos zodiacales + sol central pulsante.
|
||||
/// Uniforms: `u_time`, `u_thickness` (s), `u_center_half` (c), `u_arm_extent`,
|
||||
/// `u_line_color`, `u_rim_color`, `u_sun_color`, `u_sun_pulse`.
|
||||
pub const FS_CHACANA: &str = "#version 300 es
|
||||
precision highp float;
|
||||
@@ -119,6 +184,7 @@ in vec2 v_world;
|
||||
out vec4 fragColor;
|
||||
uniform float u_time;
|
||||
uniform float u_thickness;
|
||||
uniform float u_center_half;
|
||||
uniform float u_arm_extent;
|
||||
uniform vec3 u_line_color;
|
||||
uniform vec3 u_rim_color;
|
||||
@@ -129,65 +195,90 @@ float sdBox(vec2 p, vec2 b) {
|
||||
vec2 d = abs(p) - b;
|
||||
return length(max(d, 0.0)) + min(max(d.x, d.y), 0.0);
|
||||
}
|
||||
float sdChacana(vec2 p, float s, float L) {
|
||||
float s2 = s * 2.0;
|
||||
float halfArm = max((L - s2) * 0.5, 0.0);
|
||||
float armOff = s2 + halfArm;
|
||||
float d = sdBox(p, vec2(s, s));
|
||||
d = min(d, sdBox(p - vec2(0.0, 1.5 * s), vec2(s2, 0.5 * s)));
|
||||
d = min(d, sdBox(p - vec2(0.0, -1.5 * s), vec2(s2, 0.5 * s)));
|
||||
d = min(d, sdBox(p - vec2( 1.5 * s, 0.0), vec2(0.5 * s, s2)));
|
||||
d = min(d, sdBox(p - vec2(-1.5 * s, 0.0), vec2(0.5 * s, s2)));
|
||||
d = min(d, sdBox(p - vec2(0.0, armOff), vec2(s, halfArm)));
|
||||
d = min(d, sdBox(p - vec2(0.0, -armOff), vec2(s, halfArm)));
|
||||
d = min(d, sdBox(p - vec2( armOff, 0.0), vec2(halfArm, s)));
|
||||
d = min(d, sdBox(p - vec2(-armOff, 0.0), vec2(halfArm, s)));
|
||||
|
||||
// Chacana de 2 escalones (mística clásica): centro 2c×2c + 4 brazos
|
||||
// con 2 niveles. Inner level half-width = 2s, outer (tip) = s.
|
||||
float sdChacana(vec2 p, float s, float c) {
|
||||
float d = sdBox(p, vec2(c, c));
|
||||
float hd = 0.5 * s;
|
||||
// Nivel interno (más ancho, pegado al centro).
|
||||
float mid1 = c + 0.5 * s;
|
||||
float hw1 = 2.0 * s;
|
||||
d = min(d, sdBox(p - vec2(0.0, mid1), vec2(hw1, hd))); // N
|
||||
d = min(d, sdBox(p - vec2(0.0, -mid1), vec2(hw1, hd))); // S
|
||||
d = min(d, sdBox(p - vec2( mid1, 0.0), vec2(hd, hw1))); // E
|
||||
d = min(d, sdBox(p - vec2(-mid1, 0.0), vec2(hd, hw1))); // W
|
||||
// Punta (más angosta, externa).
|
||||
float mid2 = c + 1.5 * s;
|
||||
float hw2 = 1.0 * s;
|
||||
d = min(d, sdBox(p - vec2(0.0, mid2), vec2(hw2, hd)));
|
||||
d = min(d, sdBox(p - vec2(0.0, -mid2), vec2(hw2, hd)));
|
||||
d = min(d, sdBox(p - vec2( mid2, 0.0), vec2(hd, hw2)));
|
||||
d = min(d, sdBox(p - vec2(-mid2, 0.0), vec2(hd, hw2)));
|
||||
return d;
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 p = v_world;
|
||||
float d = sdChacana(p, u_thickness, u_arm_extent);
|
||||
float d = sdChacana(p, u_thickness, u_center_half);
|
||||
float r = length(p);
|
||||
|
||||
// Línea: gaussiana alrededor del borde.
|
||||
float lineW = 0.013;
|
||||
// Línea principal: gaussiana sobre el borde de la chacana.
|
||||
float lineW = 0.011;
|
||||
float line = exp(-(d * d) / (2.0 * lineW * lineW));
|
||||
|
||||
// Glow exterior cae más suave.
|
||||
float glow = exp(-max(d, 0.0) * 7.5) * 0.55;
|
||||
// Glow exterior cae suave hacia el infinito.
|
||||
float glow = exp(-max(d, 0.0) * 8.0) * 0.55;
|
||||
|
||||
// Fill interior tenue (ligera niebla cyan dentro).
|
||||
// Fill interior, una niebla cyan muy tenue.
|
||||
float fill = smoothstep(0.0, -0.025, d);
|
||||
|
||||
// Aro exterior: gran círculo que envuelve la chacana.
|
||||
float ringR = u_arm_extent * 1.18;
|
||||
float ringD = abs(length(p) - ringR);
|
||||
float ringW = 0.008;
|
||||
float ring = exp(-(ringD * ringD) / (2.0 * ringW * ringW)) * 0.75;
|
||||
// Aro circular que envuelve la chacana (rasgo del logo).
|
||||
float ringR_outer = u_arm_extent * 1.32;
|
||||
float ringD_outer = abs(r - ringR_outer);
|
||||
float ring_outer = exp(-(ringD_outer * ringD_outer) / (2.0 * 0.008 * 0.008)) * 0.80;
|
||||
|
||||
// Rayos sutiles (12 divisiones del círculo, como husillos del calendario).
|
||||
// Aro interior fino (segundo orbital).
|
||||
float ringR_inner = u_arm_extent * 1.18;
|
||||
float ringD_inner = abs(r - ringR_inner);
|
||||
float ring_inner = exp(-(ringD_inner * ringD_inner) / (2.0 * 0.0035 * 0.0035)) * 0.42;
|
||||
|
||||
// Ventana radial entre arm_extent y el aro exterior — para rayos y muescas.
|
||||
float ang = atan(p.y, p.x);
|
||||
float rays = pow(abs(cos(ang * 6.0)), 80.0)
|
||||
* smoothstep(u_arm_extent * 1.05, ringR * 0.97, length(p))
|
||||
* (0.18 + 0.10 * sin(u_time * 0.6));
|
||||
float band = smoothstep(u_arm_extent * 1.00, u_arm_extent * 1.10, r)
|
||||
* (1.0 - smoothstep(ringR_outer * 0.92, ringR_outer * 1.00, r));
|
||||
|
||||
// Rayos: 12 divisiones (meses andinos / horas), modulados en el tiempo.
|
||||
float rays = pow(abs(cos(ang * 6.0)), 24.0) * band
|
||||
* (0.55 + 0.45 * sin(u_time * 0.7));
|
||||
|
||||
// Marcas cardinales (4 muescas finas) — exponente alto = picos angostos.
|
||||
float card = pow(abs(cos(ang * 2.0)), 120.0) * band * 1.10;
|
||||
|
||||
// Sol central: gauss tight + corona suave + pulso.
|
||||
float sunR = u_thickness * 0.55;
|
||||
float sunDist = length(p);
|
||||
float sunR = u_thickness * 0.50;
|
||||
float sunDist = r;
|
||||
float sun = exp(-(sunDist * sunDist) / (2.0 * sunR * sunR));
|
||||
float corR = sunR * 4.5;
|
||||
float corona = exp(-(sunDist * sunDist) / (2.0 * corR * corR)) * 0.45;
|
||||
float sunMix = sun + corona * (0.75 + 0.25 * u_sun_pulse);
|
||||
float corR = sunR * 5.0;
|
||||
float corona = exp(-(sunDist * sunDist) / (2.0 * corR * corR)) * 0.50;
|
||||
float sunMix = sun * (1.0 + 0.2 * u_sun_pulse) + corona * (0.7 + 0.3 * u_sun_pulse);
|
||||
|
||||
// Halo del centro: cuadrado oscuro detrás de la chacana para profundidad.
|
||||
float coreShadow = smoothstep(u_center_half * 0.95, u_center_half * 0.3, max(abs(p.x), abs(p.y))) * 0.20;
|
||||
|
||||
vec3 col = vec3(0.0);
|
||||
col += u_line_color * line * 1.45;
|
||||
col += u_line_color * line * 1.55;
|
||||
col += u_rim_color * glow * 1.05;
|
||||
col += u_line_color * ring * 0.95;
|
||||
col += u_rim_color * rays * 1.40;
|
||||
col += u_sun_color * sunMix * 1.35;
|
||||
col += vec3(0.04, 0.06, 0.12) * fill * 0.55;
|
||||
col += u_line_color * ring_outer * 1.00;
|
||||
col += u_rim_color * ring_inner * 1.15;
|
||||
col += u_rim_color * rays * 1.20;
|
||||
col += u_line_color * card * 1.30;
|
||||
col += u_sun_color * sunMix * 1.45;
|
||||
col += vec3(0.05, 0.08, 0.14) * (fill + coreShadow) * 0.6;
|
||||
|
||||
float alpha = clamp(line * 1.2 + glow + ring + rays + sunMix + fill * 0.5, 0.0, 1.0);
|
||||
float alpha = clamp(
|
||||
line * 1.2 + glow + ring_outer + ring_inner + rays + card + sunMix + fill * 0.5,
|
||||
0.0, 1.0);
|
||||
fragColor = vec4(col, alpha);
|
||||
}
|
||||
";
|
||||
@@ -197,8 +288,10 @@ pub const FULLSCREEN_QUAD: [f32; 12] = [
|
||||
-1.0, -1.0, 1.0, -1.0, 1.0, 1.0, -1.0, -1.0, 1.0, 1.0, -1.0, 1.0,
|
||||
];
|
||||
|
||||
/// Quad ligeramente mayor que la chacana para no recortar el glow ni el aro.
|
||||
/// Quad ligeramente mayor que la chacana para no recortar aros ni glow.
|
||||
/// `arm_extent` es la distancia centro→punta; multiplicamos por un factor
|
||||
/// que cubre el aro exterior (1.32×) más halo.
|
||||
pub fn chacana_quad(arm_extent: f32) -> [f32; 12] {
|
||||
let e = arm_extent * 1.45;
|
||||
let e = arm_extent * 1.65;
|
||||
[-e, -e, e, -e, e, e, -e, -e, e, e, -e, e]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user