Mapping Fase 2: malla deformable (superficies curvas) + mascaras

- Superficies con rejilla n x n (hasta 6x6): arrastra cualquier punto para
  curvar, no solo las esquinas. Render perspectiva-correcto por celda
  (homografia cuadrado->celda).
- Mascaras: cuadrilateros negros que tapan zonas / recortan derrames de luz.
- Editable desde la previsualizacion del panel (raton/tactil) y el escenario:
  arrastrar puntos, +/- malla para subdividir, borrar por lista.
- set_mapping acepta "masks"; persiste en data/mapping.json (ignorado en git).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hacklab 2026-07-27 00:40:43 +02:00
parent 8dd58bb293
commit 7c0be6b666
4 changed files with 460 additions and 334 deletions

View file

@ -153,7 +153,8 @@ state = {
# surfaces = [{id, corners:[[x,y]x4]}] normalizado 0..1. Persiste en
# data/mapping.json.
"mapping": load_json(DATA / "mapping.json",
{"enabled": False, "edit": False, "surfaces": []}),
{"enabled": False, "edit": False,
"surfaces": [], "masks": []}),
"meta": {
"butterchurnPresets": [],
"audioDevices": [],
@ -639,6 +640,9 @@ def on_set_mapping(data):
m["edit"] = bool(data["edit"])
if "surfaces" in data and isinstance(data["surfaces"], list):
m["surfaces"] = data["surfaces"]
if "masks" in data and isinstance(data["masks"], list):
m["masks"] = data["masks"]
m.setdefault("masks", [])
save_mapping()
broadcast()

View file

@ -261,18 +261,24 @@
<input type="checkbox" id="map-edit"> Modo edicion (arrastrar en el escenario)
</label>
<div class="row">
<button id="map-add" class="cmd accent">+ Anadir superficie</button>
<button id="map-add" class="cmd accent">+ Superficie</button>
<button id="map-add-mask" class="cmd">+ Mascara</button>
<button id="map-reset" class="cmd">Reset</button>
</div>
<span class="label">Previsualizacion (arrastra las esquinas)</span>
<span class="label">Previsualizacion (arrastra los puntos)</span>
<canvas id="map-preview"
style="width:100%;aspect-ratio:16/9;background:#0a0a0a;border:1px solid #333;border-radius:6px;display:block;margin:6px 0;touch-action:none;cursor:crosshair;"></canvas>
<div class="row" id="map-selbar" hidden>
<span class="label" id="map-selinfo">Superficie seleccionada</span>
<button id="map-sub-dec" class="cmd">&minus; malla</button>
<span id="map-sub-val" class="value">1x1</span>
<button id="map-sub-inc" class="cmd">+ malla</button>
</div>
<div id="map-list"></div>
<p class="hint">1) Elige un motor que no sea projectM. 2) Pulsa
<b>Anadir superficie</b>. 3) <b>Arrastra las esquinas</b> aqui en la
previsualizacion (raton o dedo) para encajar la forma; se ve en el
proyector al instante. Cada superficie mapea la misma imagen. Se
guarda solo.</p>
<p class="hint">Arrastra los puntos para deformar. Selecciona una
superficie y usa <b>+ malla</b> para subdividir y <b>curvarla</b>.
<b>+ Mascara</b> tapa zonas con un recuadro negro (recorta derrames
de luz). Se guarda solo.</p>
</section>
</div>

View file

@ -417,25 +417,58 @@ function renderProjectM() {
}
// ==========================================================================
// Projection mapping: previsualizacion arrastrable + lista
// Projection mapping (Fase 2): previsualizacion con malla + mascaras
// ==========================================================================
let mapSurfaces = []; // copia local (para editar sin parpadeos)
let mapSel = null; // indice de superficie seleccionada
let mapDrag = null; // { idx, corner|null, sx, sy, orig }
let mapSurfaces = []; // superficies (malla) — copia local para editar
let mapMasks = []; // mascaras (cuadrilateros negros)
let mapSel = null; // { type:'surface'|'mask', id }
let mapDrag = null; // { type, id, pt|null, sx, sy, orig }
let mapEmitTimer = null;
const MAP_H = 15; // radio del tirador (px)
const MAP_H = 13; // radio del tirador (px)
const MAP_MAXN = 6;
const sidx = (c, r, n) => r * (n + 1) + c;
const cl01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
const cloneSurfaces = (arr) =>
(arr || []).map((s) => ({ id: s.id, corners: s.corners.map((p) => [p[0], p[1]]) }));
// interpolacion bilineal dentro del cuadrilatero (pts = TL,TR,BR,BL)
function bilerp(pts, u, v) {
const tx = pts[0][0] + (pts[1][0] - pts[0][0]) * u;
const ty = pts[0][1] + (pts[1][1] - pts[0][1]) * u;
const bx = pts[3][0] + (pts[2][0] - pts[3][0]) * u;
const by = pts[3][1] + (pts[2][1] - pts[3][1]) * u;
return [tx + (bx - tx) * v, ty + (by - ty) * v];
function mapMigrate(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)) {
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
}
return { id: s.id, n: 1, points: [[0.3, 0.3], [0.7, 0.3], [0.3, 0.7], [0.7, 0.7]] };
}
function mapSample(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 mapResample(s, newN) {
const pts = [];
for (let r = 0; r <= newN; r++)
for (let c = 0; c <= newN; c++) pts.push(mapSample(s, c / newN, r / newN));
return { id: s.id, n: newN, points: pts };
}
function mapPointInQuad(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;
}
function mapOutline(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)]];
}
const mapSelSurf = () => (mapSel && mapSel.type === "surface") ? mapSurfaces.find((s) => s.id === mapSel.id) : null;
function drawMapPreview() {
const cv = $("#map-preview");
@ -451,47 +484,46 @@ function drawMapPreview() {
ctx.strokeStyle = "#333"; ctx.lineWidth = 1; ctx.strokeRect(0.5, 0.5, w - 1, h - 1);
ctx.font = "600 13px system-ui, sans-serif";
ctx.textAlign = "center"; ctx.textBaseline = "middle";
if (!mapSurfaces.length) {
if (!mapSurfaces.length && !mapMasks.length) {
ctx.fillStyle = "rgba(255,255,255,0.4)";
ctx.fillText("Pulsa '+ Anadir superficie'", w / 2, h / 2);
ctx.fillText("Pulsa '+ Superficie'", w / 2, h / 2);
return;
}
// superficies (malla n x n)
mapSurfaces.forEach((s, i) => {
const pts = s.corners.map(([x, y]) => [x * w, y * h]);
const sel = i === mapSel;
// rejilla interna: hace visible la deformacion
ctx.strokeStyle = sel ? "rgba(0,229,255,0.4)" : "rgba(180,255,0,0.22)";
const n = s.n;
const sel = mapSel && mapSel.type === "surface" && mapSel.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]; };
ctx.strokeStyle = sel ? "rgba(0,229,255,0.45)" : "rgba(180,255,0,0.3)";
ctx.lineWidth = 1;
const N = 4;
for (let g = 0; g <= N; g++) {
const t = g / N;
ctx.beginPath();
for (let k = 0; k <= N; k++) { const p = bilerp(pts, k / N, t); k ? ctx.lineTo(p[0], p[1]) : ctx.moveTo(p[0], p[1]); }
ctx.stroke();
ctx.beginPath();
for (let k = 0; k <= N; k++) { const p = bilerp(pts, t, k / N); k ? ctx.lineTo(p[0], p[1]) : ctx.moveTo(p[0], p[1]); }
ctx.stroke();
for (let r = 0; r <= n; r++) { ctx.beginPath(); for (let c = 0; c <= n; c++) { const p = P(c, r); c ? ctx.lineTo(p[0], p[1]) : ctx.moveTo(p[0], p[1]); } ctx.stroke(); }
for (let c = 0; c <= n; c++) { ctx.beginPath(); for (let r = 0; r <= n; r++) { const p = P(c, r); r ? ctx.lineTo(p[0], p[1]) : ctx.moveTo(p[0], p[1]); } ctx.stroke(); }
const cc = P((n / 2) | 0, (n / 2) | 0);
ctx.fillStyle = col; ctx.fillText(String(i + 1), cc[0], cc[1]);
for (let r = 0; r <= n; r++) for (let c = 0; c <= n; c++) {
const p = P(c, r);
ctx.beginPath(); ctx.arc(p[0], p[1], MAP_H, 0, Math.PI * 2);
ctx.fillStyle = sel ? "#00e5ff" : "#fff"; ctx.fill();
ctx.lineWidth = 2; ctx.strokeStyle = "#000"; ctx.stroke();
}
// contorno + relleno
});
// mascaras (rojo)
mapMasks.forEach((m, i) => {
const sel = mapSel && mapSel.type === "mask" && mapSel.id === m.id;
const pts = m.corners.map(([x, y]) => [x * w, y * h]);
ctx.beginPath(); ctx.moveTo(pts[0][0], pts[0][1]);
for (let k = 1; k < 4; k++) ctx.lineTo(pts[k][0], pts[k][1]);
ctx.closePath();
ctx.fillStyle = sel ? "rgba(0,229,255,0.08)" : "rgba(180,255,0,0.05)";
ctx.fill();
ctx.lineWidth = sel ? 2.5 : 1.5;
ctx.strokeStyle = sel ? "#00e5ff" : "#b4ff00";
ctx.stroke();
// numero
ctx.fillStyle = "rgba(255,60,60,0.3)"; ctx.fill();
ctx.lineWidth = sel ? 3 : 2; ctx.strokeStyle = sel ? "#ff2d2d" : "#ff6b6b";
ctx.setLineDash([6, 4]); ctx.stroke(); ctx.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;
ctx.fillStyle = sel ? "#00e5ff" : "rgba(180,255,0,0.9)";
ctx.fillText(String(i + 1), cx, cy);
// tiradores
ctx.fillStyle = "#ff6b6b"; ctx.fillText("M" + (i + 1), cx, cy);
pts.forEach(([px, py]) => {
ctx.beginPath(); ctx.arc(px, py, MAP_H * 0.7, 0, Math.PI * 2);
ctx.fillStyle = sel ? "#00e5ff" : "#fff"; ctx.fill();
ctx.beginPath(); ctx.arc(px, py, MAP_H, 0, Math.PI * 2);
ctx.fillStyle = "#ff6b6b"; ctx.fill();
ctx.lineWidth = 2; ctx.strokeStyle = "#000"; ctx.stroke();
});
});
@ -501,51 +533,54 @@ function mapLocalPt(ev) {
const r = $("#map-preview").getBoundingClientRect();
return { x: (ev.clientX - r.left) / r.width, y: (ev.clientY - r.top) / r.height, w: r.width, h: r.height };
}
function mapPointInQuad(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;
function mapPick(p) {
const rx = (MAP_H + 8) / p.w, ry = (MAP_H + 8) / p.h;
for (let i = mapSurfaces.length - 1; i >= 0; i--) {
const s = mapSurfaces[i];
for (let k = 0; k < s.points.length; k++) {
const dx = (s.points[k][0] - p.x) / rx, dy = (s.points[k][1] - p.y) / ry;
if (dx * dx + dy * dy <= 1) return { type: "surface", id: s.id, pt: k };
}
}
return inside;
for (let i = mapMasks.length - 1; i >= 0; i--) {
const c = mapMasks[i].corners;
for (let k = 0; k < 4; k++) {
const dx = (c[k][0] - p.x) / rx, dy = (c[k][1] - p.y) / ry;
if (dx * dx + dy * dy <= 1) return { type: "mask", id: mapMasks[i].id, pt: k };
}
}
for (let i = mapMasks.length - 1; i >= 0; i--)
if (mapPointInQuad(p.x, p.y, mapMasks[i].corners)) return { type: "mask", id: mapMasks[i].id, pt: null };
for (let i = mapSurfaces.length - 1; i >= 0; i--)
if (mapPointInQuad(p.x, p.y, mapOutline(mapSurfaces[i]))) return { type: "surface", id: mapSurfaces[i].id, pt: null };
return null;
}
function onMapDown(ev) {
ev.preventDefault();
const p = mapLocalPt(ev);
const rx = (MAP_H + 8) / p.w, ry = (MAP_H + 8) / p.h;
for (let i = mapSurfaces.length - 1; i >= 0; i--) {
const c = mapSurfaces[i].corners;
for (let k = 0; k < 4; k++) {
const dx = (c[k][0] - p.x) / rx, dy = (c[k][1] - p.y) / ry;
if (dx * dx + dy * dy <= 1) {
mapSel = i; mapDrag = { idx: i, corner: k, sx: p.x, sy: p.y, orig: c.map((q) => q.slice()) };
drawMapPreview(); return;
}
}
}
for (let i = mapSurfaces.length - 1; i >= 0; i--) {
if (mapPointInQuad(p.x, p.y, mapSurfaces[i].corners)) {
mapSel = i; mapDrag = { idx: i, corner: null, sx: p.x, sy: p.y, orig: mapSurfaces[i].corners.map((q) => q.slice()) };
drawMapPreview(); return;
}
}
mapSel = null; mapDrag = null; drawMapPreview();
const hit = mapPick(p);
if (!hit) { mapSel = null; mapDrag = null; drawMapPreview(); updateMapSelBar(); return; }
mapSel = { type: hit.type, id: hit.id };
const arr = hit.type === "surface"
? mapSurfaces.find((s) => s.id === hit.id).points
: mapMasks.find((m) => m.id === hit.id).corners;
mapDrag = { type: hit.type, id: hit.id, pt: hit.pt, sx: p.x, sy: p.y, orig: arr.map((q) => q.slice()) };
drawMapPreview(); updateMapSelBar();
}
function onMapMove(ev) {
if (!mapDrag) return;
ev.preventDefault();
const p = mapLocalPt(ev);
const dx = p.x - mapDrag.sx, dy = p.y - mapDrag.sy;
const s = mapSurfaces[mapDrag.idx]; if (!s) return;
const cl = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
if (mapDrag.corner == null) {
for (let k = 0; k < 4; k++) {
s.corners[k][0] = cl(mapDrag.orig[k][0] + dx);
s.corners[k][1] = cl(mapDrag.orig[k][1] + dy);
}
const arr = mapDrag.type === "surface"
? (mapSurfaces.find((s) => s.id === mapDrag.id) || {}).points
: (mapMasks.find((m) => m.id === mapDrag.id) || {}).corners;
if (!arr) return;
if (mapDrag.pt == null) {
for (let k = 0; k < arr.length; k++) { arr[k][0] = cl01(mapDrag.orig[k][0] + dx); arr[k][1] = cl01(mapDrag.orig[k][1] + dy); }
} else {
s.corners[mapDrag.corner][0] = cl(mapDrag.orig[mapDrag.corner][0] + dx);
s.corners[mapDrag.corner][1] = cl(mapDrag.orig[mapDrag.corner][1] + dy);
arr[mapDrag.pt][0] = cl01(mapDrag.orig[mapDrag.pt][0] + dx);
arr[mapDrag.pt][1] = cl01(mapDrag.orig[mapDrag.pt][1] + dy);
}
drawMapPreview(); scheduleMapEmit();
}
@ -553,49 +588,54 @@ function onMapUp() { if (mapDrag) { emitMapNow(); mapDrag = null; } }
function scheduleMapEmit() { if (!mapEmitTimer) mapEmitTimer = setTimeout(() => { mapEmitTimer = null; emitMapNow(); }, 60); }
function emitMapNow() {
if (mapEmitTimer) { clearTimeout(mapEmitTimer); mapEmitTimer = null; }
socket.emit("set_mapping", { surfaces: mapSurfaces });
socket.emit("set_mapping", { surfaces: mapSurfaces, masks: mapMasks });
}
// refleja estado, dibuja la previsualizacion y la lista de superficies
function updateMapSelBar() {
const bar = $("#map-selbar");
const s = mapSelSurf();
if (!s) { bar.hidden = true; return; }
bar.hidden = false;
$("#map-sub-val").textContent = s.n + "x" + s.n;
}
// refleja estado, dibuja previsualizacion y listas (superficies + mascaras)
function renderMapping() {
const m = state.mapping || { enabled: false, edit: false, surfaces: [] };
const m = state.mapping || { enabled: false, edit: false, surfaces: [], masks: [] };
const en = $("#map-enabled"), ed = $("#map-edit");
if (document.activeElement !== en) en.checked = !!m.enabled;
if (document.activeElement !== ed) ed.checked = !!m.edit;
ed.disabled = !m.enabled;
const surfaces = m.surfaces || [];
const surfaces = m.surfaces || [], masks = m.masks || [];
// previsualizacion (no piso la geometria mientras arrastro aqui)
if (!mapDrag) mapSurfaces = cloneSurfaces(surfaces);
if (mapSel != null && mapSel >= mapSurfaces.length) mapSel = null;
if (!mapDrag) {
mapSurfaces = surfaces.map(mapMigrate);
mapMasks = masks.map((k) => ({ id: k.id, corners: k.corners.map((p) => [p[0], p[1]]) }));
if (mapSel) {
const ok = mapSel.type === "surface" ? mapSurfaces.some((s) => s.id === mapSel.id) : mapMasks.some((k) => k.id === mapSel.id);
if (!ok) mapSel = null;
}
}
drawMapPreview();
updateMapSelBar();
// lista con boton de borrar (se reconstruye solo si cambia)
// lista (superficies + mascaras) con borrar
const list = $("#map-list");
const sig = surfaces.map((s) => s.id).join("|") + "#" + surfaces.length;
const sig = surfaces.map((s) => s.id + ":" + (s.n || 1)).join("|") + "##" + masks.map((k) => k.id).join("|");
if (list.dataset.sig === sig) return;
list.dataset.sig = sig;
list.innerHTML = "";
if (!surfaces.length) return;
surfaces.forEach((s, i) => {
const row = document.createElement("div");
row.className = "row";
const lbl = document.createElement("span");
lbl.className = "label";
lbl.textContent = "Superficie " + (i + 1);
const del = document.createElement("button");
del.className = "cmd favdel";
del.textContent = "✕";
del.title = "Borrar superficie";
del.addEventListener("click", () => {
const rest = ((state.mapping && state.mapping.surfaces) || [])
.filter((x) => x.id !== s.id);
socket.emit("set_mapping", { surfaces: rest });
});
row.appendChild(lbl);
row.appendChild(del);
list.appendChild(row);
});
const addRow = (label, onDel) => {
const row = document.createElement("div"); row.className = "row";
const lbl = document.createElement("span"); lbl.className = "label"; lbl.textContent = label;
const del = document.createElement("button"); del.className = "cmd favdel"; del.textContent = "✕";
del.addEventListener("click", onDel);
row.appendChild(lbl); row.appendChild(del); list.appendChild(row);
};
surfaces.forEach((s, i) => addRow("Superficie " + (i + 1) + " (" + (s.n || 1) + "x" + (s.n || 1) + ")",
() => socket.emit("set_mapping", { surfaces: surfaces.filter((x) => x.id !== s.id) })));
masks.forEach((k, i) => addRow("Mascara " + (i + 1),
() => socket.emit("set_mapping", { masks: masks.filter((x) => x.id !== k.id) })));
}
// ==========================================================================
@ -613,15 +653,33 @@ $("#map-add").addEventListener("click", () => {
const cur = (state.mapping && state.mapping.surfaces) || [];
const s = 0.2, cx = 0.5, cy = 0.5;
const nueva = {
id: "s" + Date.now() + "_" + Math.floor(Math.random() * 1e4),
corners: [[cx - s, cy - s], [cx + s, cy - s], [cx + s, cy + s], [cx - s, cy + s]],
id: "s" + Date.now() + "_" + Math.floor(Math.random() * 1e4), n: 1,
points: [[cx - s, cy - s], [cx + s, cy - s], [cx - s, cy + s], [cx + s, cy + s]], // TL,TR,BL,BR
};
socket.emit("set_mapping",
{ enabled: true, edit: true, surfaces: cur.concat([nueva]) });
socket.emit("set_mapping", { enabled: true, edit: true, surfaces: cur.concat([nueva]) });
});
$("#map-add-mask").addEventListener("click", () => {
const cur = (state.mapping && state.mapping.masks) || [];
const s = 0.15, cx = 0.5, cy = 0.5;
const nueva = {
id: "m" + Date.now() + "_" + Math.floor(Math.random() * 1e4),
corners: [[cx - s, cy - s], [cx + s, cy - s], [cx + s, cy + s], [cx - s, cy + s]], // TL,TR,BR,BL
};
socket.emit("set_mapping", { enabled: true, edit: true, masks: cur.concat([nueva]) });
});
$("#map-reset").addEventListener("click", () => {
if (confirm("Borrar todas las superficies de mapping?"))
socket.emit("set_mapping", { surfaces: [] });
if (confirm("Borrar todas las superficies y mascaras de mapping?"))
socket.emit("set_mapping", { surfaces: [], masks: [] });
});
$("#map-sub-inc").addEventListener("click", () => {
const s = mapSelSurf(); if (!s || s.n >= MAP_MAXN) return;
const ns = mapResample(s, s.n + 1);
socket.emit("set_mapping", { surfaces: mapSurfaces.map((x) => x.id === s.id ? ns : x) });
});
$("#map-sub-dec").addEventListener("click", () => {
const s = mapSelSurf(); if (!s || s.n <= 1) return;
const ns = mapResample(s, s.n - 1);
socket.emit("set_mapping", { surfaces: mapSurfaces.map((x) => x.id === s.id ? ns : x) });
});
// Previsualizacion arrastrable (raton + tactil via pointer events)
(function () {

View file

@ -1,76 +1,91 @@
/*
* FOSFENO :: Mapper (projection mapping en el navegador)
* FOSFENO :: Mapper (projection mapping en el navegador) Fase 2
* --------------------------------------------------------------------------
* 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.
* 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).
*
* 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.
* 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:[...] }
*
* 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
* Compatibilidad: superficies antiguas { corners:[...] } se migran a n=1.
*
* corners = [ [x,y] x4 ] en 0..1 (TL, TR, BR, BL), origen arriba-izquierda.
* 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 GRID = 12; // subdivisiones de la malla (perspectiva suave)
const HANDLE = 11; // radio del tirador de esquina (px CSS)
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: [] };
let source = null; // canvas del motor activo
const M = { enabled: false, edit: false, surfaces: [], masks: [] };
let source = null;
let onChange = null;
let outputEl, editEl, gl, ectx;
let prog, aUV, uH, uTex, tex, quadBuf, idxBuf, idxCount;
let dpr = 1;
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;
// 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)
let selected = null; // { type:'surface'|'mask', id }
let drag = null; // { type, id, pt|null, sx, sy, orig }
let dragged = false;
// ------------------------------------------------------------------ utils
const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
const sidx = (c, r, n) => r * (n + 1) + c;
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)],
],
};
// ------------------------------------------------------------------ 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]] };
}
// Homografía cuadrado unidad -> cuadrilátero (corners TL,TR,BR,BL, y hacia
// abajo). Devuelve mat3 en orden por columnas para WebGL. (Heckbert 1989)
// 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];
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 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;
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;
@ -78,57 +93,69 @@
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 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, premultipliedAlpha: false })
gl = outputEl.getContext('webgl', { alpha: false, antialias: true })
|| 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;
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');
// malla GRIDxGRID de vértices con uv en 0..1
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 <= 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++) {
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);
}
idxCount = idx.length;
quadBuf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, quadBuf);
gridCount = idx.length;
gridBuf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, gridBuf);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(verts), gl.STATIC_DRAW);
idxBuf = gl.createBuffer();
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, idxBuf);
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();
@ -141,23 +168,18 @@
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;
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);
}
}
return p;
}
function renderGL() {
@ -165,28 +187,40 @@
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; }
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, quadBuf);
gl.bindBuffer(gl.ARRAY_BUFFER, gridBuf);
gl.enableVertexAttribArray(aUV);
gl.vertexAttribPointer(aUV, 2, gl.FLOAT, false, 0, 0);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, idxBuf);
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));
}
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);
// 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);
}
}
}
@ -196,155 +230,180 @@
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';
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]);
// 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 = 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
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 = 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;
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();
});
});
// ayuda
ectx.textAlign = 'left';
ectx.fillStyle = 'rgba(255,255,255,0.6)';
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;
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;
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)]];
}
const surfaceById = (id) => M.surfaces.find((s) => s.id === id);
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 = 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()),
};
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.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);
}
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 {
s.corners[drag.corner][0] = clamp01(drag.orig[drag.corner][0] + dx);
s.corners[drag.corner][1] = clamp01(drag.orig[drag.corner][1] + dy);
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 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 = s.id;
emitNow();
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) {
M.surfaces = M.surfaces.filter((s) => s.id !== selected);
selected = null;
emitNow();
ev.preventDefault();
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();
}
}
// emisión (throttle durante el arrastre, inmediata al soltar)
let emitTimer = null;
function scheduleEmit() {
if (emitTimer) return;
emitTimer = setTimeout(() => { emitTimer = null; emitNow(); }, 60);
}
function scheduleEmit() { if (!emitTimer) 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 })));
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 })),
});
}
// ------------------------------------------------------------------ tamaños
function fit(el, wantGL) {
const w = Math.round(window.innerWidth * dpr);
const h = Math.round(window.innerHeight * dpr);
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; }
void wantGL;
}
// ------------------------------------------------------------------ bucle
function loop() {
requestAnimationFrame(loop);
dpr = Math.min(2, window.devicePixelRatio || 1);
@ -354,13 +413,11 @@
editEl.style.pointerEvents = editing ? 'auto' : 'none';
document.body.style.cursor = editing ? 'crosshair' : 'none';
if (!M.enabled) return;
fit(outputEl, true);
fit(editEl, false);
fit(outputEl); fit(editEl);
renderGL();
if (editing) drawEditor();
}
// ------------------------------------------------------------------ API
const API = {
init(opts) {
opts = opts || {};
@ -382,12 +439,13 @@
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;
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; },