← back to Dw Pairs Well

lib/ai-stripes.js

493 lines

// AI-generated coordinate patterns — stripes, plaids, checks.
//
// Hard requirements (Steve 2026-05-13):
//   • Use 2-3 colors max from the source palette, never solid blocks.
//   • Cover three width tiers: 2″, 6″, 12″ stripes.
//   • Include unique styles: pinstripe, wavy, cabana, awning, hairline, etc.
//   • One pattern MUST use the lightest + darkest palette colors only.
//   • Seamless tiling is structural — every variant uses SVG <pattern>.
//   • Client can slide the repeat width (tile_in inches). Each variant snaps
//     tile_in DOWN to the nearest multiple of its pitch so the repeat always
//     divides evenly — no half-stripes at the seam.
//
// All units in inches at the spec level; px = inches × (CANVAS_PX / tile_in).

const { hexToLab, hexToRgb } = require('./color');

const CANVAS_PX = 600;

function luminance(hex) {
  const lab = hexToLab(hex);
  return lab ? lab.L : null;
}

function dedupe(arr) {
  const seen = new Set(); const out = [];
  for (const h of arr) {
    const k = String(h || '').toLowerCase();
    if (!h || seen.has(k)) continue;
    seen.add(k); out.push(h);
  }
  return out;
}

function lightestAndDarkest(palette) {
  const ranked = dedupe(palette)
    .map(h => ({ hex: h, L: luminance(h) }))
    .filter(x => x.L !== null)
    .sort((a, b) => a.L - b.L);
  if (ranked.length < 2) return null;
  return { darkest: ranked[0].hex, lightest: ranked[ranked.length - 1].hex };
}

// Pick N visually distinct colors from the palette (greedy by ΔL).
function pickN(palette, n, fallback) {
  const clean = dedupe(palette);
  if (clean.length >= n) return clean.slice(0, n);
  while (clean.length < n) clean.push(fallback || '#cccccc');
  return clean;
}

// Snap requested repeat (inches) DOWN to nearest multiple of pitchIn so the
// pattern tiles seamlessly. Floor at one full cycle.
function snapTile(tileIn, pitchIn) {
  const t = Math.max(pitchIn, Math.floor(tileIn / pitchIn) * pitchIn);
  return t;
}

function px(inches, tileIn) {
  return inches * (CANVAS_PX / tileIn);
}

function escHex(h) {
  return String(h || '#000').replace(/[^#0-9a-fA-F]/g, '');
}

// ─── Pattern body builders ──────────────────────────────────────────────────
// Each returns { body, patternW, patternH } in PIXEL units relative to a
// repeating pattern tile (the <pattern> element). The body fills exactly one
// tile and the SVG pattern engine handles seamless repetition.

function evenStripeBody(colors, pitchPx) {
  // Vertical alternating stripes: one full A/B cycle = 2 × pitchPx wide.
  // For 3-color rotations the cycle is N × pitchPx.
  const n = colors.length;
  const w = pitchPx * n;
  let body = '';
  for (let i = 0; i < n; i++) {
    body += `<rect x="${i * pitchPx}" y="0" width="${pitchPx}" height="${CANVAS_PX}" fill="${escHex(colors[i])}"/>`;
  }
  return { body, patternW: w, patternH: CANVAS_PX };
}

function pinstripeBody({ fieldColor, pinColor, fieldPx, pinPx }) {
  // field color the wide band, pinPx the thin contrast line, one cycle wide.
  const w = fieldPx + pinPx;
  const body =
    `<rect x="0" y="0" width="${fieldPx}" height="${CANVAS_PX}" fill="${escHex(fieldColor)}"/>` +
    `<rect x="${fieldPx}" y="0" width="${pinPx}" height="${CANVAS_PX}" fill="${escHex(pinColor)}"/>`;
  return { body, patternW: w, patternH: CANVAS_PX };
}

function pinstripeTriBody({ fieldColor, pinAColor, pinBColor, fieldPx, pinPx, gapPx }) {
  // field — pinA — small gap — pinB cycle.
  const w = fieldPx + pinPx + gapPx + pinPx;
  let x = 0; let body = '';
  body += `<rect x="${x}" y="0" width="${fieldPx}" height="${CANVAS_PX}" fill="${escHex(fieldColor)}"/>`; x += fieldPx;
  body += `<rect x="${x}" y="0" width="${pinPx}" height="${CANVAS_PX}" fill="${escHex(pinAColor)}"/>`;   x += pinPx;
  body += `<rect x="${x}" y="0" width="${gapPx}" height="${CANVAS_PX}" fill="${escHex(fieldColor)}"/>`;  x += gapPx;
  body += `<rect x="${x}" y="0" width="${pinPx}" height="${CANVAS_PX}" fill="${escHex(pinBColor)}"/>`;
  return { body, patternW: w, patternH: CANVAS_PX };
}

function cabanaBody({ wideColor, thinColor, accentColor, widePx, thinPx }) {
  // wide — thin — wide — thin(accent) cycle for visual interest.
  const w = widePx + thinPx + widePx + thinPx;
  let x = 0; let body = '';
  body += `<rect x="${x}" y="0" width="${widePx}" height="${CANVAS_PX}" fill="${escHex(wideColor)}"/>`;  x += widePx;
  body += `<rect x="${x}" y="0" width="${thinPx}" height="${CANVAS_PX}" fill="${escHex(thinColor)}"/>`;  x += thinPx;
  body += `<rect x="${x}" y="0" width="${widePx}" height="${CANVAS_PX}" fill="${escHex(wideColor)}"/>`;  x += widePx;
  body += `<rect x="${x}" y="0" width="${thinPx}" height="${CANVAS_PX}" fill="${escHex(accentColor || thinColor)}"/>`;
  return { body, patternW: w, patternH: CANVAS_PX };
}

function wavyStripeBody({ colorA, colorB, pitchPx, ampPx, wavelengthPx }) {
  // A two-color wavy stripe: draw a stripe whose right edge undulates as a
  // sine wave. The pattern tile is (2 × pitchPx) wide × wavelengthPx tall so
  // it tiles seamlessly in both axes.
  const w = pitchPx * 2;
  const h = wavelengthPx;
  // Build the boundary path: down the right edge of the colorA region with a
  // sinusoidal wobble, then back up the inside of the colorB region.
  const steps = 24;
  let dA = `M0,0 L${pitchPx},0 `;
  for (let i = 1; i <= steps; i++) {
    const y = (i / steps) * h;
    const xOff = ampPx * Math.sin((i / steps) * 2 * Math.PI);
    dA += `L${(pitchPx + xOff).toFixed(2)},${y.toFixed(2)} `;
  }
  dA += `L0,${h} Z`;
  let dB = `M${pitchPx},0 L${w},0 L${w},${h} L${pitchPx},${h} `;
  for (let i = steps; i >= 1; i--) {
    const y = (i / steps) * h;
    const xOff = ampPx * Math.sin((i / steps) * 2 * Math.PI);
    dB += `L${(pitchPx + xOff).toFixed(2)},${y.toFixed(2)} `;
  }
  dB += 'Z';
  const body =
    `<path d="${dA}" fill="${escHex(colorA)}"/>` +
    `<path d="${dB}" fill="${escHex(colorB)}"/>`;
  return { body, patternW: w, patternH: h };
}

function awningBody({ colorA, colorB, widePx, narrowPx }) {
  // wide A — narrow B — wide A — narrow B cycle (classic awning)
  return cabanaBody({ wideColor: colorA, thinColor: colorB, accentColor: colorB, widePx, thinPx: narrowPx });
}

function hairlineBody({ fieldColor, lineColor, fieldPx, linePx }) {
  // Many thin hairlines on a field — field — line — field — line cycle.
  const w = fieldPx + linePx;
  const body =
    `<rect x="0" y="0" width="${fieldPx}" height="${CANVAS_PX}" fill="${escHex(fieldColor)}"/>` +
    `<rect x="${fieldPx}" y="0" width="${linePx}" height="${CANVAS_PX}" fill="${escHex(lineColor)}"/>`;
  return { body, patternW: w, patternH: CANVAS_PX };
}

// ─── Plaid / check builders ────────────────────────────────────────────────
// Plaids cross vertical + horizontal bands. To avoid muddy alpha mixing we
// use multiply-mode by drawing v-bands at 100%, then h-bands with a slightly
// darker shade where they cross, computed in JS rather than via opacity.

function tintMix(a, b, t) {
  const ra = hexToRgb(a), rb = hexToRgb(b);
  if (!ra || !rb) return a;
  const mix = (x, y) => Math.round(x * (1 - t) + y * t);
  const r = mix(ra.r, rb.r), g = mix(ra.g, rb.g), bch = mix(ra.b, rb.b);
  return '#' + [r, g, bch].map(v => v.toString(16).padStart(2, '0')).join('');
}

function buffaloCheckBody({ colorA, colorB, pitchPx }) {
  // Classic two-color buffalo: A, B, A, B grid. The intersection of B-band
  // (vertical) and B-band (horizontal) is a darker mix.
  const w = pitchPx * 2;
  const h = pitchPx * 2;
  const mid = tintMix(colorA, colorB, 0.85);
  // Quadrants of the unit cell:
  //   (0,0) = A         (pitch,0)   = A+B blend (light intersection)
  //   (0,p) = A+B blend (pitch,p)   = B
  // Simpler: top-left = A, top-right = B, bottom-left = B, bottom-right = mid
  const body =
    `<rect x="0" y="0" width="${pitchPx}" height="${pitchPx}" fill="${escHex(colorA)}"/>` +
    `<rect x="${pitchPx}" y="0" width="${pitchPx}" height="${pitchPx}" fill="${escHex(colorB)}"/>` +
    `<rect x="0" y="${pitchPx}" width="${pitchPx}" height="${pitchPx}" fill="${escHex(colorB)}"/>` +
    `<rect x="${pitchPx}" y="${pitchPx}" width="${pitchPx}" height="${pitchPx}" fill="${escHex(mid)}"/>`;
  return { body, patternW: w, patternH: h };
}

function ginghamBody({ colorA, colorB, pitchPx }) {
  // Gingham: light field, medium horizontals + verticals, dark intersection.
  const w = pitchPx * 2;
  const h = pitchPx * 2;
  const light = colorA;
  const mid = tintMix(colorA, colorB, 0.55);
  const dark = colorB;
  const body =
    `<rect x="0" y="0" width="${pitchPx}" height="${pitchPx}" fill="${escHex(light)}"/>` +
    `<rect x="${pitchPx}" y="0" width="${pitchPx}" height="${pitchPx}" fill="${escHex(mid)}"/>` +
    `<rect x="0" y="${pitchPx}" width="${pitchPx}" height="${pitchPx}" fill="${escHex(mid)}"/>` +
    `<rect x="${pitchPx}" y="${pitchPx}" width="${pitchPx}" height="${pitchPx}" fill="${escHex(dark)}"/>`;
  return { body, patternW: w, patternH: h };
}

function tattersallBody({ fieldColor, lineA, lineB, cellPx, linePx }) {
  // Open grid: thin alternating lines over a field. cellPx = grid spacing.
  const w = cellPx * 2;
  const h = cellPx * 2;
  let body = `<rect x="0" y="0" width="${w}" height="${h}" fill="${escHex(fieldColor)}"/>`;
  // vertical lines at x=0 and x=cellPx
  body += `<rect x="0" y="0" width="${linePx}" height="${h}" fill="${escHex(lineA)}"/>`;
  body += `<rect x="${cellPx}" y="0" width="${linePx}" height="${h}" fill="${escHex(lineB)}"/>`;
  // horizontal lines
  body += `<rect x="0" y="0" width="${w}" height="${linePx}" fill="${escHex(lineA)}"/>`;
  body += `<rect x="0" y="${cellPx}" width="${w}" height="${linePx}" fill="${escHex(lineB)}"/>`;
  return { body, patternW: w, patternH: h };
}

function windowpaneBody({ fieldColor, lineColor, cellPx, linePx }) {
  // Large open grid, single line color.
  const body =
    `<rect x="0" y="0" width="${cellPx}" height="${cellPx}" fill="${escHex(fieldColor)}"/>` +
    `<rect x="0" y="0" width="${linePx}" height="${cellPx}" fill="${escHex(lineColor)}"/>` +
    `<rect x="0" y="0" width="${cellPx}" height="${linePx}" fill="${escHex(lineColor)}"/>`;
  return { body, patternW: cellPx, patternH: cellPx };
}

function madrasBody({ colors, pitchPx }) {
  // Asymmetric 3-color plaid. Bands at widths [2, 1, 3, 1, 2, 3] × unit/12.
  const unit = pitchPx;
  const widthsV = [3, 1, 4, 1, 3].map(n => n * unit / 3);
  const widthsH = [2, 1, 3, 1, 5].map(n => n * unit / 3);
  const w = widthsV.reduce((s, x) => s + x, 0);
  const h = widthsH.reduce((s, x) => s + x, 0);
  let body = '';
  // vertical bands
  let x = 0;
  widthsV.forEach((bw, i) => {
    body += `<rect x="${x}" y="0" width="${bw}" height="${h}" fill="${escHex(colors[i % colors.length])}"/>`;
    x += bw;
  });
  // horizontal bands at 60% blend with bottom of stack
  let y = 0;
  widthsH.forEach((bh, i) => {
    const c = colors[(i + 1) % colors.length];
    body += `<rect x="0" y="${y}" width="${w}" height="${bh}" fill="${escHex(c)}" opacity="0.55"/>`;
    y += bh;
  });
  return { body, patternW: w, patternH: h };
}

function tartanBody({ colors, pitchPx }) {
  // Crossed tartan in 3 colors, symmetric. Bands [4,1,2,1,4] × unit/6.
  const unit = pitchPx;
  const widths = [4, 1, 2, 1, 4].map(n => n * unit / 6);
  const w = widths.reduce((s, x) => s + x, 0);
  const h = w;
  let body = '';
  let x = 0;
  widths.forEach((bw, i) => {
    body += `<rect x="${x}" y="0" width="${bw}" height="${h}" fill="${escHex(colors[i % colors.length])}"/>`;
    x += bw;
  });
  let y = 0;
  widths.forEach((bh, i) => {
    const c = colors[i % colors.length];
    body += `<rect x="0" y="${y}" width="${w}" height="${bh}" fill="${escHex(c)}" opacity="0.5"/>`;
    y += bh;
  });
  return { body, patternW: w, patternH: h };
}

function glenPlaidBody({ colorA, colorB, pitchPx }) {
  // Dense Glen plaid: 2px lines, 4-cell repeat with shifted offset.
  const w = pitchPx * 4;
  const h = pitchPx * 4;
  let body = `<rect x="0" y="0" width="${w}" height="${h}" fill="${escHex(colorA)}"/>`;
  const linePx = Math.max(1, Math.round(pitchPx * 0.25));
  for (let i = 0; i < 4; i++) {
    body += `<rect x="${i * pitchPx}" y="0" width="${linePx}" height="${h}" fill="${escHex(colorB)}"/>`;
    body += `<rect x="0" y="${i * pitchPx}" width="${w}" height="${linePx}" fill="${escHex(colorB)}"/>`;
  }
  return { body, patternW: w, patternH: h };
}

function boxPlaidBody({ colors, pitchPx }) {
  // 3-color uniform box plaid.
  const w = pitchPx * 3;
  const h = pitchPx * 3;
  let body = '';
  for (let i = 0; i < 3; i++) {
    body += `<rect x="${i * pitchPx}" y="0" width="${pitchPx}" height="${h}" fill="${escHex(colors[i % colors.length])}"/>`;
  }
  for (let j = 0; j < 3; j++) {
    body += `<rect x="0" y="${j * pitchPx}" width="${w}" height="${pitchPx}" fill="${escHex(colors[(j + 1) % colors.length])}" opacity="0.55"/>`;
  }
  return { body, patternW: w, patternH: h };
}

// ─── Top-level catalog ──────────────────────────────────────────────────────

// Returns an array of variant SPECS — pure data, no SVG yet. The renderer
// turns each into an SVG poster at a given tile_in.
function variantCatalog(palette) {
  const ld = lightestAndDarkest(palette);
  // Always have something to fall back to even when palette has 1 color
  const fallback = palette[0] || '#999999';
  const darkest  = ld ? ld.darkest  : fallback;
  const lightest = ld ? ld.lightest : (palette[1] || tintMix(fallback, '#ffffff', 0.7));
  const top3     = pickN(palette, 3, fallback);
  const top2     = pickN(palette, 2, fallback);

  return [
    // ─── Lightest+Darkest required slot ─────────────────────────────────────
    { id: 1, motif: 'stripe', style: 'even',       label: '2″ Stripe — Lightest + Darkest', pitch_in: 2,  colors: [lightest, darkest], colorRoles: 'lightest+darkest' },
    { id: 2, motif: 'stripe', style: 'even',       label: '6″ Stripe — Lightest + Darkest', pitch_in: 6,  colors: [lightest, darkest], colorRoles: 'lightest+darkest' },
    { id: 3, motif: 'stripe', style: 'even',       label: '12″ Cabana — Lightest + Darkest', pitch_in: 12, colors: [lightest, darkest], colorRoles: 'lightest+darkest' },

    // ─── Even stripes (palette-driven) ──────────────────────────────────────
    { id: 4, motif: 'stripe', style: 'even',       label: '2″ Stripe — Top 2',  pitch_in: 2,  colors: top2 },
    { id: 5, motif: 'stripe', style: 'even',       label: '6″ Stripe — Top 2',  pitch_in: 6,  colors: top2 },
    { id: 6, motif: 'stripe', style: 'triple',     label: '2″ Triple — Top 3',  pitch_in: 2,  colors: top3 },

    // ─── Pinstripes ─────────────────────────────────────────────────────────
    { id: 7, motif: 'stripe', style: 'pinstripe',      label: 'Pinstripe — light field, dark pin',   pitch_in: 2, colors: [lightest, darkest], pinstripe: { field_in: 1.75, pin_in: 0.25 } },
    { id: 8, motif: 'stripe', style: 'pinstripe-pair', label: 'Twin Pinstripe — 3 colors',           pitch_in: 3, colors: top3,                pinstripe: { field_in: 2.5,  pin_in: 0.125, gap_in: 0.25 } },

    // ─── Cabana / awning ────────────────────────────────────────────────────
    { id: 9,  motif: 'stripe', style: 'cabana',  label: 'Cabana — 6″ wide + 0.5″ accent',   pitch_in: 6,  colors: top3 },
    { id: 10, motif: 'stripe', style: 'awning',  label: 'Awning — 12″ + 2″ alt.',           pitch_in: 12, colors: [lightest, darkest] },

    // ─── Wavy / hairline / block ────────────────────────────────────────────
    { id: 11, motif: 'stripe', style: 'wavy',     label: 'Wavy Stripe — 6″ undulating',  pitch_in: 6, colors: top2 },
    { id: 12, motif: 'stripe', style: 'wavy',     label: 'Wavy Stripe — 2″ tight',       pitch_in: 2, colors: top2 },
    { id: 13, motif: 'stripe', style: 'hairline', label: 'Hairline — 4″ field, 0.25″ line', pitch_in: 4, colors: top2 },
    { id: 14, motif: 'stripe', style: 'block',    label: '12″ Block — Light + Dark',     pitch_in: 12, colors: [lightest, darkest] },
    { id: 15, motif: 'stripe', style: 'even',     label: '6″ Triple — 3 colors',         pitch_in: 6, colors: top3 },
    { id: 16, motif: 'stripe', style: 'pinstripe',label: 'Pinstripe — dark field, light pin', pitch_in: 2, colors: [darkest, lightest], pinstripe: { field_in: 1.75, pin_in: 0.25 } },

    // ─── Plaids / checks ────────────────────────────────────────────────────
    { id: 17, motif: 'plaid',   style: 'buffalo',     label: 'Buffalo Check 6″ — Light + Dark', pitch_in: 6,  colors: [lightest, darkest] },
    { id: 18, motif: 'plaid',   style: 'gingham',     label: 'Gingham 2″ — Light + Dark',       pitch_in: 2,  colors: [lightest, darkest] },
    { id: 19, motif: 'plaid',   style: 'tattersall',  label: 'Tattersall — 4″ grid, 3 colors',  pitch_in: 4,  colors: top3 },
    { id: 20, motif: 'plaid',   style: 'windowpane',  label: 'Windowpane 12″',                  pitch_in: 12, colors: [lightest, darkest] },
    { id: 21, motif: 'plaid',   style: 'madras',      label: 'Madras — 3 colors',               pitch_in: 6,  colors: top3 },
    { id: 22, motif: 'tartan',  style: 'tartan',      label: 'Tartan 12″ — 3 colors',           pitch_in: 12, colors: top3 },
    { id: 23, motif: 'plaid',   style: 'glen',        label: 'Glen Plaid 2″ — Light + Dark',    pitch_in: 2,  colors: [lightest, darkest] },
    { id: 24, motif: 'plaid',   style: 'box',         label: 'Box Plaid 6″ — 3 colors',         pitch_in: 6,  colors: top3 }
  ];
}

function renderVariant(v, requestedTileIn) {
  // Snap repeat so the unit cell tiles evenly. Each pattern has a "cycle" of
  // pitch_in that the tile_in must be a multiple of.
  const cycleIn = (v.style === 'pinstripe-pair') ? v.pitch_in
                : (v.style === 'cabana')          ? v.pitch_in * 2 + 1        // wide+thin+wide+thin
                : (v.style === 'awning')          ? v.pitch_in + 2
                : v.pitch_in * (v.colors ? v.colors.length : 2);
  // Most patterns tile fine at any tile_in that's a multiple of pitch_in.
  // Use pitch_in as the snap unit for simplicity and predictability.
  const tileIn = snapTile(requestedTileIn, v.pitch_in);
  const pitchPx = px(v.pitch_in, tileIn);

  let body, patternW, patternH;
  if (v.motif === 'stripe' && v.style === 'even') {
    ({ body, patternW, patternH } = evenStripeBody(v.colors, pitchPx));
  } else if (v.motif === 'stripe' && v.style === 'triple') {
    ({ body, patternW, patternH } = evenStripeBody(v.colors.slice(0, 3), pitchPx));
  } else if (v.motif === 'stripe' && v.style === 'pinstripe') {
    const fieldPx = px(v.pinstripe.field_in, tileIn);
    const pinPx   = px(v.pinstripe.pin_in,   tileIn);
    ({ body, patternW, patternH } = pinstripeBody({
      fieldColor: v.colors[0], pinColor: v.colors[1], fieldPx, pinPx
    }));
  } else if (v.motif === 'stripe' && v.style === 'pinstripe-pair') {
    const fieldPx = px(v.pinstripe.field_in, tileIn);
    const pinPx   = px(v.pinstripe.pin_in,   tileIn);
    const gapPx   = px(v.pinstripe.gap_in,   tileIn);
    ({ body, patternW, patternH } = pinstripeTriBody({
      fieldColor: v.colors[0], pinAColor: v.colors[1], pinBColor: v.colors[2],
      fieldPx, pinPx, gapPx
    }));
  } else if (v.motif === 'stripe' && v.style === 'cabana') {
    ({ body, patternW, patternH } = cabanaBody({
      wideColor: v.colors[0], thinColor: v.colors[1], accentColor: v.colors[2] || v.colors[1],
      widePx: pitchPx, thinPx: px(0.5, tileIn)
    }));
  } else if (v.motif === 'stripe' && v.style === 'awning') {
    ({ body, patternW, patternH } = awningBody({
      colorA: v.colors[0], colorB: v.colors[1], widePx: pitchPx, narrowPx: px(2, tileIn)
    }));
  } else if (v.motif === 'stripe' && v.style === 'wavy') {
    ({ body, patternW, patternH } = wavyStripeBody({
      colorA: v.colors[0], colorB: v.colors[1],
      pitchPx, ampPx: pitchPx * 0.18, wavelengthPx: pitchPx * 4
    }));
  } else if (v.motif === 'stripe' && v.style === 'hairline') {
    ({ body, patternW, patternH } = hairlineBody({
      fieldColor: v.colors[0], lineColor: v.colors[1],
      fieldPx: pitchPx, linePx: Math.max(2, px(0.25, tileIn))
    }));
  } else if (v.motif === 'stripe' && v.style === 'block') {
    ({ body, patternW, patternH } = evenStripeBody([v.colors[0], v.colors[1]], pitchPx));
  } else if (v.style === 'buffalo') {
    ({ body, patternW, patternH } = buffaloCheckBody({ colorA: v.colors[0], colorB: v.colors[1], pitchPx }));
  } else if (v.style === 'gingham') {
    ({ body, patternW, patternH } = ginghamBody({ colorA: v.colors[0], colorB: v.colors[1], pitchPx }));
  } else if (v.style === 'tattersall') {
    ({ body, patternW, patternH } = tattersallBody({
      fieldColor: v.colors[0], lineA: v.colors[1], lineB: v.colors[2],
      cellPx: pitchPx, linePx: Math.max(2, px(0.25, tileIn))
    }));
  } else if (v.style === 'windowpane') {
    ({ body, patternW, patternH } = windowpaneBody({
      fieldColor: v.colors[0], lineColor: v.colors[1],
      cellPx: pitchPx, linePx: Math.max(2, px(0.375, tileIn))
    }));
  } else if (v.style === 'madras') {
    ({ body, patternW, patternH } = madrasBody({ colors: v.colors, pitchPx }));
  } else if (v.style === 'tartan') {
    ({ body, patternW, patternH } = tartanBody({ colors: v.colors, pitchPx }));
  } else if (v.style === 'glen') {
    ({ body, patternW, patternH } = glenPlaidBody({ colorA: v.colors[0], colorB: v.colors[1], pitchPx }));
  } else if (v.style === 'box') {
    ({ body, patternW, patternH } = boxPlaidBody({ colors: v.colors, pitchPx }));
  } else {
    ({ body, patternW, patternH } = evenStripeBody(v.colors, pitchPx));
  }

  const patternId = 'p' + v.id + '_' + Math.round(tileIn);
  const svg =
    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${CANVAS_PX} ${CANVAS_PX}" width="${CANVAS_PX}" height="${CANVAS_PX}">` +
      `<defs><pattern id="${patternId}" patternUnits="userSpaceOnUse" width="${patternW}" height="${patternH}">${body}</pattern></defs>` +
      `<rect width="${CANVAS_PX}" height="${CANVAS_PX}" fill="url(#${patternId})"/>` +
    `</svg>`;

  return {
    image_url: 'data:image/svg+xml;utf8,' + encodeURIComponent(svg),
    tile_in_used: tileIn
  };
}

function generateCoordinates({ palette, tile_in }) {
  const t = Math.max(2, Math.min(60, Number(tile_in) || 24));
  const cat = variantCatalog(palette);
  return cat.map(v => {
    const r = renderVariant(v, t);
    return {
      id: `ai-${v.id}`,
      motif: v.motif,
      style: v.style,
      title: v.label,
      pitch_in: v.pitch_in,
      tile_in: r.tile_in_used,
      colors: v.colors,
      palette: v.colors,
      color_roles: v.colorRoles || null,
      image_url: r.image_url,
      why: [v.colorRoles ? v.colorRoles : `${v.pitch_in}″ ${v.style}`],
      provenance: 'ai-generated (seamless pattern)',
      status: 'placeholder',
      url: null
    };
  });
}

// Render a single arbitrary spec — used by the pattern editor (Wix-style
// admin UI). Spec is the same shape as a catalog entry: { motif, style,
// pitch_in, colors, pinstripe?, label? }. Returns { image_url, tile_in }.
function renderCustom(spec, tile_in) {
  const v = {
    id: 'custom',
    motif: spec.motif || 'stripe',
    style: spec.style || 'even',
    label: spec.label || (spec.style || 'even') + ' ' + (spec.pitch_in || 6) + '"',
    pitch_in: Math.max(0.25, Math.min(36, Number(spec.pitch_in) || 6)),
    colors: (Array.isArray(spec.colors) ? spec.colors : []).filter(Boolean).slice(0, 5),
    pinstripe: spec.pinstripe || { field_in: 1.75, pin_in: 0.25, gap_in: 0.25 },
    colorRoles: spec.colorRoles || null
  };
  if (v.colors.length === 0) v.colors = ['#888888'];
  const t = Math.max(2, Math.min(60, Number(tile_in) || 24));
  const r = renderVariant(v, t);
  return {
    motif: v.motif,
    style: v.style,
    pitch_in: v.pitch_in,
    tile_in: r.tile_in_used,
    colors: v.colors,
    image_url: r.image_url
  };
}

module.exports = { generateCoordinates, renderCustom };