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:
parent
8dd58bb293
commit
7c0be6b666
4 changed files with 460 additions and 334 deletions
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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">− 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>
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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; },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue