383 lines
18 KiB
HTML
383 lines
18 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>PCB Tools by kennycoder + pszsh</title>
|
|
<link rel="stylesheet" href="/static/style.css">
|
|
</head>
|
|
|
|
<body>
|
|
<div class="container">
|
|
<h1>PCB Tools by kennycoder + pszsh</h1>
|
|
|
|
<div class="menu-bar">
|
|
<div class="menu-item">
|
|
<span>File</span>
|
|
<div class="menu-dropdown">
|
|
<a href="#" onclick="saveConfig()">Save Configuration</a>
|
|
<div class="menu-divider"></div>
|
|
<div id="recentsList" class="menu-recents">
|
|
<span class="menu-recents-title">Saved Configs</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="menu-item">
|
|
<span>Export</span>
|
|
<div class="menu-dropdown export-options">
|
|
<label><input type="checkbox" name="export-stl" value="stl" checked> STL (3D Mesh)</label>
|
|
<label><input type="checkbox" name="export-scad" value="scad" checked> SCAD (Native
|
|
OpenSCAD)</label>
|
|
<label><input type="checkbox" name="export-svg" value="svg"> SVG (2D Vector)</label>
|
|
<label><input type="checkbox" name="export-png" value="png"> PNG (2D Raster)</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="tabs">
|
|
<button class="tab active" data-tab="stencil">Stencil</button>
|
|
<button class="tab" data-tab="enclosure">Enclosure</button>
|
|
</div>
|
|
|
|
<!-- Tab 1: Stencil -->
|
|
<div class="tab-content active" id="tab-stencil">
|
|
<form action="/upload" method="post" enctype="multipart/form-data">
|
|
<div class="form-group tooltip-wrap">
|
|
<label for="gerber">Solder Paste Gerber File (Required)</label>
|
|
<input type="file" id="gerber" name="gerber" accept=".gbr,.gtp,.gbp" required>
|
|
<div class="tooltip">Layers to export for Gerbers
|
|
<hr>• F.Paste (front paste stencil)<br>• B.Paste (back paste stencil)
|
|
</div>
|
|
</div>
|
|
<div class="form-group tooltip-wrap">
|
|
<label for="outline">Board Outline Gerber (Optional)</label>
|
|
<input type="file" id="outline" name="outline" accept=".gbr,.gko,.gm1">
|
|
<div class="hint">Upload this to automatically crop and generate walls.</div>
|
|
<div class="tooltip">Layers to export for Gerbers
|
|
<hr>• Edge.Cuts (board outline)
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label for="height">Stencil Height (mm)</label>
|
|
<input type="number" id="height" name="height" value="0.16" step="0.01">
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="lineWidth">Nozzle Line Width (mm)</label>
|
|
<input type="number" id="lineWidth" name="lineWidth" value="0.42" step="0.01">
|
|
<div class="hint">Pad sizes snap to multiples of this.</div>
|
|
</div>
|
|
</div>
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label for="dpi">DPI</label>
|
|
<input type="number" id="dpi" name="dpi" value="1000" step="100">
|
|
</div>
|
|
<div class="form-group"></div>
|
|
</div>
|
|
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label for="wallHeight">Wall Height (mm)</label>
|
|
<input type="number" id="wallHeight" name="wallHeight" value="2.0" step="0.1">
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="wallThickness">Wall Thickness (mm)</label>
|
|
<input type="number" id="wallThickness" name="wallThickness" value="1.0" step="0.1">
|
|
</div>
|
|
</div>
|
|
|
|
<button type="submit" class="submit-btn">Convert to STL</button>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- Tab 2: Enclosure -->
|
|
<div class="tab-content" id="tab-enclosure">
|
|
<form action="/upload-enclosure" method="post" enctype="multipart/form-data">
|
|
<div class="form-group">
|
|
<label for="enc-gbrjob">Gerber Job File (Required) <span class="help-btn"
|
|
onclick="document.getElementById('help-gbrjob').classList.toggle('visible')">(?)</span></label>
|
|
<input type="file" id="enc-gbrjob" name="gbrjob" accept=".gbrjob" required>
|
|
<div class="hint">Auto-detects board layers, dimensions, and PCB thickness.</div>
|
|
<div class="help-popup" id="help-gbrjob">
|
|
<div class="help-popup-close"
|
|
onclick="document.getElementById('help-gbrjob').classList.remove('visible')">✕</div>
|
|
<img src="/static/screenshot_gerber_output_dialogue.png" alt="KiCad plot settings">
|
|
</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="enc-gerbers">Gerber Files (Required)</label>
|
|
<input type="file" id="enc-gerbers" name="gerbers" accept=".gbr,.gko,.gm1" multiple required>
|
|
<div class="hint">Select all exported .gbr files from the same folder.</div>
|
|
</div>
|
|
|
|
<div class="form-group tooltip-wrap">
|
|
<label for="enc-drill">PTH Drill File (Optional)</label>
|
|
<input type="file" id="enc-drill" name="drill" accept=".drl,.xln,.txt">
|
|
<div class="hint">Component through-holes (vias auto-filtered).</div>
|
|
<div class="tooltip">Use the <b>PTH</b> file from KiCad's drill export.</div>
|
|
</div>
|
|
<div class="form-group tooltip-wrap">
|
|
<label for="enc-npth">NPTH Drill File (Optional)</label>
|
|
<input type="file" id="enc-npth" name="npth" accept=".drl,.xln,.txt">
|
|
<div class="hint">Mounting holes — become alignment pegs.</div>
|
|
<div class="tooltip">Use the <b>NPTH</b> file — these become alignment pegs.</div>
|
|
</div>
|
|
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label for="enc-wallThickness">Wall Thickness (mm)</label>
|
|
<input type="number" id="enc-wallThickness" name="wallThickness" value="1.5" step="0.1">
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="enc-wallHeight">Wall Height (mm)</label>
|
|
<input type="number" id="enc-wallHeight" name="wallHeight" value="10.0" step="0.5">
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label for="enc-clearance">Clearance (mm)</label>
|
|
<input type="number" id="enc-clearance" name="clearance" value="0.3" step="0.05">
|
|
<div class="hint">Gap between PCB edge and enclosure wall.</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="enc-dpi">DPI</label>
|
|
<input type="number" id="enc-dpi" name="dpi" value="600" step="100">
|
|
<div class="hint">Lower = smaller file. 600 recommended.</div>
|
|
</div>
|
|
</div>
|
|
|
|
<button type="submit" class="submit-btn">Generate Enclosure</button>
|
|
</form>
|
|
</div>
|
|
|
|
<div id="loading">
|
|
<div class="spinner"></div>
|
|
<div>Processing... This may take 10-20 seconds.</div>
|
|
</div>
|
|
|
|
<!-- Instance Management Panel -->
|
|
<div id="instancePanel" style="display:none; margin-top: 1.5rem;">
|
|
<div id="profilesSection" style="display:none;">
|
|
<h3 style="font-size: 0.9rem; font-weight: 600; color: #374151; margin: 0 0 0.5rem 0;">Saved Profiles</h3>
|
|
<div id="profilesList" class="instance-list"></div>
|
|
</div>
|
|
<div id="recentsSection" style="display:none;">
|
|
<h3 style="font-size: 0.9rem; font-weight: 600; color: #6b7280; margin: 1rem 0 0.5rem 0;">Recent Enclosures</h3>
|
|
<div id="recentsList2" class="instance-list"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
// Tab switching
|
|
document.querySelectorAll('.tab').forEach(function (tab) {
|
|
tab.addEventListener('click', function () {
|
|
document.querySelectorAll('.tab').forEach(function (t) { t.classList.remove('active'); });
|
|
document.querySelectorAll('.tab-content').forEach(function (tc) { tc.classList.remove('active'); });
|
|
tab.classList.add('active');
|
|
document.getElementById('tab-' + tab.dataset.tab).classList.add('active');
|
|
});
|
|
});
|
|
|
|
// Include export config in form submissions
|
|
document.querySelectorAll('form').forEach(function (form) {
|
|
form.addEventListener('submit', function (e) {
|
|
// Clear old hidden inputs to avoid duplicates on re-submission
|
|
form.querySelectorAll('input.dynamic-export').forEach(function (el) { el.remove(); });
|
|
|
|
// Read checkboxes and inject as hidden inputs
|
|
document.querySelectorAll('.export-options input[type="checkbox"]').forEach(function (cb) {
|
|
if (cb.checked) {
|
|
var hidden = document.createElement('input');
|
|
hidden.type = 'hidden';
|
|
hidden.name = 'exports';
|
|
hidden.value = cb.value;
|
|
hidden.className = 'dynamic-export';
|
|
form.appendChild(hidden);
|
|
}
|
|
});
|
|
|
|
document.getElementById('loading').style.display = 'block';
|
|
var btn = form.querySelector('.submit-btn');
|
|
if (btn) {
|
|
btn.disabled = true;
|
|
btn.innerText = 'Processing...';
|
|
}
|
|
});
|
|
});
|
|
|
|
// Config state management
|
|
function saveConfig() {
|
|
var config = {
|
|
stencilHeight: document.getElementById('height').value,
|
|
stencilDpi: document.getElementById('dpi').value,
|
|
stencilLineWidth: document.getElementById('lineWidth').value,
|
|
stencilWallHeight: document.getElementById('wallHeight').value,
|
|
stencilWallThickness: document.getElementById('wallThickness').value,
|
|
|
|
encWallThickness: document.getElementById('enc-wallThickness').value,
|
|
encWallHeight: document.getElementById('enc-wallHeight').value,
|
|
encClearance: document.getElementById('enc-clearance').value,
|
|
encDpi: document.getElementById('enc-dpi').value
|
|
};
|
|
|
|
var name = prompt("Save configuration as:", "My Config " + new Date().toLocaleTimeString());
|
|
if (name) {
|
|
var recents = JSON.parse(localStorage.getItem('pcbConfigs') || '{}');
|
|
recents[name] = config;
|
|
localStorage.setItem('pcbConfigs', JSON.stringify(recents));
|
|
updateRecents();
|
|
}
|
|
}
|
|
|
|
function loadConfig(name) {
|
|
var recents = JSON.parse(localStorage.getItem('pcbConfigs') || '{}');
|
|
var config = recents[name];
|
|
if (config) {
|
|
if (config.stencilHeight) document.getElementById('height').value = config.stencilHeight;
|
|
if (config.stencilDpi) document.getElementById('dpi').value = config.stencilDpi;
|
|
if (config.stencilLineWidth) document.getElementById('lineWidth').value = config.stencilLineWidth;
|
|
if (config.stencilWallHeight) document.getElementById('wallHeight').value = config.stencilWallHeight;
|
|
if (config.stencilWallThickness) document.getElementById('wallThickness').value = config.stencilWallThickness;
|
|
|
|
if (config.encWallThickness) document.getElementById('enc-wallThickness').value = config.encWallThickness;
|
|
if (config.encWallHeight) document.getElementById('enc-wallHeight').value = config.encWallHeight;
|
|
if (config.encClearance) document.getElementById('enc-clearance').value = config.encClearance;
|
|
if (config.encDpi) document.getElementById('enc-dpi').value = config.encDpi;
|
|
}
|
|
}
|
|
|
|
function updateRecents() {
|
|
var recents = JSON.parse(localStorage.getItem('pcbConfigs') || '{}');
|
|
var list = document.getElementById('recentsList');
|
|
if (!list) return;
|
|
|
|
list.innerHTML = '<span class="menu-recents-title">Saved Configs</span>';
|
|
for (var name in recents) {
|
|
var a = document.createElement('a');
|
|
a.className = 'menu-recents-item';
|
|
a.innerText = name;
|
|
a.href = "#";
|
|
a.onclick = (function (n) {
|
|
return function (e) {
|
|
e.preventDefault();
|
|
loadConfig(n);
|
|
};
|
|
})(name);
|
|
list.appendChild(a);
|
|
}
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', updateRecents);
|
|
|
|
// Removed old triggerExport since we now use multi-select checkboxes
|
|
|
|
// --- Instance Management ---
|
|
function loadServerInstances() {
|
|
fetch('/api/instances').then(function(r) { return r.json(); }).then(function(data) {
|
|
var panel = document.getElementById('instancePanel');
|
|
var hasContent = false;
|
|
|
|
// Profiles
|
|
if (data.profiles && data.profiles.length > 0) {
|
|
document.getElementById('profilesSection').style.display = 'block';
|
|
var list = document.getElementById('profilesList');
|
|
list.innerHTML = '';
|
|
data.profiles.forEach(function(inst) {
|
|
list.appendChild(createInstanceCard(inst, 'profile'));
|
|
});
|
|
hasContent = true;
|
|
}
|
|
|
|
// Recents
|
|
if (data.recents && data.recents.length > 0) {
|
|
document.getElementById('recentsSection').style.display = 'block';
|
|
var list2 = document.getElementById('recentsList2');
|
|
list2.innerHTML = '';
|
|
data.recents.forEach(function(inst) {
|
|
list2.appendChild(createInstanceCard(inst, 'recent'));
|
|
});
|
|
hasContent = true;
|
|
}
|
|
|
|
if (hasContent) panel.style.display = 'block';
|
|
}).catch(function(e) { console.log('No instances:', e); });
|
|
}
|
|
|
|
function createInstanceCard(inst, type) {
|
|
var card = document.createElement('div');
|
|
card.className = 'instance-card';
|
|
|
|
var title = inst.name || inst.projectName || 'Unnamed';
|
|
var dims = (inst.boardW || 0).toFixed(1) + ' x ' + (inst.boardH || 0).toFixed(1) + ' mm';
|
|
var cutouts = (inst.sideCutouts || []).length;
|
|
var date = inst.createdAt ? new Date(inst.createdAt).toLocaleDateString() : '';
|
|
|
|
card.innerHTML =
|
|
'<div class="instance-card-info" onclick="restoreInstance(\'' + inst.id + '\')">' +
|
|
'<div class="instance-card-title">' + escapeHtml(title) + '</div>' +
|
|
'<div class="instance-card-meta">' + dims + ' · ' + cutouts + ' cutout' + (cutouts !== 1 ? 's' : '') + ' · ' + date + '</div>' +
|
|
'</div>' +
|
|
'<div class="instance-card-actions">' +
|
|
(type === 'recent' ? '<button class="inst-btn inst-save" onclick="saveAsProfile(\'' + inst.id + '\')">Save</button>' : '') +
|
|
'<button class="inst-btn inst-del" onclick="deleteInstance(\'' + inst.id + '\',\'' + type + '\')">Del</button>' +
|
|
'</div>';
|
|
return card;
|
|
}
|
|
|
|
function escapeHtml(s) {
|
|
var d = document.createElement('div');
|
|
d.textContent = s;
|
|
return d.innerHTML;
|
|
}
|
|
|
|
function restoreInstance(id) {
|
|
document.getElementById('loading').style.display = 'block';
|
|
fetch('/api/instances/restore', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ id: id })
|
|
}).then(function(r) {
|
|
if (!r.ok) return r.text().then(function(t) { throw new Error(t); });
|
|
return r.json();
|
|
}).then(function(data) {
|
|
if (data.sideCutouts && data.sideCutouts.length > 0) {
|
|
sessionStorage.setItem('restoredCutouts', JSON.stringify(data.sideCutouts));
|
|
}
|
|
window.location.href = '/preview?id=' + data.sessionId;
|
|
}).catch(function(e) {
|
|
document.getElementById('loading').style.display = 'none';
|
|
alert('Restore failed: ' + e.message);
|
|
});
|
|
}
|
|
|
|
function saveAsProfile(id) {
|
|
var name = prompt('Save profile as:');
|
|
if (!name) return;
|
|
fetch('/api/profiles', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ id: id, name: name })
|
|
}).then(function(r) {
|
|
if (!r.ok) throw new Error('Save failed');
|
|
loadServerInstances();
|
|
}).catch(function(e) { alert(e.message); });
|
|
}
|
|
|
|
function deleteInstance(id, type) {
|
|
if (!confirm('Delete this ' + type + '?')) return;
|
|
var url = type === 'profile' ? '/api/profiles/' + id : '/api/recents/' + id;
|
|
fetch(url, { method: 'DELETE' }).then(function() {
|
|
loadServerInstances();
|
|
});
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', loadServerInstances);
|
|
</script>
|
|
</body>
|
|
|
|
</html> |