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" — 432mm</option>
<option value="610" selected>24" — 610mm</option>
<option value="914">36" — 914mm</option>
<option value="1118">44" — 1118mm</option>
<option value="1524">60" — 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" — 432mm</option>
<option value="610">24" — 610mm</option>
<option value="914" selected>36" — 914mm</option>
<option value="1118">44" — 1118mm</option>
<option value="1524">60" — 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
function escSVG(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
</script>
</body>
</html>
