diff --git a/backend/server.py b/backend/server.py index 95a446f..d46667c 100644 --- a/backend/server.py +++ b/backend/server.py @@ -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() diff --git a/web/panel/index.html b/web/panel/index.html index 45bfe3f..76ce776 100644 --- a/web/panel/index.html +++ b/web/panel/index.html @@ -261,18 +261,24 @@ Modo edicion (arrastrar en el escenario)
- + +
- Previsualizacion (arrastra las esquinas) + Previsualizacion (arrastra los puntos) +
-

1) Elige un motor que no sea projectM. 2) Pulsa - Anadir superficie. 3) Arrastra las esquinas 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.

+

Arrastra los puntos para deformar. Selecciona una + superficie y usa + malla para subdividir y curvarla. + + Mascara tapa zonas con un recuadro negro (recorta derrames + de luz). Se guarda solo.

diff --git a/web/panel/panel.js b/web/panel/panel.js index 8fa250d..58497af 100644 --- a/web/panel/panel.js +++ b/web/panel/panel.js @@ -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 () { diff --git a/web/stage/mapper.js b/web/stage/mapper.js index 8be5942..4457c7f 100644 --- a/web/stage/mapper.js +++ b/web/stage/mapper.js @@ -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; },