/* * FOSFENO :: Mapper (projection mapping en el navegador) — Fase 2 * -------------------------------------------------------------------------- * Coge el canvas del motor activo como TEXTURA y lo pinta deformado sobre * SUPERFICIES con MALLA (rejilla n x n deformable => superficies curvas, no * solo keystone de 4 esquinas) y aplica MASCARAS (cuadrilateros negros que * tapan zonas / recortan derrames de luz). * * Modelo (0..1, origen arriba-izquierda; se guarda en data/mapping.json): * surface = { id, n, points:[[x,y] x (n+1)^2] } // rejilla n x n celdas * 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. * * 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 }) arranca * FosMapper.setSource(canvas) motor activo (o null) * FosMapper.setMapping(m) estado de mapping * onChange({surfaces, masks}) al editar (arrastrar), para guardar */ (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 outputEl, editEl, gl, ectx; let prog, aUV, uH, uTex, uOff, uScale; // programa de textura let progF, aPos, uColor, fillBuf; // programa de relleno (mascaras) let tex, gridBuf, gridIdx, gridCount, dpr = 1; 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 function migrate(s) { if (Array.isArray(s.points) && s.n) { return { id: s.id, n: s.n, points: s.points.map((p) => [p[0], p[1]]) }; } if (Array.isArray(s.corners)) { // formato Fase 1 -> n=1 const c = s.corners; return { id: s.id, n: 1, 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) } return { id: s.id, n: 1, points: [[0.3, 0.3], [0.7, 0.3], [0.3, 0.7], [0.7, 0.7]] }; } // 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)); return { id: s.id, n: newN, points: pts }; } // 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) { console.error('[FOSFENO] mapper: sin WebGL'); 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; void main(){ gl_FragColor = texture2D(uTex, vUV); }`); 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'); 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); tex = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, 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); gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true); return true; } 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); } } } function renderGL() { gl.viewport(0, 0, outputEl.width, outputEl.height); gl.clearColor(0, 0, 0, 1); gl.clear(gl.COLOR_BUFFER_BIT); if (!source || !source.width) return; gl.bindTexture(gl.TEXTURE_2D, tex); try { gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source); } catch (e) { return; } // superficies 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.bindTexture(gl.TEXTURE_2D, tex); gl.uniform1i(uTex, 0); if (!M.surfaces.length) { drawSurfaceGL({ n: 1, points: [[0, 0], [1, 0], [0, 1], [1, 1]] }); // pasada directa } else { for (const s of M.surfaces) drawSurfaceGL(migrate(s)); } // mascaras (cuadrilateros negros encima) if (M.masks.length) { 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 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) M.surfaces.forEach((raw, i) => { const s = migrate(raw); 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(); } // numero const cc = P(n / 2 | 0, n / 2 | 0); ectx.fillStyle = col; ectx.fillText(String(i + 1), cc[0], cc[1]); // 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, 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((s) => { const m = migrate(s); return { id: m.id, n: m.n, points: m.points }; }), 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; outputEl = document.getElementById('output'); editEl = document.getElementById('mapedit'); if (!outputEl || !editEl) { console.error('[FOSFENO] mapper: faltan #output/#mapedit'); return; } ectx = editEl.getContext('2d'); 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.map(migrate); M.masks = Array.isArray(m.masks) ? m.masks.map((k) => ({ id: k.id, corners: k.corners.map((p) => [p[0], p[1]]) })) : []; 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; })();