Skip to main content
Participant
October 18, 2026
Open for Voting

Use AI for improving selections only!!! Remove all other AI from Classic! Improve and update the Print Module and colour management to prevent disruptions after IOS updates.

Here is a nesting “App” I created with Claude AI: (Replace .txt with .html)   

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Fine-Art Print Nesting Planner</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=DM+Sans:wght@300;400;500;600&display=swap');
:root {
  --ink:#1a1a18;--paper:#f7f5f0;--warm:#fdfcf8;--border:#ddd9d0;
  --accent:#2c5f2e;--accent-light:#e8f0e8;--accent-mid:#4a8c4d;
  --warn:#8b4513;--warn-light:#fdf3e8;--danger:#991b1b;--danger-light:#fee2e2;
  --muted:#888880;--mono:'DM Mono',monospace;--sans:'DM Sans',sans-serif;
}
*{box-sizing:border-box;margin:0;padding:0;}
body{font-family:var(--sans);background:var(--paper);color:var(--ink);min-height:100vh;font-size:13px;line-height:1.5;}
.header{background:var(--ink);color:var(--paper);padding:1.25rem 2rem;display:flex;align-items:baseline;gap:1rem;border-bottom:3px solid var(--accent);}
.header h1{font-size:15px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;}
.header .sub{font-family:var(--mono);font-size:11px;color:#999;}
.main{max-width:1100px;margin:0 auto;padding:2rem;}
.section{margin-bottom:2rem;}
.section-title{font-family:var(--mono);font-size:10px;font-weight:500;color:var(--muted);text-transform:uppercase;letter-spacing:.1em;margin-bottom:.75rem;padding-bottom:.5rem;border-bottom:1px solid var(--border);}
.card{background:var(--warm);border:1px solid var(--border);border-radius:4px;padding:1.25rem;}
.fg{display:grid;gap:12px;align-items:end;}
.fg-settings{grid-template-columns:1fr 1fr 1fr;}
.fg-add{grid-template-columns:2.2fr .85fr .7fr .7fr .7fr .7fr .55fr;}
.field{display:flex;flex-direction:column;gap:5px;}
.field label{font-family:var(--mono);font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;}
.field input,.field select{border:1px solid var(--border);border-radius:3px;padding:7px 9px;font-size:13px;font-family:var(--sans);background:#fff;color:var(--ink);width:100%;transition:border-color .15s;}
.field input:focus,.field select:focus{outline:none;border-color:var(--accent-mid);}
.field .note{font-family:var(--mono);font-size:10px;color:var(--muted);}
.btn{font-family:var(--sans);font-size:13px;font-weight:500;border:none;border-radius:3px;padding:8px 16px;cursor:pointer;transition:background .15s;white-space:nowrap;}
.btn-primary{background:var(--ink);color:#fff;}
.btn-primary:hover{background:#333;}
.btn-primary:disabled{background:var(--border);color:var(--muted);cursor:not-allowed;}
.btn-outline{background:transparent;color:var(--ink);border:1px solid var(--border);}
.btn-outline:hover{border-color:#999;}
.btn-outline:disabled{opacity:.4;cursor:not-allowed;}
.btn-download{background:var(--accent);color:#fff;font-size:14px;font-weight:600;padding:12px 24px;width:100%;border-radius:3px;border:none;cursor:pointer;transition:background .15s;font-family:var(--sans);}
.btn-download:hover{background:var(--accent-mid);}
.btn-row{display:flex;gap:8px;margin-top:1rem;flex-wrap:wrap;}
.btn-row .btn{flex:1;min-width:140px;}
.live-info{font-family:var(--mono);font-size:11px;color:var(--muted);margin-top:10px;min-height:16px;}
.live-info.err{color:var(--danger);}
.err-msg{font-size:12px;color:var(--danger);margin-top:6px;min-height:16px;font-family:var(--mono);}
.table-wrap{overflow-x:auto;margin-bottom:1rem;}
table{width:100%;border-collapse:collapse;font-size:12px;}
th{font-family:var(--mono);font-size:10px;font-weight:500;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;text-align:left;padding:0 10px 8px;border-bottom:1px solid var(--border);}
td{padding:9px 10px;border-bottom:1px solid #eee;vertical-align:middle;}
tr:last-child td{border-bottom:none;}
tr:hover td{background:#faf9f6;}
.empty-row td{text-align:center;color:var(--muted);padding:2.5rem;font-family:var(--mono);font-size:11px;}
.dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:6px;vertical-align:2px;flex-shrink:0;}
.tag{display:inline-block;font-family:var(--mono);font-size:10px;padding:1px 6px;border-radius:2px;background:#eee;color:var(--muted);}
.tag-green{background:var(--accent-light);color:var(--accent);}
.tag-amber{background:var(--warn-light);color:var(--warn);}
.tag-red{background:var(--danger-light);color:var(--danger);}
.del{background:none;border:none;cursor:pointer;color:#ccc;font-size:16px;padding:2px 4px;line-height:1;transition:color .1s;}
.del:hover{color:var(--danger);}
.notice{padding:.65rem 1rem;border-left:3px solid var(--border);font-size:12px;margin-bottom:.75rem;border-radius:0 3px 3px 0;}
.notice-success{border-color:var(--accent);background:var(--accent-light);color:var(--accent);}
.notice-warn{border-color:var(--warn);background:var(--warn-light);color:var(--warn);}
.notice-info{border-color:#3b82f6;background:#eff6ff;color:#1d4ed8;}
.compare-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:1.5rem;}
.panel{border:1px solid var(--border);border-radius:4px;overflow:hidden;}
.panel.winner{border:2px solid var(--accent);}
.panel.loser{opacity:.65;}
.panel-head{padding:.75rem 1rem;display:flex;justify-content:space-between;align-items:center;}
.ph610{background:#dbeafe;}.ph914{background:var(--warn-light);}
.pt610{color:#1e3a8a;font-weight:600;font-size:14px;}
.pt914{color:#78350f;font-weight:600;font-size:14px;}
.panel-body{padding:1rem;background:var(--warm);}
.metrics{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:1rem;}
.metric{background:var(--paper);border:1px solid var(--border);border-radius:3px;padding:.6rem .75rem;}
.metric .ml{font-family:var(--mono);font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;}
.metric .mv{font-size:20px;font-weight:600;color:var(--ink);line-height:1.3;}
.vis{border:1px solid var(--border);border-radius:3px;background:#fff;padding:.75rem;overflow-x:auto;margin-bottom:1rem;}
.row-table th{font-size:10px;}
.row-table td{font-size:11px;padding:7px 10px;}
.dl-section{background:var(--accent-light);border:2px solid var(--accent);border-radius:4px;padding:1.25rem;margin-top:1.5rem;}
.dl-section .instructions{font-size:12px;color:var(--accent);line-height:1.8;margin-top:.75rem;font-family:var(--mono);}
.combos-note{font-family:var(--mono);font-size:10px;color:var(--muted);margin-bottom:8px;font-style:italic;}
</style>
</head>
<body>
<div class="header">
  <h1>Fine-Art Print Nesting Planner</h1>
  <span class="sub">roll media · ProPhoto RGB · 16-bit</span>
</div>
<div class="main">

<div class="section">
  <div class="section-title">Session Settings</div>
  <div class="card">
    <div class="fg" style="grid-template-columns:1fr 1fr 1fr 1fr;margin-bottom:10px;">
      <div class="field"><label>Output DPI</label>
        <select id="dpi">
          <option value="300">300 dpi</option>
          <option value="240">240 dpi</option>
          <option value="360">360 dpi</option>
          <option value="150">150 dpi</option>
        </select>
      </div>
      <div class="field"><label>Media type</label>
        <select id="media-type" onchange="onMediaType()">
          <option value="roll">Roll</option>
          <option value="sheet">Cut sheet</option>
        </select>
      </div>
      <div class="field"><label>Large print warning (mm)</label>
        <input type="number" id="maxlen" value="1000" min="100" step="50">
        <span class="note">warn if either dimension exceeds this</span>
      </div>
      <div class="field"></div>
    </div>

    <!-- Roll options -->
    <div id="roll-options">
      <div class="fg" style="grid-template-columns:1fr 1fr 1fr 1fr;">
        <div class="field">
          <label>Roll 1 width</label>
          <select id="roll1-preset" onchange="onRoll1Preset()">
            <option value="432">17&quot; — 432mm</option>
            <option value="610" selected>24&quot; — 610mm</option>
            <option value="914">36&quot; — 914mm</option>
            <option value="1118">44&quot; — 1118mm</option>
            <option value="1524">60&quot; — 1524mm</option>
            <option value="custom">Custom</option>
          </select>
        </div>
        <div class="field">
          <label>Roll 1 (mm)</label>
          <input type="number" id="roll1-w" value="610" min="100">
        </div>
        <div class="field">
          <label>Roll 2 width</label>
          <select id="roll2-preset" onchange="onRoll2Preset()">
            <option value="none">— none (single roll) —</option>
            <option value="432">17&quot; — 432mm</option>
            <option value="610">24&quot; — 610mm</option>
            <option value="914" selected>36&quot; — 914mm</option>
            <option value="1118">44&quot; — 1118mm</option>
            <option value="1524">60&quot; — 1524mm</option>
            <option value="custom">Custom</option>
          </select>
        </div>
        <div class="field">
          <label>Roll 2 (mm)</label>
          <input type="number" id="roll2-w" value="914" min="100">
        </div>
      </div>
    </div>

    <!-- Sheet options -->
    <div id="sheet-options" style="display:none;">
      <div class="fg" style="grid-template-columns:1fr 1fr 1fr 1fr;">
        <div class="field">
          <label>Sheet size</label>
          <select id="sheet-preset" onchange="onSheetPreset()">
            <option value="594x841">A1 (594×841mm)</option>
            <option value="420x594">A2 (420×594mm)</option>
            <option value="297x420">A3 (297×420mm)</option>
            <option value="210x297" selected>A4 (210×297mm)</option>
            <option value="148x210">A5 (148×210mm)</option>
            <option value="105x148">A6 (105×148mm)</option>
            <option value="custom">Custom size</option>
          </select>
        </div>
        <div class="field">
          <label>Sheet W (mm)</label>
          <input type="number" id="sheet-w" value="210" min="10">
        </div>
        <div class="field">
          <label>Sheet H (mm)</label>
          <input type="number" id="sheet-h" value="297" min="10">
        </div>
        <div class="field" style="justify-content:flex-end;">
          <span class="note" style="padding-bottom:6px;">Prints are packed onto one sheet. Anything that doesn't fit is flagged.</span>
        </div>
      </div>
    </div>

    <p style="font-family:var(--mono);font-size:10px;color:var(--muted);margin-top:10px;">Borders define all spacing. Set border to 0 if the image already includes its own border. Exhaustive orientation + row/column packing for optimal layout.</p>
  </div>
</div>

<div class="section">
  <div class="section-title">Add Print</div>
  <div class="card">
    <div class="fg" style="grid-template-columns:1.6fr 0.75fr 0.65fr 0.65fr 0.65fr 0.65fr 0.65fr 0.5fr;">
      <div class="field"><label>Description</label><input type="text" id="f-desc" placeholder="e.g. Jane Smith – Fynbos #3"></div>
      <div class="field"><label>Preset</label>
        <select id="f-preset">
          <option value="">Custom</option><option value="210x297">A4</option><option value="297x420">A3</option>
          <option value="420x594">A2</option><option value="594x841">A1</option><option value="148x210">A5</option><option value="279x356">Letter</option>
        </select>
      </div>
      <div class="field">
        <label>Input mode</label>
        <select id="f-mode" onchange="onModeChange()">
          <option value="paper">Paper size</option>
          <option value="cell">Print area</option>
        </select>
        <span class="note" id="f-mode-note">borders subtracted</span>
      </div>
      <div class="field"><label id="f-w-label">W (mm)</label><input type="number" id="f-w" value="210" min="10" oninput="clearPreset();updateLive()"></div>
      <div class="field"><label id="f-h-label">H (mm)</label><input type="number" id="f-h" value="297" min="10" oninput="clearPreset();updateLive()"></div>
      <div class="field"><label>Border (mm)</label><input type="number" id="f-b" value="10" min="0"><span class="note">0 = image has own border</span></div>
      <div class="field"><label>Bottom (mm)</label><input type="number" id="f-bb" placeholder="same" min="0"><span class="note">blank=same</span></div>
      <div class="field"><label>Qty</label><input type="number" id="f-qty" value="1" min="1" max="99"></div>
    </div>
    <div class="live-info" id="live-info"></div>
    <div class="err-msg" id="add-err"></div>
    <div class="btn-row" style="margin-top:.75rem;">
      <button class="btn btn-primary" style="flex:0 0 auto;" onclick="addJob()">+ Add to queue</button>
    </div>
  </div>
</div>

<div class="section">
  <div class="section-title">Print Queue</div>
  <div id="queue-warnings"></div>
  <div class="table-wrap">
    <table>
      <thead><tr>
        <th style="width:28%">Description</th><th style="width:13%">Paper</th><th style="width:12%">Borders</th>
        <th style="width:15%">Print area</th><th style="width:6%">Qty</th>
        <th style="width:11%">610mm</th><th style="width:11%">914mm</th><th style="width:4%"></th>
      </tr></thead>
      <tbody id="queue-body"><tr class="empty-row"><td colspan="8">No prints yet — add one above</td></tr></tbody>
    </table>
  </div>
  <div class="btn-row">
    <button class="btn btn-primary" id="compare-btn" onclick="runCompare()" disabled>⊞ Calculate optimal layout</button>
    <button class="btn btn-outline" id="clear-btn" onclick="clearAll()" disabled>✕ Clear all</button>
  </div>
</div>

<div id="results" style="display:none;">
  <div class="section">
    <div class="section-title">Layout Result</div>
    <div id="rec-tips"></div>
    <div class="compare-grid" id="compare-grid"></div>
    <div class="dl-section" id="dl-section" style="display:none;">
      <button class="btn-download" onclick="downloadScript()">⬇ Download .jsx script</button>
      <div class="instructions">
        <strong>Run:</strong> Photoshop → File → Scripts → Browse → select the downloaded .jsx file<br>
        One document: roll width × layout length · ProPhoto RGB · 16-bit · white background<br>
        Each print = named layer group · white paper area + grey outlined print area box<br>
        Open image → drag into group → position within box → done
      </div>
    </div>
  </div>
</div>

</div>
<script>
var jobs=[],jobId=0,winnerRollW=0,winnerPlacements=[],winnerTotalMM=0,winnerTotalW=0,winnerMediaType='roll',winnerSheetW=0,winnerSheetH=0;
var COLORS=['#2c5f2e','#1e40af','#8b4513','#6b21a8','#be123c','#0e7490','#b45309','#166534','#7c3aed','#b91c1c'];

document.getElementById('f-preset').addEventListener('change',function(){
  var v=this.value;if(!v)return;
  var p=v.split('x');
  var mode=document.getElementById('f-mode').value;
  var b=parseFloat(document.getElementById('f-b').value)||0;
  var bb=getBB();
  if(mode==='cell'){
    // Preset is paper size — show as print area (subtract borders)
    document.getElementById('f-w').value=parseInt(p[0])-b*2;
    document.getElementById('f-h').value=parseInt(p[1])-b-bb;
  } else {
    document.getElementById('f-w').value=p[0];
    document.getElementById('f-h').value=p[1];
  }
  updateLive();
});
['f-w','f-h'].forEach(function(id){
  document.getElementById(id).addEventListener('input',function(){document.getElementById('f-preset').value='';updateLive();});
});
['f-b','f-bb'].forEach(function(id){document.getElementById(id).addEventListener('input',updateLive);});
document.getElementById('f-desc').addEventListener('keydown',function(e){if(e.key==='Enter')addJob();});

function onModeChange(){
  var mode=document.getElementById('f-mode').value;
  var noteEl=document.getElementById('f-mode-note');
  var wLbl=document.getElementById('f-w-label');
  var hLbl=document.getElementById('f-h-label');
  if(mode==='cell'){
    noteEl.textContent='borders added to get paper size';
    wLbl.textContent='Print area W (mm)';
    hLbl.textContent='Print area H (mm)';
  } else {
    noteEl.textContent='borders subtracted to get print area';
    wLbl.textContent='W (mm)';
    hLbl.textContent='H (mm)';
  }
  document.getElementById('f-preset').value='';
  updateLive();
}

// Returns paper dims and print area regardless of input mode
function getPaperDims(){
  var mode=document.getElementById('f-mode').value;
  var w=parseFloat(document.getElementById('f-w').value)||0;
  var h=parseFloat(document.getElementById('f-h').value)||0;
  var b=parseFloat(document.getElementById('f-b').value)||0;
  var bb=getBB();
  if(mode==='cell'){
    // w,h are print area — add borders to get paper size
    return {paperW:w+b*2, paperH:h+b+bb, printW:w, printH:h};
  } else {
    // w,h are paper size — subtract borders
    return {paperW:w, paperH:h, printW:w-b*2, printH:h-b-bb};
  }
}

function getBB(){
  var b=parseFloat(document.getElementById('f-b').value)||0;
  var raw=document.getElementById('f-bb').value.trim();
  return raw!==''?(parseFloat(raw)||0):b;
}

function updateLive(){
  var d=getPaperDims();
  var w=d.paperW,h=d.paperH,pw=d.printW,ph=d.printH;
  var b=parseFloat(document.getElementById('f-b').value)||0;
  var bb=getBB();
  var el=document.getElementById('live-info');
  document.getElementById('add-err').textContent='';
  el.className='live-info';
  if(!w||!h){el.textContent='';return;}
  if(pw<=0||ph<=0){el.textContent='Border exceeds paper size.';el.className='live-info err';return;}
  var maxLen=parseFloat(document.getElementById('maxlen').value)||1000;
  var fits610=w<=610||h<=610,fits914=w<=914||h<=914;
  var mode=document.getElementById('f-mode').value;
  var parts=mode==='cell'
    ?['print area: '+pw+'×'+ph+'mm  →  paper: '+w+'×'+h+'mm']
    :['print area: '+pw+'×'+ph+'mm'];
  if(bb!==b)parts.push('bottom: '+bb+'mm');
  parts.push(fits610?'✓ 610mm':'✗ 610mm');
  parts.push(fits914?'✓ 914mm':'✗ 914mm');
  if(Math.max(w,h)>maxLen)parts.push('⚠ exceeds '+maxLen+'mm');
  el.textContent=parts.join('  ·  ');
}

function addJob(){
  var desc=document.getElementById('f-desc').value.trim();
  var d=getPaperDims();
  var w=d.paperW,h=d.paperH;
  var b=parseFloat(document.getElementById('f-b').value)||0;
  var bb=getBB();
  var qty=parseInt(document.getElementById('f-qty').value)||1;
  var errEl=document.getElementById('add-err');
  errEl.textContent='';
  if(!desc){errEl.textContent='Please enter a description.';return;}
  if(!w||!h||w<10||h<10){errEl.textContent='Enter valid paper dimensions.';return;}
  if(b<0||bb<0){errEl.textContent='Border cannot be negative.';return;}
  if(w-b*2<=0||h-b-bb<=0){errEl.textContent='Border exceeds paper size.';return;}
  var maxLen=parseFloat(document.getElementById('maxlen').value)||1000;
  var colorIdx=jobId%COLORS.length;
  for(var q=0;q<qty;q++){
    jobs.push({id:++jobId,desc:qty>1?desc+' ('+(q+1)+')':desc,w:w,h:h,b:b,bb:bb,colorIdx:colorIdx,isLarge:Math.max(w,h)>maxLen});
  }
  // Keep description (sticky name) — only clear qty and bottom override
  document.getElementById('f-qty').value='1';
  document.getElementById('f-bb').value='';
  document.getElementById('results').style.display='none';
  document.getElementById('dl-section').style.display='none';
  renderQueue();updateLive();
  // Select all text in desc field so user can type a new name or just hit Enter to reuse
  var descEl=document.getElementById('f-desc');
  descEl.focus();descEl.select();
}

function removeJob(id){jobs=jobs.filter(function(j){return j.id!==id;});renderQueue();document.getElementById('results').style.display='none';}
function clearAll(){jobs=[];winnerRollW=0;winnerPlacements=[];renderQueue();document.getElementById('results').style.display='none';}

function renderQueue(){
  var maxLen=parseFloat(document.getElementById('maxlen').value)||1000;
  jobs.forEach(function(j){j.isLarge=Math.max(j.w,j.h)>maxLen;});
  var largeJobs=jobs.filter(function(j){return j.isLarge;});
  document.getElementById('queue-warnings').innerHTML=largeJobs.length>0
    ?'<div class="notice notice-warn">\u26a0 Large prints: '+largeJobs.map(function(j){return j.desc+' ('+Math.max(j.w,j.h)+'mm)';}).join(', ')+'. Still included.</div>':'';
  document.getElementById('compare-btn').disabled=jobs.length===0;
  document.getElementById('clear-btn').disabled=jobs.length===0;
  var tbody=document.getElementById('queue-body');
  if(jobs.length===0){tbody.innerHTML='<tr class="empty-row"><td colspan="8">No prints yet \u2014 add one above</td></tr>';return;}
  var html='';
  jobs.forEach(function(j){
    var pw=j.w-j.b*2,ph=j.h-j.b-j.bb;
    var bDesc=j.bb!==j.b?j.b+'/\u2193'+j.bb+'mm':j.b+'mm';
    var fits610=j.w<=610||j.h<=610,fits914=j.w<=914||j.h<=914;
    html+='<tr><td><span class="dot" style="background:'+COLORS[j.colorIdx]+'"></span><strong>'+esc(j.desc)+'</strong></td>';
    html+='<td style="font-family:var(--mono)">'+j.w+'\u00d7'+j.h+'</td><td style="font-family:var(--mono)">'+bDesc+'</td>';
    html+='<td style="font-family:var(--mono);color:var(--muted)">'+pw+'\u00d7'+ph+'</td><td>1</td>';
    html+='<td>'+(fits610?'<span class="tag tag-green">\u2713 fits</span>':'<span class="tag tag-red">\u2717 no</span>')+'</td>';
    html+='<td>'+(fits914?'<span class="tag tag-green">\u2713 fits</span>':'<span class="tag tag-red">\u2717 no</span>')+(j.isLarge?' <span class="tag tag-amber">\u26a0</span>':'')+'</td>';
    html+='<td><button class="del" onclick="removeJob('+j.id+')">\u00d7</button></td></tr>';
  });
  tbody.innerHTML=html;
}

// ═══════════════════════════════════════════════════════════════════
// STRIP-COLUMN PACKER
// Places items into vertical columns across the roll width.
// Each column tracks its current fill height.
// This correctly handles tall narrow items (like panoramic strips)
// by filling the space alongside them with shorter stacked items.
// ═══════════════════════════════════════════════════════════════════

function stripPack(items, rollW) {
  // Each placement: {item, x, y, useW, useH}
  var placements = [];
  // Columns: [{x, w, filledH}]
  var cols = [];
  var totalH = 0; // overall roll length used

  // Sort by height descending so tall items anchor first
  var sorted = items.slice().sort(function(a,b){ return b.useH - a.useH; });

  for (var i = 0; i < sorted.length; i++) {
    var item = sorted[i];
    var placed = false;

    // Try to fit item into an existing column that has enough width
    // and remaining height capacity (column hasn't exceeded totalH yet — 
    // we allow stacking within the tallest item's height)
    // Strategy: find column(s) where item.useW fits as a sub-column
    // and item fits within remaining height

    // First pass: try fitting alongside an existing tall item
    // by finding a span of adjacent free width at any y offset
    placed = tryPlaceInColumns(item, cols, rollW, totalH, placements);

    if (!placed) {
      // Start a new column at the far left of available space
      var usedW = 0;
      for (var c = 0; c < cols.length; c++) usedW += cols[c].w;
      if (usedW + item.useW <= rollW) {
        // Add a new column
        cols.push({ x: usedW, w: item.useW, filledH: item.useH });
        placements.push({ item: item, x: usedW, y: 0, useW: item.useW, useH: item.useH });
        if (item.useH > totalH) totalH = item.useH;
        placed = true;
      }
    }

    if (!placed) {
      // Full new row below everything
      var x = 0;
      cols = [{ x: 0, w: item.useW, filledH: totalH + item.useH }];
      placements.push({ item: item, x: 0, y: totalH, useW: item.useW, useH: item.useH });
      totalH += item.useH;
    }
  }

  return { placements: placements, totalH: totalH };
}

function tryPlaceInColumns(item, cols, rollW, totalH, placements) {
  // Try to stack item on top of an existing column with matching or wider width
  for (var c = 0; c < cols.length; c++) {
    var col = cols[c];
    if (item.useW <= col.w && col.filledH + item.useH <= Math.max(totalH, col.filledH + item.useH)) {
      // Check if placing here keeps us within the roll length efficiently
      // Place it stacked in this column
      placements.push({ item: item, x: col.x, y: col.filledH, useW: item.useW, useH: item.useH });
      col.filledH += item.useH;
      if (col.filledH > totalH) {
        // This extends the roll — only accept if no better option
        // For now accept and update totalH
      }
      return true;
    }
  }

  // Try fitting in the remaining width (between/after columns) at y=0
  var usedW = 0;
  for (var c = 0; c < cols.length; c++) usedW = Math.max(usedW, cols[c].x + cols[c].w);
  if (usedW + item.useW <= rollW) {
    cols.push({ x: usedW, w: item.useW, filledH: item.useH });
    placements.push({ item: item, x: usedW, y: 0, useW: item.useW, useH: item.useH });
    return true;
  }

  return false;
}

// Better packer: true column-based strip packing
function rowPack(orientedItems, rollW) {
  // Pack items into horizontal rows across the roll.
  // Each row is as tall as the tallest item in it.
  // Best for items of similar height that can share a row.
  var sorted = orientedItems.slice().sort(function(a,b){ return b.useH - a.useH; });
  var rows = []; // [{items:[{item,x}], w, h}]
  var skipped = [];

  for (var i = 0; i < sorted.length; i++) {
    var item = sorted[i];
    var placed = false;
    for (var r = 0; r < rows.length; r++) {
      if (rows[r].w + item.useW <= rollW) {
        rows[r].items.push({ item: item, x: rows[r].w });
        rows[r].w += item.useW;
        if (item.useH > rows[r].h) rows[r].h = item.useH;
        placed = true;
        break;
      }
    }
    if (!placed) {
      if (item.useW <= rollW) {
        rows.push({ items: [{ item: item, x: 0 }], w: item.useW, h: item.useH });
      } else {
        skipped.push(item);
      }
    }
  }

  var placements = [];
  var y = 0;
  for (var r = 0; r < rows.length; r++) {
    for (var k = 0; k < rows[r].items.length; k++) {
      var ri = rows[r].items[k];
      placements.push({ item: ri.item, x: ri.x, y: y, useW: ri.item.useW, useH: ri.item.useH });
    }
    y += rows[r].h;
  }

  // Skipped items appended below
  for (var i = 0; i < skipped.length; i++) {
    var sk = skipped[i];
    placements.push({ item: sk, x: 0, y: y, useW: sk.useW, useH: sk.useH });
    y += sk.useH;
  }

  var totalH = 0;
  for (var p = 0; p < placements.length; p++) {
    var bot = placements[p].y + placements[p].useH;
    if (bot > totalH) totalH = bot;
  }
  return { placements: placements, totalH: totalH };
}

function columnPack(orientedItems, rollW) {
  // Pack items into vertical columns.
  // Best for tall narrow items (like panoramic strips) with shorter items alongside.
  var cols = [];
  var skipped = [];
  var sorted = orientedItems.slice().sort(function(a,b){ return b.useH - a.useH; });

  for (var i = 0; i < sorted.length; i++) {
    var item = sorted[i];
    var placed = false;

    // 1. Find existing column with exact same width
    var bestCol = -1, bestH = Infinity;
    for (var c = 0; c < cols.length; c++) {
      if (item.useW === cols[c].w && cols[c].filledH < bestH) {
        bestH = cols[c].filledH; bestCol = c;
      }
    }
    if (bestCol >= 0) {
      cols[bestCol].items.push({ item: item, y: cols[bestCol].filledH });
      cols[bestCol].filledH += item.useH;
      placed = true;
    }

    // 2. Start a new column in unused horizontal space
    if (!placed) {
      var occupied = [];
      for (var c = 0; c < cols.length; c++) occupied.push({ x: cols[c].x, w: cols[c].w });
      occupied.sort(function(a,b){ return a.x - b.x; });
      var cursor = 0;
      for (var g = 0; g <= occupied.length && !placed; g++) {
        var nextX = g < occupied.length ? occupied[g].x : rollW;
        if (item.useW <= nextX - cursor) {
          cols.push({ x: cursor, w: item.useW, filledH: item.useH, items: [{ item: item, y: 0 }] });
          placed = true;
        }
        if (g < occupied.length) cursor = occupied[g].x + occupied[g].w;
      }
    }

    // 3. Stack into any column where item fits by width
    if (!placed) {
      for (var c = 0; c < cols.length; c++) {
        if (item.useW <= cols[c].w) {
          cols[c].items.push({ item: item, y: cols[c].filledH });
          cols[c].filledH += item.useH;
          placed = true;
          break;
        }
      }
    }

    if (!placed) skipped.push(item);
  }

  var placements = [];
  for (var c = 0; c < cols.length; c++) {
    for (var k = 0; k < cols[c].items.length; k++) {
      var ci = cols[c].items[k];
      placements.push({ item: ci.item, x: cols[c].x, y: ci.y, useW: ci.item.useW, useH: ci.item.useH });
    }
  }

  var totalH = 0;
  for (var c = 0; c < cols.length; c++) if (cols[c].filledH > totalH) totalH = cols[c].filledH;

  // Skipped: pack into rows below
  var skipY = totalH, rowX = 0, rowMaxH = 0;
  for (var i = 0; i < skipped.length; i++) {
    var item = skipped[i];
    if (rowX + item.useW > rollW) { skipY += rowMaxH; rowX = 0; rowMaxH = 0; }
    placements.push({ item: item, x: rowX, y: skipY, useW: item.useW, useH: item.useH });
    rowX += item.useW;
    if (item.useH > rowMaxH) rowMaxH = item.useH;
  }
  if (rowMaxH > 0) totalH = skipY + rowMaxH;

  return { placements: placements, totalH: totalH };
}

function nestOnRoll(rollW) {
  var fittable = [], skipped = 0;
  jobs.forEach(function(j) {
    var canUp = j.w <= rollW, canRot = j.h <= rollW;
    if (!canUp && !canRot) { skipped++; return; }
    fittable.push({ job: j, canUp: canUp, canRot: canRot });
  });

  var flexible = fittable.filter(function(e) { return e.canUp && e.canRot && e.job.w !== e.job.h; });
  var nFlex = flexible.length;
  var nCombos = Math.pow(2, nFlex);
  var bestPlacements = null, bestLen = Infinity;

  for (var mask = 0; mask < nCombos; mask++) {
    var items = [];
    var flexIdx = 0;
    for (var fi = 0; fi < fittable.length; fi++) {
      var e = fittable[fi];
      var rotated;
      if (!e.canUp) rotated = true;
      else if (!e.canRot) rotated = false;
      else if (e.job.w === e.job.h) rotated = false;
      else { rotated = !!(mask & (1 << flexIdx)); flexIdx++; }
      items.push({
        id: e.job.id, desc: e.job.desc,
        w: e.job.w, h: e.job.h, b: e.job.b, bb: e.job.bb,
        colorIdx: e.job.colorIdx, isLarge: e.job.isLarge,
        useW: rotated ? e.job.h : e.job.w,
        useH: rotated ? e.job.w : e.job.h,
        rotated: rotated
      });
    }

    // Try BOTH row packing and column packing — take the shorter result
    var rRow = rowPack(items, rollW);
    var rCol = columnPack(items, rollW);
    var best = rRow.totalH <= rCol.totalH ? rRow : rCol;

    if (best.totalH < bestLen) {
      bestLen = best.totalH;
      bestPlacements = best.placements;
    }
  }

  var printArea = 0;
  for (var i = 0; i < bestPlacements.length; i++) {
    printArea += bestPlacements[i].useW * bestPlacements[i].useH;
  }
  var eff = bestLen > 0 ? Math.round((printArea / (rollW * bestLen)) * 100) : 0;

  return {
    placements: bestPlacements,
    totalMM: bestLen, totalM: bestLen / 1000,
    eff: eff, skipped: skipped,
    placed: fittable.length, combos: nCombos
  };
}

// ── Sheet nesting ─────────────────────────────────────────────────
function nestOnSheet(sheetW, sheetH) {
  var fittable = [], skipped = 0;
  jobs.forEach(function(j) {
    var canUp  = j.w <= sheetW && j.h <= sheetH;
    var canRot = j.h <= sheetW && j.w <= sheetH;
    if (!canUp && !canRot) { skipped++; return; }
    fittable.push({ job: j, canUp: canUp, canRot: canRot });
  });

  var flexible = fittable.filter(function(e) { return e.canUp && e.canRot && e.job.w !== e.job.h; });
  var nFlex = flexible.length;
  var nCombos = Math.pow(2, nFlex);
  var bestPlacements = null, bestEff = -1;

  for (var mask = 0; mask < nCombos; mask++) {
    var items = [];
    var flexIdx = 0;
    for (var fi = 0; fi < fittable.length; fi++) {
      var e = fittable[fi];
      var rotated;
      if (!e.canUp) rotated = true;
      else if (!e.canRot) rotated = false;
      else if (e.job.w === e.job.h) rotated = false;
      else { rotated = !!(mask & (1 << flexIdx)); flexIdx++; }
      items.push({
        id: e.job.id, desc: e.job.desc,
        w: e.job.w, h: e.job.h, b: e.job.b, bb: e.job.bb,
        colorIdx: e.job.colorIdx, isLarge: e.job.isLarge,
        useW: rotated ? e.job.h : e.job.w,
        useH: rotated ? e.job.w : e.job.h,
        rotated: rotated
      });
    }
    // For sheet: try row and column pack, pick whichever places MORE prints
    // (we can't go longer — sheet is fixed — so maximise placement count)
    var rRow = rowPackSheet(items, sheetW, sheetH);
    var rCol = colPackSheet(items, sheetW, sheetH);
    var best = rRow.placed >= rCol.placed ? rRow : rCol;
    if (best.placed > bestEff) {
      bestEff = best.placed;
      bestPlacements = best.placements;
    }
  }

  var printArea = 0;
  for (var i = 0; i < bestPlacements.length; i++)
    printArea += bestPlacements[i].useW * bestPlacements[i].useH;
  var eff = Math.round((printArea / (sheetW * sheetH)) * 100);

  return { placements: bestPlacements, placed: bestEff, skipped: skipped + (fittable.length - bestEff), combos: nCombos, eff: eff };
}

function rowPackSheet(items, sw, sh) {
  var sorted = items.slice().sort(function(a,b){ return b.useH - a.useH; });
  var rows = [];
  var placed = 0;
  for (var i = 0; i < sorted.length; i++) {
    var item = sorted[i];
    var rowY = 0;
    for (var r = 0; r < rows.length; r++) rowY += rows[r].h;
    var inRow = false;
    for (var r = 0; r < rows.length; r++) {
      var rY = 0; for (var rr = 0; rr < r; rr++) rY += rows[rr].h;
      if (rY + rows[r].h + 0 <= sh || true) { // check height fits
        if (rows[r].w + item.useW <= sw) {
          // check row bottom fits on sheet
          var rowTop = 0; for (var rr = 0; rr < r; rr++) rowTop += rows[rr].h;
          if (rowTop + Math.max(rows[r].h, item.useH) <= sh) {
            rows[r].items.push({ item: item, x: rows[r].w });
            rows[r].w += item.useW;
            if (item.useH > rows[r].h) rows[r].h = item.useH;
            inRow = true; placed++; break;
          }
        }
      }
    }
    if (!inRow) {
      var newRowY = 0; for (var r = 0; r < rows.length; r++) newRowY += rows[r].h;
      if (newRowY + item.useH <= sh && item.useW <= sw) {
        rows.push({ items: [{ item: item, x: 0 }], w: item.useW, h: item.useH });
        placed++;
      }
    }
  }
  var placements = []; var y = 0;
  for (var r = 0; r < rows.length; r++) {
    for (var k = 0; k < rows[r].items.length; k++) {
      var ri = rows[r].items[k];
      placements.push({ item: ri.item, x: ri.x, y: y, useW: ri.item.useW, useH: ri.item.useH });
    }
    y += rows[r].h;
  }
  return { placements: placements, placed: placed };
}

function colPackSheet(items, sw, sh) {
  var sorted = items.slice().sort(function(a,b){ return b.useH - a.useH; });
  var cols = []; var placed = 0;
  for (var i = 0; i < sorted.length; i++) {
    var item = sorted[i]; var done = false;
    // exact width match
    for (var c = 0; c < cols.length && !done; c++) {
      if (item.useW === cols[c].w && cols[c].filledH + item.useH <= sh) {
        cols[c].items.push({ item: item, y: cols[c].filledH });
        cols[c].filledH += item.useH; placed++; done = true;
      }
    }
    // new column in gap
    if (!done) {
      var occ = []; for (var c = 0; c < cols.length; c++) occ.push({ x: cols[c].x, w: cols[c].w });
      occ.sort(function(a,b){ return a.x-b.x; });
      var cursor = 0;
      for (var g = 0; g <= occ.length && !done; g++) {
        var nx = g < occ.length ? occ[g].x : sw;
        if (item.useW <= nx - cursor && item.useH <= sh) {
          cols.push({ x: cursor, w: item.useW, filledH: item.useH, items: [{ item: item, y: 0 }] });
          placed++; done = true;
        }
        if (g < occ.length) cursor = occ[g].x + occ[g].w;
      }
    }
    // stack in any fitting column
    if (!done) {
      for (var c = 0; c < cols.length && !done; c++) {
        if (item.useW <= cols[c].w && cols[c].filledH + item.useH <= sh) {
          cols[c].items.push({ item: item, y: cols[c].filledH });
          cols[c].filledH += item.useH; placed++; done = true;
        }
      }
    }
  }
  var placements = [];
  for (var c = 0; c < cols.length; c++)
    for (var k = 0; k < cols[c].items.length; k++) {
      var ci = cols[c].items[k];
      placements.push({ item: ci.item, x: cols[c].x, y: ci.y, useW: ci.item.useW, useH: ci.item.useH });
    }
  return { placements: placements, placed: placed };
}

// ── Sheet panel ───────────────────────────────────────────────────
function buildSheetPanel(grid, sw, sh, res, maxLen) {
  var panel = document.createElement('div');
  panel.className = 'panel winner';
  var skNote = res.skipped > 0 ? '<p style="font-size:11px;color:var(--danger);margin-bottom:8px;">\u26a0 ' + res.skipped + ' print' + (res.skipped > 1 ? 's' : '') + ' did not fit on the sheet</p>' : '';
  panel.innerHTML =
    '<div class="panel-head" style="background:var(--accent-light);">' +
      '<span style="color:var(--accent);font-weight:600;font-size:14px;">Sheet ' + sw + '\u00d7' + sh + 'mm</span>' +
      '<span class="tag tag-green">single sheet</span>' +
    '</div>' +
    '<div class="panel-body">' +
      '<div class="metrics">' +
        '<div class="metric"><div class="ml">Prints placed</div><div class="mv">' + res.placed + '</div></div>' +
        '<div class="metric"><div class="ml">Utilisation</div><div class="mv">' + res.eff + '%</div></div>' +
        '<div class="metric"><div class="ml">Sheet size</div><div class="mv" style="font-size:14px;">' + sw + '\u00d7' + sh + 'mm</div></div>' +
        '<div class="metric"><div class="ml">Orientations tested</div><div class="mv">' + res.combos + '</div></div>' +
      '</div>' +
      skNote +
      '<p class="combos-note">Row/column packing on fixed sheet \u2014 maximising prints placed.</p>' +
      '<div class="vis"><svg id="svgsheet" style="display:block;"></svg></div>' +
      '<div class="table-wrap">' +
        '<table class="row-table"><thead><tr><th style="width:40%">Print</th><th style="width:15%">X pos</th><th style="width:15%">Y pos</th><th style="width:15%">Size</th><th style="width:15%">Orient</th></tr></thead>' +
        '<tbody id="tbodysheet"></tbody></table>' +
      '</div>' +
      '<div id="wastesheet"></div>' +
    '</div>';
  grid.appendChild(panel);
  drawSheetSVG(sw, sh, res, maxLen);
  drawPlacementTableById('tbodysheet', res, maxLen);

  var wasteS = analyseWaste(res.placements, sw, sh);
  var wasteElS = document.getElementById('wastesheet');
  if (wasteElS) wasteElS.innerHTML = renderWasteNote(wasteS);
}

function drawSheetSVG(sw, sh, res, maxLen) {
  var svg = document.getElementById('svgsheet'); if (!svg) return;
  var maxPxW = 380, maxPxH = 480;
  var scaleX = maxPxW / sw, scaleY = maxPxH / sh;
  var scale = Math.min(scaleX, scaleY);
  var pxW = sw * scale, pxH = sh * scale;
  svg.setAttribute('width', Math.round(pxW));
  svg.setAttribute('height', Math.round(pxH) + 1);
  svg.setAttribute('viewBox', '0 0 ' + Math.round(pxW) + ' ' + (Math.round(pxH) + 1));
  var html = '<rect x="0" y="0" width="' + pxW.toFixed(1) + '" height="' + pxH.toFixed(1) + '" fill="#fafafa" stroke="#ccc" stroke-width="1.5"/>';
  for (var i = 0; i < res.placements.length; i++) {
    var pl = res.placements[i]; var item = pl.item;
    var x = pl.x * scale, y = pl.y * scale;
    var iw = pl.useW * scale, ih = pl.useH * scale;
    var col = COLORS[item.colorIdx];
    var isLarge = Math.max(item.w, item.h) > maxLen;
    html += '<rect x="' + x.toFixed(1) + '" y="' + y.toFixed(1) + '" width="' + iw.toFixed(1) + '" height="' + ih.toFixed(1) + '" fill="' + col + '" fill-opacity="0.12" stroke="' + col + '" stroke-width="' + (isLarge?'1.5':'0.75') + '" stroke-dasharray="' + (isLarge?'3,2':'') + '"/>';
    // Border inset: account for weighted bottom and rotation
    var bTop  = item.b * scale;
    var bBot  = (item.rotated ? item.b : item.bb) * scale;
    var bLeft = (item.rotated ? item.bb : item.b) * scale;
    var bRight = item.b * scale;
    var paw = iw - bLeft - bRight, pah = ih - bTop - bBot;
    if (paw > 2 && pah > 2)
      html += '<rect x="' + (x+bLeft).toFixed(1) + '" y="' + (y+bTop).toFixed(1) + '" width="' + paw.toFixed(1) + '" height="' + pah.toFixed(1) + '" fill="' + col + '" fill-opacity="0.32" stroke="' + col + '" stroke-width="1" stroke-opacity="0.8"/>';
    if (item.rotated && iw > 10 && ih > 10) {
      var ax = x+iw-3, ay = y+3;
      html += '<path d="M' + (ax-5).toFixed(1) + ',' + ay.toFixed(1) + ' A5,5 0 0,1 ' + ax.toFixed(1) + ',' + (ay+5).toFixed(1) + '" fill="none" stroke="' + col + '" stroke-width="1.5" stroke-linecap="round"/>';
      html += '<polyline points="' + ax.toFixed(1) + ',' + (ay+5).toFixed(1) + ' ' + ax.toFixed(1) + ',' + (ay+2).toFixed(1) + ' ' + (ax+2).toFixed(1) + ',' + (ay+5).toFixed(1) + '" fill="none" stroke="' + col + '" stroke-width="1.5" stroke-linejoin="round"/>';
    }
    if (iw > 28 && ih > 14) {
      var lbl = item.desc.length > 14 ? item.desc.substring(0,13)+'\u2026' : item.desc;
      html += '<text x="' + (x+iw/2).toFixed(1) + '" y="' + (y+Math.min(ih/2,20)+3).toFixed(1) + '" text-anchor="middle" font-size="8" fill="' + col + '" font-weight="600" font-family="DM Sans,sans-serif">' + escSVG(lbl) + '</text>';
    }
  }
  svg.innerHTML = html;
}

function drawPlacementTableById(tbodyId, res, maxLen) {
  var tbody = document.getElementById(tbodyId); if (!tbody) return;
  var html = '';
  for (var i = 0; i < res.placements.length; i++) {
    var pl = res.placements[i]; var item = pl.item;
    var isLarge = Math.max(item.w, item.h) > maxLen;
    html += '<tr><td><span class="dot" style="background:' + COLORS[item.colorIdx] + '"></span>' + esc(item.desc) + (isLarge?' <span class="tag tag-amber">\u26a0</span>':'') + '</td>';
    html += '<td style="font-family:var(--mono)">' + pl.x + 'mm</td><td style="font-family:var(--mono)">' + pl.y + 'mm</td>';
    html += '<td style="font-family:var(--mono)">' + pl.useW + '\u00d7' + pl.useH + '</td>';
    html += '<td>' + (item.rotated?'<span class="tag tag-amber">\u21bb rotated</span>':'<span class="tag">upright</span>') + '</td></tr>';
  }
  tbody.innerHTML = html;
}


// ── Media type switching ─────────────────────────────────────────────
function onMediaType() {
  var t = document.getElementById('media-type').value;
  document.getElementById('roll-options').style.display = t === 'roll' ? '' : 'none';
  document.getElementById('sheet-options').style.display = t === 'sheet' ? '' : 'none';
}

function onRoll1Preset() {
  var v = document.getElementById('roll1-preset').value;
  if (v !== 'custom') document.getElementById('roll1-w').value = v;
}
function onRoll2Preset() {
  var v = document.getElementById('roll2-preset').value;
  if (v === 'none') { document.getElementById('roll2-w').value = ''; document.getElementById('roll2-w').disabled = true; }
  else { document.getElementById('roll2-w').disabled = false; if (v !== 'custom') document.getElementById('roll2-w').value = v; }
}
function onSheetPreset() {
  var v = document.getElementById('sheet-preset').value;
  if (v === 'custom') return;
  var p = v.split('x');
  document.getElementById('sheet-w').value = p[0];
  document.getElementById('sheet-h').value = p[1];
}

function runCompare() {
  if (jobs.length === 0) return;
  var mediaType = document.getElementById('media-type').value;
  var maxLen = parseFloat(document.getElementById('maxlen').value) || 1000;

  document.getElementById('rec-tips').innerHTML = '';
  var grid = document.getElementById('compare-grid');
  grid.innerHTML = '';

  if (mediaType === 'sheet') {
    runSheetLayout(maxLen, grid);
  } else {
    runRollLayout(maxLen, grid);
  }

  document.getElementById('results').style.display = 'block';
  document.getElementById('dl-section').style.display = 'block';
  document.getElementById('results').scrollIntoView({ behavior: 'smooth', block: 'start' });
}

function runRollLayout(maxLen, grid) {
  var roll1w = parseInt(document.getElementById('roll1-w').value) || 0;
  var roll2preset = document.getElementById('roll2-preset').value;
  var roll2w = roll2preset === 'none' ? 0 : (parseInt(document.getElementById('roll2-w').value) || 0);

  if (!roll1w) { document.getElementById('rec-tips').innerHTML = '<div class="notice notice-warn">Please enter a valid Roll 1 width.</div>'; return; }

  var r1 = nestOnRoll(roll1w);
  var r2 = roll2w ? nestOnRoll(roll2w) : null;

  var tips = '';
  var largeCount = jobs.filter(function(j) { return j.isLarge; }).length;
  if (largeCount > 0) tips += '<div class="notice notice-warn">\u26a0 ' + largeCount + ' print' + (largeCount > 1 ? 's exceed' : ' exceeds') + ' your ' + maxLen + 'mm warning threshold \u2014 included.</div>';

  var winner, wr;
  if (!r2) {
    winner = roll1w; wr = r1;
    tips += '<div class="notice notice-info">Layout calculated for ' + roll1w + 'mm roll. Tested ' + r1.combos + ' orientation combination' + (r1.combos > 1 ? 's' : '') + ' \u2014 optimal result shown.</div>';
    grid.style.gridTemplateColumns = '1fr';
  } else {
    // Calculate wasted area percentage for each roll
    var waste1 = Math.round(((roll1w * r1.totalMM) - r1.placements.reduce(function(s,p){return s+p.useW*p.useH;},0)) / (roll1w * r1.totalMM) * 100);
    var waste2 = Math.round(((roll2w * r2.totalMM) - r2.placements.reduce(function(s,p){return s+p.useW*p.useH;},0)) / (roll2w * r2.totalMM) * 100);

    if (r1.skipped > 0 && r2.skipped === 0) winner = roll2w;
    else if (r2.skipped > 0 && r1.skipped === 0) winner = roll1w;
    else if (r1.skipped > 0 && r2.skipped > 0) winner = r1.skipped <= r2.skipped ? roll1w : roll2w;
    else winner = waste1 <= waste2 ? roll1w : roll2w;  // lower waste % wins
    wr = winner === roll1w ? r1 : r2;
    var wasteDiff = Math.abs(waste1 - waste2);
    var winnerWaste = winner === roll1w ? waste1 : waste2;
    if (r1.skipped > 0 && r2.skipped === 0)
      tips += '<div class="notice notice-success">' + roll2w + 'mm roll recommended \u2014 ' + r1.skipped + ' print' + (r1.skipped > 1 ? 's are' : ' is') + ' too wide for ' + roll1w + 'mm.</div>';
    else if (winner === roll1w)
      tips += '<div class="notice notice-success">' + roll1w + 'mm roll is more economical \u2014 ' + winnerWaste + '% area wasted vs ' + (winnerWaste === waste1 ? waste2 : waste1) + '% on ' + roll2w + 'mm. Tested ' + r1.combos + ' orientation combinations.</div>';
    else
      tips += '<div class="notice notice-success">' + roll2w + 'mm roll is more economical \u2014 ' + winnerWaste + '% area wasted vs ' + (winnerWaste === waste2 ? waste1 : waste2) + '% on ' + roll1w + 'mm' + (r1.skipped > 0 ? ', and fits all prints' : '') + '. Tested ' + r2.combos + ' orientation combinations.</div>';
    grid.style.gridTemplateColumns = '1fr 1fr';
    buildPanel(grid, roll1w, r1, winner === roll1w, maxLen);
    buildPanel(grid, roll2w, r2, winner === roll2w, maxLen);
  }

  winnerRollW = winner;
  winnerPlacements = wr.placements;
  winnerTotalMM = wr.totalMM;
  winnerMediaType = 'roll';

  document.getElementById('rec-tips').innerHTML = tips;
  if (!r2) buildPanel(grid, roll1w, r1, true, maxLen);
}

function runSheetLayout(maxLen, grid) {
  var sw = parseInt(document.getElementById('sheet-w').value) || 0;
  var sh = parseInt(document.getElementById('sheet-h').value) || 0;
  if (!sw || !sh) { document.getElementById('rec-tips').innerHTML = '<div class="notice notice-warn">Please enter valid sheet dimensions.</div>'; return; }

  var result = nestOnSheet(sw, sh);
  var tips = '';
  var largeCount = jobs.filter(function(j) { return j.isLarge; }).length;
  if (largeCount > 0) tips += '<div class="notice notice-warn">\u26a0 ' + largeCount + ' print' + (largeCount > 1 ? 's exceed' : ' exceeds') + ' your ' + maxLen + 'mm warning threshold \u2014 included.</div>';
  tips += '<div class="notice notice-info">Sheet layout: ' + sw + '\u00d7' + sh + 'mm. Tested ' + result.combos + ' orientation combination' + (result.combos > 1 ? 's' : '') + ' \u2014 optimal result shown.</div>';
  if (result.skipped > 0) tips += '<div class="notice notice-warn">\u26a0 ' + result.skipped + ' print' + (result.skipped > 1 ? 's' : '') + ' did not fit on the sheet and have been excluded.</div>';

  document.getElementById('rec-tips').innerHTML = tips;
  grid.style.gridTemplateColumns = '1fr';

  winnerRollW = sw;
  winnerPlacements = result.placements;
  winnerTotalMM = sh;
  winnerMediaType = 'sheet';
  winnerSheetW = sw;
  winnerSheetH = sh;

  buildSheetPanel(grid, sw, sh, result, maxLen);
}

function buildPanel(grid, rollW, res, isWinner, maxLen) {
  var panel = document.createElement('div');
  panel.className = 'panel' + (isWinner ? ' winner' : ' loser');
  var hCls = rollW === 610 ? 'ph610' : 'ph914';
  var tCls = rollW === 610 ? 'pt610' : 'pt914';
  var badge = isWinner ? '<span class="tag tag-green">recommended</span>' : (res.skipped > 0 ? '<span class="tag tag-red">missing prints</span>' : '');
  var skNote = res.skipped > 0 ? '<p style="font-size:11px;color:var(--danger);margin-bottom:8px;">\u26a0 ' + res.skipped + ' print' + (res.skipped > 1 ? 's' : '') + ' too wide for this roll</p>' : '';

  panel.innerHTML =
    '<div class="panel-head ' + hCls + '"><span class="' + tCls + '">' + rollW + 'mm roll</span>' + badge + '</div>' +
    '<div class="panel-body">' +
      '<div class="metrics">' +
        '<div class="metric"><div class="ml">Linear length</div><div class="mv">' + res.totalM.toFixed(2) + 'm</div></div>' +
        '<div class="metric"><div class="ml">Area wasted</div><div class="mv">' + (100 - res.eff) + '%</div></div>' +
        '<div class="metric"><div class="ml">Prints placed</div><div class="mv">' + res.placed + (res.skipped > 0 ? '<span style="font-size:12px;color:var(--danger)"> /' + jobs.length + '</span>' : '') + '</div></div>' +
        '<div class="metric"><div class="ml">Orientations tested</div><div class="mv">' + res.combos + '</div></div>' +
      '</div>' +
      skNote +
      '<p class="combos-note">Strip-column packing \u2014 tall prints anchor columns, shorter prints stack alongside.</p>' +
      '<div class="vis"><svg id="svg' + rollW + '" style="display:block;"></svg></div>' +
      '<div class="table-wrap">' +
        '<table class="row-table">' +
          '<thead><tr><th style="width:40%">Print</th><th style="width:15%">X pos</th><th style="width:15%">Y pos</th><th style="width:15%">Size</th><th style="width:15%">Orient</th></tr></thead>' +
          '<tbody id="tbody' + rollW + '"></tbody>' +
        '</table>' +
      '</div>' +
      '<div id="waste' + rollW + '"></div>' +
    '</div>';

  grid.appendChild(panel);
  drawSVG(rollW, res, maxLen);
  drawPlacementTableById('tbody' + rollW, res, maxLen);

  // Waste analysis
  var waste = analyseWaste(res.placements, rollW, res.totalMM);
  var wasteEl = document.getElementById('waste' + rollW);
  if (wasteEl) wasteEl.innerHTML = renderWasteNote(waste);
}

function drawSVG(rollW, res, maxLen) {
  var svg = document.getElementById('svg' + rollW); if (!svg) return;
  var maxPxW = 380;
  var scale = maxPxW / rollW;
  var pxW = rollW * scale, pxH = res.totalMM * scale;
  // Cap height for display
  var dispH = Math.min(pxH, 600);
  var dispScale = dispH / pxH;
  svg.setAttribute('width', Math.round(pxW));
  svg.setAttribute('height', Math.round(dispH) + 1);
  svg.setAttribute('viewBox', '0 0 ' + Math.round(pxW) + ' ' + (Math.round(pxH) + 1));
  svg.style.maxHeight = '600px';

  var html = '<rect x="0" y="0" width="' + pxW.toFixed(1) + '" height="' + pxH.toFixed(1) + '" fill="none" stroke="#ddd" stroke-width="1"/>';

  for (var i = 0; i < res.placements.length; i++) {
    var pl = res.placements[i];
    var item = pl.item;
    var x = pl.x * scale, y = pl.y * scale;
    var iw = pl.useW * scale, ih = pl.useH * scale;
    var col = COLORS[item.colorIdx];
    var isLarge = Math.max(item.w, item.h) > maxLen;

    html += '<rect x="' + x.toFixed(1) + '" y="' + y.toFixed(1) + '" width="' + iw.toFixed(1) + '" height="' + ih.toFixed(1) + '" fill="' + col + '" fill-opacity="0.12" stroke="' + col + '" stroke-width="' + (isLarge ? '1.5' : '0.75') + '" stroke-dasharray="' + (isLarge ? '3,2' : '') + '"/>';

    var bTopS  = item.b * scale;
    var bBotS  = (item.rotated ? item.b : item.bb) * scale;
    var bLeftS = (item.rotated ? item.bb : item.b) * scale;
    var bRightS = item.b * scale;
    var paw = iw - bLeftS - bRightS, pah = ih - bTopS - bBotS;
    if (paw > 2 && pah > 2) {
      html += '<rect x="' + (x + bLeftS).toFixed(1) + '" y="' + (y + bTopS).toFixed(1) + '" width="' + paw.toFixed(1) + '" height="' + pah.toFixed(1) + '" fill="' + col + '" fill-opacity="0.32" stroke="' + col + '" stroke-width="1" stroke-opacity="0.8"/>';
    }

    if (pl.item.rotated && iw > 10 && ih > 10) {
      var ax = x + iw - 3, ay = y + 3;
      html += '<path d="M' + (ax-5).toFixed(1) + ',' + ay.toFixed(1) + ' A5,5 0 0,1 ' + ax.toFixed(1) + ',' + (ay+5).toFixed(1) + '" fill="none" stroke="' + col + '" stroke-width="1.5" stroke-linecap="round"/>';
      html += '<polyline points="' + ax.toFixed(1) + ',' + (ay+5).toFixed(1) + ' ' + ax.toFixed(1) + ',' + (ay+2).toFixed(1) + ' ' + (ax+2).toFixed(1) + ',' + (ay+5).toFixed(1) + '" fill="none" stroke="' + col + '" stroke-width="1.5" stroke-linejoin="round"/>';
    }

    if (iw > 28 && ih > 14) {
      var lbl = item.desc.length > 14 ? item.desc.substring(0,13) + '\u2026' : item.desc;
      html += '<text x="' + (x + iw/2).toFixed(1) + '" y="' + (y + Math.min(ih/2, 20) + 3).toFixed(1) + '" text-anchor="middle" font-size="8" fill="' + col + '" font-weight="600" font-family="DM Sans,sans-serif">' + escSVG(lbl) + '</text>';
    }
  }
  svg.innerHTML = html;
}

// ── JSX generation ─────────────────────────────────────────────────


// ── Waste analysis ────────────────────────────────────────────────
function analyseWaste(placements, totalW, totalH) {
  var placedArea = 0;
  for (var i = 0; i < placements.length; i++) {
    placedArea += placements[i].useW * placements[i].useH;
  }
  var totalArea = totalW * totalH;
  var wastedArea = totalArea - placedArea;
  var wastePercent = Math.round((wastedArea / totalArea) * 100);

  // Grid scan at 5mm resolution
  var res = 5;
  var gW = Math.ceil(totalW / res);
  var gH = Math.ceil(totalH / res);
  var grid = [];
  for (var r = 0; r < gH; r++) {
    grid.push(new Array(gW).fill(0));
  }
  for (var i = 0; i < placements.length; i++) {
    var pl = placements[i];
    var x0 = Math.floor(pl.x / res), y0 = Math.floor(pl.y / res);
    var x1 = Math.ceil((pl.x + pl.useW) / res), y1 = Math.ceil((pl.y + pl.useH) / res);
    for (var r = y0; r < Math.min(y1, gH); r++)
      for (var c = x0; c < Math.min(x1, gW); c++)
        grid[r][c] = 1;
  }

  // Find ALL significant rectangles — collect candidates, filter by minimum useful size
  var candidates = [];
  var heights = new Array(gW).fill(0);

  for (var r = 0; r < gH; r++) {
    for (var c = 0; c < gW; c++) {
      heights[c] = grid[r][c] === 0 ? heights[c] + 1 : 0;
    }
    // Maximal rectangle in histogram — collect all candidates
    var stack = [];
    for (var c = 0; c <= gW; c++) {
      var h = c < gW ? heights[c] : 0;
      var start = c;
      while (stack.length && stack[stack.length-1].h > h) {
        var top = stack.pop();
        var rW = (c - top.c) * res;
        var rH = top.h * res;
        // Only keep rectangles where the SHORTER dimension is at least 150mm
        // This eliminates narrow slivers that can't fit anything useful
        var shortSide = Math.min(rW, rH);
        if (shortSide >= 150) {
          candidates.push({ w: rW, h: rH, area: rW * rH });
        }
        start = top.c;
      }
      stack.push({ h: h, c: start });
    }
  }

  // Sort by area descending, pick the best useful gap
  candidates.sort(function(a,b) { return b.area - a.area; });
  var bestW = 0, bestH = 0;
  if (candidates.length > 0) {
    bestW = candidates[0].w;
    bestH = candidates[0].h;
  }

  // Suggest standard sizes that fit
  var stdSizes = [
    { name: 'Postcard', w: 100, h: 148 },
    { name: 'A6', w: 105, h: 148 }, { name: 'A5', w: 148, h: 210 },
    { name: 'A4', w: 210, h: 297 }, { name: 'A3', w: 297, h: 420 },
    { name: 'A2', w: 420, h: 594 }, { name: 'A1', w: 594, h: 841 }
  ];
  var suggestions = [];
  for (var i = 0; i < stdSizes.length; i++) {
    var s = stdSizes[i];
    if ((s.w <= bestW && s.h <= bestH) || (s.h <= bestW && s.w <= bestH)) {
      suggestions.push(s.name);
    }
  }

  return {
    placedArea: placedArea, totalArea: totalArea,
    wastedArea: wastedArea, wastePercent: wastePercent,
    largestGapW: bestW, largestGapH: bestH,
    suggestions: suggestions
  };
}

function renderWasteNote(analysis) {
  var html = '<div style="margin-top:1rem;padding:.75rem 1rem;background:var(--paper);border:1px solid var(--border);border-radius:4px;font-size:12px;">';
  html += '<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;margin-bottom:.75rem;">';
  html += '<div><span style="font-family:var(--mono);font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;">Area used</span><br><strong style="font-size:16px;">' + Math.round(analysis.placedArea / 100) + 'cm²</strong></div>';
  html += '<div><span style="font-family:var(--mono);font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;">Area wasted</span><br><strong style="font-size:16px;">' + Math.round(analysis.wastedArea / 100) + 'cm²</strong> <span style="color:var(--muted);font-size:11px;">(' + analysis.wastePercent + '%)</span></div>';
  html += '<div><span style="font-family:var(--mono);font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;">Largest gap</span><br><strong style="font-size:16px;">' + analysis.largestGapW + '×' + analysis.largestGapH + 'mm</strong></div>';
  html += '</div>';
  if (analysis.suggestions.length > 0) {
    html += '<div style="font-family:var(--mono);font-size:11px;color:var(--accent);padding:.5rem .75rem;background:var(--accent-light);border-radius:3px;">';
    html += '✓ Largest gap could fit: <strong>' + analysis.suggestions.join(', ') + '</strong>';
    html += '</div>';
  } else if (analysis.largestGapW > 50 && analysis.largestGapH > 50) {
    html += '<div style="font-family:var(--mono);font-size:11px;color:var(--muted);padding:.5rem .75rem;background:#f5f5f5;border-radius:3px;">';
    html += 'Largest gap (' + analysis.largestGapW + '×' + analysis.largestGapH + 'mm) is smaller than standard sizes.';
    html += '</div>';
  }
  html += '</div>';
  return html;
}

function buildJSX() {
  var dpi = parseInt(document.getElementById('dpi').value) || 300;
  var isSheet = winnerMediaType === 'sheet';
  var docW = isSheet ? winnerSheetW : winnerRollW;
  var docH = isSheet ? winnerSheetH : winnerTotalMM;
  var docLabel = isSheet
    ? 'Print Session \u2014 ' + winnerSheetW + '\u00d7' + winnerSheetH + 'mm sheet'
    : 'Print Session \u2014 ' + winnerRollW + 'mm roll';

  var L = [];
  L.push('#target photoshop');
  L.push('');
  L.push('// Fine-art inkjet print session \u2014 single document layout');
  L.push('// ' + docLabel + '  |  ' + dpi + ' dpi  |  ProPhoto RGB  |  16-bit');
  L.push('// ' + winnerPlacements.length + ' print' + (winnerPlacements.length !== 1 ? 's' : '') + ' placed via strip-column nesting');
  L.push('// Generated by Fine-Art Print Nesting Planner');
  L.push('');
  L.push('var dpi    = ' + dpi + ';');
  L.push('var mmToPx = dpi / 25.4;');
  L.push('var docW   = ' + docW + ';  // mm');
  L.push('var docH   = ' + (isSheet ? docH : docH.toFixed(2)) + ';  // mm');
  L.push('');
  L.push('var prints = [');
  for (var i = 0; i < winnerPlacements.length; i++) {
    var pl = winnerPlacements[i];
    var item = pl.item;
    var docW = pl.useW, docH = pl.useH;
    var bTop    = item.b;
    var bBottom = item.rotated ? item.b  : item.bb;
    var bLeft   = item.rotated ? item.bb : item.b;
    var bRight  = item.b;
    var comma = i < winnerPlacements.length - 1 ? ',' : '';
    L.push('  { name: ' + JSON.stringify(item.desc + (item.rotated ? ' [rotated]' : '')) +
      ', x: ' + pl.x + ', y: ' + pl.y +
      ', paperW: ' + docW + ', paperH: ' + docH +
      ', bTop: ' + bTop + ', bBottom: ' + bBottom +
      ', bLeft: ' + bLeft + ', bRight: ' + bRight +
      ', printW: ' + (docW-bLeft-bRight) + ', printH: ' + (docH-bTop-bBottom) + ' }' + comma);
  }
  L.push('];');
  L.push('');
  L.push('var doc = app.documents.add(');
  L.push('  new UnitValue(docW,"mm"), new UnitValue(docH,"mm"),');
  L.push('  dpi, ' + JSON.stringify(docLabel) + ',');
  L.push('  NewDocumentMode.RGB, DocumentFill.WHITE, 1, BitsPerChannelType.SIXTEEN);');
  L.push('app.activeDocument = doc;');
  L.push('try { doc.colorProfileName = "ProPhoto RGB"; } catch(e) {}');
  L.push('');
  L.push('var ru = app.preferences.rulerUnits;');
  L.push('app.preferences.rulerUnits = Units.PIXELS;');
  L.push('');
  L.push('for (var i = 0; i < prints.length; i++) {');
  L.push('  var p     = prints[i];');
  L.push('  var xPx   = Math.round(p.x       * mmToPx);');
  L.push('  var yPx   = Math.round(p.y       * mmToPx);');
  L.push('  var pwPx  = Math.round(p.paperW  * mmToPx);');
  L.push('  var phPx  = Math.round(p.paperH  * mmToPx);');
  L.push('  var bTPx  = Math.round(p.bTop    * mmToPx);');
  L.push('  var bBPx  = Math.round(p.bBottom * mmToPx);');
  L.push('  var bLPx  = Math.round(p.bLeft   * mmToPx);');
  L.push('  var bRPx  = Math.round(p.bRight  * mmToPx);');
  L.push('  var prWpx = Math.round(p.printW  * mmToPx);');
  L.push('  var prHpx = Math.round(p.printH  * mmToPx);');
  L.push('  var grp   = doc.layerSets.add();');
  L.push('  grp.name  = p.name;');
  L.push('  var paperLyr = doc.artLayers.add();');
  L.push('  paperLyr.name = "paper";');
  L.push('  paperLyr.move(grp, ElementPlacement.INSIDE);');
  L.push('  var white = new SolidColor();');
  L.push('  white.rgb.red=255; white.rgb.green=255; white.rgb.blue=255;');
  L.push('  doc.selection.select([[xPx,yPx],[xPx+pwPx,yPx],[xPx+pwPx,yPx+phPx],[xPx,yPx+phPx]]);');
  L.push('  doc.selection.fill(white); doc.selection.deselect();');
  L.push('}');
  L.push('');
  L.push('// OUTLINES group — at top of stack, one click hides all outlines');
  L.push('var outlinesGrp = doc.layerSets.add();');
  L.push('outlinesGrp.name = "\u25ba OUTLINES \u2014 hide when done";');
  L.push('outlinesGrp.move(doc, ElementPlacement.PLACEATBEGINNING);');
  L.push('');
  L.push('// Draw each outline directly into the group');
  L.push('for (var i = 0; i < prints.length; i++) {');
  L.push('  var p     = prints[i];');
  L.push('  var xPx   = Math.round(p.x       * mmToPx);');
  L.push('  var yPx   = Math.round(p.y       * mmToPx);');
  L.push('  var bLPx  = Math.round(p.bLeft   * mmToPx);');
  L.push('  var bTPx  = Math.round(p.bTop    * mmToPx);');
  L.push('  var prWpx = Math.round(p.printW  * mmToPx);');
  L.push('  var prHpx = Math.round(p.printH  * mmToPx);');
  L.push('  var ox = xPx + bLPx, oy = yPx + bTPx;');
  L.push('  var outlineLyr = doc.artLayers.add();');
  L.push('  outlineLyr.name = p.name + "  [" + p.printW + "\u00d7" + p.printH + "mm]";');
  L.push('  outlineLyr.move(outlinesGrp, ElementPlacement.INSIDE);');
  L.push('  doc.selection.select([[ox,oy],[ox+prWpx,oy],[ox+prWpx,oy+prHpx],[ox,oy+prHpx]]);');
  L.push('  var strokeClr = new SolidColor();');
  L.push('  strokeClr.rgb.red=0; strokeClr.rgb.green=120; strokeClr.rgb.blue=215;');
  L.push('  doc.selection.stroke(strokeClr, 4, StrokeLocation.INSIDE, ColorBlendMode.NORMAL, 100, false);');
  L.push('  doc.selection.deselect();');
  L.push('}');
  L.push('app.preferences.rulerUnits = ru;');
  L.push('alert("Done! ' + winnerPlacements.length + ' print' + (winnerPlacements.length !== 1 ? 's' : '') + ' laid out.");');

  return L.join('\n');
}

function downloadScript() {
  var jsx = buildJSX();
  var blob = new Blob([jsx], { type: 'text/plain' });
  var a = document.createElement('a');
  a.href = URL.createObjectURL(blob);
  // Use first print's description as filename, sanitised for file system
  var rawName = winnerPlacements.length > 0 ? winnerPlacements[0].item.desc : 'PrintSession';
  // Strip rotation suffix, trim whitespace
  rawName = rawName.replace(/\s*\[rotated\]/gi, '').trim();
  // Remove characters not safe for filenames
  var safeName = rawName.replace(/[\/\\:*?"<>|]/g, '-').replace(/\s+/g, ' ').trim();
  if (!safeName) safeName = 'PrintSession';
  a.download = safeName + '.jsx';
  document.body.appendChild(a);
  a.click();
  document.body.removeChild(a);
  URL.revokeObjectURL(a.href);
}

function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
function escSVG(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
</script>
</body>
</html>

    1 reply

    Participant
    October 18, 2026

    Sorry! That does not work! Here is a link: https://forum.luminous-landscape.com/index.php?topic=145783.0 

    Translate