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>
This commit is contained in:
parent
0b1546cecc
commit
8dd58bb293
12 changed files with 1815 additions and 85 deletions
396
web/stage/mapper.js
Normal file
396
web/stage/mapper.js
Normal file
|
|
@ -0,0 +1,396 @@
|
|||
/*
|
||||
* 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;
|
||||
})();
|
||||
Loading…
Add table
Add a link
Reference in a new issue