FOSFENO/web/stage/mapper.js
hacklab f017246f0e Capas independientes, projectM en el mapping y catalogo de la galeria
Mapping
- Los clips salian del reves: mapper.js activaba UNPACK_FLIP_Y_WEBGL, pero el
  modelo va con origen arriba-izquierda y texImage2D ya sube la primera fila
  en t=0, asi que el flip la mandaba al final. Comprobado renderizando el
  mapper real en Chromium headless con una fuente mitad roja / mitad azul.
- projectM se puede usar YA en una capa: los .milk de data/presets-projectm se
  traducen a Butterchurn en el navegador al elegirlos (milkdrop-preset-
  converter). La capa MilkDrop gana 'biblioteca' (butter | projectm) sin
  cambiar su firma, para no recrear el contexto WebGL al cambiar de una a otra.
  Medido: 100/100 presets de una muestra convierten, 84/84 de los que llevan
  shaders warp/comp; ~7 ms por preset.
- Cada capa tiene su pestana arriba y su propia vista, y '+ CAPA' crea una y
  entra en ella: con varias capas, ir y volver a MAPPING no era viable.
- Dos capas MilkDrop sin preset ya no salen identicas (cogian el indice 0):
  cada instancia elige uno al azar y lo escribe en el estado.
- Una capa nueva ya no nace en la fuente "motor", que es el lienzo del motor
  activo y hacia que dos capas ensenaran lo mismo.

Galeria de visuales
- scripts/catalogar-visuales.py: ficha de cada clip (pelicula, personajes,
  duracion) y, sobre todo, si es una silueta de verdad y cuanta figura tiene.
  Distingue silueta de corte crudo por el negro puro del fondo: 0,63-0,90
  frente a 0,04-0,06, sin zona gris.
- El panel lista los clips agrupados por pelicula, con nombre legible y aviso
  de los que casi no tienen figura, en vez del nombre del archivo.
- Soporte de siluetas con canal alfa (.webm VP9): transparencia de verdad, sin
  recorte por luminancia. El shader del mapper ya la respeta.

Documentacion
- docs/visuales.md nuevo; pendiente.md al dia con lo hecho y lo que queda.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 14:56:01 +02:00

615 lines
26 KiB
JavaScript

/*
* FOSFENO :: Mapper (projection mapping en el navegador) — Fase 3
* --------------------------------------------------------------------------
* Pinta CAPAS deformadas sobre superficies con MALLA (rejilla n x n => curvas,
* no solo keystone de 4 esquinas) y aplica MASCARAS (cuadrilateros negros que
* tapan zonas / recortan derrames de luz).
*
* Cada superficie es una CAPA con su propia fuente: el motor activo, una
* instancia propia de MilkDrop, un shader, un clip o la camara (las crea
* layers.js). Asi se puede tener MilkDrop a la derecha, otro preset a la
* izquierda y un video en el centro, cada uno encajado en su sitio.
*
* Modelo (0..1, origen arriba-izquierda; se guarda en data/mapping.json):
* surface = { id, n, points:[[x,y] x (n+1)^2],
* nombre, visible, opacidad, mezcla:"normal"|"sumar",
* fuente:{ tipo, ... } } // ver layers.js
* mask = { id, corners:[[x,y] x4] } // TL,TR,BR,BL, negro
* mapping = { enabled, edit, surfaces:[...], masks:[...] }
*
* Compatibilidad: superficies antiguas { corners:[...] } se migran a n=1, y
* las que no llevan fuente se pintan con el motor activo, como siempre.
*
* Render: cada celda de la malla se dibuja como un parche con homografia
* (cuadrado unidad -> celda) sobre una sub-malla fina => perspectiva correcta.
*
* API global:
* FosMapper.init({ onChange, resolver }) arranca
* FosMapper.setSource(canvas) lienzo del motor activo (fuente "motor")
* FosMapper.setMapping(m) estado de mapping
* onChange({surfaces, masks}) al editar (arrastrar), para guardar
* resolver(surface) -> { el, clave } con la textura de la capa
*/
(function () {
'use strict';
const CELLDIV = 8; // subdivisiones internas por celda (perspectiva suave)
const HANDLE = 10; // radio del tirador (px)
const MAXN = 6; // maximo de subdivisiones por superficie
const M = { enabled: false, edit: false, surfaces: [], masks: [] };
let source = null;
let onChange = null;
let resolver = null; // surface -> { el, clave } (lo pone layers.js)
let aviso = function () {}; // report() del escenario: nada se queda callado
let outputEl, editEl, gl, ectx;
let prog, aUV, uH, uTex, uOff, uScale, uAlpha; // programa de textura
let progF, aPos, uColor, fillBuf; // programa de relleno (mascaras)
let gridBuf, gridIdx, gridCount, dpr = 1;
const texturas = new Map(); // clave de fuente -> { tex, subida, visto }
let frame = 0;
let selected = null; // { type:'surface'|'mask', id }
let drag = null; // { type, id, pt|null, sx, sy, orig }
let dragged = false;
const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
const sidx = (c, r, n) => r * (n + 1) + c;
// ------------------------------------------------------------------ modelo
/* Normaliza una superficie: geometria a formato malla y ajustes de capa con
sus valores por defecto. Lo que no venga (superficies de antes de las
capas) se rellena para que el resto del codigo no tenga que preguntar. */
function migrate(s) {
const capa = {
id: s.id,
nombre: s.nombre || '',
visible: s.visible !== false,
opacidad: typeof s.opacidad === 'number' ? s.opacidad : 1,
mezcla: s.mezcla === 'sumar' ? 'sumar' : 'normal',
fuente: s.fuente || { tipo: 'motor' },
};
if (Array.isArray(s.points) && s.n) {
capa.n = s.n; capa.points = s.points.map((p) => [p[0], p[1]]);
} else if (Array.isArray(s.corners)) { // formato Fase 1 -> n=1
const c = s.corners;
capa.n = 1;
capa.points = [[c[0][0], c[0][1]], [c[1][0], c[1][1]],
[c[3][0], c[3][1]], [c[2][0], c[2][1]]]; // TL,TR,BL,BR (row-major)
} else {
capa.n = 1;
capa.points = [[0.3, 0.3], [0.7, 0.3], [0.3, 0.7], [0.7, 0.7]];
}
return capa;
}
/* Mascaras saneadas. Una sin esquinas (mapping.json a medio escribir, de
otra version o tocado a mano) no puede tumbar el escenario entero: se
descarta y punto. */
function migrateMasks(lista) {
return (Array.isArray(lista) ? lista : [])
.filter((k) => k && Array.isArray(k.corners) && k.corners.length === 4)
.map((k) => ({ id: k.id, corners: k.corners.map((p) => [p[0], p[1]]) }));
}
// punto [x,y] a coordenada normalizada (u,v) de la superficie (bilineal por celda)
function sampleSurf(s, u, v) {
const n = s.n;
let ci = Math.min(n - 1, Math.floor(u * n)); if (ci < 0) ci = 0;
let cj = Math.min(n - 1, Math.floor(v * n)); if (cj < 0) cj = 0;
const lu = u * n - ci, lv = v * n - cj;
const P = (c, r) => s.points[sidx(c, r, n)];
const tl = P(ci, cj), tr = P(ci + 1, cj), bl = P(ci, cj + 1), br = P(ci + 1, cj + 1);
const ax = tl[0] + (tr[0] - tl[0]) * lu, ay = tl[1] + (tr[1] - tl[1]) * lu;
const bx = bl[0] + (br[0] - bl[0]) * lu, by = bl[1] + (br[1] - bl[1]) * lu;
return [ax + (bx - ax) * lv, ay + (by - ay) * lv];
}
function resample(s, newN) {
const pts = [];
for (let r = 0; r <= newN; r++)
for (let c = 0; c <= newN; c++) pts.push(sampleSurf(s, c / newN, r / newN));
// Solo cambia la malla: los ajustes de la capa (fuente, nombre, opacidad)
// se conservan tal cual.
const out = migrate(s);
out.n = newN; out.points = pts;
return out;
}
// homografia cuadrado unidad -> cuadrilatero (Heckbert), mat3 por columnas
function squareToQuad(c) {
const x0 = c[0][0], y0 = c[0][1], x1 = c[1][0], y1 = c[1][1];
const x2 = c[2][0], y2 = c[2][1], x3 = c[3][0], y3 = c[3][1];
const dx1 = x1 - x2, dx2 = x3 - x2, dx3 = x0 - x1 + x2 - x3;
const dy1 = y1 - y2, dy2 = y3 - y2, dy3 = y0 - y1 + y2 - y3;
let a, b, cc, d, e, f, g, h;
if (Math.abs(dx3) < 1e-9 && Math.abs(dy3) < 1e-9) {
a = x1 - x0; b = x2 - x1; cc = x0; d = y1 - y0; e = y2 - y1; f = y0; g = 0; h = 0;
} else {
const den = dx1 * dy2 - dy1 * dx2 || 1e-9;
g = (dx3 * dy2 - dy3 * dx2) / den;
h = (dx1 * dy3 - dy1 * dx3) / den;
a = x1 - x0 + g * x1; b = x3 - x0 + h * x3; cc = x0;
d = y1 - y0 + g * y1; e = y3 - y0 + h * y3; f = y0;
}
return new Float32Array([a, d, g, b, e, h, cc, f, 1]);
}
// ------------------------------------------------------------------ WebGL
function compile(type, src) {
const sh = gl.createShader(type);
gl.shaderSource(sh, src); gl.compileShader(sh);
if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS))
console.error('[FOSFENO] mapper shader:', gl.getShaderInfoLog(sh));
return sh;
}
function linkProg(vs, fs) {
const p = gl.createProgram();
gl.attachShader(p, compile(gl.VERTEX_SHADER, vs));
gl.attachShader(p, compile(gl.FRAGMENT_SHADER, fs));
gl.linkProgram(p);
if (!gl.getProgramParameter(p, gl.LINK_STATUS))
console.error('[FOSFENO] mapper link:', gl.getProgramInfoLog(p));
return p;
}
function initGL() {
gl = outputEl.getContext('webgl', { alpha: false, antialias: true })
|| outputEl.getContext('experimental-webgl');
if (!gl) {
aviso('error', 'El mapping necesita WebGL y este navegador no lo tiene: '
+ 'las visuales saldran sin deformar.');
return false;
}
prog = linkProg(
`attribute vec2 aUV; uniform mat3 uH; uniform vec2 uOff; uniform vec2 uScale;
varying vec2 vUV;
void main(){ vec3 p = uH * vec3(aUV,1.0); vec2 d = p.xy/p.z;
gl_Position = vec4(d.x*2.0-1.0, 1.0-d.y*2.0, 0.0, 1.0);
vUV = uOff + aUV*uScale; }`,
`precision mediump float; varying vec2 vUV; uniform sampler2D uTex;
uniform float uAlpha;
void main(){ vec4 c = texture2D(uTex, vUV);
gl_FragColor = vec4(c.rgb, c.a * uAlpha); }`);
aUV = gl.getAttribLocation(prog, 'aUV');
uH = gl.getUniformLocation(prog, 'uH');
uTex = gl.getUniformLocation(prog, 'uTex');
uOff = gl.getUniformLocation(prog, 'uOff');
uScale = gl.getUniformLocation(prog, 'uScale');
uAlpha = gl.getUniformLocation(prog, 'uAlpha');
progF = linkProg(
`attribute vec2 aPos; void main(){ gl_Position = vec4(aPos,0.0,1.0); }`,
`precision mediump float; uniform vec4 uColor; void main(){ gl_FragColor = uColor; }`);
aPos = gl.getAttribLocation(progF, 'aPos');
uColor = gl.getUniformLocation(progF, 'uColor');
fillBuf = gl.createBuffer();
// rejilla unidad CELLDIV x CELLDIV (se reutiliza por cada celda)
const verts = [];
for (let j = 0; j <= CELLDIV; j++)
for (let i = 0; i <= CELLDIV; i++) verts.push(i / CELLDIV, j / CELLDIV);
const idx = []; const row = CELLDIV + 1;
for (let j = 0; j < CELLDIV; j++)
for (let i = 0; i < CELLDIV; i++) {
const p = j * row + i;
idx.push(p, p + 1, p + row, p + 1, p + row + 1, p + row);
}
gridCount = idx.length;
gridBuf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, gridBuf);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(verts), gl.STATIC_DRAW);
gridIdx = gl.createBuffer();
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, gridIdx);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(idx), gl.STATIC_DRAW);
/* SIN voltear la textura al subirla. El modelo va con el origen
arriba-izquierda (vUV.y = 0 es el borde de arriba de la superficie) y
texImage2D ya sube la primera fila de la imagen en t = 0. Poniendo
UNPACK_FLIP_Y a true, t = 0 pasaba a ser la ultima fila y todo salia
del reves — se notaba sobre todo con los clips de video. */
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
gl.enable(gl.BLEND);
return true;
}
/* Una textura por fuente, subida una sola vez por fotograma: si dos
superficies comparten el mismo clip, la imagen no viaja dos veces. */
function texturaDe(src) {
const el = src.el;
if (!el) return null;
const w = el.videoWidth || el.naturalWidth || el.width;
const h = el.videoHeight || el.naturalHeight || el.height;
if (!w || !h) return null;
if (el.tagName === 'VIDEO' && el.readyState < 2) return null;
let e = texturas.get(src.clave);
if (!e) {
e = { tex: gl.createTexture(), subida: -1, visto: -1 };
gl.bindTexture(gl.TEXTURE_2D, e.tex);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
texturas.set(src.clave, e);
}
e.visto = frame;
if (e.subida !== frame) {
e.subida = frame;
gl.bindTexture(gl.TEXTURE_2D, e.tex);
try { gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, el); }
catch (err) { return null; }
}
return e.tex;
}
/* Tira las texturas de fuentes que ya no se usan (capas borradas). */
function limpiarTexturas() {
if (frame % 300) return;
texturas.forEach((e, clave) => {
if (frame - e.visto > 120) { gl.deleteTexture(e.tex); texturas.delete(clave); }
});
}
function drawSurfaceGL(s) {
const n = s.n;
const P = (c, r) => s.points[sidx(c, r, n)];
for (let cj = 0; cj < n; cj++) {
for (let ci = 0; ci < n; ci++) {
const cell = [P(ci, cj), P(ci + 1, cj), P(ci + 1, cj + 1), P(ci, cj + 1)]; // TL,TR,BR,BL
gl.uniformMatrix3fv(uH, false, squareToQuad(cell));
gl.uniform2f(uOff, ci / n, cj / n);
gl.uniform2f(uScale, 1 / n, 1 / n);
gl.drawElements(gl.TRIANGLES, gridCount, gl.UNSIGNED_SHORT, 0);
}
}
}
// Pantalla entera: lo que se ve cuando el mapping esta activo pero aun no
// hay ninguna superficie creada (pasada directa del motor).
const PANTALLA = { id: '__pantalla__', n: 1, visible: true, opacidad: 1,
mezcla: 'normal', fuente: { tipo: 'motor' },
points: [[0, 0], [1, 0], [0, 1], [1, 1]] };
// La fuente de una capa: la resuelve layers.js; si no hay resolver (o la
// capa es del motor), tira del lienzo del motor activo.
function fuenteDeCapa(capa) {
if (resolver) {
const r = resolver(capa);
if (r && r.el) return r;
if (capa.fuente && capa.fuente.tipo !== 'motor') return null;
}
return (source && source.width) ? { el: source, clave: 'motor' } : null;
}
function renderGL() {
frame++;
gl.viewport(0, 0, outputEl.width, outputEl.height);
gl.clearColor(0, 0, 0, 1);
gl.clear(gl.COLOR_BUFFER_BIT);
// superficies (cada una con su propia fuente)
gl.useProgram(prog);
gl.bindBuffer(gl.ARRAY_BUFFER, gridBuf);
gl.enableVertexAttribArray(aUV);
gl.vertexAttribPointer(aUV, 2, gl.FLOAT, false, 0, 0);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, gridIdx);
gl.activeTexture(gl.TEXTURE0);
gl.uniform1i(uTex, 0);
// M.surfaces ya viene normalizado de setMapping / onDown / onDblClick:
// no hace falta migrar otra vez en cada fotograma (eran cientos de
// arrays nuevos por segundo, justo en la maquina que menos lo aguanta).
const lista = M.surfaces.length ? M.surfaces : [PANTALLA];
for (const capa of lista) {
if (!capa.visible || capa.opacidad <= 0) continue;
if (capa.fuente.tipo === 'negro') continue;
const src = fuenteDeCapa(capa);
if (!src) continue;
const t = texturaDe(src);
if (!t) continue;
gl.bindTexture(gl.TEXTURE_2D, t);
gl.uniform1f(uAlpha, capa.opacidad);
// "sumar" apila luz (las zonas oscuras dejan ver la capa de abajo);
// "normal" tapa lo que haya debajo.
if (capa.mezcla === 'sumar') gl.blendFunc(gl.SRC_ALPHA, gl.ONE);
else gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
drawSurfaceGL(capa);
}
limpiarTexturas();
// mascaras (cuadrilateros negros encima). Vuelve a mezcla normal: si la
// ultima capa iba en "sumar", un negro sumado no taparia nada.
if (M.masks.length) {
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
gl.useProgram(progF);
gl.uniform4f(uColor, 0, 0, 0, 1);
gl.bindBuffer(gl.ARRAY_BUFFER, fillBuf);
gl.enableVertexAttribArray(aPos);
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);
for (const m of M.masks) {
const c = m.corners;
const clip = new Float32Array([
c[0][0] * 2 - 1, 1 - c[0][1] * 2, c[1][0] * 2 - 1, 1 - c[1][1] * 2,
c[2][0] * 2 - 1, 1 - c[2][1] * 2, c[3][0] * 2 - 1, 1 - c[3][1] * 2]);
gl.bufferData(gl.ARRAY_BUFFER, clip, gl.DYNAMIC_DRAW);
gl.drawArrays(gl.TRIANGLE_FAN, 0, 4);
}
}
}
// ------------------------------------------------------------------ editor
const NOMBRE_FUENTE = {
motor: 'motor activo', butter: 'MilkDrop', shader: 'shader',
video: 'clip', camara: 'camara', negro: 'negro',
};
function etiquetaFuente(f) {
f = f || {};
const base = NOMBRE_FUENTE[f.tipo] || 'motor activo';
if (f.tipo === 'butter' && f.preset) return base + ' · ' + f.preset;
if (f.tipo === 'video' && f.archivo) return base + ' · ' + f.archivo;
return base;
}
/* Rotulo centrado que no se sale de la pantalla: una capa pegada al borde
tendria el nombre cortado por la mitad. */
function rotulo(txt, x, y, w) {
let t = txt;
while (t.length > 6 && ectx.measureText(t).width > w - 24) t = t.slice(0, -2);
if (t !== txt) t = t.slice(0, -1) + '…';
const media = ectx.measureText(t).width / 2;
ectx.fillText(t, Math.min(Math.max(x, media + 10), w - media - 10), y);
}
function drawEditor() {
const w = window.innerWidth, h = window.innerHeight;
ectx.setTransform(dpr, 0, 0, dpr, 0, 0);
ectx.clearRect(0, 0, w, h);
ectx.font = '600 14px system-ui, sans-serif';
ectx.textAlign = 'center'; ectx.textBaseline = 'middle';
// superficies (malla). Ya vienen normalizadas de setMapping.
M.surfaces.forEach((s, i) => {
const n = s.n;
const sel = selected && selected.type === 'surface' && selected.id === s.id;
const col = sel ? '#00e5ff' : '#b4ff00';
const P = (c, r) => { const p = s.points[sidx(c, r, n)]; return [p[0] * w, p[1] * h]; };
// lineas de la malla
ectx.strokeStyle = col; ectx.lineWidth = sel ? 2 : 1.3;
for (let r = 0; r <= n; r++) { ectx.beginPath(); for (let c = 0; c <= n; c++) { const p = P(c, r); c ? ectx.lineTo(p[0], p[1]) : ectx.moveTo(p[0], p[1]); } ectx.stroke(); }
for (let c = 0; c <= n; c++) { ectx.beginPath(); for (let r = 0; r <= n; r++) { const p = P(c, r); r ? ectx.lineTo(p[0], p[1]) : ectx.moveTo(p[0], p[1]); } ectx.stroke(); }
// nombre de la capa y su fuente, en el centro de verdad: el medio de
// las cuatro esquinas (con n=1, P(n/2|0, n/2|0) caia en la esquina).
const esq = [P(0, 0), P(n, 0), P(n, n), P(0, n)];
const cc = [(esq[0][0] + esq[1][0] + esq[2][0] + esq[3][0]) / 4,
(esq[0][1] + esq[1][1] + esq[2][1] + esq[3][1]) / 4];
ectx.fillStyle = col;
rotulo(s.nombre || ('Capa ' + (i + 1)), cc[0], cc[1] - 9, w);
ectx.font = '12px system-ui, sans-serif';
rotulo(etiquetaFuente(s.fuente), cc[0], cc[1] + 10, w);
ectx.font = '600 14px system-ui, sans-serif';
// tiradores (todos los puntos)
for (let r = 0; r <= n; r++) for (let c = 0; c <= n; c++) {
const p = P(c, r);
ectx.beginPath(); ectx.arc(p[0], p[1], HANDLE, 0, Math.PI * 2);
ectx.fillStyle = sel ? '#00e5ff' : '#fff'; ectx.fill();
ectx.lineWidth = 2; ectx.strokeStyle = '#000'; ectx.stroke();
}
});
// mascaras (rojo)
M.masks.forEach((m, i) => {
const sel = selected && selected.type === 'mask' && selected.id === m.id;
const pts = m.corners.map(([x, y]) => [x * w, y * h]);
ectx.beginPath(); ectx.moveTo(pts[0][0], pts[0][1]);
for (let k = 1; k < 4; k++) ectx.lineTo(pts[k][0], pts[k][1]);
ectx.closePath();
ectx.fillStyle = 'rgba(255,60,60,0.25)'; ectx.fill();
ectx.lineWidth = sel ? 3 : 2; ectx.strokeStyle = sel ? '#ff2d2d' : '#ff6b6b';
ectx.setLineDash([6, 4]); ectx.stroke(); ectx.setLineDash([]);
const cx = (pts[0][0] + pts[1][0] + pts[2][0] + pts[3][0]) / 4;
const cy = (pts[0][1] + pts[1][1] + pts[2][1] + pts[3][1]) / 4;
ectx.fillStyle = '#ff6b6b'; ectx.fillText('M' + (i + 1), cx, cy);
pts.forEach(([px, py]) => {
ectx.beginPath(); ectx.arc(px, py, HANDLE, 0, Math.PI * 2);
ectx.fillStyle = '#ff6b6b'; ectx.fill();
ectx.lineWidth = 2; ectx.strokeStyle = '#000'; ectx.stroke();
});
});
ectx.textAlign = 'left'; ectx.fillStyle = 'rgba(255,255,255,0.6)';
ectx.font = '13px system-ui, sans-serif';
ectx.fillText('MAPPING · arrastra puntos · +/- subdivisiones · doble clic: nueva · Supr: borrar', 16, h - 18);
}
function pointInQuad(x, y, c) {
let inside = false;
for (let i = 0, j = 3; i < 4; j = i++) {
const xi = c[i][0], yi = c[i][1], xj = c[j][0], yj = c[j][1];
if (((yi > y) !== (yj > y)) && (x < ((xj - xi) * (y - yi)) / ((yj - yi) || 1e-9) + xi)) inside = !inside;
}
return inside;
}
// esquinas exteriores de una superficie (para el test de "dentro")
function surfOutline(s) {
const n = s.n;
return [s.points[sidx(0, 0, n)], s.points[sidx(n, 0, n)], s.points[sidx(n, n, n)], s.points[sidx(0, n, n)]];
}
function pick(mx, my) {
const w = window.innerWidth, h = window.innerHeight;
const R = HANDLE + 6;
// puntos de superficie
for (let i = M.surfaces.length - 1; i >= 0; i--) {
const s = migrate(M.surfaces[i]);
for (let k = 0; k < s.points.length; k++) {
const px = s.points[k][0] * w, py = s.points[k][1] * h;
if (Math.hypot(mx - px, my - py) <= R) return { type: 'surface', id: s.id, pt: k };
}
}
// esquinas de mascara
for (let i = M.masks.length - 1; i >= 0; i--) {
const m = M.masks[i];
for (let k = 0; k < 4; k++) {
const px = m.corners[k][0] * w, py = m.corners[k][1] * h;
if (Math.hypot(mx - px, my - py) <= R) return { type: 'mask', id: m.id, pt: k };
}
}
// interior mascara (mover)
for (let i = M.masks.length - 1; i >= 0; i--)
if (pointInQuad(mx / w, my / h, M.masks[i].corners)) return { type: 'mask', id: M.masks[i].id, pt: null };
// interior superficie (mover)
for (let i = M.surfaces.length - 1; i >= 0; i--) {
const s = migrate(M.surfaces[i]);
if (pointInQuad(mx / w, my / h, surfOutline(s))) return { type: 'surface', id: s.id, pt: null };
}
return null;
}
const findSurf = (id) => M.surfaces.findIndex((s) => s.id === id);
const findMask = (id) => M.masks.findIndex((m) => m.id === id);
function onDown(ev) {
const mx = ev.clientX, my = ev.clientY;
const hit = pick(mx, my);
dragged = false;
if (!hit) { selected = null; drag = null; return; }
selected = { type: hit.type, id: hit.id };
if (hit.type === 'surface') {
const s = migrate(M.surfaces[findSurf(hit.id)]);
M.surfaces[findSurf(hit.id)] = s; // normaliza a formato malla
drag = { type: 'surface', id: hit.id, pt: hit.pt, sx: mx, sy: my, orig: s.points.map((p) => p.slice()) };
} else {
const m = M.masks[findMask(hit.id)];
drag = { type: 'mask', id: hit.id, pt: hit.pt, sx: mx, sy: my, orig: m.corners.map((p) => p.slice()) };
}
ev.preventDefault();
}
function onMove(ev) {
if (!drag) return;
const w = window.innerWidth, h = window.innerHeight;
const dx = (ev.clientX - drag.sx) / w, dy = (ev.clientY - drag.sy) / h;
if (Math.abs(ev.clientX - drag.sx) + Math.abs(ev.clientY - drag.sy) > 1) dragged = true;
const arr = drag.type === 'surface'
? M.surfaces[findSurf(drag.id)].points
: M.masks[findMask(drag.id)].corners;
if (!arr) return;
if (drag.pt == null) {
for (let k = 0; k < arr.length; k++) { arr[k][0] = clamp01(drag.orig[k][0] + dx); arr[k][1] = clamp01(drag.orig[k][1] + dy); }
} else {
arr[drag.pt][0] = clamp01(drag.orig[drag.pt][0] + dx);
arr[drag.pt][1] = clamp01(drag.orig[drag.pt][1] + dy);
}
scheduleEmit();
}
function onUp() { if (drag && dragged) emitNow(); drag = null; }
function defaultSurface(cx, cy, s) {
return { id: 's' + Math.floor(performance.now()) + '_' + Math.floor(Math.random() * 1e4),
n: 1, nombre: '', visible: true, opacidad: 1, mezcla: 'normal',
fuente: { tipo: 'motor' },
points: [[clamp01(cx - s), clamp01(cy - s)], [clamp01(cx + s), clamp01(cy - s)],
[clamp01(cx - s), clamp01(cy + s)], [clamp01(cx + s), clamp01(cy + s)]] };
}
function onDblClick(ev) {
const s = defaultSurface(ev.clientX / window.innerWidth, ev.clientY / window.innerHeight, 0.2);
M.surfaces.push(s); selected = { type: 'surface', id: s.id }; emitNow();
}
function onKey(ev) {
if (!M.edit) return;
if ((ev.key === 'Delete' || ev.key === 'Backspace') && selected) {
if (selected.type === 'surface') M.surfaces = M.surfaces.filter((s) => s.id !== selected.id);
else M.masks = M.masks.filter((m) => m.id !== selected.id);
selected = null; emitNow(); ev.preventDefault();
} else if ((ev.key === '+' || ev.key === '=') && selected && selected.type === 'surface') {
const i = findSurf(selected.id); const s = migrate(M.surfaces[i]);
if (s.n < MAXN) { M.surfaces[i] = resample(s, s.n + 1); emitNow(); } ev.preventDefault();
} else if ((ev.key === '-' || ev.key === '_') && selected && selected.type === 'surface') {
const i = findSurf(selected.id); const s = migrate(M.surfaces[i]);
if (s.n > 1) { M.surfaces[i] = resample(s, s.n - 1); emitNow(); } ev.preventDefault();
}
}
let emitTimer = null;
function scheduleEmit() { if (!emitTimer) emitTimer = setTimeout(() => { emitTimer = null; emitNow(); }, 60); }
function emitNow() {
if (emitTimer) { clearTimeout(emitTimer); emitTimer = null; }
if (onChange) onChange({
surfaces: M.surfaces.map(migrate), // geometria + ajustes de la capa
masks: M.masks.map((m) => ({ id: m.id, corners: m.corners })),
});
}
function fit(el) {
const w = Math.round(window.innerWidth * dpr), h = Math.round(window.innerHeight * dpr);
if (el.width !== w || el.height !== h) { el.width = w; el.height = h; }
}
function loop() {
requestAnimationFrame(loop);
dpr = Math.min(2, window.devicePixelRatio || 1);
const editing = M.enabled && M.edit;
outputEl.style.display = M.enabled ? 'block' : 'none';
editEl.style.display = editing ? 'block' : 'none';
editEl.style.pointerEvents = editing ? 'auto' : 'none';
document.body.style.cursor = editing ? 'crosshair' : 'none';
if (!M.enabled) return;
fit(outputEl); fit(editEl);
renderGL();
if (editing) drawEditor();
}
const API = {
init(opts) {
opts = opts || {};
onChange = opts.onChange || null;
resolver = opts.resolver || null;
aviso = opts.report || function () {};
outputEl = document.getElementById('output');
editEl = document.getElementById('mapedit');
if (!outputEl || !editEl) {
aviso('error', 'Falta el lienzo del mapping en la pagina del '
+ 'escenario: recarga el escenario.');
return;
}
ectx = editEl.getContext('2d');
// Si el navegador tira este contexto (demasiadas capas a la vez), el
// mapping se queda negro en silencio: que al menos se sepa por que.
outputEl.addEventListener('webglcontextlost', (e) => {
e.preventDefault();
aviso('error', 'El navegador ha cerrado el contexto grafico del '
+ 'mapping (demasiadas capas a la vez). Quita alguna capa de '
+ 'MilkDrop o de shader y recarga el escenario.');
});
if (!initGL()) return;
editEl.addEventListener('mousedown', onDown);
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
editEl.addEventListener('dblclick', onDblClick);
window.addEventListener('keydown', onKey);
requestAnimationFrame(loop);
},
setSource(canvas) { source = canvas || null; },
setMapping(m) {
m = m || {};
M.enabled = !!m.enabled;
M.edit = !!m.edit;
if (!drag) { // no piso la geometria mientras arrastro aqui
if (Array.isArray(m.surfaces)) {
M.surfaces = m.surfaces.filter((s) => s && s.id).map(migrate);
}
M.masks = migrateMasks(m.masks);
if (selected) {
const ok = selected.type === 'surface' ? findSurf(selected.id) >= 0 : findMask(selected.id) >= 0;
if (!ok) selected = null;
}
}
},
getMapping() { return M; },
};
window.FosMapper = API;
})();