/*!
 * Anime2.5DRig — rigger.js
 * PSD (parsed by ag-psd, useImageData mode) -> rig definition.
 * Pure typed-array implementation: runs in browser and Node (testable).
 * MIT License
 */
(function (root, factory) {
  var api = factory();
  if (typeof module !== 'undefined' && module.exports) {
    module.exports = api;
    if (require.main === module) {
      api.trackingSelfCheck();
      api.rigSelfCheck();
      console.log('Anime2.5DRig tracking and physics self-check passed');
    }
  } else root.Rigger = api;
})(typeof self !== 'undefined' ? self : this, function () {
  'use strict';

  // ---------- layer naming ----------
  function normName(n) {
    n = (n || '').normalize('NFKC').trim().replace(/ のコピー\s*\d*$/,'').toLowerCase();
    if (n === 'eyelash_c') n = 'eye_close';       // legacy aliases
    if (n === 'mouth_c') n = 'mouth_close';
    if (n === 'mouth' || /^mouth[ _-]?\d+$/.test(n)) n = 'mouth_open';   // see-through raw output
    if (n === 'レイヤー 1') n = 'facedetail';
    return n;
  }
  function baseName(n) { return n.replace(/(?:[-_][lr]|_\d+)$/, ''); }
  function sideName(n) { var m = n.match(/[-_]([lr])$/); return m ? m[1] : null; }

  // ---------- MediaPipe expression calibration ----------
  function unit(v) { return v < 0 ? 0 : v > 1 ? 1 : v; }
  function smoothUnit(v) { v = unit(v); return v * v * (3 - 2 * v); }
  function chestWeight(y, cy, ry) {
    return 1 - smoothUnit((y - (cy - ry * 0.20)) / Math.max(1, ry * 0.55));
  }
  function pointDistance(points, a, b) {
    var p = points[a], q = points[b];
    return Math.hypot(p.x - q.x, p.y - q.y);
  }
  function faceScore(scores, name) {
    var v = scores && scores[name];
    return typeof v === 'number' && isFinite(v) ? v : 0;
  }
  function eyeRatio(points, outer, inner, pairs) {
    var aperture = 0;
    for (var i = 0; i < pairs.length; i++) aperture += pointDistance(points, pairs[i][0], pairs[i][1]);
    return aperture / pairs.length / Math.max(1e-4, pointDistance(points, outer, inner));
  }
  function mapFaceTracking(points, scores, sensitivity) {
    if (!points || points.length < 468) throw new Error('MediaPipe face landmarks are missing');
    sensitivity = unit(typeof sensitivity === 'number' ? sensitivity : 0.3);
    var leftRatio = eyeRatio(points, 33, 133, [[160, 144], [159, 145], [158, 153]]);
    var rightRatio = eyeRatio(points, 362, 263, [[385, 380], [386, 374], [387, 373]]);
    var landmarkEyeOpen = (smoothUnit((leftRatio - 0.09) / 0.17) + smoothUnit((rightRatio - 0.09) / 0.17)) / 2;
    var blink = (faceScore(scores, 'eyeBlinkLeft') + faceScore(scores, 'eyeBlinkRight')) / 2;
    var blendshapeEyeOpen = 1 - smoothUnit((blink - 0.12) / 0.43);
    var eyeOpen = Math.max(landmarkEyeOpen, blendshapeEyeOpen);
    var agreement = 0.4 + sensitivity * 0.5;
    if (landmarkEyeOpen < agreement && blendshapeEyeOpen < agreement) {
      eyeOpen = (landmarkEyeOpen + blendshapeEyeOpen) / 2;
    }
    if (eyeOpen < 0.06 + sensitivity * 0.2) eyeOpen = 0;
    else if (eyeOpen > 0.96) eyeOpen = 1;

    var leftSquint = Math.max(faceScore(scores, 'eyeSquintLeft'), faceScore(scores, 'cheekSquintLeft'));
    var rightSquint = Math.max(faceScore(scores, 'eyeSquintRight'), faceScore(scores, 'cheekSquintRight'));
    var lowerLid = smoothUnit(((leftSquint + rightSquint) / 2 - 0.10) / 0.60);

    var mouthRatio = pointDistance(points, 13, 14) / Math.max(1e-4, pointDistance(points, 61, 291));
    var landmarkMouthOpen = smoothUnit((mouthRatio - 0.03) / 0.22);
    var jaw = Math.max(faceScore(scores, 'jawOpen'),
      faceScore(scores, 'mouthFunnel') * 0.85,
      (faceScore(scores, 'mouthLowerDownLeft') + faceScore(scores, 'mouthLowerDownRight')) * 0.5);
    var blendshapeMouthOpen = smoothUnit((jaw - 0.08) / 0.28);

    // ponytail: require both sensors to agree before closing one synchronized pair of eyelids.
    return { eyeOpen: eyeOpen, lowerLid: lowerLid,
      mouthOpen: Math.max(landmarkMouthOpen, blendshapeMouthOpen) };
  }

  function trackingSelfCheck() {
    function face(eyeRatio, mouthRatio) {
      var points = Array.from({ length: 468 }, function () { return { x: 0, y: 0 }; });
      points[33] = { x: 0, y: 0 }; points[133] = { x: 1, y: 0 };
      points[160] = points[159] = points[158] = { x: 0.5, y: -eyeRatio / 2 };
      points[144] = points[145] = points[153] = { x: 0.5, y: eyeRatio / 2 };
      points[362] = { x: 0, y: 1 }; points[263] = { x: 1, y: 1 };
      points[385] = points[386] = points[387] = { x: 0.5, y: 1 - eyeRatio / 2 };
      points[380] = points[374] = points[373] = { x: 0.5, y: 1 + eyeRatio / 2 };
      points[61] = { x: 0, y: 2 }; points[291] = { x: 1, y: 2 };
      points[13] = { x: 0.5, y: 2 - mouthRatio / 2 }; points[14] = { x: 0.5, y: 2 + mouthRatio / 2 };
      return points;
    }
    function check(ok, message) { if (!ok) throw new Error(message); }
    var neutral = mapFaceTracking(face(0.30, 0.01), { eyeBlinkLeft: 0.03, eyeBlinkRight: 0.04, jawOpen: 0.04 });
    check(neutral.eyeOpen === 1 && neutral.lowerLid === 0 && neutral.mouthOpen === 0, 'neutral face must stay still');
    var full = mapFaceTracking(face(0.03, 0.35), { eyeBlinkLeft: 0.8, eyeBlinkRight: 0.8, jawOpen: 0.45 });
    check(full.eyeOpen === 0 && full.mouthOpen === 1, 'full blink and open mouth must reach their endpoints');
    var noise = mapFaceTracking(face(0.30, 0.01), { eyeBlinkLeft: 0.20, eyeBlinkRight: 0.02, jawOpen: 0.04 });
    check(noise.eyeOpen > 0.99, 'one-eye tracking noise must not twitch the rig');
    var landmarkNoise = mapFaceTracking(face(0.03, 0.01), { eyeBlinkLeft: 0.03, eyeBlinkRight: 0.03 });
    check(landmarkNoise.eyeOpen > 0.99, 'landmark noise alone must not close the eyes');
    var blendshapeNoise = mapFaceTracking(face(0.30, 0.01), { eyeBlinkLeft: 0.8, eyeBlinkRight: 0.8 });
    check(blendshapeNoise.eyeOpen > 0.99, 'blendshape noise alone must not close the eyes');
    var residual = mapFaceTracking(face(0.06, 0.01), { eyeBlinkLeft: 0.65, eyeBlinkRight: 0.65 });
    check(residual.eyeOpen === 0, 'two-sensor blink evidence must reach fully closed');
    var moderate = mapFaceTracking(face(0.19, 0.01), { eyeBlinkLeft: 0.45, eyeBlinkRight: 0.45 }, 0.6);
    check(moderate.eyeOpen < 0.4, 'blink sensitivity must recover moderate blinks');
    var lower = mapFaceTracking(face(0.30, 0.01), { eyeSquintLeft: 0.7, eyeSquintRight: 0.7 });
    check(lower.lowerLid === 1 && lower.eyeOpen === 1, 'lower eyelids must track independently of blinking');
    var speech = mapFaceTracking(face(0.10, 0.10), { jawOpen: 0.22 });
    check(speech.mouthOpen >= 0.49, 'normal speech must clear the visible-mouth threshold');
    check(chestWeight(80, 100, 40) > 0.6, 'upper chest must retain spring weight');
    check(chestWeight(112, 100, 40) < 0.1, 'under-bust movement must fade smoothly');
    check(chestWeight(150, 100, 40) === 0, 'navel movement must be anchored');
    return true;
  }

  function rigSelfCheck() {
    function layer(name, left, top, width, height) {
      var data = new Uint8ClampedArray(width * height * 4);
      for (var i = 0; i < width * height; i++) {
        data[i * 4] = 160; data[i * 4 + 1] = 210; data[i * 4 + 2] = 255; data[i * 4 + 3] = 255;
      }
      return { name: name, left: left, top: top, imageData: { width: width, height: height, data: data } };
    }
    var rig = buildRig({ width: 256, height: 256, children: [
      layer('face', 100, 18, 56, 56),
      layer('tail', 28, 80, 90, 160),
      layer('tail_1', 150, 80, 80, 160),
      layer('back hair', 0, 0, 256, 256)
    ] });
    var tails = rig.layers.filter(function (part) { return baseName(part.name) === 'tail'; });
    if (tails.length !== 2 || tails.some(function (part) { return part.phys !== 'tail' || !part.strands.length; })) {
      throw new Error('tail layers must receive spring strands');
    }
    var backHairIndex = rig.layers.findIndex(function (part) { return baseName(part.name) === 'back hair'; });
    if (backHairIndex < 0 || tails.some(function (part) { return rig.layers.indexOf(part) >= backHairIndex; })) {
      throw new Error('tail layers must render behind back hair');
    }
    var hair = layer('back hair', 0, 0, 9, 9), hd = hair.imageData.data;
    for (var hy = 2; hy <= 6; hy++) for (var hx = 4; hx <= 5; hx++) hd[(hy * 9 + hx) * 4 + 3] = 0;
    var filled = fillHairPinholes(hair.imageData, 9, 9, 2);
    if (!filled || !hd[(4 * 9 + 4) * 4 + 3]) throw new Error('small transparent hair slits must be repaired');
    var gradient = layer('back hair', 0, 0, 15, 15), gd = gradient.imageData.data;
    for (var gy = 0; gy < 15; gy++) for (var gx = 0; gx < 15; gx++) {
      var gi = (gy * 15 + gx) * 4; gd[gi] = gy * 10; gd[gi + 1] = gx * 10;
    }
    for (gy = 6; gy <= 9; gy++) for (gx = 6; gx <= 9; gx++) gd[(gy * 15 + gx) * 4 + 3] = 0;
    fillHairPinholes(gradient.imageData, 15, 15, 1);
    var center = (7 * 15 + 7) * 4;
    if (gd[center] !== 50 || gd[center + 1] !== 70) throw new Error('hair repair must continue texture vertically');

    var whites = layer('eyewhite', 70, 80, 116, 16), wd = whites.imageData.data;
    for (var wy = 0; wy < 16; wy++) for (var wx = 30; wx < 86; wx++) wd[(wy * 116 + wx) * 4 + 3] = 0;
    var eyeRig = buildRig({ width: 256, height: 256, children: [
      layer('face', 100, 18, 56, 56),
      layer('irides-l', 164, 83, 12, 10),
      layer('eyelash-r', 68, 78, 34, 8),
      whites,
      layer('eye_close-l', 156, 82, 30, 6),
      layer('eyelash-l', 154, 78, 34, 8),
      layer('irides-r', 80, 83, 12, 10),
      layer('eye_close-r', 70, 82, 30, 6)
    ] });
    ['L', 'R'].forEach(function (side) {
      var anchor = eyeRig.anchors['eye' + side];
      if (!anchor || anchor.icx < anchor.x0 || anchor.icx > anchor.x1) throw new Error('iris must use its anatomical eye socket');
      var at = function (name) { return eyeRig.layers.findIndex(function (part) { return baseName(part.name) === name && part.side === side; }); };
      if (!(at('eyewhite') < at('irides') && at('irides') < at('eyelash') && at('irides') < at('eye_close'))) {
        throw new Error('iris must render behind open and closed eyelids');
      }
    });
    var oneEyeRig = buildRig({ width: 256, height: 256, children: [
      layer('face', 100, 18, 56, 56),
      layer('irides-l', 164, 83, 12, 10),
      layer('eyewhite', 156, 80, 30, 16),
      layer('eyelash-l', 154, 78, 34, 8)
    ] }, { generic: { eyeL: layer('generic eye', 0, 0, 24, 7).imageData } });
    if (!oneEyeRig.synth.eye || !oneEyeRig.layers.some(function (part) { return part.name === 'eye_close_l'; })) {
      throw new Error('one-eyed rigs must generate their available closed eyelid');
    }
    return true;
  }

  var SLOTS = {
    'back hair':   { depth: 0.55, group: 'head', phys: 'hair' },
    'bottomwear':  { depth: 0.88, group: 'body' },
    'legwear':     { depth: 0.87, group: 'body' },
    'neck':        { depth: 0.95, group: 'body' },
    'neckwear':    { depth: 0.94, group: 'body' },
    'topwear':     { depth: 0.90, group: 'body' },
    'handwear':    { depth: 0.86, group: 'body' },
    'tail':        { depth: 0.48, group: 'body', phys: 'tail' },
    'earwear':     { depth: 0.97, group: 'head' },
    'ears':        { depth: 0.96, group: 'head' },
    'face':        { depth: 1.00, group: 'head' },
    'facedetail':  { depth: 1.02, group: 'head' },
    'headwear':    { depth: 1.20, group: 'head' },
    'mouth_close': { depth: 1.08, group: 'head', fade: 'mouthClose' },
    'mouth_open':  { depth: 1.08, group: 'head', fade: 'mouthOpen' },
    'nose':        { depth: 1.15, group: 'head' },
    'eyewhite':    { depth: 1.06, group: 'head', split: true, fade: 'eyeOpen' },
    'eyebrow':     { depth: 1.14, group: 'head', split: true },
    'irides':      { depth: 1.08, group: 'head', split: true, fade: 'eyeOpen' },
    'eyelash':     { depth: 1.12, group: 'head', split: true, fade: 'eyeOpen' },
    'eye_close':   { depth: 1.12, group: 'head', split: true, fade: 'eyeClose' },
    'front hair':  { depth: 1.28, group: 'head', phys: 'hair' }
  };

  // ---------- image ops (full-canvas alpha as Uint8Array) ----------
  function fullAlphaOf(layer, W, H) {
    var a = new Uint8Array(W * H);
    var img = layer.imageData, lw = img.width, lh = img.height;
    var lx = layer.left | 0, ly = layer.top | 0, d = img.data;
    for (var y = 0; y < lh; y++) {
      var cy = y + ly; if (cy < 0 || cy >= H) continue;
      var ro = cy * W, lo = y * lw;
      for (var x = 0; x < lw; x++) {
        var cx = x + lx; if (cx < 0 || cx >= W) continue;
        a[ro + cx] = d[(lo + x) * 4 + 3];
      }
    }
    return a;
  }

  function labelComponents(alpha, W, H, thr) {
    var lab = new Int32Array(W * H), sizes = [0], sumX = [0], cnt = 0;
    var stack = new Int32Array(W * H);
    for (var s = 0; s < W * H; s++) {
      if (lab[s] || alpha[s] <= thr) continue;
      cnt++; var sp = 0; stack[sp++] = s; lab[s] = cnt;
      var size = 0, sx = 0;
      while (sp) {
        var q = stack[--sp]; size++; sx += q % W;
        var x = q % W, y = (q / W) | 0;
        if (x > 0        && !lab[q - 1] && alpha[q - 1] > thr) { lab[q - 1] = cnt; stack[sp++] = q - 1; }
        if (x < W - 1    && !lab[q + 1] && alpha[q + 1] > thr) { lab[q + 1] = cnt; stack[sp++] = q + 1; }
        if (y > 0        && !lab[q - W] && alpha[q - W] > thr) { lab[q - W] = cnt; stack[sp++] = q - W; }
        if (y < H - 1    && !lab[q + W] && alpha[q + W] > thr) { lab[q + W] = cnt; stack[sp++] = q + W; }
      }
      sizes.push(size); sumX.push(sx);
    }
    return { lab: lab, count: cnt, sizes: sizes, sumX: sumX };
  }

  function dilate(mask, W, H, r) {
    var tmp = new Uint8Array(W * H), out = new Uint8Array(W * H), x, y, k;
    for (y = 0; y < H; y++) {
      var o = y * W;
      for (x = 0; x < W; x++) {
        var v = 0;
        for (k = -r; k <= r; k++) { var xx = x + k; if (xx >= 0 && xx < W && mask[o + xx]) { v = 1; break; } }
        tmp[o + x] = v;
      }
    }
    for (x = 0; x < W; x++) for (y = 0; y < H; y++) {
      var v2 = 0;
      for (k = -r; k <= r; k++) { var yy = y + k; if (yy >= 0 && yy < H && tmp[yy * W + x]) { v2 = 1; break; } }
      out[y * W + x] = v2;
    }
    return out;
  }

  function erode(mask, W, H, r) {
    var tmp = new Uint8Array(W * H), out = new Uint8Array(W * H), x, y, k;
    for (y = 0; y < H; y++) {
      var o = y * W;
      for (x = 0; x < W; x++) {
        var v = 1;
        for (k = -r; k <= r; k++) { var xx = x + k; if (xx < 0 || xx >= W || !mask[o + xx]) { v = 0; break; } }
        tmp[o + x] = v;
      }
    }
    for (x = 0; x < W; x++) for (y = 0; y < H; y++) {
      var v2 = 1;
      for (k = -r; k <= r; k++) { var yy = y + k; if (yy < 0 || yy >= H || !tmp[yy * W + x]) { v2 = 0; break; } }
      out[y * W + x] = v2;
    }
    return out;
  }

  function fillHairPinholes(img, W, H, r) {
    var d = img.data, mask = new Uint8Array(W * H), i, x, y;
    for (i = 0; i < W * H; i++) mask[i] = d[i * 4 + 3] > 16 ? 1 : 0;
    var closed = erode(dilate(mask, W, H, r), W, H, r), filled = 0;
    for (y = 0; y < H; y++) for (x = 0; x < W; x++) {
      i = y * W + x; if (mask[i] || !closed[i]) continue;
      var best = -1, bestD = Infinity, bestAlpha = 0;
      for (var yy = Math.max(0, y - r * 2); yy <= Math.min(H - 1, y + r * 2); yy++) {
        for (var xx = Math.max(0, x - r * 2); xx <= Math.min(W - 1, x + r * 2); xx++) {
          var q = yy * W + xx, ds = (xx - x) * (xx - x) + (yy - y) * (yy - y);
          var qa = d[q * 4 + 3];
          if (mask[q] && (qa > bestAlpha || (qa === bestAlpha && ds < bestD))) { best = q; bestD = ds; bestAlpha = qa; }
        }
      }
      if (best < 0) continue;
      d[i * 4] = d[best * 4]; d[i * 4 + 1] = d[best * 4 + 1]; d[i * 4 + 2] = d[best * 4 + 2];
      d[i * 4 + 3] = d[best * 4 + 3]; filled++;
    }
    // Continue narrow gaps along the vertical grain of hair instead of smearing across strands.
    for (x = 0; x < W; x++) {
      var run = -1;
      for (y = 0; y <= H; y++) {
        var opaque = y < H && d[(y * W + x) * 4 + 3] > 16;
        if (!opaque && y < H && run < 0) run = y;
        if (opaque && run >= 0) {
          var height = y - run, top = run - 1;
          if (top >= 0 && height <= r * 6 && d[(top * W + x) * 4 + 3] > 16) {
            for (var fy = run; fy < y; fy++) {
              var src = d[(top * W + x) * 4 + 3] >= d[(y * W + x) * 4 + 3] ? top : y;
              var dst = fy * W + x, from = src * W + x;
              d[dst * 4] = d[from * 4]; d[dst * 4 + 1] = d[from * 4 + 1]; d[dst * 4 + 2] = d[from * 4 + 2];
              d[dst * 4 + 3] = d[from * 4 + 3]; filled++;
            }
          }
          run = -1;
        }
      }
    }
    return filled;
  }

  // kill stray dust: keep components >= minPx (on alpha>16), dilate, zero the rest
  function cleanAlpha(alpha, W, H, minPx) {
    var L = labelComponents(alpha, W, H, 16);
    if (!L.count) return alpha;
    var keepComp = new Uint8Array(L.count + 1), any = false, i;
    for (i = 1; i <= L.count; i++) if (L.sizes[i] >= minPx) { keepComp[i] = 1; any = true; }
    if (!any) return alpha;
    var mask = new Uint8Array(W * H);
    for (i = 0; i < W * H; i++) if (keepComp[L.lab[i]]) mask[i] = 1;
    mask = dilate(mask, W, H, 3);
    for (i = 0; i < W * H; i++) if (!mask[i]) alpha[i] = 0;
    return alpha;
  }

  // See-through suffixes are anatomical: image-left is the character's right eye.
  function splitSides(alpha, W, H, faceCx) {
    var L = labelComponents(alpha, W, H, 16);
    var side = new Uint8Array(L.count + 1); // 1=left 2=right
    for (var c = 1; c <= L.count; c++) {
      if (L.sizes[c] < 20) continue;
      side[c] = (L.sumX[c] / L.sizes[c]) < faceCx ? 2 : 1;
    }
    var ml = new Uint8Array(W * H), mr = new Uint8Array(W * H), i;
    for (i = 0; i < W * H; i++) {
      if (side[L.lab[i]] === 1) ml[i] = 1; else if (side[L.lab[i]] === 2) mr[i] = 1;
    }
    var out = {};
    var nl = 0, nr = 0;
    for (i = 0; i < W * H; i++) { nl += ml[i]; nr += mr[i]; }
    if (nl) out.l = dilate(ml, W, H, 3);
    if (nr) out.r = dilate(mr, W, H, 3);
    return out;
  }

  function bboxOf(alpha, W, H, thr) {
    var x0 = W, y0 = H, x1 = -1, y1 = -1;
    for (var y = 0; y < H; y++) {
      var o = y * W;
      for (var x = 0; x < W; x++) if (alpha[o + x] > thr) {
        if (x < x0) x0 = x; if (x > x1) x1 = x;
        if (y < y0) y0 = y; if (y > y1) y1 = y;
      }
    }
    return x1 < 0 ? null : { x0: x0, y0: y0, x1: x1, y1: y1 };
  }

  function centroidOf(alpha, W, H) {
    var sx = 0, sy = 0, s = 0;
    for (var y = 0; y < H; y++) {
      var o = y * W;
      for (var x = 0; x < W; x++) { var a = alpha[o + x]; if (a) { sx += x * a; sy += y * a; s += a; } }
    }
    return s ? { cx: sx / s, cy: sy / s } : null;
  }

  // ---------- generic close-diff synthesis ----------
  function resampleRGBA(src, tw, th) {   // bilinear
    var out = new Uint8ClampedArray(tw * th * 4);
    var sw = src.width, sh = src.height, d = src.data;
    for (var y = 0; y < th; y++) {
      var sy = (y + 0.5) * sh / th - 0.5;
      var y0 = Math.max(0, Math.floor(sy)), y1 = Math.min(sh - 1, y0 + 1), fy = sy - y0;
      for (var x = 0; x < tw; x++) {
        var sx = (x + 0.5) * sw / tw - 0.5;
        var x0 = Math.max(0, Math.floor(sx)), x1 = Math.min(sw - 1, x0 + 1), fx = sx - x0;
        var o = (y * tw + x) * 4;
        for (var c = 0; c < 4; c++) {
          var v00 = d[(y0 * sw + x0) * 4 + c], v01 = d[(y0 * sw + x1) * 4 + c];
          var v10 = d[(y1 * sw + x0) * 4 + c], v11 = d[(y1 * sw + x1) * 4 + c];
          out[o + c] = v00 * (1 - fx) * (1 - fy) + v01 * fx * (1 - fy) + v10 * (1 - fx) * fy + v11 * fx * fy;
        }
      }
    }
    return out;
  }
  function meanColorOfImg(img, darkWeight) {   // alpha-weighted mean RGB
    var d = img.data, r = 0, g = 0, b = 0, s = 0;
    for (var i = 0; i < d.length; i += 4) {
      var a = d[i + 3]; if (a < 24) continue;
      var w = a;
      if (darkWeight) { var lum = (d[i] + d[i + 1] + d[i + 2]) / 3; w = a * Math.pow(1 - lum / 255, 2); }
      r += d[i] * w; g += d[i + 1] * w; b += d[i + 2] * w; s += w;
    }
    return s ? [r / s, g / s, b / s] : null;
  }
  function recolorTo(data, target) {   // flat recolor preserving relative luminance
    var lumSum = 0, n = 0, i;
    for (i = 0; i < data.length; i += 4) if (data[i + 3] > 24) { lumSum += (data[i] + data[i + 1] + data[i + 2]) / 3; n++; }
    if (!n) return;
    var mean = Math.max(8, lumSum / n);
    for (i = 0; i < data.length; i += 4) {
      if (!data[i + 3]) continue;
      var f = Math.min(2.2, ((data[i] + data[i + 1] + data[i + 2]) / 3) / mean);
      data[i] = target[0] * f; data[i + 1] = target[1] * f; data[i + 2] = target[2] * f;
    }
  }
  function synthPart(name, gimg, targetW, cx, anchorY, vAlign, tint, slot, side) {
    var scale = targetW / gimg.width;
    var tw = Math.max(2, Math.round(targetW)), th = Math.max(2, Math.round(gimg.height * scale));
    var data = resampleRGBA(gimg, tw, th);
    if (tint) recolorTo(data, tint);
    return { name: name, x: Math.round(cx - tw / 2), y: Math.round(anchorY - vAlign * th),
             w: tw, h: th, z: 0, depth: slot.depth, group: 'head', phys: null,
             fade: slot.fade || null, side: side || null, strands: null, synthetic: true,
             img: { width: tw, height: th, data: data } };
  }
  function lastIndexWhere(arr, pred) {
    for (var i = arr.length - 1; i >= 0; i--) if (pred(arr[i])) return i;
    return -1;
  }

  // ---------- flat-image utilities (custom generic-diff PSDs) ----------
  function trimImg(img, thr) {
    thr = thr || 8;
    var W = img.width, H = img.height, d = img.data;
    var x0 = W, y0 = H, x1 = -1, y1 = -1, x, y;
    for (y = 0; y < H; y++) for (x = 0; x < W; x++)
      if (d[(y * W + x) * 4 + 3] > thr) {
        if (x < x0) x0 = x; if (x > x1) x1 = x;
        if (y < y0) y0 = y; if (y > y1) y1 = y;
      }
    if (x1 < 0) return null;
    var w = x1 - x0 + 1, h = y1 - y0 + 1;
    var out = new Uint8ClampedArray(w * h * 4);
    for (y = 0; y < h; y++) out.set(d.subarray(((y + y0) * W + x0) * 4, ((y + y0) * W + x0 + w) * 4), y * w * 4);
    return { width: w, height: h, data: out };
  }
  // alpha-over composite of all layers, trimmed
  function flattenPsdToImg(psd) {
    var W = psd.width, H = psd.height;
    var buf = new Uint8ClampedArray(W * H * 4);
    (psd.children || []).forEach(function (c) {
      if (!c.imageData) return;
      var img = c.imageData, lw = img.width, lh = img.height, lx = c.left | 0, ly = c.top | 0, d = img.data;
      for (var y = 0; y < lh; y++) {
        var cy = y + ly; if (cy < 0 || cy >= H) continue;
        for (var x = 0; x < lw; x++) {
          var cx = x + lx; if (cx < 0 || cx >= W) continue;
          var si = (y * lw + x) * 4, di = (cy * W + cx) * 4, a = d[si + 3] / 255;
          if (!a) continue;
          for (var k = 0; k < 3; k++) buf[di + k] = d[si + k] * a + buf[di + k] * (1 - a);
          buf[di + 3] = Math.min(255, d[si + 3] + buf[di + 3] * (1 - a));
        }
      }
    });
    return trimImg({ width: W, height: H, data: buf });
  }
  // split a both-eyes image into L/R at the widest interior empty-column gap
  function splitImgLR(img) {
    var W = img.width, H = img.height, d = img.data;
    var col = new Uint8Array(W), x, y;
    for (x = 0; x < W; x++) for (y = 0; y < H; y++) if (d[(y * W + x) * 4 + 3] > 16) { col[x] = 1; break; }
    var best = null, start = -1;
    for (x = 0; x < W; x++) {
      if (!col[x]) { if (start < 0) start = x; }
      else { if (start > 0 && (!best || x - start > best[1] - best[0])) best = [start, x]; start = -1; }
    }
    if (!best) return null;
    var mid = (best[0] + best[1]) >> 1;
    function cut(a, b) {
      var w = b - a, out = new Uint8ClampedArray(w * H * 4);
      for (var yy = 0; yy < H; yy++) out.set(d.subarray((yy * W + a) * 4, (yy * W + b) * 4), yy * w * 4);
      return trimImg({ width: w, height: H, data: out });
    }
    var l = cut(0, mid), r = cut(mid, W);
    return (l && r) ? { l: l, r: r } : null;
  }

  // ---------- strand detection ----------
  function findPeaks(a, minDist, minProm) {
    var n = a.length, cand = [], i;
    for (i = 1; i < n - 1; i++) if (a[i] > a[i - 1] && a[i] >= a[i + 1]) cand.push(i);
    var peaks = [];
    for (var ci = 0; ci < cand.length; ci++) {
      var p = cand[ci], lmin = a[p], rmin = a[p], j;
      for (j = p - 1; j >= 0; j--) { if (a[j] > a[p]) break; if (a[j] < lmin) lmin = a[j]; }
      for (j = p + 1; j < n; j++) { if (a[j] > a[p]) break; if (a[j] < rmin) rmin = a[j]; }
      var prom = a[p] - Math.max(lmin, rmin);
      if (prom >= minProm) peaks.push({ x: p, prom: prom });
    }
    peaks.sort(function (u, v) { return v.prom - u.prom; });
    var kept = [];
    for (var k = 0; k < peaks.length; k++) {
      var pk = peaks[k], ok = true;
      for (var m = 0; m < kept.length; m++) if (Math.abs(kept[m].x - pk.x) < minDist) { ok = false; break; }
      if (ok) kept.push(pk);
    }
    return kept; // prominence-ordered
  }

  function detectStrands(alpha, W, H, minSep, want) {
    var bottom = new Float32Array(W), top = new Float32Array(W);
    var minX = W, maxX = -1, x, y;
    for (x = 0; x < W; x++) {
      top[x] = -1; bottom[x] = 0;
      for (y = 0; y < H; y++) if (alpha[y * W + x] > 16) { top[x] = y; break; }
      if (top[x] < 0) continue;
      for (y = H - 1; y >= 0; y--) if (alpha[y * W + x] > 16) { bottom[x] = y; break; }
      if (x < minX) minX = x; if (x > maxX) maxX = x;
    }
    if (maxX < 0) return [];
    // box smooth k=41, zero-padded 'same'
    var k = 41, hk = 20, sm = new Float32Array(W);
    var pre = new Float32Array(W + 1);
    for (x = 0; x < W; x++) pre[x + 1] = pre[x] + bottom[x];
    for (x = 0; x < W; x++) {
      var a0 = Math.max(0, x - hk), a1 = Math.min(W - 1, x + hk);
      sm[x] = (pre[a1 + 1] - pre[a0]) / k;
    }
    var pk = findPeaks(sm, minSep, 10);
    var xs = [];
    for (var i = 0; i < pk.length && xs.length < want; i++) xs.push(pk[i].x);
    // fill up: farthest-from-existing candidates with content
    var guard = 0;
    while (xs.length < want && guard++ < 50) {
      var best = -1, bestD = -1;
      for (var t = 0; t < 40; t++) {
        var cx = Math.round(minX + 30 + (maxX - minX - 60) * t / 39);
        if (cx < 0 || cx >= W || top[cx] < 0) continue;
        var dmin = 1e9;
        for (var m = 0; m < xs.length; m++) dmin = Math.min(dmin, Math.abs(cx - xs[m]));
        if (xs.length === 0) dmin = 1e9 - t;
        if (dmin > bestD) { bestD = dmin; best = cx; }
      }
      if (best < 0) break;
      xs.push(best);
    }
    xs.sort(function (a, b) { return a - b; });
    var strands = [];
    for (var s = 0; s < xs.length; s++) {
      var sx = xs[s];
      if (top[sx] < 0) continue;
      strands.push({ x: sx, tipY: bottom[sx], rootY: top[sx] });
    }
    return strands;
  }

  // ---------- part record ----------
  // mask: optional Uint8 side mask multiplied onto alpha
  function makePart(name, layer, fullAlpha, mask, W, H, slot, z, side, strands) {
    var eff = fullAlpha;
    if (mask) {
      eff = new Uint8Array(W * H);
      for (var i = 0; i < W * H; i++) eff[i] = mask[i] ? fullAlpha[i] : 0;
    }
    var bb = bboxOf(eff, W, H, 8);
    if (!bb) return null;
    var pad = 2;
    var x0 = Math.max(0, bb.x0 - pad), y0 = Math.max(0, bb.y0 - pad);
    var x1 = Math.min(W, bb.x1 + 1 + pad), y1 = Math.min(H, bb.y1 + 1 + pad);
    var w = x1 - x0, h = y1 - y0;
    var data = new Uint8ClampedArray(w * h * 4);
    var img = layer.imageData, lw = img.width, lh = img.height;
    var lx = layer.left | 0, ly = layer.top | 0, ld = img.data;
    for (var y = y0; y < y1; y++) {
      for (var x = x0; x < x1; x++) {
        var di = ((y - y0) * w + (x - x0)) * 4;
        var yy = y - ly, xx = x - lx;
        if (yy >= 0 && yy < lh && xx >= 0 && xx < lw) {
          var li = (yy * lw + xx) * 4;
          data[di] = ld[li]; data[di + 1] = ld[li + 1]; data[di + 2] = ld[li + 2];
        }
        data[di + 3] = eff[y * W + x];
      }
    }
    return {
      name: name, x: x0, y: y0, w: w, h: h, z: z,
      depth: slot.depth, group: slot.group, phys: slot.phys || null,
      fade: slot.fade || null, side: side || null, strands: strands || null,
      img: { width: w, height: h, data: data }
    };
  }

  // ---------- main ----------
  function buildRig(psd, opts) {
    opts = opts || {};
    var W = psd.width, H = psd.height;
    var warnings = [];
    var kids = (psd.children || []).filter(function (c) { return c.imageData; });
    if (!kids.length) throw new Error('No image layers found. Use a flat PSD without layer groups.');

    // full alphas, cleaned
    var entries = [];
    for (var i = 0; i < kids.length; i++) {
      var name = normName(kids[i].name);
      var fa = cleanAlpha(fullAlphaOf(kids[i], W, H), W, H, 40);
      entries.push({ name: name, layer: kids[i], alpha: fa });
    }
    var byName = {};
    entries.forEach(function (e) { byName[e.name] = e; });

    // face anchor (required-ish)
    var faceE = byName['face'];
    var FACE;
    if (faceE) {
      var fb = bboxOf(faceE.alpha, W, H, 8), fc = centroidOf(faceE.alpha, W, H);
      FACE = { cx: fc.cx, cy: fc.cy, x0: fb.x0, x1: fb.x1, y0: fb.y0, y1: fb.y1 };
    } else {
      warnings.push('No face layer; using the canvas center as the face anchor');
      FACE = { cx: W / 2, cy: H * 0.3, x0: W * 0.35, x1: W * 0.65, y0: H * 0.1, y1: H * 0.5 };
    }

    // build parts
    var parts = [], z = 0, sided = {};
    for (var ei = 0; ei < entries.length; ei++) {
      var e = entries[ei];
      var bn = baseName(e.name);
      var slot = SLOTS[bn];
      if (!slot) {
        var c0 = centroidOf(e.alpha, W, H);
        slot = { depth: 1.0, group: (c0 && c0.cy < FACE.y1) ? 'head' : 'body' };
        warnings.push('Unknown layer "' + e.name + '"; treating it as ' + slot.group);
      }
      var explicitSide = sideName(e.name);
      if (slot.split && explicitSide) {
        var rec0 = makePart(bn + '_' + explicitSide, e.layer, e.alpha, null, W, H, slot, z, explicitSide.toUpperCase(), null);
        if (rec0) {
          parts.push(rec0); z++;
          sided[bn + '|' + explicitSide] = e.alpha;
        }
      } else if (slot.split) {
        var masks = splitSides(e.alpha, W, H, FACE.cx);
        var got = false;
        ['l', 'r'].forEach(function (s) {
          if (!masks[s]) return;
          var rec = makePart(e.name + '_' + s, e.layer, e.alpha, masks[s], W, H, slot, z, s.toUpperCase(), null);
          if (rec) {
            parts.push(rec); z++;
            var ma = new Uint8Array(W * H);
            for (var q = 0; q < W * H; q++) ma[q] = masks[s][q] ? e.alpha[q] : 0;
            sided[bn + '|' + s] = ma;
            got = true;
          }
        });
        if (!got) warnings.push('Could not split "' + e.name + '" into left/right parts');
      } else if (slot.phys) {
        var isPart = /_\d+$/.test(e.name);
        var bb2 = bboxOf(e.alpha, W, H, 16);
        var wpx = bb2 ? (bb2.x1 - bb2.x0) : 0;
        var want = isPart ? Math.max(2, Math.min(6, Math.round(wpx / 110))) : 6;
        var minSep = Math.max(30, Math.round(wpx / (want * 1.6)));
        var strands = detectStrands(e.alpha, W, H, minSep, want);
        var rec2 = makePart(e.name, e.layer, e.alpha, null, W, H, slot, z, null, strands);
        if (rec2) { parts.push(rec2); z++; }
      } else {
        var rec3 = makePart(e.name, e.layer, e.alpha, null, W, H, slot, z, null, null);
        if (rec3) { parts.push(rec3); z++; }
      }
    }

    // anchors
    var anchors = { face: FACE };
    ['l', 'r'].forEach(function (s) {
      var ew = sided['eyewhite|' + s], ir = sided['irides|' + s], ec = sided['eye_close|' + s];
      var K = 'eye' + s.toUpperCase();
      if (ew) {
        var b = bboxOf(ew, W, H, 8);
        var a = { x0: b.x0, x1: b.x1, y0: b.y0, y1: b.y1 };
        var ic = ir ? centroidOf(ir, W, H) : centroidOf(ew, W, H);
        a.icx = ic.cx; a.icy = ic.cy;
        var cc = ec ? centroidOf(ec, W, H) : null;
        a.closeY = cc ? cc.cy : (b.y0 + (b.y1 - b.y0) * 0.62);
        anchors[K] = a;
      }
    });
    if (!anchors.eyeL || !anchors.eyeR) warnings.push('Eye anchors are incomplete; check eyewhite and irides layers');

    var mSrc = byName['mouth_open'] || byName['mouth_close'];
    if (mSrc) {
      var mb = bboxOf(mSrc.alpha, W, H, 8), mc = centroidOf(mSrc.alpha, W, H);
      anchors.mouth = { x0: mb.x0, x1: mb.x1, y0: mb.y0, y1: mb.y1, cx: mc.cx, cy: mc.cy };
    } else {
      warnings.push('No mouth_open or mouth_close layer');
      anchors.mouth = { x0: FACE.cx - 20, x1: FACE.cx + 20, y0: FACE.cy + 40, y1: FACE.cy + 60, cx: FACE.cx, cy: FACE.cy + 50 };
    }

    var nE = byName['neck'];
    if (nE) {
      var nb = bboxOf(nE.alpha, W, H, 8), nc = centroidOf(nE.alpha, W, H);
      anchors.neckPivot = { cx: nc.cx, cy: nb.y0 + (nb.y1 - nb.y0) * 0.85 };
      anchors.neckTop = nb.y0; anchors.neckBottom = nb.y1;
    } else {
      anchors.neckPivot = { cx: FACE.cx, cy: FACE.y1 + 20 };
      anchors.neckTop = FACE.y1; anchors.neckBottom = FACE.y1 + 60;
    }
    anchors.bodyPivot = { cx: anchors.neckPivot.cx, cy: H };
    anchors.faceScale = (FACE.x1 - FACE.x0) / 333.0;
    anchors.hairRootY = FACE.y0 + 60;

    // ---------- synthesize missing close diffs from generic parts ----------
    var synth = { eye: false, mouth: false };
    var G = opts.generic;
    if (G) {
      var findPart = function (pref) { return parts.filter(function (p) { return p.name.indexOf(pref) === 0; }); };
      // eyes
      var generatedEyes = [];
      if ((G.eyeL && anchors.eyeL) || (G.eyeR && anchors.eyeR)) {
        var slotEC = SLOTS['eye_close'];
        var mk = function (S, gimg, side) {
          var lash = findPart('eyelash_' + side.toLowerCase())[0] || findPart('eyebrow_' + side.toLowerCase())[0];
          var tint = lash ? meanColorOfImg(lash.img || { data: new Uint8ClampedArray(0) }, false) : null;
          return synthPart('eye_close_' + side.toLowerCase(), gimg,
            (S.x1 - S.x0) * 1.1, (S.x0 + S.x1) / 2, S.closeY, 0.55, tint, slotEC, side);
        };
        ['L', 'R'].forEach(function (side) {
          if (G['eye' + side] && anchors['eye' + side] && !findPart('eye_close_' + side.toLowerCase()).length) {
            generatedEyes.push(mk(anchors['eye' + side], G['eye' + side], side));
          }
        });
      }
      if (generatedEyes.length) {
        var idxE = lastIndexWhere(parts, function (p) { return p.name.indexOf('eyelash') === 0; });
        if (idxE < 0) idxE = lastIndexWhere(parts, function (p) { return p.name.indexOf('irides') === 0; });
        if (idxE < 0) idxE = lastIndexWhere(parts, function (p) { return p.name === 'face'; });
        parts.splice.apply(parts, [idxE + 1, 0].concat(generatedEyes));
        synth.eye = true;
        warnings.push('Generated closed-eye layer(s) because eye_close was missing');
      }
      // mouth
      if (G.mouth && !findPart('mouth_close').length && byName['mouth_open']) {
        var m = anchors.mouth;
        var mc = synthPart('mouth_close', G.mouth,
          (m.x1 - m.x0) * 1.1, m.cx, m.y0 + 0.30 * (m.y1 - m.y0), 0.5,
          (function () { var mo = parts.filter(function (p) { return p.name === 'mouth_open'; })[0];
                         return mo ? meanColorOfImg(mo.img, true) : null; })(),
          SLOTS['mouth_close'], null);
        var idxM = lastIndexWhere(parts, function (p) { return p.name === 'mouth_open'; });
        if (idxM < 0) idxM = lastIndexWhere(parts, function (p) { return p.name === 'face'; });
        parts.splice(idxM + 1, 0, mc);
        synth.mouth = true;
        warnings.push('Generated a closed-mouth layer because mouth_close was missing');
      }
    }
    var tailParts = parts.filter(function (part) { return baseName(part.name) === 'tail'; });
    if (tailParts.length) {
      parts = parts.filter(function (part) { return baseName(part.name) !== 'tail'; });
      var firstHair = parts.findIndex(function (part) {
        var name = baseName(part.name);
        return name === 'back hair' || name === 'front hair';
      });
      if (firstHair >= 0) parts.splice.apply(parts, [firstHair, 0].concat(tailParts));
      else parts = tailParts.concat(parts);
    }
    var eyeParts = parts.filter(function (part) { return part.side; });
    if (eyeParts.length) {
      parts = parts.filter(function (part) { return !part.side; });
      eyeParts.sort(function (a, b) { return (a.depth - b.depth) || (a.z - b.z); });
      var eyeAt = lastIndexWhere(parts, function (part) { return part.group === 'head' && part.depth < eyeParts[0].depth; });
      parts.splice.apply(parts, [eyeAt + 1, 0].concat(eyeParts));
    }
    for (var zi = 0; zi < parts.length; zi++) parts[zi].z = zi;

    return { canvas: { w: W, h: H }, layers: parts, anchors: anchors, warnings: warnings, synth: synth };
  }

  // in-place preprocessing: denoise every layer (connected components >= 40px)
  // and trim to content bbox. Fixes PSDs with low-alpha noise across the canvas.
  function cleanPsdLayers(psd) {
    var stats = { noisy: 0, pinholes: 0, layers: 0 };
    var kids = (psd.children || []).filter(function (c) { return c.imageData; });
    for (var i = 0; i < kids.length; i++) {
      var c = kids[i], img = c.imageData, W = img.width, H = img.height, d = img.data;
      stats.layers++;
      var a = new Uint8Array(W * H), j, before = 0, after = 0;
      for (j = 0; j < W * H; j++) { a[j] = d[j * 4 + 3]; if (a[j]) before++; }
      cleanAlpha(a, W, H, 40);
      for (j = 0; j < W * H; j++) { if (a[j]) after++; d[j * 4 + 3] = a[j]; }
      if (/^(front hair|back hair)$/.test(baseName(normName(c.name)))) {
        var filled = fillHairPinholes(img, W, H, 2); stats.pinholes += filled;
        var mask = c.mask && c.mask.imageData;
        if (filled && mask) {
          var md = mask.data, MW = mask.width, MH = mask.height;
          for (var py = 0; py < H; py++) for (var px = 0; px < W; px++) {
            j = py * W + px; var alpha = d[j * 4 + 3]; if (alpha <= a[j]) continue;
            var mx = (c.left | 0) + px - (c.mask.left | 0), my = (c.top | 0) + py - (c.mask.top | 0);
            if (mx < 0 || my < 0 || mx >= MW || my >= MH) continue;
            var mi = (my * MW + mx) * 4; md[mi] = md[mi + 1] = md[mi + 2] = 255;
          }
        }
      }
      if (after < before) stats.noisy++;
      for (j = 0; j < W * H; j++) a[j] = d[j * 4 + 3];
      var bb = bboxOf(a, W, H, 0);
      if (!bb) continue;
      var pad = 4;
      var x0 = Math.max(0, bb.x0 - pad), y0 = Math.max(0, bb.y0 - pad);
      var x1 = Math.min(W - 1, bb.x1 + pad), y1 = Math.min(H - 1, bb.y1 + pad);
      var w = x1 - x0 + 1, h = y1 - y0 + 1;
      if (w >= W && h >= H) continue;
      var nd = new Uint8ClampedArray(w * h * 4);
      for (var y = 0; y < h; y++) {
        var so = ((y + y0) * W + x0) * 4;
        nd.set(d.subarray(so, so + w * 4), y * w * 4);
      }
      c.imageData = { width: w, height: h, data: nd };
      c.left = (c.left | 0) + x0; c.top = (c.top | 0) + y0;
      c.right = c.left + w; c.bottom = c.top + h;
      if (c.canvas) c.canvas = undefined;
    }
    return stats;
  }

  return { buildRig: buildRig, normName: normName, baseName: baseName, sideName: sideName, cleanPsdLayers: cleanPsdLayers,
           mapFaceTracking: mapFaceTracking, chestWeight: chestWeight, trackingSelfCheck: trackingSelfCheck, rigSelfCheck: rigSelfCheck,
           flattenPsdToImg: flattenPsdToImg, splitImgLR: splitImgLR,
           _internals: { findPeaks: findPeaks, detectStrands: detectStrands,
                          labelComponents: labelComponents, cleanAlpha: cleanAlpha, fillHairPinholes: fillHairPinholes } };
});
