← back to Dw Pairs Well
ai-coordinates: rebuild generator — 24 real seamless patterns (3 width tiers × multiple styles + plaids), lightest+darkest required, ?tile_in slider, /render endpoint for editor
56de022760ce5bcaa88f1e674783da01f85d312a · 2026-05-13 13:35:33 -0700 · Steve Abrams
Files touched
A lib/ai-stripes.jsM server.js
Diff
commit 56de022760ce5bcaa88f1e674783da01f85d312a
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed May 13 13:35:33 2026 -0700
ai-coordinates: rebuild generator — 24 real seamless patterns (3 width tiers × multiple styles + plaids), lightest+darkest required, ?tile_in slider, /render endpoint for editor
---
lib/ai-stripes.js | 492 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
server.js | 110 +++++-------
2 files changed, 537 insertions(+), 65 deletions(-)
diff --git a/lib/ai-stripes.js b/lib/ai-stripes.js
new file mode 100644
index 0000000..b3614b5
--- /dev/null
+++ b/lib/ai-stripes.js
@@ -0,0 +1,492 @@
+// 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 };
diff --git a/server.js b/server.js
index 86a4f42..096c185 100644
--- a/server.js
+++ b/server.js
@@ -5,6 +5,7 @@ const path = require('path');
const { parseTags, classify, score } = require('./lib/classify');
const { hexToLab, deltaE2000, safeParseHexArray } = require('./lib/color');
const { nearestPaintsBatch } = require('./lib/paint');
+const { generateCoordinates, renderCustom } = require('./lib/ai-stripes');
const PORT = Number(process.env.PORT || 9799);
const DATABASE_URL = process.env.DATABASE_URL;
@@ -396,50 +397,16 @@ app.get('/api/pairs', async (req, res) => {
// ============================================================
// /api/ai-coordinates — AI-generated coordinate designs
-// Returns N stripe/plaid concepts seeded from the source palette.
-// In dev/preview these are gradient posters generated server-side from the
-// palette (so the page never looks broken). When wallco-ai's
-// stripe-generation endpoint is wired in, swap the SVG body for the
-// generated PNG urls — the rest of the contract stays identical.
+// Returns 24 stripe + plaid concepts seeded from the source palette using
+// lib/ai-stripes.js. Three width tiers (2″, 6″, 12″) × multiple styles
+// (even, pinstripe, cabana, wavy, awning, hairline, block) + 8 plaids
+// (buffalo, gingham, tattersall, windowpane, madras, tartan, glen, box).
+// Three slots use the lightest+darkest palette pair (Steve's standing rule).
+// Seamless tiling is structural — every variant uses SVG <pattern>.
+// Client can slide the repeat width via ?tile_in=N (12-48 inches); each
+// variant snaps tile_in DOWN to its pitch so the repeat always fits.
// ============================================================
-function svgPoster({ palette, motif, idx }) {
- const colors = (palette || []).filter(Boolean).slice(0, 6);
- if (!colors.length) colors.push('#cccccc');
- function rotate(arr, n) { return arr.slice(n).concat(arr.slice(0, n)); }
- const seq = rotate(colors, idx % colors.length);
- const w = 600, h = 600;
-
- let body = '';
- if (motif === 'plaid' || motif === 'tartan' || motif === 'check') {
- // Two-axis bands so they cross.
- const bands = Math.max(4, Math.min(10, colors.length * 2));
- const step = w / bands;
- for (let i = 0; i < bands; i++) {
- const c = seq[i % seq.length];
- body += `<rect x="${i*step}" y="0" width="${step}" height="${h}" fill="${c}" opacity="0.65"/>`;
- }
- for (let j = 0; j < bands; j++) {
- const c = seq[(j + 2) % seq.length];
- body += `<rect x="0" y="${j*step}" width="${w}" height="${step}" fill="${c}" opacity="0.55"/>`;
- }
- } else {
- // Stripe — vary stripe widths so 24 results don't look identical.
- const widthSeed = (idx % 4) + 1;
- let x = 0;
- let i = 0;
- while (x < w) {
- const stripeW = (15 + ((i + widthSeed) % 5) * 18);
- const c = seq[i % seq.length];
- body += `<rect x="${x}" y="0" width="${stripeW}" height="${h}" fill="${c}"/>`;
- x += stripeW;
- i++;
- }
- }
- const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" width="${w}" height="${h}">${body}</svg>`;
- return 'data:image/svg+xml;utf8,' + encodeURIComponent(svg);
-}
-
app.get('/api/ai-coordinates', async (req, res) => {
try {
const dw_sku = req.query.dw_sku && SAFE_KEY.test(req.query.dw_sku) ? req.query.dw_sku : null;
@@ -448,7 +415,7 @@ app.get('/api/ai-coordinates', async (req, res) => {
if (!dw_sku && !handle && paletteParam.length === 0) {
return res.status(400).json({ ok: false, error: 'pass dw_sku, handle, or palette' });
}
- const k = Math.max(1, Math.min(24, parseInt(req.query.limit, 10) || 24));
+ const tile_in = Math.max(2, Math.min(60, parseFloat(req.query.tile_in) || 24));
let source, palette;
if (paletteParam.length) {
@@ -463,32 +430,12 @@ app.get('/api/ai-coordinates', async (req, res) => {
}
if (!palette.length) return res.json({ ok: true, source: { dw_sku: source.dw_sku, handle: source.handle }, items: [] });
- // 16 stripes + 8 plaids = 24 by default; ordering shuffles so the strongest
- // colorway-permutations land first.
- const MOTIFS = [
- 'stripe','stripe','stripe','stripe','stripe','stripe','stripe','stripe',
- 'stripe','stripe','stripe','stripe','stripe','stripe','stripe','stripe',
- 'plaid','plaid','plaid','plaid','tartan','tartan','check','check'
- ].slice(0, k);
-
- const items = MOTIFS.map((motif, idx) => {
- // Cycle the palette so each card uses a different starting color.
- const cardPalette = palette.slice(idx % palette.length).concat(palette.slice(0, idx % palette.length));
- return {
- id: `ai-${idx + 1}`,
- motif: motif,
- title: `AI ${motif} • colorway ${idx + 1}`,
- palette: cardPalette,
- image_url: svgPoster({ palette: cardPalette, motif, idx }),
- url: null, // not a product yet — needs Steve approval before catalog ingest
- provenance: 'ai-generated (preview poster)',
- status: 'placeholder'
- };
- });
+ const items = generateCoordinates({ palette, tile_in });
res.set('Cache-Control', 'public, max-age=300');
res.json({
ok: true,
+ tile_in: tile_in,
source: {
dw_sku: source.dw_sku,
handle: source.handle,
@@ -504,6 +451,39 @@ app.get('/api/ai-coordinates', async (req, res) => {
}
});
+// Render ONE pattern from a custom user-edited spec. Used by the Wix-style
+// pattern editor in the wallco.ai admin UI. Accepts:
+// GET /api/ai-coordinates/render?motif=stripe&style=even&pitch_in=6&colors=#000,#fff&tile_in=24
+// POST /api/ai-coordinates/render body: { motif, style, pitch_in, colors:[], tile_in, pinstripe? }
+app.use('/api/ai-coordinates/render', express.json({ limit: '8kb' }));
+function handleRender(req, res) {
+ try {
+ const src = (req.method === 'POST' ? req.body : req.query) || {};
+ const colors = Array.isArray(src.colors)
+ ? src.colors
+ : String(src.colors || '').split(',').map(s => s.trim()).filter(Boolean);
+ const spec = {
+ motif: String(src.motif || 'stripe').slice(0, 20),
+ style: String(src.style || 'even').slice(0, 24),
+ pitch_in: parseFloat(src.pitch_in) || 6,
+ colors: colors.filter(h => /^#?[0-9a-fA-F]{3}([0-9a-fA-F]{3})?$/.test(h))
+ .map(h => h.startsWith('#') ? h : '#' + h)
+ .slice(0, 5),
+ pinstripe: src.pinstripe || undefined,
+ label: src.label ? String(src.label).slice(0, 120) : undefined
+ };
+ const tile_in = parseFloat(src.tile_in) || 24;
+ const rendered = renderCustom(spec, tile_in);
+ res.set('Cache-Control', 'no-store');
+ res.json({ ok: true, ...rendered });
+ } catch (e) {
+ console.error('ai-coordinates/render error', e);
+ res.status(500).json({ ok: false, error: e.message });
+ }
+}
+app.get('/api/ai-coordinates/render', handleRender);
+app.post('/api/ai-coordinates/render', handleRender);
+
app.listen(PORT, () => {
console.log(`dw-pairs-well listening on :${PORT}`);
});
← c7d0bfc CORS_ORIGIN: add wallco.ai (+ www variants, local dev) — wal
·
back to Dw Pairs Well
·
preview.html: surface attempted key in fetch-failure UI + es 386eea8 →