← AE Lab
El producto que se arma solo
La ficha de producto que enseña lo que hay dentro: al bajar, las diez piezas de una electrobomba llegan volando, se colocan en fila con su nombre unido por una línea y se montan solas en 3D. Con un botón grande para despiezar y montar, y en el móvil se gira con el dedo.
// CÓMO ESTÁ HECHO
- Cada pieza se modela con figuras de torno (un perfil [x, radio] que gira alrededor del eje) y cajas, en WebGL puro y sin librerías: diez piezas en un solo archivo de 44 KB.
- Cada pieza tiene tres sitios: fuera (flotando y girando), despiezada (en fila a lo largo del eje) y montada. El progreso p de 0 a 1 las mueve con dos mezclas: pos = mezcla(mezcla(fuera, fila, llegar), montada, encajar).
- El scroll da p sin JavaScript: animation-timeline: view() anima la variable --p de 0 a 1 mientras la escena está pegada (position: sticky). Si el navegador no lo sabe, se calcula con getBoundingClientRect.
- Cada pieza llega y encaja con su propio retraso (llegar = suave((p − retraso) / 0,17)), en el orden real de montaje: primero el estator, luego el rotor… y al encajar, un destello verde.
- El detalle pro: los rótulos siguen a cada pieza en 3D. En cada fotograma se proyecta un punto de la pieza a la pantalla con la misma matriz de la cámara, se reparten para que no se pisen y se dibuja la línea con un codo. La cámara además encuadra sola lo que ocupan las piezas, dejando sitio a los textos.
- Accesible y en móvil: botón grande «Montar / Despiezar», tocar la bomba hace lo mismo y arrastrar en horizontal la gira; en vertical las piezas se apilan de arriba abajo. Con prefers-reduced-motion sale montada y quieta, y sin WebGL se ve la lista de piezas.
// EL CÓDIGO
<header class="cabecera">
<div class="marca">
<svg viewBox="0 0 32 32" aria-hidden="true"><circle cx="16" cy="16" r="13" fill="none" stroke="#00FF7F" stroke-width="2.5"/><path d="M16 6c4 5 6 8 6 11a6 6 0 0 1-12 0c0-3 2-6 6-11z" fill="#00FF7F"/></svg>
<div>ORZA<small>Bombas industriales</small></div>
</div>
<ul class="menu"><li>Bombas</li><li>Riego</li><li>Servicio técnico</li></ul>
<a class="pedir" href="#ficha">Pedir precio</a>
</header>
<main>
<section class="pista" id="pista" aria-label="La electrobomba CM-40, pieza a pieza">
<div class="escena" id="escena">
<canvas id="lienzo" role="img" aria-label="Electrobomba centrífuga CM-40 en 3D. Al bajar, sus diez piezas llegan volando, se colocan en fila con su nombre y se montan solas."></canvas>
<svg id="lineas" aria-hidden="true"></svg>
<ol class="rotulos" id="rotulos" aria-label="Piezas de la bomba"></ol>
<div class="intro" id="intro">
<span class="antetitulo"><i></i>Nueva serie CM · 4 kW</span>
<h1>Mírala <em>por dentro</em> antes de comprarla.</h1>
<p>Diez piezas, ningún secreto. Baja y mira cómo se monta.</p>
</div>
<div class="final" id="final">
<span class="estado"><i></i>Montada · lista para instalar</span>
<h2>Electrobomba <em>CM-40</em></h2>
<ul class="chips">
<li><strong>4 kW</strong>potencia</li>
<li><strong>45 m³/h</strong>caudal</li>
<li><strong>38 m</strong>altura</li>
<li><strong>IE3</strong>eficiencia</li>
</ul>
<a class="pedir" href="#ficha">Pedir presupuesto</a>
</div>
<div class="baja" id="baja"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M12 4v15M6 13l6 6 6-6"/></svg>Baja y mira cómo se monta</div>
<div class="mandos">
<ol class="pasos" id="pasos" aria-hidden="true"><li>1 · Piezas</li><li>2 · Despiece</li><li>3 · Montada</li></ol>
<button class="accion" id="accion" type="button">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 12h6M14 12h6M10 8l4 4-4 4"/></svg>
<span id="accionTexto">Montar</span>
</button>
</div>
<p class="solo-lector" id="aviso" aria-live="polite"></p>
</div>
</section>
<section class="tecnica" id="ficha" aria-labelledby="tituloFicha">
<h2 id="tituloFicha">Ficha técnica · <em>CM-40</em></h2>
<ul class="tabla">
<li>Potencia<strong>4 kW · 400 V trifásica</strong></li>
<li>Caudal máximo<strong>45 m³/h</strong></li>
<li>Altura máxima<strong>38 m</strong></li>
<li>Velocidad<strong>2.900 rpm</strong></li>
<li>Cuerpo<strong>Fundición GG-25</strong></li>
<li>Rodete<strong>Bronce</strong></li>
<li>Cierre<strong>Carburo de silicio</strong></li>
<li>Motor<strong>IE3 · IP55</strong></li>
</ul>
<a class="pedir" href="#ficha">Pedir presupuesto</a>
<p class="pie">Demostración de AE Lab: producto y marca de ejemplo.</p>
</section>
</main>
:root {
--fondo: #05070A;
--superficie: #0D1410;
--borde: #1B2A21;
--verde: #00FF7F;
--blanco: #E8F5EE;
--gris: #8A988F;
--cabecera: 60px;
}
@property --p { syntax: '<number>'; inherits: true; initial-value: 0; }
* { box-sizing: border-box; margin: 0; padding: 0; }
html { background: var(--fondo); }
body {
background: var(--fondo);
color: var(--blanco);
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
-webkit-tap-highlight-color: transparent;
}
/* Cabecera fija de la tienda de ejemplo */
.cabecera {
position: fixed; inset: 0 0 auto 0; z-index: 5;
height: var(--cabecera);
display: flex; align-items: center; justify-content: space-between; gap: 16px;
padding: 0 clamp(16px, 3vw, 40px);
background: linear-gradient(var(--fondo) 30%, rgba(5, 7, 10, 0));
}
.marca { display: flex; align-items: center; gap: 10px; font-weight: 800; letter-spacing: .02em; }
.marca svg { width: 26px; height: 26px; }
.marca small { display: block; font: 500 10.5px/1 ui-monospace, monospace; color: var(--gris); letter-spacing: .08em; text-transform: uppercase; margin-top: 3px; }
.menu { display: flex; gap: 26px; list-style: none; color: var(--gris); font-size: 14px; }
.pedir {
font: 600 13px/1 system-ui, sans-serif; color: var(--fondo); text-decoration: none;
background: var(--verde); padding: 10px 16px; border-radius: 99px;
}
/* La pista: alta para que el scroll tenga recorrido; dentro, la escena se queda pegada a la pantalla */
.pista { position: relative; height: 330vh; height: 330svh; }
.escena {
position: sticky; top: 0;
height: 100vh; height: 100svh;
overflow: hidden;
touch-action: pan-y; /* el dedo en vertical hace scroll; en horizontal gira la bomba */
background: radial-gradient(60% 55% at 50% 52%, #0f1d17 0%, var(--fondo) 70%);
}
/* El scroll mueve --p de 0 a 1 sin JavaScript (animation-timeline). Si el navegador no lo sabe, lo calcula el JS. */
@supports (animation-timeline: view()) {
.pista { view-timeline: --montaje block; }
.escena { animation: montaje linear both; animation-timeline: --montaje; animation-range: contain 0% contain 100%; }
}
@keyframes montaje { from { --p: 0; } to { --p: 1; } }
#lienzo { position: absolute; inset: 0; width: 100%; height: 100%; display: block; cursor: grab; }
.arrastrando #lienzo { cursor: grabbing; }
#lineas { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; overflow: visible; }
#lineas path { stroke: rgba(0, 255, 127, .7); stroke-width: 1.2; fill: none; }
#lineas circle { fill: var(--fondo); stroke: var(--verde); stroke-width: 1.5; }
/* Los rótulos de cada pieza: se colocan con transform (60 fps) y se unen a su pieza con una línea */
.rotulos { list-style: none; }
.rotulo {
position: absolute; left: 0; top: 0;
display: flex; align-items: center; gap: 8px;
max-width: 210px;
padding: 6px 10px 6px 6px;
border: 1px solid var(--borde); border-radius: 10px;
background: rgba(13, 20, 16, .88);
font-size: 13px; line-height: 1.15; font-weight: 600;
opacity: 0; will-change: transform, opacity;
pointer-events: auto;
}
.rotulo b {
flex: none; display: grid; place-items: center;
width: 22px; height: 22px; border-radius: 6px;
background: var(--verde); color: var(--fondo);
font: 800 11px/1 ui-monospace, monospace;
}
.rotulo small { display: block; margin-top: 2px; font-weight: 500; font-size: 11px; color: var(--gris); }
.rotulo.activo { border-color: var(--verde); }
/* Presentación (al principio) y ficha final (al montarse) */
.intro {
position: absolute; left: clamp(16px, 4vw, 56px); top: calc(var(--cabecera) + clamp(10px, 4vh, 40px));
max-width: 440px; pointer-events: none; will-change: opacity, transform;
}
.antetitulo {
display: inline-flex; align-items: center; gap: 8px;
font: 500 11.5px/1 ui-monospace, monospace; letter-spacing: .06em; text-transform: uppercase;
color: var(--verde); border: 1px solid var(--borde); background: rgba(13, 20, 16, .7);
padding: 7px 11px; border-radius: 99px;
}
.antetitulo i { width: 7px; height: 7px; border-radius: 50%; background: var(--verde); box-shadow: 0 0 10px var(--verde); }
h1 { margin-top: 14px; font-size: clamp(34px, 4.6vw, 64px); line-height: .98; letter-spacing: -.04em; font-weight: 800; }
h1 em, h2 em { font-style: normal; color: var(--verde); white-space: nowrap; }
.intro p { margin-top: 12px; color: var(--gris); line-height: 1.45; font-size: clamp(15px, 1.2vw, 17px); max-width: 36ch; }
.final {
position: absolute; left: clamp(16px, 4vw, 56px); top: 50%;
width: min(400px, 34vw);
transform: translateY(-50%);
opacity: 0; pointer-events: none; will-change: opacity, transform;
}
.final.visible { pointer-events: auto; }
.estado { display: inline-flex; align-items: center; gap: 8px; font: 600 12px/1 ui-monospace, monospace; color: var(--verde); letter-spacing: .04em; text-transform: uppercase; }
.estado i { width: 8px; height: 8px; border-radius: 50%; background: var(--verde); box-shadow: 0 0 12px var(--verde); animation: latido 1.2s ease-in-out infinite; }
@keyframes latido { 50% { opacity: .35; } }
h2 { margin-top: 12px; font-size: clamp(28px, 3.4vw, 46px); line-height: 1; letter-spacing: -.035em; }
.chips { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; list-style: none; }
.chips li { border: 1px solid var(--borde); background: rgba(13, 20, 16, .8); border-radius: 10px; padding: 8px 10px; font-size: 12px; color: var(--gris); }
.chips strong { display: block; color: var(--blanco); font: 700 17px/1.1 ui-monospace, monospace; }
.final .pedir { display: inline-block; margin-top: 18px; padding: 13px 20px; font-size: 15px; }
/* Mandos: los tres pasos, el botón grande y la pista de scroll */
.mandos {
position: absolute; left: 50%; bottom: calc(18px + env(safe-area-inset-bottom));
transform: translateX(-50%);
display: flex; align-items: center; gap: 14px;
}
.pasos { display: flex; gap: 4px; list-style: none; padding: 4px; border: 1px solid var(--borde); border-radius: 99px; background: rgba(13, 20, 16, .85); }
.pasos li { font: 600 12px/1 system-ui, sans-serif; color: var(--gris); padding: 9px 12px; border-radius: 99px; transition: background .3s, color .3s; white-space: nowrap; }
.pasos li.activo { background: rgba(0, 255, 127, .14); color: var(--verde); }
.accion {
appearance: none; border: 0; cursor: pointer;
display: inline-flex; align-items: center; gap: 10px;
height: 52px; padding: 0 24px; border-radius: 99px;
background: var(--verde); color: var(--fondo);
font: 700 16px/1 system-ui, sans-serif;
box-shadow: 0 10px 30px rgba(0, 255, 127, .22);
}
.accion svg { width: 20px; height: 20px; }
.accion:focus-visible, .pedir:focus-visible { outline: 3px solid var(--blanco); outline-offset: 4px; }
.baja {
position: absolute; left: 50%; bottom: calc(86px + env(safe-area-inset-bottom));
transform: translateX(-50%);
display: flex; align-items: center; gap: 8px;
font: 500 12px/1 ui-monospace, monospace; color: var(--gris); letter-spacing: .05em; text-transform: uppercase;
pointer-events: none; transition: opacity .4s;
}
.baja svg { width: 16px; height: 16px; color: var(--verde); animation: bajar 1.4s ease-in-out infinite; }
@keyframes bajar { 50% { transform: translateY(5px); } }
.baja.fuera { opacity: 0; }
/* Ficha técnica, debajo */
.tecnica { max-width: 980px; margin: 0 auto; padding: 72px clamp(16px, 4vw, 40px) 90px; }
.tecnica h2 { margin: 0 0 22px; }
.tabla { display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: 10px; list-style: none; }
.tabla li { border: 1px solid var(--borde); background: var(--superficie); border-radius: 14px; padding: 14px 16px; color: var(--gris); font-size: 13px; }
.tabla strong { display: block; margin-top: 4px; color: var(--blanco); font-size: 18px; }
.tecnica .pedir { display: inline-block; margin-top: 26px; padding: 15px 24px; font-size: 16px; }
.pie { margin-top: 18px; color: var(--gris); font-size: 12px; }
.solo-lector { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
/* Sin WebGL: la lista de piezas, quieta y legible */
.sin-3d #lienzo, .sin-3d #lineas, .sin-3d .baja { display: none; }
.sin-3d .rotulos { position: absolute; inset: calc(var(--cabecera) + 200px) 16px 100px; display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 8px; align-content: start; }
.sin-3d .rotulo { position: static; opacity: 1 !important; transform: none !important; max-width: none; }
/* Móvil */
@media (max-width: 700px) {
.menu { display: none; }
.rotulo { font-size: 11.5px; padding: 4px 7px 4px 4px; gap: 6px; border-radius: 8px; }
.rotulo b { width: 18px; height: 18px; font-size: 10px; border-radius: 5px; }
.rotulo small { display: none; }
.intro { right: 16px; }
.final { left: 16px; right: 16px; width: auto; top: auto; bottom: calc(92px + env(safe-area-inset-bottom)); transform: none; text-align: center; }
.final .chips { justify-content: center; }
.final .pedir { display: none; }
.chips li { padding: 6px 8px; font-size: 11px; }
.chips strong { font-size: 15px; }
.pasos { display: none; }
.baja { bottom: calc(84px + env(safe-area-inset-bottom)); }
}
/* Movimiento reducido: la bomba sale montada y quieta; el botón cambia de vista sin vuelos */
@media (prefers-reduced-motion: reduce) {
.pista { height: auto; }
.escena { position: relative; animation: none; }
.baja { display: none; }
.estado i { animation: none; }
}
(() => {
'use strict';
/* ================== Las piezas ==================
Cada pieza se modela con figuras de torno (un perfil que gira alrededor del eje) y cajas.
El eje de la bomba es X; las medidas están en «unidades» (1 ≈ 10 cm). */
const PIEZAS = [
{id: 'tapa', nombre: 'Tapa del ventilador', sub: 'Chapa de acero'},
{id: 'ventilador', nombre: 'Ventilador', sub: 'Refrigera el motor'},
{id: 'carcasa', nombre: 'Carcasa del motor', sub: 'Aluminio con aletas'},
{id: 'estator', nombre: 'Estator', sub: 'Bobinado de cobre'},
{id: 'rotor', nombre: 'Rotor y eje', sub: 'Acero inoxidable'},
{id: 'rodamiento', nombre: 'Rodamiento', sub: 'De bolas, sellado'},
{id: 'linterna', nombre: 'Linterna', sub: 'Une motor y bomba'},
{id: 'cierre', nombre: 'Cierre mecánico', sub: 'Carburo de silicio'},
{id: 'rodete', nombre: 'Rodete', sub: 'Bronce, 7 álabes'},
{id: 'voluta', nombre: 'Cuerpo de bomba', sub: 'Fundición GG-25'},
];
// Orden en que encajan al montarse (0 = primero) y lado del rótulo (+1 arriba/derecha, −1 abajo/izquierda)
const ORDEN_MONTAJE = {estator: 0, rotor: 1, rodamiento: 2, ventilador: 3, linterna: 4, tapa: 5, cierre: 6, rodete: 7, voluta: 8, carcasa: 4};
const MAT = {
azul: {c: [0.16, 0.37, 0.70], m: 0.35, r: 0.42},
tapa: {c: [0.30, 0.34, 0.38], m: 0.45, r: 0.45},
acero: {c: [0.74, 0.77, 0.80], m: 0.92, r: 0.26},
chapas: {c: [0.46, 0.50, 0.55], m: 0.7, r: 0.4},
cobre: {c: [0.90, 0.47, 0.22], m: 0.85, r: 0.32},
bronce: {c: [0.86, 0.64, 0.32], m: 0.88, r: 0.28},
plastico: {c: [0.60, 0.65, 0.70], m: 0.0, r: 0.55},
ceramica: {c: [0.92, 0.90, 0.84], m: 0.0, r: 0.3},
carbon: {c: [0.10, 0.10, 0.11], m: 0.2, r: 0.4},
placa: {c: [0.85, 0.87, 0.89], m: 0.75, r: 0.3},
};
const TAU = Math.PI * 2;
const clamp = (x, a = 0, b = 1) => Math.min(b, Math.max(a, x));
const suave = (x) => { x = clamp(x); return x * x * (3 - 2 * x); };
const salir = (x) => { x = clamp(x); return 1 - Math.pow(1 - x, 3); };
const mezcla = (a, b, t) => a + (b - a) * t;
/* ---------- Matrices 4×4 (por columnas, como las quiere WebGL) ---------- */
const M4 = {
id() { const m = new Float32Array(16); m[0] = m[5] = m[10] = m[15] = 1; return m; },
mul(a, b) {
const o = new Float32Array(16);
for (let c = 0; c < 4; c++) for (let r = 0; r < 4; r++) {
o[c * 4 + r] = a[r] * b[c * 4] + a[4 + r] * b[c * 4 + 1] + a[8 + r] * b[c * 4 + 2] + a[12 + r] * b[c * 4 + 3];
}
return o;
},
tr(x, y, z) { const m = M4.id(); m[12] = x; m[13] = y; m[14] = z; return m; },
rx(a) { const m = M4.id(), c = Math.cos(a), s = Math.sin(a); m[5] = c; m[6] = s; m[9] = -s; m[10] = c; return m; },
ry(a) { const m = M4.id(), c = Math.cos(a), s = Math.sin(a); m[0] = c; m[2] = -s; m[8] = s; m[10] = c; return m; },
rz(a) { const m = M4.id(), c = Math.cos(a), s = Math.sin(a); m[0] = c; m[1] = s; m[4] = -s; m[5] = c; return m; },
persp(fy, asp, n, f) {
const t = 1 / Math.tan(fy / 2), m = new Float32Array(16);
m[0] = t / asp; m[5] = t; m[10] = (f + n) / (n - f); m[11] = -1; m[14] = 2 * f * n / (n - f);
return m;
},
mira(e, c, u) {
let f = [c[0] - e[0], c[1] - e[1], c[2] - e[2]];
const lf = Math.hypot(...f); f = f.map((v) => v / lf);
let s = [f[1] * u[2] - f[2] * u[1], f[2] * u[0] - f[0] * u[2], f[0] * u[1] - f[1] * u[0]];
const ls = Math.hypot(...s); s = s.map((v) => v / ls);
const v = [s[1] * f[2] - s[2] * f[1], s[2] * f[0] - s[0] * f[2], s[0] * f[1] - s[1] * f[0]];
const m = M4.id();
m[0] = s[0]; m[4] = s[1]; m[8] = s[2];
m[1] = v[0]; m[5] = v[1]; m[9] = v[2];
m[2] = -f[0]; m[6] = -f[1]; m[10] = -f[2];
m[12] = -(s[0] * e[0] + s[1] * e[1] + s[2] * e[2]);
m[13] = -(v[0] * e[0] + v[1] * e[1] + v[2] * e[2]);
m[14] = f[0] * e[0] + f[1] * e[1] + f[2] * e[2];
return m;
},
punto(m, p) { return [m[0] * p[0] + m[4] * p[1] + m[8] * p[2] + m[12], m[1] * p[0] + m[5] * p[1] + m[9] * p[2] + m[13], m[2] * p[0] + m[6] * p[1] + m[10] * p[2] + m[14]]; },
};
/* ---------- Geometría ---------- */
// Torno: un perfil [[x, radio], ...] gira alrededor del eje X
function torno(perfil, seg = 48) {
const P = [], N = [];
for (let j = 0; j < perfil.length - 1; j++) {
const [x0, r0] = perfil[j], [x1, r1] = perfil[j + 1];
const dx = x1 - x0, dr = r1 - r0, l = Math.hypot(dx, dr);
if (l < 1e-6) continue;
const nx = -dr / l, nr = dx / l;
for (let i = 0; i < seg; i++) {
const t0 = i / seg * TAU, t1 = (i + 1) / seg * TAU;
const c0 = Math.cos(t0), s0 = Math.sin(t0), c1 = Math.cos(t1), s1 = Math.sin(t1);
const a = [x0, r0 * c0, r0 * s0], b = [x1, r1 * c0, r1 * s0], c = [x1, r1 * c1, r1 * s1], d = [x0, r0 * c1, r0 * s1];
const na = [nx, nr * c0, nr * s0], nc = [nx, nr * c1, nr * s1];
P.push(...a, ...b, ...c, ...a, ...c, ...d);
N.push(...na, ...na, ...nc, ...na, ...nc, ...nc);
}
}
return {P, N};
}
function caja(sx, sy, sz) {
const x = sx / 2, y = sy / 2, z = sz / 2, P = [], N = [];
const cara = (n, a, b, c, d) => { P.push(...a, ...b, ...c, ...a, ...c, ...d); for (let k = 0; k < 6; k++) N.push(...n); };
cara([1, 0, 0], [x, -y, -z], [x, y, -z], [x, y, z], [x, -y, z]);
cara([-1, 0, 0], [-x, -y, z], [-x, y, z], [-x, y, -z], [-x, -y, -z]);
cara([0, 1, 0], [-x, y, -z], [-x, y, z], [x, y, z], [x, y, -z]);
cara([0, -1, 0], [-x, -y, z], [-x, -y, -z], [x, -y, -z], [x, -y, z]);
cara([0, 0, 1], [-x, -y, z], [x, -y, z], [x, y, z], [-x, y, z]);
cara([0, 0, -1], [x, -y, -z], [-x, -y, -z], [-x, y, -z], [x, y, -z]);
return {P, N};
}
function bola(radio, seg = 10) {
const perfil = [];
for (let k = 0; k <= seg; k++) { const f = Math.PI - k / seg * Math.PI; perfil.push([radio * Math.cos(f), radio * Math.sin(f)]); }
return torno(perfil, seg * 2);
}
// tornillo: cabeza hexagonal y caña corta, a lo largo de +X
const tornillo = (largo) => juntar(torno([[0, 0], [0, 0.04], [0.035, 0.04], [0.035, 0]], 6), largo ? torno([[-largo, 0], [-largo, 0.018], [0, 0.018]], 8) : {P: [], N: []});
function mover(g, m) {
const P = [], N = [];
for (let i = 0; i < g.P.length; i += 3) {
P.push(...M4.punto(m, [g.P[i], g.P[i + 1], g.P[i + 2]]));
const n = g.N.slice(i, i + 3);
N.push(m[0] * n[0] + m[4] * n[1] + m[8] * n[2], m[1] * n[0] + m[5] * n[1] + m[9] * n[2], m[2] * n[0] + m[6] * n[1] + m[10] * n[2]);
}
return {P, N};
}
const juntar = (...gs) => ({P: gs.flatMap((g) => g.P), N: gs.flatMap((g) => g.N)});
const corona = (n, fn) => juntar(...Array.from({length: n}, (_, k) => fn(k)));
// patrones del sombreado: 1 rejilla, 2 chapas apiladas, 3 hilo de cobre, 4 ventanas de la linterna
function modelar(id) {
switch (id) {
case 'tapa': return [{g: torno([[-1.70, 0.80], [-1.70, 0.84], [-2.16, 0.84], [-2.24, 0.80], [-2.29, 0.72], [-2.31, 0.60], [-2.31, 0]]), mat: 'tapa', pat: 1}];
case 'ventilador': return [{g: juntar(
torno([[-2.12, 0], [-2.12, 0.13], [-1.99, 0.13], [-1.99, 0]], 24),
corona(8, (k) => mover(caja(0.09, 0.5, 0.03), M4.mul(M4.rx(k * TAU / 8), M4.mul(M4.tr(-2.055, 0.38, 0), M4.ry(0.55))))),
), mat: 'plastico'}];
case 'carcasa': return [
{g: juntar(
torno([[-1.72, 0.74], [-1.72, 0.80], [0.22, 0.80], [0.22, 0.86], [0.30, 0.86], [0.30, 0.74], [-1.72, 0.74]]),
corona(20, (k) => {
const a = k * 18 + 9;
if (a < 30 || a > 330 || Math.abs(a - 90) < 14) return {P: [], N: []}; // arriba la caja de bornes; al lado la placa
return mover(caja(1.86, 0.13, 0.035), M4.mul(M4.rx(a * Math.PI / 180), M4.tr(-0.72, 0.86, 0)));
}),
mover(caja(0.62, 0.24, 0.5), M4.tr(-0.75, 0.9, 0)),
), mat: 'azul'},
{g: mover(caja(0.68, 0.05, 0.56), M4.tr(-0.75, 1.04, 0)), mat: 'tapa'},
{g: mover(caja(0.5, 0.27, 0.02), M4.tr(-0.85, 0, 0.805)), mat: 'placa'},
];
case 'estator': return [
{g: torno([[-1.45, 0.44], [-1.45, 0.73], [0.0, 0.73], [0.0, 0.44], [-1.45, 0.44]]), mat: 'chapas', pat: 2},
{g: juntar(
torno([[-1.45, 0.67], [-1.52, 0.65], [-1.58, 0.57], [-1.62, 0.47], [-1.45, 0.47]]),
torno([[0.0, 0.67], [0.07, 0.65], [0.13, 0.57], [0.17, 0.47], [0.0, 0.47]]),
), mat: 'cobre', pat: 3},
];
case 'rotor': return [
{g: torno([[-1.42, 0.08], [-1.42, 0.40], [-0.03, 0.40], [-0.03, 0.08]]), mat: 'chapas', pat: 2},
{g: juntar(
torno([[-2.02, 0], [-2.02, 0.07], [1.34, 0.07], [1.34, 0]], 24),
torno([[-0.03, 0.07], [-0.03, 0.1], [0.36, 0.1], [0.36, 0.07]], 24),
), mat: 'acero'},
];
case 'rodamiento': return [{g: juntar(
torno([[0.38, 0.19], [0.38, 0.25], [0.52, 0.25], [0.52, 0.19], [0.38, 0.19]], 36),
torno([[0.38, 0.10], [0.38, 0.14], [0.52, 0.14], [0.52, 0.10], [0.38, 0.10]], 36),
corona(9, (k) => mover(bola(0.045, 6), M4.mul(M4.rx(k * TAU / 9), M4.tr(0.45, 0.165, 0)))),
), mat: 'acero'}];
case 'linterna': return [{g: torno([[0.31, 0.26], [0.31, 0.86], [0.38, 0.86], [0.38, 0.72], [0.62, 0.52], [0.88, 0.52], [0.88, 0.80], [0.97, 0.80], [0.97, 0.26], [0.31, 0.26]]), mat: 'azul', pat: 4}];
case 'cierre': return [
{g: torno([[0.98, 0.1], [0.98, 0.2], [1.03, 0.2], [1.03, 0.1], [0.98, 0.1]], 32), mat: 'carbon'},
{g: torno([[1.03, 0.1], [1.03, 0.19], [1.07, 0.19], [1.07, 0.1], [1.03, 0.1]], 32), mat: 'ceramica'},
{g: torno([[1.07, 0.1], [1.07, 0.16], [1.085, 0.178], [1.1, 0.155], [1.115, 0.178], [1.13, 0.16], [1.13, 0.1]], 32), mat: 'acero'},
];
case 'rodete': return [{g: juntar(
torno([[1.14, 0.1], [1.14, 0.6], [1.17, 0.6], [1.17, 0.12]]),
torno([[1.13, 0.07], [1.13, 0.14], [1.32, 0.12], [1.32, 0.07]], 24),
corona(7, (k) => mover(caja(0.18, 0.3, 0.03), M4.mul(M4.rx(k * TAU / 7), M4.mul(M4.tr(1.255, 0.4, 0), M4.rx(0.45))))),
torno([[1.345, 0.22], [1.345, 0.58], [1.375, 0.58], [1.375, 0.25], [1.42, 0.25], [1.42, 0.22]]),
), mat: 'bronce'}];
case 'voluta': return [{g: juntar(
torno([[0.99, 0.27], [0.99, 0.90], [1.05, 0.99], [1.20, 1.03], [1.40, 1.02], [1.53, 0.95], [1.60, 0.82], [1.63, 0.60], [1.64, 0.30],
[2.00, 0.30], [2.00, 0.44], [2.10, 0.44], [2.10, 0.22], [0.99, 0.22], [0.99, 0.27]]),
mover(torno([[0, 0.2], [0, 0.23], [0.62, 0.23], [0.62, 0.36], [0.70, 0.36], [0.70, 0.2]], 32), M4.mul(M4.tr(1.32, 0.72, -0.5), M4.rz(Math.PI / 2))),
), mat: 'azul'}, {g: juntar( // tornillería de las bridas
corona(10, (k) => mover(tornillo(0), M4.mul(M4.rx(k * TAU / 10 + 0.3), M4.tr(0.95, 0.86, 0)))),
corona(4, (k) => mover(tornillo(0.05), M4.mul(M4.rx(k * TAU / 4 + 0.78), M4.tr(2.1, 0.36, 0)))),
corona(4, (k) => mover(tornillo(0.05), M4.mul(M4.tr(1.32, 1.42, -0.5), M4.mul(M4.ry(k * TAU / 4 + 0.78), M4.mul(M4.tr(0.29, 0, 0), M4.rz(Math.PI / 2)))))),
), mat: 'acero'}];
}
return [];
}
/* ---------- Página ---------- */
const $ = (s) => document.querySelector(s);
const escena = $('#escena'), pista = $('#pista'), lienzo = $('#lienzo'), svg = $('#lineas'), lista = $('#rotulos');
const boton = $('#accion'), botonTexto = $('#accionTexto'), aviso = $('#aviso'), intro = $('#intro'), fin = $('#final'), baja = $('#baja');
const pasosLi = [...document.querySelectorAll('#pasos li')];
const QUIETO = matchMedia('(prefers-reduced-motion: reduce)').matches;
const CSS_SCROLL = !!(window.CSS && CSS.supports && CSS.supports('animation-timeline: view()'));
const SVGNS = 'http://www.w3.org/2000/svg';
// Rótulos con su línea y su punto
PIEZAS.forEach((pz, i) => {
const li = document.createElement('li');
li.className = 'rotulo';
li.innerHTML = `<b>${i + 1}</b><span>${pz.nombre}<small>${pz.sub}</small></span>`;
li.addEventListener('pointerenter', () => { foco = i; li.classList.add('activo'); });
li.addEventListener('pointerleave', () => { foco = -1; li.classList.remove('activo'); });
lista.appendChild(li);
pz.li = li;
pz.linea = document.createElementNS(SVGNS, 'path');
pz.punto = document.createElementNS(SVGNS, 'circle');
pz.punto.setAttribute('r', '3.5');
svg.append(pz.linea, pz.punto);
pz.lado = i % 2 ? -1 : 1;
});
let foco = -1;
/* ---------- WebGL ---------- */
const gl = lienzo.getContext('webgl', {antialias: true, alpha: true, preserveDrawingBuffer: true});
if (!gl) { document.documentElement.classList.add('sin-3d'); aviso.textContent = 'Tu navegador no muestra 3D: aquí tienes la lista de piezas.'; return; }
const VS = `
attribute vec3 aP; attribute vec3 aN;
uniform mat4 uM, uVP;
varying vec3 vW, vN, vO;
void main() {
vec4 w = uM * vec4(aP, 1.0);
vW = w.xyz; vN = (uM * vec4(aN, 0.0)).xyz; vO = aP;
gl_Position = uVP * w;
}`;
const FS = `
precision highp float;
varying vec3 vW, vN, vO;
uniform vec3 uCol, uCam; uniform float uMet, uRug, uPat, uBrillo;
vec3 entorno(vec3 r) { // un estudio de fotografía de mentira: techo de luz y una franja
vec3 c = mix(vec3(.02, .03, .03), vec3(.17, .21, .24), smoothstep(-.5, .6, r.y));
c += vec3(1.0) * smoothstep(.55, .95, r.y) * .85;
c += vec3(.55, .65, .75) * exp(-pow((r.y - .05) * 8.0, 2.0)) * .55;
c += vec3(0.0, 1.0, .5) * smoothstep(.55, 1.0, -r.x) * .22;
return c;
}
void main() {
vec3 N = normalize(vN), V = normalize(uCam - vW);
if (dot(N, V) < 0.0) N = -N; // piezas huecas: se ven bien por dentro y por fuera
vec3 base = uCol;
float r = length(vO.yz), a = atan(vO.z, vO.y);
if (uPat > .5 && uPat < 1.5) { // rejilla de la tapa
float zona = step(vO.x, -2.28) * step(.2, r) * step(r, .66);
base *= 1.0 - .8 * zona * step(.5, fract(a * 24.0 / 6.2832));
} else if (uPat > 1.5 && uPat < 2.5) { // chapas apiladas
base *= .78 + .22 * step(.3, fract(vO.x * 36.0));
} else if (uPat > 2.5 && uPat < 3.5) { // hilo de cobre
base *= .75 + .25 * smoothstep(.15, .85, abs(fract(a * 48.0 / 6.2832) - .5) * 2.0);
} else if (uPat > 3.5) { // ventanas de la linterna
float v = step(.5, vO.x) * step(vO.x, .84) * step(.5, r) * step(abs(abs(a) - 1.5708), .5);
base *= 1.0 - .85 * v;
}
vec3 L1 = normalize(vec3(-.45, .85, .55)), L2 = normalize(vec3(.75, .15, .45));
float d1 = max(dot(N, L1), 0.0), d2 = max(dot(N, L2), 0.0) * .4;
vec3 H = normalize(L1 + V);
float s = pow(max(dot(N, H), 0.0), 2.0 / max(uRug * uRug, .02)) * (1.0 - uRug);
vec3 amb = mix(vec3(.05, .06, .07), vec3(.17, .2, .23), N.y * .5 + .5);
vec3 esp = mix(vec3(.04), base, uMet);
vec3 col = base * (amb + d1 * vec3(1.0, .96, .9) * .95 + d2 * vec3(.6, .75, .9)) * (1.0 - uMet * .7)
+ entorno(reflect(-V, N)) * esp * (1.0 - uRug * .55) + s * esp * 1.2;
float fr = pow(1.0 - max(dot(N, V), 0.0), 3.0);
col += vec3(0.0, 1.0, .5) * (fr * .2 + uBrillo * (.25 + fr * .9));
col = col / (1.0 + col * .12);
gl_FragColor = vec4(pow(col, vec3(1.0 / 1.12)), 1.0);
}`;
function sombreador(tipo, src) {
const s = gl.createShader(tipo); gl.shaderSource(s, src); gl.compileShader(s);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s));
return s;
}
const prog = gl.createProgram();
gl.attachShader(prog, sombreador(gl.VERTEX_SHADER, VS));
gl.attachShader(prog, sombreador(gl.FRAGMENT_SHADER, FS));
gl.linkProgram(prog);
gl.useProgram(prog);
const U = {};
['uM', 'uVP', 'uCol', 'uCam', 'uMet', 'uRug', 'uPat', 'uBrillo'].forEach((k) => { U[k] = gl.getUniformLocation(prog, k); });
const aP = gl.getAttribLocation(prog, 'aP'), aN = gl.getAttribLocation(prog, 'aN');
gl.enableVertexAttribArray(aP); gl.enableVertexAttribArray(aN);
gl.enable(gl.DEPTH_TEST);
gl.clearColor(0, 0, 0, 0);
// Sube cada parte a la tarjeta gráfica y mide cada pieza a lo largo del eje
PIEZAS.forEach((pz) => {
let x0 = Infinity, x1 = -Infinity, rmax = 0;
pz.partes = modelar(pz.id).map(({g, mat, pat}) => {
const datos = new Float32Array(g.P.length * 2);
for (let i = 0, v = 0; i < g.P.length; i += 3, v += 6) {
datos.set(g.P.slice(i, i + 3), v); datos.set(g.N.slice(i, i + 3), v + 3);
x0 = Math.min(x0, g.P[i]); x1 = Math.max(x1, g.P[i]); rmax = Math.max(rmax, Math.hypot(g.P[i + 1], g.P[i + 2]));
}
const buf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(gl.ARRAY_BUFFER, datos, gl.STATIC_DRAW);
return {buf, n: g.P.length / 3, mat: MAT[mat], pat: pat || 0};
});
pz.x0 = x0; pz.x1 = x1; pz.cx = (x0 + x1) / 2; pz.radio = Math.min(rmax, 1.0);
});
const MIN = Math.min(...PIEZAS.map((p) => p.x0)), MAX = Math.max(...PIEZAS.map((p) => p.x1));
const CENTRO = (MIN + MAX) / 2;
// Tres sitios por pieza: fuera (flotando), despiezada (en fila) y montada (en su sitio)
function colocar(hueco) {
let cursor = 0;
PIEZAS.forEach((pz) => { pz.dx = cursor - pz.x0; cursor = pz.x1 + pz.dx + hueco; });
const largo = cursor - hueco;
PIEZAS.forEach((pz, i) => {
pz.despiece = pz.dx - largo / 2; // desplazamiento en X de la fila despiezada (centrada en 0)
pz.montada = -CENTRO; // todas juntas, con el conjunto centrado
const ang = i * 2.399 + 0.7; // ángulo áureo: repartidas alrededor sin amontonarse
pz.fuera = vertical ? [pz.despiece * 0.55 + 1.6 + Math.cos(ang) * 1.6, Math.sin(ang) * 1.9, ((i % 3) - 1) * 1.6]
: [pz.despiece * 0.6 + 2.3 + Math.cos(ang) * 2.4, Math.sin(ang) * 2.3 - 0.3, ((i % 3) - 1) * 1.8];
pz.giro = [Math.sin(i * 7.1) * 2.4, Math.cos(i * 3.3) * 2.0, Math.sin(i * 5.7 + 1) * 2.2];
pz.llega = 0.02 + i * 0.026; // cuándo empieza a llegar (p)
pz.encaja = 0.55 + (ORDEN_MONTAJE[pz.id] ?? i) * 0.022; // cuándo empieza a encajar (p)
});
return largo;
}
/* ---------- Estado ---------- */
let ancho = 0, alto = 0, dpr = 1, vertical = false, largoDespiece = 0;
let pVista = QUIETO ? 1 : 0; // lo que se dibuja (0 fuera · ~0,47 despiezada · 1 montada)
let demo = !QUIETO; // modo demostración hasta que alguien toca
let demoT = 0, giroUsuario = 0, giroVel = 0, ultimo = 0, reloj = 0, avisoPrevio = '';
let quietoEstado = 1; // con movimiento reducido: 1 montada, 0,47 despiezada
const DESPIECE = 0.47;
function medir() {
const r = escena.getBoundingClientRect();
ancho = r.width; alto = r.height;
dpr = Math.min(devicePixelRatio || 1, 2);
lienzo.width = Math.round(ancho * dpr); lienzo.height = Math.round(alto * dpr);
gl.viewport(0, 0, lienzo.width, lienzo.height);
vertical = ancho / alto < 0.85;
largoDespiece = colocar(vertical ? 0.16 : 0.26);
PIEZAS.forEach((pz) => { pz.w = pz.li.offsetWidth; pz.h = pz.li.offsetHeight; });
}
// Progreso del scroll: lo da el CSS (animation-timeline) o, si no lo sabe, lo calculamos
function progresoScroll() {
if (CSS_SCROLL) {
const v = parseFloat(getComputedStyle(escena).getPropertyValue('--p'));
if (Number.isFinite(v)) return v;
}
const r = pista.getBoundingClientRect();
return clamp(-r.top / Math.max(1, r.height - innerHeight));
}
function scrollPara(p) { return pista.offsetTop + p * (pista.offsetHeight - innerHeight); }
// Demostración en bucle de 14 s: llegan, se ven con sus nombres, se montan, y vuelta a empezar
function pDemo(t) {
t %= 14;
if (t < 0.8) return 0;
if (t < 5.2) return DESPIECE * suave((t - 0.8) / 4.4);
if (t < 7.4) return DESPIECE;
if (t < 10.6) return mezcla(DESPIECE, 1, suave((t - 7.4) / 3.2));
if (t < 12.9) return 1;
return 1 - Math.pow((t - 12.9) / 1.1, 2);
}
function pararDemo() {
if (!demo) return;
demo = false;
baja.classList.add('fuera');
}
['wheel', 'touchstart', 'keydown'].forEach((ev) => addEventListener(ev, pararDemo, {passive: true}));
addEventListener('scroll', () => { if (scrollY > 2) pararDemo(); }, {passive: true});
// El botón grande (y tocar la bomba) lleva al siguiente estado: con scroll suave, que es quien manda
function alternar() {
pararDemo();
const montar = pVista < 0.75;
if (QUIETO) { quietoEstado = montar ? 1 : DESPIECE; return; }
scrollTo({top: scrollPara(montar ? 1 : DESPIECE), behavior: 'smooth'});
}
boton.addEventListener('click', alternar);
// Arrastrar en horizontal gira la bomba; un toque sin arrastrar la monta o la despieza
let arrastre = null;
lienzo.addEventListener('pointerdown', (e) => {
pararDemo();
arrastre = {x: e.clientX, y: e.clientY, t: performance.now(), movido: 0, ultX: e.clientX};
escena.classList.add('arrastrando');
});
addEventListener('pointermove', (e) => {
if (!arrastre) return;
const dx = e.clientX - arrastre.ultX;
arrastre.ultX = e.clientX;
arrastre.movido = Math.max(arrastre.movido, Math.hypot(e.clientX - arrastre.x, e.clientY - arrastre.y));
giroUsuario += dx * 0.008; giroVel = dx * 0.008;
});
const soltar = () => {
if (!arrastre) return;
if (arrastre.movido < 8 && performance.now() - arrastre.t < 450) alternar();
arrastre = null;
escena.classList.remove('arrastrando');
};
addEventListener('pointerup', soltar);
addEventListener('pointercancel', () => { arrastre = null; escena.classList.remove('arrastrando'); });
/* ---------- Cámara: encuadra la fila despiezada o la bomba montada dejando sitio a los textos ---------- */
const FOV = 30 * Math.PI / 180;
function margenes(montada) {
// espacio libre en píxeles (izquierda, arriba, derecha, abajo) para los textos de cada estado
if (vertical) return montada ? [20, 120, 20, alto * 0.36] : [Math.min(124, ancho * 0.3), 76, Math.min(124, ancho * 0.3), 118];
return montada ? [ancho > 900 ? Math.min(470, ancho * 0.36) : 40, 96, 40, 110] : [36, 64 + 96, 36, 92 + 92];
}
// encuadra la caja que ocupan AHORA las piezas (de x0 a x1 en el eje), dejando libres los márgenes m
function encuadre(x0, x1, m, W) {
const esquinas = [];
for (const x of [x0, x1]) for (const y of [-1.0, 1.42]) for (const z of [-0.95, 0.95]) esquinas.push(M4.punto(W, [x, y, z]));
const c = [0, 1, 2].map((k) => esquinas.reduce((s, e) => s + e[k], 0) / esquinas.length);
const mx = Math.max(0.2, (ancho - m[0] - m[2]) / ancho), my = Math.max(0.2, (alto - m[1] - m[3]) / alto);
const el = vertical ? 0.2 : 0.3;
const dir = [0, Math.sin(el), Math.cos(el)], arriba = [0, Math.cos(el), -Math.sin(el)];
const th = Math.tan(FOV / 2), asp = ancho / alto;
let d = 0;
for (const e of esquinas) {
const r = [e[0] - c[0], e[1] - c[1], e[2] - c[2]];
const k = r[0] * dir[0] + r[1] * dir[1] + r[2] * dir[2];
const xc = r[0], yc = r[0] * arriba[0] + r[1] * arriba[1] + r[2] * arriba[2];
d = Math.max(d, Math.abs(xc) / (mx * th * asp) + k, Math.abs(yc) / (my * th) + k);
}
return {c, d, el, ox: (m[0] - m[2]) / ancho, oy: (m[3] - m[1]) / alto};
}
/* ---------- Bucle de dibujo ---------- */
function dibujar(ahora) {
const dt = Math.min(0.05, (ahora - (ultimo || ahora)) / 1000);
ultimo = ahora; reloj += dt;
// 1 · ¿Qué progreso toca?
let objetivo;
if (QUIETO) objetivo = quietoEstado;
else if (demo) { demoT += dt; objetivo = pDemo(demoT); }
else objetivo = progresoScroll();
pVista = QUIETO || demo ? objetivo : pVista + (objetivo - pVista) * (1 - Math.exp(-dt * 9));
const p = pVista;
// 2 · Cada pieza: llegar (fuera → fila) y encajar (fila → su sitio)
let montaje = 0;
PIEZAS.forEach((pz) => {
pz.l = QUIETO ? 1 : salir((p - pz.llega) / 0.17);
pz.e = suave((p - pz.encaja) / 0.2);
montaje += pz.e / PIEZAS.length;
const u = (p - pz.encaja - 0.2) / 0.035;
pz.brillo = QUIETO ? 0 : Math.max(0, 1 - Math.abs(u)) * 0.9 + (foco === PIEZAS.indexOf(pz) ? 0.55 : 0);
});
// 3 · Mundo y cámara
const giroBase = vertical ? mezcla(0.55, 0.8, montaje) : mezcla(-0.42, -0.62, montaje);
if (!arrastre) { giroUsuario += giroVel; giroVel *= 0.92; }
const balanceo = QUIETO ? 0 : Math.sin(reloj * 0.45) * 0.1;
const W = M4.mul(M4.ry(giroBase + balanceo + giroUsuario), vertical ? M4.rz(-Math.PI / 2) : M4.id());
const k = suave(montaje), mA = margenes(false), mB = margenes(true);
let x0 = Infinity, x1 = -Infinity;
PIEZAS.forEach((pz) => { const o = mezcla(pz.despiece, pz.montada, pz.e); x0 = Math.min(x0, o + pz.x0); x1 = Math.max(x1, o + pz.x1); });
const cam = encuadre(x0, x1, mA.map((v, i) => mezcla(v, mB[i], k)), W);
const c = cam.c, d = cam.d, el = cam.el;
const ojo = [c[0], c[1] + d * Math.sin(el), c[2] + d * Math.cos(el)];
const proy = M4.persp(FOV, ancho / alto, 0.1, 200);
proy[8] -= cam.ox; proy[9] -= cam.oy;
const VP = M4.mul(proy, M4.mira(ojo, c, [0, 1, 0]));
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.uniformMatrix4fv(U.uVP, false, VP);
gl.uniform3fv(U.uCam, ojo);
PIEZAS.forEach((pz, i) => {
const fuera = 1 - pz.l;
const flota = QUIETO ? 0 : fuera;
const x = mezcla(mezcla(pz.fuera[0], pz.despiece, pz.l), pz.montada, pz.e) + Math.sin(reloj * 0.7 + i) * 0.12 * flota;
const y = mezcla(pz.fuera[1], 0, pz.l) + Math.cos(reloj * 0.6 + i * 1.3) * 0.12 * flota;
const z = mezcla(pz.fuera[2], 0, pz.l);
const g = pz.giro.map((a, j) => a * fuera + (j === 1 ? reloj * 0.25 * flota : 0));
// gira sobre su propio centro y se coloca
let M = M4.mul(W, M4.tr(x + pz.cx, y, z));
M = M4.mul(M, M4.mul(M4.rz(g[2]), M4.mul(M4.ry(g[1]), M4.rx(g[0]))));
M = M4.mul(M, M4.tr(-pz.cx, 0, 0));
pz.M = M;
gl.uniformMatrix4fv(U.uM, false, M);
gl.uniform1f(U.uBrillo, pz.brillo);
pz.partes.forEach((pt) => {
gl.bindBuffer(gl.ARRAY_BUFFER, pt.buf);
gl.vertexAttribPointer(aP, 3, gl.FLOAT, false, 24, 0);
gl.vertexAttribPointer(aN, 3, gl.FLOAT, false, 24, 12);
gl.uniform3fv(U.uCol, pt.mat.c);
gl.uniform1f(U.uMet, pt.mat.m);
gl.uniform1f(U.uRug, pt.mat.r);
gl.uniform1f(U.uPat, pt.pat);
gl.drawArrays(gl.TRIANGLES, 0, pt.n);
});
});
// 4 · Rótulos: el punto 3D de cada pieza se proyecta a la pantalla y el rótulo lo sigue
const aPantalla = (q) => {
const v = [VP[0] * q[0] + VP[4] * q[1] + VP[8] * q[2] + VP[12], VP[1] * q[0] + VP[5] * q[1] + VP[9] * q[2] + VP[13], VP[3] * q[0] + VP[7] * q[1] + VP[11] * q[2] + VP[15]];
return [(v[0] / v[2] * 0.5 + 0.5) * ancho, (0.5 - v[1] / v[2] * 0.5) * alto];
};
const bandas = {[1]: [], [-1]: []};
PIEZAS.forEach((pz) => {
pz.op = QUIETO ? (quietoEstado < 1 ? 1 : 0) : suave((pz.l - 0.8) / 0.2) * (1 - suave(pz.e / 0.35));
pz.ancla = aPantalla(M4.punto(pz.M, [pz.cx, pz.lado * pz.radio * 0.72, 0]));
if (pz.op > 0.01) bandas[pz.lado].push(pz);
});
const arriba = 64, abajo = alto - 92;
[1, -1].forEach((lado) => {
const b = bandas[lado];
// posición deseada y reparto sin que se pisen (1D: ordenar y empujar)
b.forEach((pz) => { pz.deseo = vertical ? pz.ancla[1] : pz.ancla[0]; pz.tam = vertical ? pz.h : pz.w; });
b.sort((u, v) => u.deseo - v.deseo);
const lim0 = vertical ? arriba + 6 : 8, lim1 = vertical ? alto - 104 : ancho - 8;
let prev = lim0;
b.forEach((pz) => { pz.pos = Math.max(pz.deseo, prev + pz.tam / 2 + (prev === lim0 ? 0 : 6)); prev = pz.pos + pz.tam / 2; });
let tope = lim1;
for (let j = b.length - 1; j >= 0; j--) { const pz = b[j]; pz.pos = Math.min(pz.pos, tope - pz.tam / 2); tope = pz.pos - pz.tam / 2 - 6; }
});
PIEZAS.forEach((pz) => {
const vis = pz.op > 0.01 ? pz.op : 0;
pz.li.style.opacity = vis.toFixed(3);
pz.linea.style.opacity = pz.punto.style.opacity = vis.toFixed(3);
pz.li.style.visibility = vis ? 'visible' : 'hidden';
if (!vis) return;
let lx, ly, px, py;
if (vertical) {
ly = pz.pos - pz.h / 2;
lx = pz.lado > 0 ? ancho - 8 - pz.w : 8;
px = pz.lado > 0 ? lx : lx + pz.w; py = pz.pos;
} else {
lx = clamp(pz.pos - pz.w / 2, 8, ancho - 8 - pz.w);
ly = pz.lado > 0 ? arriba + 20 : abajo - 20 - pz.h;
px = lx + pz.w / 2; py = pz.lado > 0 ? ly + pz.h : ly;
}
const sube = (1 - vis) * 10 * pz.lado;
pz.li.style.transform = `translate3d(${lx.toFixed(1)}px, ${(ly + sube).toFixed(1)}px, 0)`;
const [ax, ay] = pz.ancla;
const codo = vertical ? `L${((ax + px) / 2).toFixed(1)} ${ay.toFixed(1)} L${((ax + px) / 2).toFixed(1)} ${py.toFixed(1)}`
: `L${ax.toFixed(1)} ${((ay + py) / 2).toFixed(1)} L${px.toFixed(1)} ${((ay + py) / 2).toFixed(1)}`;
pz.linea.setAttribute('d', `M${ax.toFixed(1)} ${ay.toFixed(1)} ${codo} L${px.toFixed(1)} ${(py + sube).toFixed(1)}`);
pz.punto.setAttribute('cx', ax.toFixed(1)); pz.punto.setAttribute('cy', ay.toFixed(1));
});
// 5 · Textos de la escena, pasos y botón
const vIntro = QUIETO ? (quietoEstado < 1 ? 0 : 0) : 1 - suave((p - 0.06) / 0.1);
intro.style.opacity = vIntro.toFixed(3);
intro.style.transform = `translate3d(0, ${(-(1 - vIntro) * 16).toFixed(1)}px, 0)`;
const vFin = suave((montaje - 0.9) / 0.1);
fin.style.opacity = vFin.toFixed(3);
fin.style.transform = (innerWidth > 700 ? 'translateY(-50%) ' : '') + `translate3d(0, ${((1 - vFin) * 18).toFixed(1)}px, 0)`;
fin.classList.toggle('visible', vFin > 0.5);
if (!baja.classList.contains('fuera')) baja.style.opacity = (1 - suave(montaje / 0.3)).toFixed(3); // la pista de scroll no tapa la ficha
const paso = p < 0.3 ? 0 : montaje < 0.9 ? 1 : 2;
pasosLi.forEach((li, j) => li.classList.toggle('activo', j === paso));
const texto = p < 0.75 ? 'Montar' : 'Despiezar';
if (botonTexto.textContent !== texto) botonTexto.textContent = texto;
const estado = montaje > 0.95 ? 'Bomba montada y lista.' : (p > 0.38 && montaje < 0.05 ? 'Vista despiezada: diez piezas, cada una con su nombre.' : '');
if (!demo && estado && estado !== avisoPrevio) { aviso.textContent = estado; avisoPrevio = estado; }
requestAnimationFrame(dibujar);
}
if (QUIETO) { intro.style.display = 'none'; }
medir();
addEventListener('resize', medir);
if (document.fonts && document.fonts.ready) document.fonts.ready.then(medir);
requestAnimationFrame(dibujar);
})();