[object Object]

← 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

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 →