/* * 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; })();