scripts/ghostyle3d-uv-renderer.jsjavascript
import { t } from './i18n.js';

/**
 * @module ghostyle3d-uv-renderer
 * @description
 * Libreria UV renderer pura per Ghostyle 3D.
 * Nessun side effect al load e nessuna dipendenza diretta da `window.*`.
 */

/**
 * Crea un renderer UV 3D side-effect free.
 *
 * @param {{
 *   uvPath: string,
 *   getFaceLandmarker: ?(function(): any),
 *   log: ?(function(string): void)
 * }} options
 * @returns {{ ensureLoaded: (function(): Promise<void>), render: (function(any, CanvasRenderingContext2D, any[], object): void) }}
 */
export function createUvRenderer(options) {
   const uvPath = options && options.uvPath;
   const getFaceLandmarker = (options && options.getFaceLandmarker)
      ? options.getFaceLandmarker
      : () => null;
   const log = (options && options.log) ? options.log : () => {};

   if (!uvPath) throw new Error(t('uv_path_required_error'));

   function resolveUvPath(path) {
      try {
         return new URL(path).href;
      } catch {
         const base =
            (typeof document !== 'undefined' && document.baseURI && document.baseURI !== 'about:blank')
               ? document.baseURI
               : (typeof window !== 'undefined' && window.location && window.location.href && window.location.href !== 'about:blank')
                  ? window.location.href
                  : import.meta.url;
         return new URL(path, base).href;
      }
   }

   let uvData = null;
   let loadPromise = null;

   function ensureLoaded() {
      if (uvData) return Promise.resolve();
      if (loadPromise) return loadPromise;

      loadPromise = fetch(resolveUvPath(uvPath))
         .then((response) => {
            if (!response.ok) throw new Error(`HTTP ${response.status}`);
            return response.json();
         })
         .then((data) => {
            uvData = data;
            log(t('uv_map_loaded_log', { landmarks: data.numLandmarks, triangles: data.numTriangles }));
         })
         .catch((err) => {
            console.error(t('console_uv_load_error'), err);
            log(t('uv_map_load_error_log', { message: err.message }));
            loadPromise = null;
         });

      return loadPromise;
   }

   // Cache: ogni modulo plugin ha la sua entry { canvas, key, size }.
   // WeakMap così non trattiene il modulo se viene scaricato (futuro hot-reload).
   const cache = new WeakMap();

   // ---- Region label map -------------------------------------------------

   // ID delle regioni. 0 = nessuna regione (pixel fuori dal volto).
   const REGION_IDS = {
      skin:    1,
      eyebrow: 2,
      eye:     3,
      lips:    4,
      iris:    5
   };
   const REGION_NAMES = Object.fromEntries(
      Object.entries(REGION_IDS).map(([n, id]) => [id, n])
   );
   const REGION_LIST = Object.keys(REGION_IDS);

   // Cache delle label maps: size → { labels: Uint8Array, size }
   const labelsBySize = new Map();
   // Cache delle mask binarie per spec dichiarativo: `${size}|${specHash}` → Canvas
   const maskCache = new Map();

   // Estrae cicli (loop chiusi e path aperti) da una lista di segmenti
   // {start, end} costruendo un grafo. Ogni componente connessa diventa una
   // sequenza ordinata di indici.
   function extractChains(segments) {
      const adj = new Map();
      for (const s of segments) {
         if (!adj.has(s.start)) adj.set(s.start, []);
         if (!adj.has(s.end))   adj.set(s.end,   []);
         adj.get(s.start).push(s.end);
         adj.get(s.end).push(s.start);
      }
      const visited = new Set();
      const chains = [];
      for (const start of adj.keys()) {
         if (visited.has(start)) continue;
         // Cerca un nodo di grado 1 (estremo di un path) per partire da lì,
         // altrimenti parti da `start` (loop chiuso).
         let entry = start;
         for (const n of adj.keys()) {
            if (!visited.has(n) && adj.get(n).length === 1) { entry = n; break; }
         }
         const chain = [entry];
         visited.add(entry);
         let current = entry;
         let prev = -1;
         while (true) {
            const neighbors = adj.get(current).filter(n => n !== prev);
            const next = neighbors.find(n => !visited.has(n));
            if (next == null) {
               // Verifica chiusura: se uno dei vicini è il punto di partenza
               // e abbiamo almeno 3 nodi, è un loop chiuso.
               const closing = neighbors.find(n => n === entry);
               if (closing != null && chain.length >= 3) chain.push(entry);
               break;
            }
            chain.push(next);
            visited.add(next);
            prev = current;
            current = next;
         }
         if (chain.length >= 2) chains.push(chain);
      }
      return chains;
   }

   function isClosedChain(chain) {
      return chain.length >= 4 && chain[0] === chain[chain.length - 1];
   }

   function fillChainOnCtx(ctx, chain, uv, size) {
      ctx.beginPath();
      for (let i = 0; i < chain.length; i++) {
         const p = uv[chain[i]];
         if (!p) continue;
         const x = p[0] * size, y = p[1] * size;
         if (i === 0) ctx.moveTo(x, y);
         else         ctx.lineTo(x, y);
      }
      ctx.closePath();
      ctx.fill();
   }

   function strokeChainOnCtx(ctx, chain, uv, size, lineWidth) {
      ctx.lineWidth = lineWidth;
      ctx.lineCap = 'round';
      ctx.lineJoin = 'round';
      ctx.beginPath();
      let started = false;
      for (let i = 0; i < chain.length; i++) {
         const p = uv[chain[i]];
         if (!p) continue;
         const x = p[0] * size, y = p[1] * size;
         if (!started) { ctx.moveTo(x, y); started = true; }
         else          ctx.lineTo(x, y);
      }
      ctx.stroke();
   }

   // Costruisce la label map per `size`. Ogni regione viene rasterizzata su un
   // canvas temporaneo separato per evitare il color-mixing dell'anti-aliasing
   // (un pixel di edge tra eye e skin altrimenti darebbe un ID intermedio
   // sbagliato). Le regioni interne vengono passate dopo quelle esterne, così
   // sovrascrivono nel buffer labels finale. Outside-of-faceOval = 0.
   function buildLabels(size) {
      const faceLandmarker = getFaceLandmarker();
      if (!faceLandmarker || !uvData) return null;
      const uv = uvData.uv;

      const tmp = document.createElement('canvas');
      tmp.width = size;
      tmp.height = size;
      const tctx = tmp.getContext('2d', { willReadFrequently: true });
      tctx.fillStyle = 'white';
      tctx.strokeStyle = 'white';

      const STROKE_W = Math.max(4, Math.round(size * 0.04)); // ~4% del lato
      const labels = new Uint8Array(size * size);

      // Rasterizza i `segments` su `tmp` (in bianco) e marca con `regionId`
      // ogni pixel del buffer `labels` la cui alpha sul tmp supera 128.
      function paintAndLabel(regionId, segments) {
         if (!segments) return;
         tctx.clearRect(0, 0, size, size);
         const chains = extractChains(segments);
         for (const ch of chains) {
            if (isClosedChain(ch)) fillChainOnCtx(tctx, ch, uv, size);
            else                   strokeChainOnCtx(tctx, ch, uv, size, STROKE_W);
         }
         const img = tctx.getImageData(0, 0, size, size);
         const data = img.data;
         for (let i = 3, j = 0; j < labels.length; i += 4, j++) {
            if (data[i] > 128) labels[j] = regionId;
         }
      }

      // Ordine di pittura = ordine di sovrascrittura. Le interne vincono.
      paintAndLabel(REGION_IDS.skin,    faceLandmarker.FACE_LANDMARKS_FACE_OVAL);
      paintAndLabel(REGION_IDS.eyebrow, faceLandmarker.FACE_LANDMARKS_LEFT_EYEBROW);
      paintAndLabel(REGION_IDS.eyebrow, faceLandmarker.FACE_LANDMARKS_RIGHT_EYEBROW);
      paintAndLabel(REGION_IDS.eye,     faceLandmarker.FACE_LANDMARKS_LEFT_EYE);
      paintAndLabel(REGION_IDS.eye,     faceLandmarker.FACE_LANDMARKS_RIGHT_EYE);
      paintAndLabel(REGION_IDS.lips,    faceLandmarker.FACE_LANDMARKS_LIPS);
      paintAndLabel(REGION_IDS.iris,    faceLandmarker.FACE_LANDMARKS_LEFT_IRIS);
      paintAndLabel(REGION_IDS.iris,    faceLandmarker.FACE_LANDMARKS_RIGHT_IRIS);

      // Diagnostica: conta i pixel etichettati per regione.
      const counts = {};
      for (let i = 0; i < labels.length; i++) {
         const id = labels[i];
         counts[id] = (counts[id] || 0) + 1;
      }
      const tot = labels.length;
      const pct = id => ((counts[id] || 0) / tot * 100).toFixed(1);
      log(
         `Label map ${size}×${size}: outside ${pct(0)}% · skin ${pct(REGION_IDS.skin)}% ` +
         `· eyebrow ${pct(REGION_IDS.eyebrow)}% · eye ${pct(REGION_IDS.eye)}% ` +
         `· lips ${pct(REGION_IDS.lips)}% · iris ${pct(REGION_IDS.iris)}%`
      );

      return { labels, size };
   }

   function ensureLabels(size) {
      let entry = labelsBySize.get(size);
      if (entry) return entry;
      entry = buildLabels(size);
      if (entry) labelsBySize.set(size, entry);
      return entry;
   }

   function buildHelpers(size) {
      const entry = ensureLabels(size);
      const labels = entry ? entry.labels : null;

      function regionAt(u, v) {
         if (!labels) return null;
         const px = Math.min(size - 1, Math.max(0, Math.floor(u * size)));
         const py = Math.min(size - 1, Math.max(0, Math.floor(v * size)));
         const id = labels[py * size + px];
         return id === 0 ? null : (REGION_NAMES[id] || null);
      }

      return {
         regionAt,
         labels,
         regions: REGION_IDS,
         regionsList: REGION_LIST.slice()
      };
   }

   // Mask binaria (RGBA con alpha = 255 dove la spec è soddisfatta) per
   // applicazione automatica via destination-in dopo paintUV.
   function getDeclarativeMask(size, spec) {
      if (!spec) return null;
      const include = Array.isArray(spec.include) ? spec.include
                    : (typeof spec.include === 'string' ? [spec.include] : null);
      const exclude = Array.isArray(spec.exclude) ? spec.exclude.slice() : [];
      if (!include && exclude.length === 0) return null;

      const key = `${size}|inc:${(include || ['*']).slice().sort().join(',')}|exc:${exclude.slice().sort().join(',')}`;
      const cached = maskCache.get(key);
      if (cached) return cached;

      const entry = ensureLabels(size);
      if (!entry) return null;
      const labels = entry.labels;

      const c = document.createElement('canvas');
      c.width = size;
      c.height = size;
      const mctx = c.getContext('2d');
      const img = mctx.createImageData(size, size);
      const data = img.data;

      const includeIds = include
         ? new Set(include.map(n => REGION_IDS[n]).filter(Boolean))
         : null;
      const excludeIds = new Set(exclude.map(n => REGION_IDS[n]).filter(Boolean));

      let included = 0;
      for (let i = 0, j = 0; j < labels.length; i += 4, j++) {
         const id = labels[j];
         const inIncluded = includeIds ? includeIds.has(id) : true;
         const inExcluded = excludeIds.has(id);
         if (inIncluded && !inExcluded) {
            data[i + 3] = 255;
            included++;
         } else {
            data[i + 3] = 0;
         }
      }
      // Fallback: se la spec non risulta in nessun pixel incluso (label map
      // costruita male o spec degenere), salta la mask invece di azzerare
      // tutta la texture. Loggiamo per diagnosi.
      if (included === 0) {
         log(`Mask region degenere (0 px inclusi) per spec ${JSON.stringify(spec)} - skip applicazione`);
         maskCache.set(key, null);
         return null;
      }
      mctx.putImageData(img, 0, 0);
      maskCache.set(key, c);
      return c;
   }

   function hashParams(params) {
      // Stringificazione deterministica: chiavi ordinate, valori numerici troncati.
      const keys = Object.keys(params || {}).sort();
      const parts = [];
      for (const k of keys) {
         const v = params[k];
         if (typeof v === 'number') parts.push(`${k}:${v.toFixed(6)}`);
         else parts.push(`${k}:${JSON.stringify(v)}`);
      }
      return parts.join('|');
   }

   // Proxy che warna in console quando il plugin legge una chiave di params
   // non dichiarata nello schema. Aiuta a smascherare typo (es. `min_pahse`).
   // Una sola warning per chiave per non spammare.
   function makeParamsProxy(module, values) {
      if (!Array.isArray(module.params)) return values;
      const declared = new Set(module.params.map(p => p.name));
      const warned = new Set();
      return new Proxy(values, {
         get(target, key) {
            if (typeof key === 'string' && !declared.has(key) && !warned.has(key)) {
               warned.add(key);
               console.warn(`[uv-renderer] plugin legge param non dichiarato: '${key}'`);
            }
            return target[key];
         }
      });
   }

   function ensureTexture(module, params) {
      const size = (typeof module.textureSize === 'number' && module.textureSize > 0)
         ? Math.round(module.textureSize)
         : 256;
      const key = `${size}#${hashParams(params)}`;
      let entry = cache.get(module);
      if (!entry || entry.key !== key || entry.size !== size) {
         if (!entry) {
            const c = document.createElement('canvas');
            c.width = size;
            c.height = size;
            entry = { canvas: c, ctx: c.getContext('2d'), key: '', size };
            cache.set(module, entry);
         } else if (entry.size !== size) {
            entry.canvas.width = size;
            entry.canvas.height = size;
            entry.size = size;
         }
         entry.ctx.clearRect(0, 0, size, size);
         try {
            const helpers = buildHelpers(size);
            const safeParams = makeParamsProxy(module, params);
            module.paintUV(entry.ctx, safeParams, helpers);
         } catch (err) {
            console.error(t('console_uv_paint_error'), err);
         }
         // Applica mask dichiarativa `region = { include, exclude }` se
         // dichiarata dal plugin. L'effetto è equivalente a un destination-in.
         const declarativeMask = getDeclarativeMask(size, module.region);
         if (declarativeMask) {
            const prev = entry.ctx.globalCompositeOperation;
            entry.ctx.globalCompositeOperation = 'destination-in';
            entry.ctx.drawImage(declarativeMask, 0, 0);
            entry.ctx.globalCompositeOperation = prev;
         }
         entry.key = key;
      }
      return entry;
   }

   function render(module, ctx, landmarks, params) {
      if (!module || typeof module.paintUV !== 'function') return;
      if (!uvData) { ensureLoaded(); return; }
      if (!landmarks || !ctx) return;

      const tex = ensureTexture(module, params || {});
      const texSize = tex.size;
      const w = ctx.canvas.width;
      const h = ctx.canvas.height;
      const uv = uvData.uv;
      const tri = uvData.triangles;

      for (let i = 0; i < tri.length; i++) {
         const t = tri[i];
         const ia = t[0], ib = t[1], ic = t[2];
         const la = landmarks[ia], lb = landmarks[ib], lc = landmarks[ic];
         if (!la || !lb || !lc) continue;
         const ua = uv[ia], ub = uv[ib], uc = uv[ic];
         if (!ua || !ub || !uc) continue;

         const tAx = ua[0] * texSize, tAy = ua[1] * texSize;
         const tBx = ub[0] * texSize, tBy = ub[1] * texSize;
         const tCx = uc[0] * texSize, tCy = uc[1] * texSize;
         const sAx = la.x * w, sAy = la.y * h;
         const sBx = lb.x * w, sBy = lb.y * h;
         const sCx = lc.x * w, sCy = lc.y * h;

         // Affine 3-point: M tale che M*(t_i) = s_i per i in {A,B,C}
         const det = (tAx - tCx) * (tBy - tCy) - (tBx - tCx) * (tAy - tCy);
         if (Math.abs(det) < 1e-6) continue;

         const inv = 1 / det;
         const m11 = ((sAx - sCx) * (tBy - tCy) - (sBx - sCx) * (tAy - tCy)) * inv;
         const m12 = ((sBx - sCx) * (tAx - tCx) - (sAx - sCx) * (tBx - tCx)) * inv;
         const m13 = sCx - m11 * tCx - m12 * tCy;
         const m21 = ((sAy - sCy) * (tBy - tCy) - (sBy - sCy) * (tAy - tCy)) * inv;
         const m22 = ((sBy - sCy) * (tAx - tCx) - (sAy - sCy) * (tBx - tCx)) * inv;
         const m23 = sCy - m21 * tCx - m22 * tCy;

         ctx.save();
         ctx.beginPath();
         ctx.moveTo(sAx, sAy);
         ctx.lineTo(sBx, sBy);
         ctx.lineTo(sCx, sCy);
         ctx.closePath();
         ctx.clip();
         // Canvas setTransform(a,b,c,d,e,f) usa la matrice [a c e; b d f; 0 0 1]
         ctx.setTransform(m11, m21, m12, m22, m13, m23);
         ctx.drawImage(tex.canvas, 0, 0);
         ctx.restore();
      }
   }

   return {
      ensureLoaded,
      render
   };
}