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>
615 lines
26 KiB
JavaScript
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;
|
|
})();
|