← 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 };