From 0daa89656f6440d04fa530f8a4a50a8b1af41389 Mon Sep 17 00:00:00 2001 From: luciano Date: Fri, 31 Jul 2026 16:22:43 +0200 Subject: [PATCH] Projection mapping: warped zones for images and the live visual New Mappatura tab + mapping.js engine: each zone is a quad warped with a projective homography (square->quad closed form, inverted per fragment) on a dedicated WebGL2 canvas above the visual, black outside. Sources: the live visual (with a source sub-rect for multi-panel LED walls) or an image file (corner-pin onto physical surfaces). Edit mode draws handles ON THE OUTPUT: corners (or whole quads) are dragged while looking at the real projection, edits flow back to the panel which persists them. Zone list with name, source portion, opacity and delete; included in recordings; re-sent when the output restarts. Co-Authored-By: Claude Fable 5 --- src/control.html | 22 ++++ src/control.js | 87 ++++++++++++++++ src/mapping.js | 260 +++++++++++++++++++++++++++++++++++++++++++++++ src/output.html | 4 + src/output.js | 35 +++++++ src/style.css | 38 +++++++ 6 files changed, 446 insertions(+) create mode 100644 src/mapping.js diff --git a/src/control.html b/src/control.html index b122c9f..d59ef39 100644 --- a/src/control.html +++ b/src/control.html @@ -18,6 +18,7 @@ + @@ -201,6 +202,27 @@ + +
+
+ +
+ + +
+
+
+ + + +
+
Ogni zona è un quadrilatero proiettato: con “Modifica zone” trascini i + 4 angoli (o l'intera zona) direttamente sull'output guardando la superficie reale. + Le zone Visual mostrano l'effetto live (anche solo una porzione, per wall multi-pannello); + le zone Immagine fanno corner-pin di un'immagine su un oggetto. Fuori dalle zone: nero.
+
+
+
diff --git a/src/control.js b/src/control.js index 7bee3b1..9b4edb0 100644 --- a/src/control.js +++ b/src/control.js @@ -1408,6 +1408,86 @@ $('#ticker-bold').addEventListener('change', (e) => send({ type: 'tickerWeight', $('#ticker-color').addEventListener('input', (e) => send({ type: 'tickerColor', value: e.target.value })); $('#ticker-fx').addEventListener('change', (e) => send({ type: 'tickerFx', value: e.target.value })); +// ---------------------------------------------------------------- mapping +// Projection mapping zones: the panel is the source of truth (persisted); +// corner drags on the output flow back via the 'mapZones' report. +let mapCfg = { on: false, zones: [] }; +try { mapCfg = Object.assign(mapCfg, JSON.parse(localStorage.getItem('mapcfg') || '{}')); } catch (e) {} +let mapNextId = mapCfg.zones.reduce((m2, z) => Math.max(m2, z.id || 0), 0) + 1; +function mapSave() { localStorage.setItem('mapcfg', JSON.stringify({ on: mapCfg.on, zones: mapCfg.zones })); } +function mapSendZones() { send({ type: 'mapZones', zones: mapCfg.zones }); } +function mapSendAll() { + mapSendZones(); + send({ type: 'mapOn', on: mapCfg.on }); + send({ type: 'mapEdit', on: $('#map-edit').checked }); +} +function mapAddZone(src) { + const n = mapCfg.zones.length; + const off = (n % 4) * 0.05; + mapCfg.zones.push({ id: mapNextId++, name: 'Zona ' + mapNextId, + src, corners: [[0.25+off, 0.25+off], [0.75+off, 0.25+off], [0.75+off, 0.75+off], [0.25+off, 0.75+off]], + srcRect: [0, 0, 1, 1], opacity: 1 }); + mapSave(); mapSendZones(); renderMapZones(); +} +function renderMapZones() { + const box = $('#map-zones'); + box.innerHTML = ''; + if (!mapCfg.zones.length) { + box.innerHTML = '
Nessuna zona: aggiungine una qui sotto.
'; + return; + } + mapCfg.zones.forEach((z, i) => { + const row = document.createElement('div'); + row.className = 'map-zone'; + const isImg = z.src && z.src.type === 'image'; + row.innerHTML = + '' + (isImg ? '🖼' : '🌀') + '' + + '' + + (isImg ? '' + + String(z.src.path || '').split(/[\\/]/).pop() + '' + : 'Porzione ' + + '%,' + + '% ' + + '%×' + + '%') + + '' + + ''; + row.querySelector('.mz-name').addEventListener('change', (e) => { + z.name = e.target.value; mapSave(); mapSendZones(); + }); + row.querySelectorAll('.mz-r').forEach(inp => inp.addEventListener('change', (e) => { + const k = parseInt(e.target.dataset.k, 10); + z.srcRect[k] = Math.max(0, Math.min(1, (parseInt(e.target.value, 10) || 0)/100)); + if (z.srcRect[2] <= 0) z.srcRect[2] = 0.01; + if (z.srcRect[3] <= 0) z.srcRect[3] = 0.01; + mapSave(); mapSendZones(); + })); + row.querySelector('.mz-op').addEventListener('input', (e) => { + z.opacity = parseFloat(e.target.value); mapSave(); mapSendZones(); + }); + row.querySelector('.mz-del').addEventListener('click', () => { + mapCfg.zones.splice(i, 1); mapSave(); mapSendZones(); renderMapZones(); + }); + box.appendChild(row); + }); +} +$('#map-on').checked = mapCfg.on; +$('#map-on').addEventListener('change', (e) => { + mapCfg.on = e.target.checked; mapSave(); send({ type: 'mapOn', on: mapCfg.on }); +}); +$('#map-edit').addEventListener('change', (e) => send({ type: 'mapEdit', on: e.target.checked })); +$('#map-add-vis').addEventListener('click', () => mapAddZone({ type: 'visual' })); +$('#map-add-img').addEventListener('click', () => $('#map-img-input').click()); +$('#map-img-input').addEventListener('change', (e) => { + const f = e.target.files[0]; + if (f) { + const p = djv.pathForFile(f); + if (p) mapAddZone({ type: 'image', path: p }); + } + e.target.value = ''; +}); +renderMapZones(); + // ---------------------------------------------------------------- displays async function refreshDisplays() { const list = await djv.listDisplays(); @@ -1729,11 +1809,18 @@ djv.onReport((m) => { } break; case 'glbClips': renderGlbClips(m.names); break; + case 'mapZones': + // corner edits made on the output: adopt and persist + mapCfg.zones = m.zones || []; + mapSave(); + renderMapZones(); + break; case 'devices': { // The output reports its devices once at startup: good moment to push // config the (possibly recreated) output window doesn't have yet. if (ledCfg.on) ledSend(); glbSend(); + mapSendAll(); const sel = $('#device-select'); const cur = sel.value; sel.innerHTML = ''; diff --git a/src/mapping.js b/src/mapping.js new file mode 100644 index 0000000..9711615 --- /dev/null +++ b/src/mapping.js @@ -0,0 +1,260 @@ +// Projection mapping: quads ("zone") warped with a projective homography, +// sourcing either the LIVE VISUAL (the main canvas, optionally a sub-region: +// multi-panel LED walls) or an IMAGE file (corner-pin onto physical objects). +// Runs on its own WebGL2 canvas above the visual; editing handles are drawn +// on the output itself so corners are dragged while looking at the real +// projection surface. +(function () { + +const QUAD_VS = `#version 300 es +in vec2 aPos; // zone-space position (0..1 output coords, y down) +out vec2 vZ; +void main(){ + vZ = aPos; + gl_Position = vec4(aPos.x*2.0-1.0, 1.0-aPos.y*2.0, 0.0, 1.0); +}`; +const QUAD_FS = `#version 300 es +precision highp float; +in vec2 vZ; out vec4 frag; +uniform mat3 uH; // output coords -> unit square of the zone +uniform sampler2D uTex; +uniform float uOpacity; +uniform vec4 uSrc; // source sub-rect (x,y,w,h) in texture uv +void main(){ + vec3 q = uH * vec3(vZ, 1.0); + vec2 uv = q.xy / q.z; + if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) discard; + frag = vec4(texture(uTex, uSrc.xy + uv*uSrc.zw).rgb, uOpacity); +}`; +const H_VS = `#version 300 es +in vec2 aPos; uniform float uPt; +void main(){ + gl_Position = vec4(aPos.x*2.0-1.0, 1.0-aPos.y*2.0, 0.0, 1.0); + gl_PointSize = uPt; +}`; +const H_FS = `#version 300 es +precision highp float; uniform vec4 uCol; out vec4 frag; +void main(){ frag = uCol; }`; + +// homography mapping the unit square onto quad c (TL,TR,BR,BL), column-major +function squareToQuad(c) { + const [p0, p1, p2, p3] = c; + const dx1 = p1[0]-p2[0], dx2 = p3[0]-p2[0], dx3 = p0[0]-p1[0]+p2[0]-p3[0]; + const dy1 = p1[1]-p2[1], dy2 = p3[1]-p2[1], dy3 = p0[1]-p1[1]+p2[1]-p3[1]; + const den = dx1*dy2 - dy1*dx2 || 1e-9; + const g = (dx3*dy2 - dy3*dx2)/den, h = (dx1*dy3 - dy1*dx3)/den; + return [p1[0]-p0[0]+g*p1[0], p1[1]-p0[1]+g*p1[1], g, + p3[0]-p0[0]+h*p3[0], p3[1]-p0[1]+h*p3[1], h, + p0[0], p0[1], 1]; +} +function inv3(m) { + const [a,b,c,d,e,f,g,h,i] = [m[0],m[3],m[6], m[1],m[4],m[7], m[2],m[5],m[8]]; + const A = e*i-f*h, B = c*h-b*i, C = b*f-c*e; + const det = a*A + d*B + g*C || 1e-12; + return [A/det, (f*g-d*i)/det, (d*h-e*g)/det, + B/det, (a*i-c*g)/det, (b*g-a*h)/det, + C/det, (c*d-a*f)/det, (a*e-b*d)/det]; +} + +class MappingSim { + constructor(canvas, mainCanvas) { + this.canvas = canvas; + this.main = mainCanvas; + const gl = canvas.getContext('webgl2', { antialias: true, alpha: false, preserveDrawingBuffer: true }); + if (!gl) throw new Error('WebGL2 non disponibile (mappatura)'); + this.gl = gl; + const compile = (t, src) => { + const sh = gl.createShader(t); + gl.shaderSource(sh, src); gl.compileShader(sh); + if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) + throw new Error('Mapping shader: ' + gl.getShaderInfoLog(sh)); + return sh; + }; + const prog = (v, f) => { + const p = gl.createProgram(); + gl.attachShader(p, compile(gl.VERTEX_SHADER, v)); + gl.attachShader(p, compile(gl.FRAGMENT_SHADER, f)); + gl.linkProgram(p); + if (!gl.getProgramParameter(p, gl.LINK_STATUS)) + throw new Error('Mapping link: ' + gl.getProgramInfoLog(p)); + return p; + }; + this.progQ = prog(QUAD_VS, QUAD_FS); + this.progH = prog(H_VS, H_FS); + this.uQ = { uH: gl.getUniformLocation(this.progQ, 'uH'), + uTex: gl.getUniformLocation(this.progQ, 'uTex'), + uOpacity: gl.getUniformLocation(this.progQ, 'uOpacity'), + uSrc: gl.getUniformLocation(this.progQ, 'uSrc') }; + this.uH = { uCol: gl.getUniformLocation(this.progH, 'uCol'), + uPt: gl.getUniformLocation(this.progH, 'uPt') }; + this.aQ = gl.getAttribLocation(this.progQ, 'aPos'); + this.aH = gl.getAttribLocation(this.progH, 'aPos'); + this.vbo = gl.createBuffer(); + + // live-visual texture, refreshed from the main canvas every frame + const mkTex = () => { + const t = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_2D, t); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + return t; + }; + this.visTex = mkTex(); + this._mkTex = mkTex; + this.imgTex = {}; // path -> {tex, ok} + this.zones = []; + this.editOn = false; + this.selected = -1; + this.onChange = null; // (zones) => {} after a drag edit + this._drag = null; + this._bindPointer(); + } + + setZones(zones) { + this.zones = (zones || []).map(z => ({ + id: z.id, name: z.name || 'Zona', + src: z.src || { type: 'visual' }, + corners: (z.corners || [[0.25,0.25],[0.75,0.25],[0.75,0.75],[0.25,0.75]]).map(c => c.slice()), + srcRect: z.srcRect || [0, 0, 1, 1], + opacity: z.opacity !== undefined ? z.opacity : 1 + })); + if (this.selected >= this.zones.length) this.selected = this.zones.length - 1; + // preload image textures + const gl = this.gl; + for (const z of this.zones) { + if (z.src.type !== 'image' || !z.src.url || this.imgTex[z.src.url]) continue; + const entry = this.imgTex[z.src.url] = { tex: this._mkTex(), ok: false }; + const img = new Image(); + img.onload = () => { + gl.bindTexture(gl.TEXTURE_2D, entry.tex); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img); + entry.ok = true; + }; + img.src = z.src.url; + } + } + + _bindPointer() { + const cv = this.canvas; + const pos = (e) => { + const r = cv.getBoundingClientRect(); + return [(e.clientX - r.left)/Math.max(1, r.width), (e.clientY - r.top)/Math.max(1, r.height)]; + }; + cv.addEventListener('mousedown', (e) => { + if (!this.editOn) return; + const [x, y] = pos(e); + const r = cv.getBoundingClientRect(); + const tol = 14/Math.max(1, r.width); + // nearest corner of any zone + let best = null, bd = tol; + this.zones.forEach((z, zi) => z.corners.forEach((c, ci) => { + const d = Math.hypot(c[0]-x, (c[1]-y)*(r.height/r.width)); + if (d < bd) { bd = d; best = { zi, ci }; } + })); + if (best) { this._drag = best; this.selected = best.zi; } + else { + // click inside a zone selects it and drags the whole quad + for (let zi = this.zones.length-1; zi >= 0; zi--) { + if (this._inside(this.zones[zi].corners, x, y)) { + this._drag = { zi, ci: -1, lx: x, ly: y }; + this.selected = zi; + break; + } + } + } + }); + window.addEventListener('mousemove', (e) => { + if (!this._drag) return; + const [x, y] = pos(e); + const z = this.zones[this._drag.zi]; + if (!z) { this._drag = null; return; } + if (this._drag.ci >= 0) { + z.corners[this._drag.ci] = [Math.min(1.2, Math.max(-0.2, x)), Math.min(1.2, Math.max(-0.2, y))]; + } else { + const dx = x - this._drag.lx, dy = y - this._drag.ly; + z.corners.forEach(c => { c[0] += dx; c[1] += dy; }); + this._drag.lx = x; this._drag.ly = y; + } + }); + window.addEventListener('mouseup', () => { + if (this._drag && this.onChange) this.onChange(this.zones); + this._drag = null; + }); + } + + _inside(c, x, y) { + let hit = false; + for (let i = 0, j = 3; i < 4; j = i++) { + if ((c[i][1] > y) !== (c[j][1] > y) && + x < (c[j][0]-c[i][0])*(y-c[i][1])/(c[j][1]-c[i][1]) + c[i][0]) hit = !hit; + } + return hit; + } + + render() { + const gl = this.gl, cv = this.canvas; + const w = this.main.width, h = this.main.height; + if (cv.width !== w || cv.height !== h) { cv.width = w; cv.height = h; } + gl.viewport(0, 0, w, h); + gl.clearColor(0, 0, 0, 1); + gl.clear(gl.COLOR_BUFFER_BIT); + + const needVis = this.zones.some(z => z.src.type === 'visual'); + if (needVis) { + gl.bindTexture(gl.TEXTURE_2D, this.visTex); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, this.main); + } + + gl.enable(gl.BLEND); + gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); + gl.useProgram(this.progQ); + gl.bindBuffer(gl.ARRAY_BUFFER, this.vbo); + gl.enableVertexAttribArray(this.aQ); + gl.vertexAttribPointer(this.aQ, 2, gl.FLOAT, false, 0, 0); + for (const z of this.zones) { + let tex = this.visTex; + if (z.src.type === 'image') { + const e = this.imgTex[z.src.url]; + if (!e || !e.ok) continue; + tex = e.tex; + } + const c = z.corners; + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([ + c[0][0], c[0][1], c[1][0], c[1][1], c[2][0], c[2][1], + c[0][0], c[0][1], c[2][0], c[2][1], c[3][0], c[3][1] + ]), gl.DYNAMIC_DRAW); + gl.uniformMatrix3fv(this.uQ.uH, false, inv3(squareToQuad(c))); + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, tex); + gl.uniform1i(this.uQ.uTex, 0); + gl.uniform1f(this.uQ.uOpacity, z.opacity); + gl.uniform4fv(this.uQ.uSrc, z.srcRect); + gl.drawArrays(gl.TRIANGLES, 0, 6); + } + + if (this.editOn) { + gl.useProgram(this.progH); + gl.bindBuffer(gl.ARRAY_BUFFER, this.vbo); + gl.enableVertexAttribArray(this.aH); + gl.vertexAttribPointer(this.aH, 2, gl.FLOAT, false, 0, 0); + this.zones.forEach((z, zi) => { + const c = z.corners; + const flat = new Float32Array([c[0][0],c[0][1], c[1][0],c[1][1], c[2][0],c[2][1], c[3][0],c[3][1]]); + gl.bufferData(gl.ARRAY_BUFFER, flat, gl.DYNAMIC_DRAW); + const sel = zi === this.selected; + gl.uniform4fv(this.uH.uCol, sel ? [0.4, 0.75, 1, 0.95] : [1, 1, 1, 0.45]); + gl.uniform1f(this.uH.uPt, 1); + gl.drawArrays(gl.LINE_LOOP, 0, 4); + gl.uniform1f(this.uH.uPt, sel ? 16 : 10); + gl.drawArrays(gl.POINTS, 0, 4); + }); + } + gl.disable(gl.BLEND); + } +} + +window.MappingSim = MappingSim; + +})(); diff --git a/src/output.html b/src/output.html index d8864bc..54931fa 100644 --- a/src/output.html +++ b/src/output.html @@ -12,6 +12,9 @@
+ + + @@ -50,6 +53,7 @@ + diff --git a/src/output.js b/src/output.js index 17d699b..1d9cd44 100644 --- a/src/output.js +++ b/src/output.js @@ -89,6 +89,10 @@ function composeFrame() { // Draw the canvas where it actually sits (the LED area may confine it). const cr = canvas.getBoundingClientRect(); recCtx.drawImage(canvas, cr.x * sx, cr.y * sy, cr.width * sx, cr.height * sy); + if (mapOn && mapping) { + const mr = mapping.canvas.getBoundingClientRect(); + try { recCtx.drawImage(mapping.canvas, mr.x * sx, mr.y * sy, mr.width * sx, mr.height * sy); } catch (e) {} + } if (bgVideo.classList.contains('show') && bgVideo.readyState >= 2 && bgVideo.videoWidth) { recCtx.globalAlpha = parseFloat(getComputedStyle(bgVideo).opacity) || 1; @@ -324,6 +328,22 @@ djv.onControl(async (m) => { break; case 'svg': loadCustomTexture(m.dataUrl); break; case 'modelBg': viz.modelBg = m.mode || 'gradient'; break; + case 'mapZones': + try { + ensureMapping().setZones((m.zones || []).map(z => Object.assign({}, z, + { src: z.src && z.src.type === 'image' + ? { type: 'image', url: toFileURL(z.src.path), path: z.src.path } + : { type: 'visual' } }))); + } catch (e) { djv.report({ type: 'error', message: 'Mappatura: ' + e.message }); } + break; + case 'mapOn': + mapOn = !!m.on; + $('#map-canvas').classList.toggle('show', mapOn); + break; + case 'mapEdit': + if (mapping) mapping.editOn = !!m.on; + $('#map-canvas').classList.toggle('edit', !!m.on); + break; case 'glbAnims': viz.setClipFilter(m.names || []); break; case 'modelBpm': viz.setManualBpm(m.bpm || 0); break; case 'modelSpread': viz.setArmSpread(m.deg || 0); break; @@ -574,6 +594,7 @@ function frame() { const a = audio.values; avTick(performance.now()); viz.render(t * speed, a); + if (mapOn && mapping) mapping.render(); // Interactive family: tell the control panel if the camera didn't start // (permission denied / missing device). Mouse interaction still works. @@ -632,6 +653,20 @@ function frame() { } requestAnimationFrame(frame); +// ------------------------------------------------------------- mapping +let mapping = null, mapOn = false; +function ensureMapping() { + if (!mapping) { + mapping = new window.MappingSim($('#map-canvas'), canvas); + // corner drags on the output flow back to the panel, which persists them + mapping.onChange = (zones) => djv.report({ type: 'mapZones', + zones: zones.map(z => ({ id: z.id, name: z.name, + src: z.src.type === 'image' ? { type: 'image', path: z.src.path } : { type: 'visual' }, + corners: z.corners, srcRect: z.srcRect, opacity: z.opacity })) }); + } + return mapping; +} + // Tell the panel which dance clips the current model can play. function reportClips() { const names = viz.model3d && viz.model3d.anims ? viz.model3d.anims.map(a => a.name) : []; diff --git a/src/style.css b/src/style.css index 5f352b8..3c5fc66 100644 --- a/src/style.css +++ b/src/style.css @@ -39,6 +39,18 @@ body.hide-cursor { cursor: none; } display: block; } +/* Projection-mapping layer: sits over the visual, black outside the zones */ +#map-canvas { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + display: none; + pointer-events: none; +} +#map-canvas.show { display: block; } +#map-canvas.edit { pointer-events: auto; cursor: crosshair; } + /* ============ CONTROL WINDOW LAYOUT ============ */ body.control { display: flex; @@ -882,3 +894,29 @@ kbd { opacity: 0.7; } .glb-models .hint-mini { grid-column: 1 / -1; } + +/* Projection-mapping zone list */ +.map-zone { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 8px; + margin: 4px 0; + border: 1px solid rgba(255,255,255,0.1); + border-radius: 9px; + font-size: 11.5px; +} +.map-zone .mz-ico { flex: 0 0 auto; } +.map-zone .mz-name { width: 90px; flex: 0 0 auto; } +.map-zone .mz-src { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; opacity: 0.75; } +.map-zone .mz-rect { flex: 1; display: flex; align-items: center; gap: 2px; white-space: nowrap; opacity: 0.9; } +.map-zone .mz-r { width: 44px; flex: 0 0 auto; } +.map-zone .mz-op { flex: 0 0 70px; } +.map-zone .mz-del { + flex: 0 0 auto; + width: auto; + background: transparent; + border: none; + color: #ff8a8a; + padding: 0 3px; +}