← back to Wallco Ai
wallco.ai: wall-fit crop tool on /design/:id (W×H input, tiled canvas preview, roll-count readout, PNG download); seamless-check.py edge-diff validator wired into regen as is_published gate; unpublished 3540 (top/bottom diff 67.66) and 3544 (settlement violation)
5fee57ae0c496ff3ef9caa74c38cfe881530ba2b · 2026-05-13 12:32:26 -0700 · SteveStudio2
Files touched
M scripts/regen-bamboo-trellis-to-cane.jsA scripts/seamless-check.pyM server.js
Diff
commit 5fee57ae0c496ff3ef9caa74c38cfe881530ba2b
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Wed May 13 12:32:26 2026 -0700
wallco.ai: wall-fit crop tool on /design/:id (W×H input, tiled canvas preview, roll-count readout, PNG download); seamless-check.py edge-diff validator wired into regen as is_published gate; unpublished 3540 (top/bottom diff 67.66) and 3544 (settlement violation)
---
scripts/regen-bamboo-trellis-to-cane.js | 27 +++++-
scripts/seamless-check.py | 78 ++++++++++++++++
server.js | 161 ++++++++++++++++++++++++++++++++
3 files changed, 261 insertions(+), 5 deletions(-)
diff --git a/scripts/regen-bamboo-trellis-to-cane.js b/scripts/regen-bamboo-trellis-to-cane.js
index c147041..be4edb4 100644
--- a/scripts/regen-bamboo-trellis-to-cane.js
+++ b/scripts/regen-bamboo-trellis-to-cane.js
@@ -105,14 +105,30 @@ print(json.dumps(palette))
let palette = []; let dominant = null;
try { palette = JSON.parse(py.stdout.trim()); dominant = palette[0]?.hex; } catch {}
- // Insert new row (is_published=TRUE) AND unpublish the original it replaces.
+ // Seamlessness gate — every wallco.ai design must tile cleanly all the way
+ // around. Steve's standing rule (2026-05-13). Run the edge-diff check;
+ // images that fail go in as is_published=FALSE so a human can either
+ // re-edit them or regenerate.
+ const seam = spawnSync('python3', [path.join(__dirname, 'seamless-check.py'), outPath], { encoding: 'utf8' });
+ let seamless = true; let seamMeta = null;
+ try {
+ seamMeta = JSON.parse(seam.stdout.trim());
+ seamless = !!seamMeta.ok;
+ } catch {}
+ const publishFlag = seamless ? 'TRUE' : 'FALSE';
+ const seamNote = seamless
+ ? null
+ : `Seamlessness fail: top/bottom diff ${seamMeta?.top_bottom_diff}, left/right diff ${seamMeta?.left_right_diff}, tolerance ${seamMeta?.tolerance}. Will seam visibly when tiled.`;
+
+ // Insert new row (is_published=publishFlag) AND unpublish the original it replaces.
const promptEsc = (v.title + ' — ' + v.prompt).replace(/'/g, "''");
const palJson = JSON.stringify(palette).replace(/'/g, "''");
+ const notesField = seamNote ? esc(seamNote) : 'NULL';
const insertSql = `
UPDATE spoon_all_designs SET is_published = FALSE WHERE id = ${v.replaces};
INSERT INTO spoon_all_designs
(kind, width_in, height_in, generator, prompt, seed, dominant_hex, palette,
- local_path, image_url, category, motifs, tags, is_published, request_text, parent_design_id)
+ local_path, image_url, category, motifs, tags, is_published, request_text, parent_design_id, notes)
VALUES ('seamless_tile', 24, 24, 'gemini-2.5-flash-image-edit',
'${promptEsc}', ${seed},
${dominant ? "'" + dominant + "'" : 'NULL'},
@@ -121,11 +137,12 @@ print(json.dumps(palette))
'butterfly-trellis',
ARRAY['butterfly', 'cane', 'lattice']::text[],
ARRAY['butterfly', 'cane', 'rattan', 'lattice', 'pattern']::text[],
- TRUE, ${esc('regen: bamboo→cane: ' + v.title)}, ${v.replaces})
+ ${publishFlag}, ${esc('regen: bamboo→cane: ' + v.title)}, ${v.replaces}, ${notesField})
RETURNING id;`;
const newId = parseInt(psql(insertSql), 10);
- console.log(` [${idx + 1}/${REGEN.length}] ${v.title} → was=${v.replaces}, new=${newId} ${elapsed}s ${filename}`);
- return { oldId: v.replaces, newId, title: v.title };
+ const seamTag = seamless ? '✓ seamless' : `✗ seam=${seamMeta?.top_bottom_diff}/${seamMeta?.left_right_diff} → unpublished`;
+ console.log(` [${idx + 1}/${REGEN.length}] ${v.title} → was=${v.replaces}, new=${newId} ${elapsed}s ${filename} [${seamTag}]`);
+ return { oldId: v.replaces, newId, title: v.title, seamless };
}
(async () => {
diff --git a/scripts/seamless-check.py b/scripts/seamless-check.py
new file mode 100644
index 0000000..1e645c4
--- /dev/null
+++ b/scripts/seamless-check.py
@@ -0,0 +1,78 @@
+#!/usr/bin/env python3
+"""
+Seamlessness check for wallco.ai pattern PNGs. Compares the top edge to the
+bottom edge and the left edge to the right edge — pure pixel-difference at a
+small downscaled resolution so it's fast. If both edge-pairs match within the
+tolerance, the tile is seamless. If either fails, the image will visibly seam
+when tiled — that violates Steve's standing rule "all patterns must match all
+the way around seamlessly" (2026-05-13).
+
+Output: prints a JSON line and exits 0 (seamless) or 1 (NOT seamless).
+
+ { "ok": true|false, "top_bottom_diff": <float>, "left_right_diff": <float>,
+ "tolerance": <float>, "image": <path> }
+
+Tolerance: average per-channel diff in 0–255 space across the edge row/col.
+Default 12 — perceptually clean tiles land under 4; honest soft repeats land
+under 8; visible seams land 15+. Override via SEAMLESS_TOLERANCE env var.
+
+Usage:
+ python3 scripts/seamless-check.py path/to/tile.png
+ SEAMLESS_TOLERANCE=8 python3 scripts/seamless-check.py path/to/tile.png
+
+Used by:
+ - scripts/regen-bamboo-trellis-to-cane.js (post-gen guard, before INSERT)
+ - scripts/generate-butterfly-trellis-batch.js (same)
+ - scripts/drunk_animals_batch.js (same)
+"""
+import json
+import os
+import sys
+from PIL import Image
+
+
+def check(path: str, tolerance: float = 12.0) -> dict:
+ img = Image.open(path).convert('RGB')
+ # Downscale to 512px on the long edge so the check is fast regardless of
+ # source dim. Seamlessness is a low-frequency property — high-res isn't
+ # informative for the edge-diff.
+ img.thumbnail((512, 512))
+ w, h = img.size
+ px = img.load()
+
+ def row_avg_diff(y0, y1):
+ s = 0.0
+ for x in range(w):
+ r0, g0, b0 = px[x, y0]
+ r1, g1, b1 = px[x, y1]
+ s += abs(r0 - r1) + abs(g0 - g1) + abs(b0 - b1)
+ return s / (w * 3.0)
+
+ def col_avg_diff(x0, x1):
+ s = 0.0
+ for y in range(h):
+ r0, g0, b0 = px[x0, y]
+ r1, g1, b1 = px[x1, y]
+ s += abs(r0 - r1) + abs(g0 - g1) + abs(b0 - b1)
+ return s / (h * 3.0)
+
+ tb = row_avg_diff(0, h - 1)
+ lr = col_avg_diff(0, w - 1)
+ ok = (tb <= tolerance) and (lr <= tolerance)
+ return {
+ 'ok': bool(ok),
+ 'top_bottom_diff': round(tb, 2),
+ 'left_right_diff': round(lr, 2),
+ 'tolerance': tolerance,
+ 'image': path,
+ }
+
+
+if __name__ == '__main__':
+ if len(sys.argv) < 2:
+ sys.stderr.write('Usage: seamless-check.py <image>\n')
+ sys.exit(2)
+ tol = float(os.environ.get('SEAMLESS_TOLERANCE', '12'))
+ result = check(sys.argv[1], tolerance=tol)
+ print(json.dumps(result))
+ sys.exit(0 if result['ok'] else 1)
diff --git a/server.js b/server.js
index cf4c2bf..213957d 100644
--- a/server.js
+++ b/server.js
@@ -5674,8 +5674,169 @@ ${htmlHeader('/designs')}
Pick your roll width — all three are available; the request form below will lock in your choice.
</div>
</div>
+
+ <!-- ──────────────────────────────────────────────────────────────
+ Wall-fit crop tool — user enters wall dimensions, sees the
+ pattern tiled to scale + computed roll count + can download the
+ composition as a PNG. Steve's standing rule (2026-05-13): every
+ wallco.ai design must let users crop a section based on their
+ measurements BEFORE sample/order so they preview the actual fit.
+ Pattern is already seamless (24" tile), so this just tiles it
+ across an aspect-correct canvas — no edge-matching math needed.
+ ── ─────────────────────────────────────────────────────────────── -->
+ <div class="wallfit-crop" style="margin-top:22px;padding-top:18px;border-top:1px solid var(--line)">
+ <label style="font:11px var(--sans);text-transform:uppercase;letter-spacing:.08em;color:var(--ink-faint);display:block;margin-bottom:8px">Crop to your wall</label>
+ <div style="display:grid;grid-template-columns:1fr 1fr auto;gap:10px;align-items:end;margin-bottom:12px">
+ <label style="font:11px var(--sans);color:var(--ink-soft,#555);display:block">
+ Wall width
+ <div style="display:flex;align-items:center;gap:4px;margin-top:2px">
+ <input id="wallfit-w" type="number" min="12" max="600" step="0.5" value="120" style="width:100%;padding:8px 10px;border:1px solid var(--line);border-radius:6px;font:14px var(--sans)">
+ <select id="wallfit-w-unit" style="padding:7px 4px;border:1px solid var(--line);border-radius:6px;font:11px var(--sans);background:white">
+ <option value="in">in</option><option value="ft">ft</option>
+ </select>
+ </div>
+ </label>
+ <label style="font:11px var(--sans);color:var(--ink-soft,#555);display:block">
+ Wall height
+ <div style="display:flex;align-items:center;gap:4px;margin-top:2px">
+ <input id="wallfit-h" type="number" min="12" max="240" step="0.5" value="96" style="width:100%;padding:8px 10px;border:1px solid var(--line);border-radius:6px;font:14px var(--sans)">
+ <select id="wallfit-h-unit" style="padding:7px 4px;border:1px solid var(--line);border-radius:6px;font:11px var(--sans);background:white">
+ <option value="in">in</option><option value="ft">ft</option>
+ </select>
+ </div>
+ </label>
+ <button type="button" id="wallfit-download" style="padding:9px 14px;background:var(--gold,#c9a14b);color:var(--accent,#0d0d0d);border:0;border-radius:6px;font:600 11px var(--sans);letter-spacing:.06em;text-transform:uppercase;cursor:pointer;white-space:nowrap">↓ PNG</button>
+ </div>
+ <div style="position:relative;background:#f4f1ea;border:1px solid var(--line);border-radius:8px;overflow:hidden">
+ <canvas id="wallfit-canvas" style="display:block;width:100%;height:auto"></canvas>
+ <!-- Scale ribbon: shows wall width in ft beneath the preview -->
+ <div id="wallfit-scale" style="position:absolute;bottom:6px;left:8px;right:8px;display:flex;justify-content:space-between;font:10px ui-monospace,Menlo,monospace;color:#fff;text-shadow:0 1px 2px rgba(0,0,0,.6);letter-spacing:.04em;pointer-events:none"></div>
+ </div>
+ <div id="wallfit-readout" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(110px,1fr));gap:8px;margin-top:10px;font:11px var(--sans);color:var(--ink-soft,#555)">
+ <!-- filled by JS: rolls needed, drops per roll, waste % -->
+ </div>
+ </div>
</div>
+ <script>
+ (function(){
+ // Wall-fit crop tool. Reads roll width from localStorage (synced
+ // with the .w-tab buttons above). Renders the seamless tile across
+ // a wall-shaped canvas at scale. Roll count = ceil(walls / roll).
+ // Yard length assumed 396" (11 yd standard double-roll) for waste
+ // math — adjust ROLL_LENGTH_IN if real roll length differs.
+ var imgUrl = ${JSON.stringify(design.image_url)};
+ var defaultRepeatIn = 24; // sane default if Pattern scale slider hasn't been touched
+ var ROLL_LENGTH_IN = 396; // 11 yards (American double-roll), conservative
+ var c = document.getElementById('wallfit-canvas');
+ if (!c) return;
+ var ctx = c.getContext('2d');
+ var wIn = document.getElementById('wallfit-w');
+ var hIn = document.getElementById('wallfit-h');
+ var wU = document.getElementById('wallfit-w-unit');
+ var hU = document.getElementById('wallfit-h-unit');
+ var readout = document.getElementById('wallfit-readout');
+ var scaleEl = document.getElementById('wallfit-scale');
+ var dl = document.getElementById('wallfit-download');
+
+ // Restore previous wall dims
+ try {
+ var prev = JSON.parse(localStorage.getItem('wallco.wallfit') || '{}');
+ if (prev.w) { wIn.value = prev.w; if (prev.wu) wU.value = prev.wu; }
+ if (prev.h) { hIn.value = prev.h; if (prev.hu) hU.value = prev.hu; }
+ } catch {}
+
+ var img = new Image();
+ img.crossOrigin = 'anonymous';
+ var imgReady = false;
+ img.onload = function() { imgReady = true; paint(); };
+ img.onerror = function() { /* silently leave canvas blank */ };
+ img.src = imgUrl;
+
+ function toIn(val, unit) { return unit === 'ft' ? val * 12 : val; }
+ function getRollWidth() {
+ try {
+ var m = JSON.parse(localStorage.getItem('wallco.material') || 'null');
+ var w = m && parseInt(m.w, 10);
+ if ([24, 36, 52].indexOf(w) >= 0) return w;
+ } catch {}
+ return 24;
+ }
+ function getRepeatIn() {
+ var v = parseInt(localStorage.getItem('wallco.scale') || '', 10);
+ return (v && v >= 4) ? v : defaultRepeatIn;
+ }
+
+ function paint() {
+ var wallW = toIn(parseFloat(wIn.value) || 120, wU.value);
+ var wallH = toIn(parseFloat(hIn.value) || 96, hU.value);
+ var rollW = getRollWidth();
+ var repeat = Math.min(getRepeatIn(), rollW);
+ // Persist
+ try { localStorage.setItem('wallco.wallfit', JSON.stringify({ w: wIn.value, wu: wU.value, h: hIn.value, hu: hU.value })); } catch {}
+ // Canvas size: keep px-per-inch constant so wide walls get wide
+ // canvases. Cap total px to ~1100 wide for layout sanity.
+ var pxPerIn = Math.min(8, Math.max(2, 1100 / wallW));
+ var W = Math.round(wallW * pxPerIn);
+ var H = Math.round(wallH * pxPerIn);
+ c.width = W; c.height = H;
+ // Background fill for unloaded / failed image
+ ctx.fillStyle = '#f4f1ea'; ctx.fillRect(0, 0, W, H);
+ if (imgReady) {
+ // Tile size in px = repeat inches × pxPerIn. Pattern image is
+ // already seamless so we can drop-repeat without offsetting.
+ var tilePx = Math.max(8, Math.round(repeat * pxPerIn));
+ for (var y = 0; y < H; y += tilePx) {
+ for (var x = 0; x < W; x += tilePx) {
+ ctx.drawImage(img, x, y, tilePx, tilePx);
+ }
+ }
+ // Roll-seam guides — vertical lines at every roll-width
+ ctx.save();
+ ctx.strokeStyle = 'rgba(255,255,255,.35)';
+ ctx.lineWidth = 1;
+ for (var rx = rollW * pxPerIn; rx < W; rx += rollW * pxPerIn) {
+ ctx.beginPath(); ctx.moveTo(rx, 0); ctx.lineTo(rx, H); ctx.stroke();
+ }
+ ctx.restore();
+ }
+ // Scale ribbon
+ scaleEl.innerHTML = '<span>0 ft</span><span>' + (wallW / 12).toFixed(1) + ' ft wide × ' + (wallH / 12).toFixed(1) + ' ft tall</span><span>' + (wallW / 12).toFixed(1) + ' ft</span>';
+ // Read-out: rolls + drops + waste
+ var drops = Math.ceil(wallW / rollW);
+ var rollsNeeded = Math.ceil((drops * wallH) / ROLL_LENGTH_IN);
+ var coverage = drops * rollW * wallH;
+ var purchased = rollsNeeded * rollW * ROLL_LENGTH_IN;
+ var waste = purchased ? Math.max(0, ((purchased - coverage) / purchased) * 100) : 0;
+ readout.innerHTML =
+ '<div><b style="color:var(--ink)">' + rollsNeeded + '</b> roll' + (rollsNeeded === 1 ? '' : 's') + ' needed</div>' +
+ '<div><b style="color:var(--ink)">' + drops + '</b> vertical drop' + (drops === 1 ? '' : 's') + '</div>' +
+ '<div><b style="color:var(--ink)">' + rollW + '" </b> roll width</div>' +
+ '<div><b style="color:var(--ink)">' + repeat + '" </b> pattern repeat</div>' +
+ '<div><b style="color:var(--ink)">~' + waste.toFixed(0) + '%</b> waste</div>';
+ }
+
+ [wIn, hIn, wU, hU].forEach(function(el){ el.addEventListener('input', paint); el.addEventListener('change', paint); });
+ window.addEventListener('storage', function(e){ if (e.key === 'wallco.material' || e.key === 'wallco.scale') paint(); });
+ // Re-paint when any .w-tab on the page changes (same-tab; storage event only fires cross-tab)
+ document.querySelectorAll('.w-tab').forEach(function(t){ t.addEventListener('click', function(){ setTimeout(paint, 0); }); });
+ var scaleSl = document.getElementById('scale-slider');
+ if (scaleSl) scaleSl.addEventListener('input', function(){ setTimeout(paint, 0); });
+
+ dl.addEventListener('click', function(){
+ if (!imgReady) return;
+ var url = c.toDataURL('image/png');
+ var a = document.createElement('a');
+ var did = ${JSON.stringify(String(design.id).padStart(4,'0'))};
+ a.href = url;
+ a.download = 'wallco-' + did + '-wall-crop.png';
+ document.body.appendChild(a); a.click(); a.remove();
+ });
+
+ paint();
+ })();
+ </script>
+
<script>
(function(){
// The viewport visually represents one roll-width × ~2/3 roll-width (1 yard tall when roll=36).
← 77d9a4b settlement: db-level trigger blocks is_published=true UPDATE
·
back to Wallco Ai
·
api/designs: filter out rows whose image file is missing on 39b4997 →