FOSFENO/web/stage/mapper.js
hacklab 8dd58bb293 Mapping in-browser (Fase 1) + trabajo en curso
Projection mapping editable desde el navegador, sin herramientas externas:
- web/stage/mapper.js (nuevo): compositor WebGL que texturiza el motor activo
  sobre superficies deformables (homografia por vertice: keystone correcto).
- Editor con raton en el escenario (crear, arrastrar esquinas, mover, borrar)
  y previsualizacion arrastrable en el panel (raton/tactil) para editar desde
  el movil sin tocar la ventana de visuales.
- Backend: estado "mapping" + evento set_mapping, persistido en data/mapping.json
  (ignorado en git). Seccion "Mapping" en el panel + texto de ayuda.
- Solo motores web (butterchurn/hydra/shaders/mixer); projectM no se mapea.
- .gitignore: excluye config de runtime (mapping.json, pm-favoritos.json).

Incluye ademas trabajo en curso previo sin commitear (server.py, stage.js,
panel.js/css, ayuda.json, hydra-sketches.json, problemas.md,
start-projectm.sh), subido junto a peticion del autor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 00:19:30 +02:00

396 lines
14 KiB
JavaScript

/*
* FOSFENO :: Mapper (projection mapping en el navegador)
* --------------------------------------------------------------------------
* Coge el canvas del motor activo (butterchurn/hydra/shaders/mixer) como
* TEXTURA y lo pinta deformado sobre una o varias SUPERFICIES (cuadriláteros)
* en un canvas WebGL a pantalla completa (#output). Cada superficie se ajusta
* con el ratón: crear, arrastrar esquinas, mover, borrar. La geometría se
* guarda en el servidor (data/mapping.json) y se sincroniza por WebSocket.
*
* Deformación: homografía "cuadrado unidad -> cuadrilátero" (Heckbert),
* evaluada por vértice sobre una malla subdividida => keystone/perspectiva
* correctos. La base ya sirve para crecer a malla curva y máscaras.
*
* API global (la usa stage.js):
* FosMapper.init({ onChange }) arranca (crea contextos y el bucle)
* FosMapper.setSource(canvas) canvas del motor activo (o null)
* FosMapper.setMapping(m) { enabled, edit, surfaces:[{id,corners}] }
* onChange(surfaces) se llama al editar (arrastrar) para guardar
*
* corners = [ [x,y] x4 ] en 0..1 (TL, TR, BR, BL), origen arriba-izquierda.
*/
(function () {
'use strict';
const GRID = 12; // subdivisiones de la malla (perspectiva suave)
const HANDLE = 11; // radio del tirador de esquina (px CSS)
const M = { enabled: false, edit: false, surfaces: [] };
let source = null; // canvas del motor activo
let onChange = null;
let outputEl, editEl, gl, ectx;
let prog, aUV, uH, uTex, tex, quadBuf, idxBuf, idxCount;
let dpr = 1;
// interacción
let selected = null; // id de superficie seleccionada
let drag = null; // { id, corner|null, startX, startY, orig }
let dragged = false; // hubo movimiento real (para no emitir de más)
// ------------------------------------------------------------------ utils
const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
function defaultSurface(cx, cy, s) {
// cuadrilátero centrado en (cx,cy) de semilado s (en 0..1)
cx = cx == null ? 0.5 : cx;
cy = cy == null ? 0.5 : cy;
s = s == null ? 0.28 : s;
return {
id: 's' + Math.floor(performance.now()) + '_' + Math.floor(Math.random() * 1e4),
corners: [
[clamp01(cx - s), clamp01(cy - s)],
[clamp01(cx + s), clamp01(cy - s)],
[clamp01(cx + s), clamp01(cy + s)],
[clamp01(cx - s), clamp01(cy + s)],
],
};
}
// Homografía cuadrado unidad -> cuadrilátero (corners TL,TR,BR,BL, y hacia
// abajo). Devuelve mat3 en orden por columnas para WebGL. (Heckbert 1989)
function squareToQuad(c) {
const x0 = c[0][0], y0 = c[0][1];
const x1 = c[1][0], y1 = c[1][1];
const x2 = c[2][0], y2 = c[2][1];
const 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;
}
// columnas: col0=(a,d,g) col1=(b,e,h) col2=(cc,f,1)
return new Float32Array([a, d, g, b, e, h, cc, f, 1]);
}
// ------------------------------------------------------------------ WebGL
function initGL() {
gl = outputEl.getContext('webgl', { alpha: false, antialias: true, premultipliedAlpha: false })
|| outputEl.getContext('experimental-webgl');
if (!gl) { console.error('[FOSFENO] mapper: sin WebGL'); return false; }
const vs = `
attribute vec2 aUV;
uniform mat3 uH;
varying vec2 vUV;
void main() {
vec3 p = uH * vec3(aUV, 1.0);
vec2 d = p.xy / p.z; // destino 0..1, y hacia abajo
gl_Position = vec4(d.x * 2.0 - 1.0, 1.0 - d.y * 2.0, 0.0, 1.0);
vUV = aUV;
}`;
const fs = `
precision mediump float;
varying vec2 vUV;
uniform sampler2D uTex;
void main() { gl_FragColor = texture2D(uTex, vUV); }`;
prog = link(vs, fs);
if (!prog) return false;
aUV = gl.getAttribLocation(prog, 'aUV');
uH = gl.getUniformLocation(prog, 'uH');
uTex = gl.getUniformLocation(prog, 'uTex');
// malla GRIDxGRID de vértices con uv en 0..1
const verts = [];
for (let j = 0; j <= GRID; j++)
for (let i = 0; i <= GRID; i++)
verts.push(i / GRID, j / GRID);
const idx = [];
const row = GRID + 1;
for (let j = 0; j < GRID; j++)
for (let i = 0; i < GRID; i++) {
const p = j * row + i;
idx.push(p, p + 1, p + row, p + 1, p + row + 1, p + row);
}
idxCount = idx.length;
quadBuf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, quadBuf);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(verts), gl.STATIC_DRAW);
idxBuf = gl.createBuffer();
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, idxBuf);
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 link(vsSrc, fsSrc) {
const c = (t, s) => {
const sh = gl.createShader(t);
gl.shaderSource(sh, s); gl.compileShader(sh);
if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS))
console.error('[FOSFENO] mapper shader:', gl.getShaderInfoLog(sh));
return sh;
};
const p = gl.createProgram();
gl.attachShader(p, c(gl.VERTEX_SHADER, vsSrc));
gl.attachShader(p, c(gl.FRAGMENT_SHADER, fsSrc));
gl.linkProgram(p);
if (!gl.getProgramParameter(p, gl.LINK_STATUS)) {
console.error('[FOSFENO] mapper link:', gl.getProgramInfoLog(p));
return null;
}
return p;
}
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;
// sube el frame actual del motor a la textura
gl.bindTexture(gl.TEXTURE_2D, tex);
try {
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source);
} catch (e) { return; }
gl.useProgram(prog);
gl.bindBuffer(gl.ARRAY_BUFFER, quadBuf);
gl.enableVertexAttribArray(aUV);
gl.vertexAttribPointer(aUV, 2, gl.FLOAT, false, 0, 0);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, idxBuf);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.uniform1i(uTex, 0);
const surfs = M.surfaces.length
? M.surfaces
: [{ corners: [[0, 0], [1, 0], [1, 1], [0, 1]] }]; // sin superficies: pasada directa
for (const s of surfs) {
gl.uniformMatrix3fv(uH, false, squareToQuad(s.corners));
gl.drawElements(gl.TRIANGLES, idxCount, gl.UNSIGNED_SHORT, 0);
}
}
// ------------------------------------------------------------------ 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';
M.surfaces.forEach((s, i) => {
const pts = s.corners.map(([x, y]) => [x * w, y * h]);
const sel = s.id === selected;
// relleno + contorno
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 = sel ? 'rgba(0,229,255,0.12)' : 'rgba(180,255,0,0.06)';
ectx.fill();
ectx.lineWidth = sel ? 3 : 2;
ectx.strokeStyle = sel ? '#00e5ff' : '#b4ff00';
ectx.stroke();
// número de superficie en el centro
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 = sel ? '#00e5ff' : 'rgba(180,255,0,0.85)';
ectx.fillText(String(i + 1), cx, cy);
// tiradores de esquina
pts.forEach(([px, py], ci) => {
ectx.beginPath();
ectx.arc(px, py, HANDLE, 0, Math.PI * 2);
ectx.fillStyle = sel ? '#00e5ff' : '#ffffff';
ectx.fill();
ectx.lineWidth = 2;
ectx.strokeStyle = '#000';
ectx.stroke();
void ci;
});
});
// ayuda
ectx.textAlign = 'left';
ectx.fillStyle = 'rgba(255,255,255,0.6)';
ectx.font = '13px system-ui, sans-serif';
ectx.fillText('MAPPING · arrastra esquinas · mueve dentro · doble clic: nueva · Supr: borrar', 16, h - 18);
}
function pick(mx, my) {
const w = window.innerWidth, h = window.innerHeight;
// primero esquinas (de la más reciente/encima hacia atrás)
for (let i = M.surfaces.length - 1; i >= 0; i--) {
const s = M.surfaces[i];
for (let c = 0; c < 4; c++) {
const px = s.corners[c][0] * w, py = s.corners[c][1] * h;
if (Math.hypot(mx - px, my - py) <= HANDLE + 6)
return { id: s.id, corner: c };
}
}
// luego interior de una superficie
for (let i = M.surfaces.length - 1; i >= 0; i--) {
const s = M.surfaces[i];
if (pointInQuad(mx / w, my / h, s.corners)) return { id: s.id, corner: null };
}
return null;
}
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;
}
const surfaceById = (id) => M.surfaces.find((s) => s.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 = hit.id;
const s = surfaceById(hit.id);
drag = {
id: hit.id, corner: hit.corner,
startX: mx, startY: my,
orig: s.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.startX) / w;
const dy = (ev.clientY - drag.startY) / h;
if (Math.abs(ev.clientX - drag.startX) + Math.abs(ev.clientY - drag.startY) > 1) dragged = true;
const s = surfaceById(drag.id);
if (!s) return;
if (drag.corner == null) {
for (let c = 0; c < 4; c++) {
s.corners[c][0] = clamp01(drag.orig[c][0] + dx);
s.corners[c][1] = clamp01(drag.orig[c][1] + dy);
}
} else {
s.corners[drag.corner][0] = clamp01(drag.orig[drag.corner][0] + dx);
s.corners[drag.corner][1] = clamp01(drag.orig[drag.corner][1] + dy);
}
scheduleEmit();
}
function onUp() {
if (drag && dragged) emitNow();
drag = null;
}
function onDblClick(ev) {
const s = defaultSurface(ev.clientX / window.innerWidth, ev.clientY / window.innerHeight, 0.2);
M.surfaces.push(s);
selected = s.id;
emitNow();
}
function onKey(ev) {
if (!M.edit) return;
if ((ev.key === 'Delete' || ev.key === 'Backspace') && selected) {
M.surfaces = M.surfaces.filter((s) => s.id !== selected);
selected = null;
emitNow();
ev.preventDefault();
}
}
// emisión (throttle durante el arrastre, inmediata al soltar)
let emitTimer = null;
function scheduleEmit() {
if (emitTimer) return;
emitTimer = setTimeout(() => { emitTimer = null; emitNow(); }, 60);
}
function emitNow() {
if (emitTimer) { clearTimeout(emitTimer); emitTimer = null; }
if (onChange) onChange(M.surfaces.map((s) => ({ id: s.id, corners: s.corners })));
}
// ------------------------------------------------------------------ tamaños
function fit(el, wantGL) {
const w = Math.round(window.innerWidth * dpr);
const h = Math.round(window.innerHeight * dpr);
if (el.width !== w || el.height !== h) { el.width = w; el.height = h; }
void wantGL;
}
// ------------------------------------------------------------------ bucle
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, true);
fit(editEl, false);
renderGL();
if (editing) drawEditor();
}
// ------------------------------------------------------------------ API
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;
// no piso la geometría mientras se está arrastrando en esta pantalla
if (!drag && Array.isArray(m.surfaces)) {
M.surfaces = m.surfaces.map((s) => ({
id: s.id, corners: s.corners.map((p) => [p[0], p[1]]),
}));
if (selected && !surfaceById(selected)) selected = null;
}
},
getMapping() { return M; },
};
window.FosMapper = API;
})();