/
diz
/
stencil
Обзор
Документация
Войти
/
diz
/
stencil
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
src/app.js
873 строки
32 KB
Dmitry
fix: footer date → Aquarius, revert Gerber tabs to original layout
30 июл 2026, 12:14
30 июл 2026, 12:14
9d407a6
Код
Авторство
О чём код?
/* ================================================================ SMT Stencil Aperture Validator — IPC-7525B/C Vanilla JS application logic ================================================================ */ (function() { 'use strict'; // ===================== STATE ===================== const state = { thickness: 120, // µm thicknessUnit: 'um', // 'um' | 'mil' tech: 'laser', // 'laser' | 'electropolished' | 'nano' displayUnit: 'mm', // 'mm' | 'mil' darkMode: false, apertures: [], // master list of apertures selectedId: null, expressRows: [], // row data for express tab nextId: 1, settingsOpen: true, }; // ===================== CONSTANTS ===================== const COMPONENT_TEMPLATES = [ { type: '0201', w: 0.25, l: 0.40, shape: 'rectangle', note: '0603 метрика' }, { type: '0402', w: 0.50, l: 0.60, shape: 'rectangle', note: '1005 метрика' }, { type: '0603', w: 0.80, l: 0.90, shape: 'rectangle', note: '1608 метрика' }, { type: '0805', w: 1.20, l: 1.40, shape: 'rectangle', note: '2012 метрика' }, { type: '1206', w: 1.60, l: 2.00, shape: 'rectangle', note: '3216 метрика' }, { type: '2512', w: 3.20, l: 6.40, shape: 'rectangle', note: '6432 метрика' }, { type: 'SOT-23', w: 0.60, l: 1.00, shape: 'rounded', note: '3 вывода, шаг 1.9' }, { type: 'SOT-89', w: 0.80, l: 1.60, shape: 'rounded', note: '3 вывода + тепл. площадка' }, { type: 'SOIC-8', w: 0.45, l: 1.80, shape: 'rounded', note: 'шаг 1.27 мм' }, { type: 'SOIC-16', w: 0.45, l: 1.80, shape: 'rounded', note: 'шаг 1.27 мм' }, { type: 'QFP-32', w: 0.30, l: 1.50, shape: 'rounded', note: 'шаг 0.8 мм' }, { type: 'QFP-44', w: 0.30, l: 2.00, shape: 'rounded', note: 'шаг 0.8 мм' }, { type: 'QFP-48', w: 0.25, l: 2.00, shape: 'rounded', note: 'шаг 0.5 мм' }, { type: 'QFN-16 (3×3)', w: 0.25, l: 0.60, shape: 'rounded', note: 'периферийные выводы' }, { type: 'QFN Thermal Pad 3×3', w: 3.00, l: 3.00, shape: 'rounded', note: 'window-pane 2×2' }, { type: 'QFN Thermal Pad 4×4', w: 4.00, l: 4.00, shape: 'rounded', note: 'window-pane 3×3' }, { type: 'QFN Thermal Pad 5×5', w: 5.00, l: 5.00, shape: 'rounded', note: 'window-pane 3×3' }, { type: 'BGA ⌀ 0.3', w: 0.30, l: null, shape: 'circle', note: 'мелкий шаг < 0.8' }, { type: 'BGA ⌀ 0.35', w: 0.35, l: null, shape: 'circle', note: 'стандартный' }, { type: 'BGA ⌀ 0.4', w: 0.40, l: null, shape: 'circle', note: 'стандартный' }, { type: 'BGA ⌀ 0.5', w: 0.50, l: null, shape: 'circle', note: 'крупный шаг' }, { type: 'DPAK', w: 2.00, l: 3.00, shape: 'rounded', note: 'thermal pad + выводы' }, ]; // PRESETS built from COMPONENT_TEMPLATES — single source of truth const PRESET_KEY_MAP = { '0201': '0201', '0402': '0402', '0603': '0603', '0805': '0805', '1206': '1206', 'SOT23': 'SOT-23', 'SOIC8': 'SOIC-8', 'QFP48': 'QFP-48', 'BGA': 'BGA ⌀ 0.35', 'QFN': 'QFN Thermal Pad 4×4', }; const PRESETS = {}; for (const t of COMPONENT_TEMPLATES) { for (const [key, typeName] of Object.entries(PRESET_KEY_MAP)) { if (t.type === typeName) { PRESETS[key] = { w: t.w, l: t.l, shape: t.shape, desc: t.note }; break; } } } // ===================== UNIT CONVERSION ===================== function umToMm(um) { return um / 1000; } function mmToUm(mm) { return mm * 1000; } function milToMm(mil) { return mil * 0.0254; } function mmToMil(mm) { return mm / 0.0254; } function formatDim(mm, unit) { const val = unit === 'mil' ? mmToMil(mm) : mm; return val.toFixed(2) + ' ' + unit; } function getThicknessMm() { if (state.thicknessUnit === 'mil') return milToMm(state.thickness); return umToMm(state.thickness); } function getAreaRatioThreshold() { if (state.tech === 'nano') return 0.55; return 0.66; } // ===================== CALC ENGINE ===================== function calcAperture(a) { const T = getThicknessMm(); if (T <= 0) return null; let AR, areaR, area, volume; const shape = a.shape || 'rectangle'; const W = a.width || 0; const L = (shape === 'circle') ? W : (a.length || W || 0); if (shape === 'circle') { if (W <= 0) return null; AR = W / T; areaR = W / (4 * T); area = Math.PI * W * W / 4; volume = area * T; } else { if (W <= 0 || L <= 0) return null; const minDim = Math.min(W, L); AR = minDim / T; areaR = (W * L) / (2 * T * (W + L)); area = W * L; volume = area * T; } const arThreshold = 1.5; const areaThreshold = getAreaRatioThreshold(); let arStatus = AR >= arThreshold ? 'pass' : 'fail'; let areaStatus; if (areaR >= areaThreshold) areaStatus = 'pass'; else if (areaR >= 0.55) areaStatus = 'warn'; else areaStatus = 'fail'; // Reduction recommendation let reduction = ''; if (areaStatus === 'fail' || areaStatus === 'warn') { if (shape === 'circle') { reduction = 'Уменьшить D на 10–15% или уменьшить T'; } else { const targetAreaR = areaThreshold; const k = targetAreaR / areaR; const redPct = Math.round((1 - Math.min(k, 0.95)) * 100); if (W > 2 && L > 2) { reduction = 'Window-pane разделение (матрица апертур)'; } else if (redPct > 0 && redPct <= 50) { reduction = `Редукция W/L на ~${redPct}% (×${Math.min(k, 0.95).toFixed(2)})`; } else if (redPct > 50) { reduction = 'Window-pane разделение или ступенчатый трафарет'; } else { reduction = 'Параметры в норме, редукция не требуется'; } } } else { reduction = '✓ Параметры в норме'; } return { AR, areaR, area, volume, arStatus, areaStatus, reduction }; } // ===================== RECALCULATE ALL ===================== function recalculateAll() { const results = []; for (const a of state.apertures) { const calc = calcAperture(a); if (calc) { results.push({ ...a, ...calc }); } else { results.push({ ...a, AR: 0, areaR: 0, area: 0, volume: 0, arStatus: 'fail', areaStatus: 'fail', reduction: 'Некорректные размеры' }); } } return results; } // ===================== RENDER RESULTS TABLE ===================== function renderResults() { const results = recalculateAll(); const tbody = document.getElementById('resultsBody'); const count = document.getElementById('apertureCount'); if (results.length === 0) { tbody.innerHTML = '<tr><td colspan="9" style="text-align:center;color:var(--text-secondary);padding:2rem;">Добавьте апертуры через калькулятор или Gerber-парсер</td></tr>'; count.textContent = '(0 апертур)'; return; } count.textContent = `(${results.length} апертур)`; let html = ''; for (const r of results) { const wStr = r.shape === 'circle' ? '⌀' + r.width.toFixed(3) : r.width.toFixed(3); const lStr = r.shape === 'circle' ? '—' : (r.length ? r.length.toFixed(3) : '—'); const arBadge = r.arStatus === 'pass' ? `<span class="badge-pass">PASS ${r.AR.toFixed(2)}</span>` : `<span class="badge-fail">FAIL ${r.AR.toFixed(2)}</span>`; let areaBadge; if (r.areaStatus === 'pass') areaBadge = `<span class="badge-pass">PASS ${r.areaR.toFixed(3)}</span>`; else if (r.areaStatus === 'warn') areaBadge = `<span class="badge-warn">WARN ${r.areaR.toFixed(3)}</span>`; else areaBadge = `<span class="badge-fail">FAIL ${r.areaR.toFixed(3)}</span>`; const volMm3 = r.volume.toFixed(4); const infoUnit = state.displayUnit; const selAttr = `onclick="selectAperture(${r.id})"`; const selClass = state.selectedId === r.id ? 'selected' : ''; html += `<tr class="${selClass}" ${selAttr} data-id="${r.id}"> <td>${r.id}</td> <td><strong>${escHtml(r.component)}</strong>${r.dCode ? '<br><span style="font-size:.7rem;color:var(--text-secondary);">D-' + escHtml(r.dCode) + '</span>' : ''}</td> <td>${shapeLabel(r.shape)}</td> <td>${wStr}</td> <td>${lStr}</td> <td>${arBadge}</td> <td>${areaBadge}</td> <td>${volMm3} <span style="font-size:.7rem;color:var(--text-secondary);">${infoUnit === 'mil' ? 'mil³' : 'мм³'}</span></td> <td style="font-size:.75rem;max-width:160px;">${escHtml(r.reduction)}</td> </tr>`; } tbody.innerHTML = html; } function shapeLabel(s) { const map = { rectangle: '▭ Прям.', rounded: '▣ Скругл.', circle: '○ Круг', obround: '◯ Оброунд' }; return map[s] || s; } function escHtml(s) { if (!s) return ''; return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); } // ===================== SELECT APERTURE ===================== window.selectAperture = function(id) { state.selectedId = id; renderResults(); renderCanvas(id); }; // ===================== CANVAS 2D PREVIEW ===================== function renderCanvas(id) { const a = state.apertures.find(x => x.id === id); const canvas = document.getElementById('previewCanvas'); const placeholder = document.getElementById('canvasPlaceholder'); const info = document.getElementById('canvasInfo'); if (!a) { canvas.style.display = 'none'; placeholder.style.display = 'block'; info.style.display = 'none'; return; } placeholder.style.display = 'none'; canvas.style.display = 'block'; info.style.display = 'block'; const calc = calcAperture(a); const T = getThicknessMm(); const ctx = canvas.getContext('2d'); const W = canvas.width; const H = canvas.height; ctx.clearRect(0, 0, W, H); // Determine drawing scale const padW = a.width || 1; const padL = a.length || a.width || 1; const maxDim = Math.max(padW, padL) * 1.8; const scale = Math.min((W - 80) / maxDim, (H - 80) / maxDim, 120); const cx = W / 2; const cy = H / 2; const halfW = padW * scale / 2; const halfL = padL * scale / 2; // Background grid ctx.strokeStyle = 'rgba(128,128,128,0.12)'; ctx.lineWidth = 0.5; for (let x = 0; x < W; x += 20) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke(); } for (let y = 0; y < H; y += 20) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke(); } // Recommended aperture reduction let redW = padW, redL = padL; if (calc && (calc.areaStatus === 'fail' || calc.areaStatus === 'warn') && a.shape !== 'circle') { const targetR = getAreaRatioThreshold(); const k = Math.min(targetR / calc.areaR, 0.92); if (k > 0.1 && k < 1) { redW = padW * Math.sqrt(k); redL = padL * Math.sqrt(k); } } // For thermal pads, show window-pane let isWindowPane = false; if (padW > 2 && padL > 2 && a.shape !== 'circle') { isWindowPane = true; } const r = Math.min(padW, padL) * 0.1 * scale; // corner radius const redHalfW = redW * scale / 2; const redHalfL = redL * scale / 2; // Draw original pad (dashed) ctx.save(); ctx.strokeStyle = '#94a3b8'; ctx.lineWidth = 1.5; ctx.setLineDash([5, 4]); if (a.shape === 'circle') { const D = a.width * scale / 2; ctx.beginPath(); ctx.arc(cx, cy, D, 0, Math.PI * 2); ctx.stroke(); ctx.setLineDash([]); ctx.fillStyle = 'rgba(148,163,184,0.08)'; ctx.fill(); } else if (isWindowPane) { ctx.strokeRect(cx - halfW, cy - halfL, halfW * 2, halfL * 2); ctx.setLineDash([]); const gridN = Math.max(2, Math.round(Math.min(padW, padL) / 1.5)); const cellW = (redW * 0.8) / gridN; const cellL = (redL * 0.8) / gridN; const gridOffsetX = (redW * 0.8) / 2; const gridOffsetY = (redL * 0.8) / 2; if (cellW > 0.05 && cellL > 0.05) { ctx.fillStyle = 'rgba(37,99,235,0.3)'; for (let i = 0; i < gridN; i++) { for (let j = 0; j < gridN; j++) { const gx = cx - gridOffsetX * scale + (i + 0.5) * cellW * scale; const gy = cy - gridOffsetY * scale + (j + 0.5) * cellL * scale; const gw = cellW * scale * 0.85; const gl = cellL * scale * 0.85; roundRect(ctx, gx - gw/2, gy - gl/2, gw, gl, Math.min(gw, gl) * 0.15); ctx.fill(); } } } info.innerHTML = `Window-pane: ${gridN}×${gridN} апертур, общая площадь ~${(redW * redL * 0.64).toFixed(2)} мм²`; } else { if (a.shape === 'rounded' || a.shape === 'obround') { roundRect(ctx, cx - halfW, cy - halfL, halfW * 2, halfL * 2, r); ctx.stroke(); ctx.setLineDash([]); ctx.fillStyle = 'rgba(148,163,184,0.08)'; ctx.fill(); } else { ctx.strokeRect(cx - halfW, cy - halfL, halfW * 2, halfL * 2); ctx.setLineDash([]); ctx.fillStyle = 'rgba(148,163,184,0.08)'; ctx.fillRect(cx - halfW, cy - halfL, halfW * 2, halfL * 2); } } ctx.restore(); // Draw recommended aperture (filled) ctx.save(); ctx.fillStyle = 'rgba(37,99,235,0.18)'; ctx.strokeStyle = '#2563eb'; ctx.lineWidth = 2; ctx.setLineDash([]); if (a.shape === 'circle') { const D = a.width * scale / 2; ctx.beginPath(); ctx.arc(cx, cy, D, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); } else if (!isWindowPane) { if (a.shape === 'rounded' || a.shape === 'obround') { roundRect(ctx, cx - redHalfW, cy - redHalfL, redHalfW * 2, redHalfL * 2, r); ctx.fill(); ctx.stroke(); } else { const rr = Math.min(redW, redL) * scale * 0.03; roundRect(ctx, cx - redHalfW, cy - redHalfL, redHalfW * 2, redHalfL * 2, rr); ctx.fill(); ctx.stroke(); } } ctx.restore(); // Labels ctx.save(); ctx.fillStyle = getComputedStyle(document.body).getPropertyValue('--text-primary').trim() || '#0f172a'; ctx.font = '11px sans-serif'; ctx.textAlign = 'center'; ctx.fillText(`${a.component || 'Апертура'}`, cx, 16); ctx.font = '10px sans-serif'; ctx.fillStyle = getComputedStyle(document.body).getPropertyValue('--text-secondary').trim() || '#475569'; const unit = state.displayUnit === 'mil' ? 'mil' : 'мм'; if (a.shape === 'circle') { ctx.fillText(`⌀ ${a.width.toFixed(3)} ${unit}`, cx, H - 10); } else { ctx.fillText(`${a.width.toFixed(3)} × ${(a.length || a.width).toFixed(3)} ${unit}`, cx, H - 10); } ctx.restore(); // Info if (!isWindowPane && calc) { info.innerHTML = ` <div style="display:flex;gap:1rem;flex-wrap:wrap;"> <span><strong>AR:</strong> ${calc.AR.toFixed(2)} ${calc.arStatus === 'pass' ? '✅' : '❌'}</span> <span><strong>AreaR:</strong> ${calc.areaR.toFixed(3)} ${calc.areaStatus === 'pass' ? '✅' : calc.areaStatus === 'warn' ? '⚠️' : '❌'}</span> <span><strong>Объём:</strong> ${calc.volume.toFixed(4)} мм³</span> <span><strong>T:</strong> ${state.thickness} ${state.thicknessUnit === 'mil' ? 'mil' : 'мкм'}</span> </div> <div style="margin-top:.3rem;"><em>${escHtml(calc.reduction)}</em></div> `; } } function roundRect(ctx, x, y, w, h, r) { r = Math.min(r, w/2, h/2); ctx.beginPath(); ctx.moveTo(x + r, y); ctx.lineTo(x + w - r, y); ctx.arcTo(x + w, y, x + w, y + r, r); ctx.lineTo(x + w, y + h - r); ctx.arcTo(x + w, y + h, x + w - r, y + h, r); ctx.lineTo(x + r, y + h); ctx.arcTo(x, y + h, x, y + h - r, r); ctx.lineTo(x, y + r); ctx.arcTo(x, y, x + r, y, r); ctx.closePath(); } // ===================== GERBER PARSER ===================== window.parseGerber = function() { const text = document.getElementById('gerberInput').value; if (!text.trim()) { document.getElementById('gerberResult').innerHTML = '<span style="color:var(--fail-color);">Нет данных для разбора.</span>'; return; } const apertures = []; // Detect unit from %MOIN*% or %MOMM*% let unit = 'mm'; const moMatch = text.match(/%MO(IN|MM)\*/i); if (moMatch) { unit = moMatch[1].toUpperCase() === 'IN' ? 'in' : 'mm'; } // Parse aperture definitions: %ADD<dcode><shape>,<params>% const addRegex = /%ADD(\d+)([CRO])([0-9.\-+]+(?:(?:X[0-9.\-+]+)?))%/g; let match; while ((match = addRegex.exec(text)) !== null) { const dCode = match[1]; const shapeLetter = match[2]; const params = match[3]; let shape, w, l; if (shapeLetter === 'C') { shape = 'circle'; w = parseFloat(params); l = null; } else if (shapeLetter === 'R') { shape = 'rectangle'; const parts = params.split('X'); w = parseFloat(parts[0]); l = parts.length > 1 ? parseFloat(parts[1]) : w; } else if (shapeLetter === 'O') { shape = 'obround'; const parts = params.split('X'); w = parseFloat(parts[0]); l = parts.length > 1 ? parseFloat(parts[1]) : w; } else { continue; } // Convert to mm if unit is inches if (unit === 'in') { w = w * 25.4; l = l ? l * 25.4 : null; } if (w && w > 0) { apertures.push({ dCode: dCode.toString(), component: `D-${dCode}`, shape, width: w, length: l, source: 'gerber', }); } } // Display result const resultDiv = document.getElementById('gerberResult'); if (apertures.length === 0) { resultDiv.innerHTML = '<span style="color:var(--warn-color);">Апертуры не найдены. Проверьте формат (%ADD...%).</span>'; return; } const unitLabel = unit === 'in' ? 'дюймы' : 'мм'; let html = `<div style="margin-bottom:.3rem;color:var(--pass-color);font-weight:600;">Найдено апертур: ${apertures.length} (единицы: ${unitLabel})</div>`; for (const a of apertures) { const dimStr = a.shape === 'circle' ? `⌀ ${a.width.toFixed(3)} мм` : `${a.width.toFixed(3)} × ${a.length.toFixed(3)} мм`; html += `<div style="padding:2px 0;">D-${a.dCode}: ${shapeLabel(a.shape)} ${dimStr}</div>`; } html += `<div style="margin-top:.5rem;"><button class="btn btn-sm btn-primary" onclick="importGerberApertures()">📥 Импортировать ${apertures.length} апертур(ы)</button></div>`; resultDiv.innerHTML = html; // Store for import window._gerberApertures = apertures; }; window.importGerberApertures = function() { const apertures = window._gerberApertures || []; if (apertures.length === 0) return; for (const a of apertures) { state.apertures.push({ id: state.nextId++, component: a.component, dCode: a.dCode, shape: a.shape, width: a.width, length: a.length, source: a.source, }); } window._gerberApertures = []; renderResults(); updateExpressFromMaster(); }; window.clearGerber = function() { document.getElementById('gerberInput').value = ''; document.getElementById('gerberResult').innerHTML = 'Очищено.'; window._gerberApertures = []; }; // ===================== EXPRESS CALCULATOR ===================== function initExpressTable() { // Start with 2 empty rows window.addExpressRow(); window.addExpressRow(); } let _expressRowCounter = 0; function addExpressRow(preset) { const tbody = document.getElementById('expressBody'); const idx = state.expressRows.length; const rowId = ++_expressRowCounter; let comp = '', shape = 'rectangle', w = '', l = '', d = ''; if (preset && PRESETS[preset]) { const p = PRESETS[preset]; comp = preset; shape = p.shape; w = p.w !== undefined ? p.w.toString() : ''; l = p.l !== null ? (p.l || '').toString() : ''; if (shape === 'circle') d = w; } state.expressRows.push({ rowId, comp, shape, w, l, d }); const tr = document.createElement('tr'); tr.dataset.rowId = rowId; tr.innerHTML = ` <td>${state.expressRows.length}</td> <td><input type="text" class="expr-comp" value="${escHtml(comp)}" placeholder="напр. 0402" style="width:90px;"></td> <td> <select class="expr-shape" style="width:140px;font-size:.72rem;"> <option value="rectangle" ${shape === 'rectangle' ? 'selected' : ''}>Прямоугольник</option> <option value="rounded" ${shape === 'rounded' ? 'selected' : ''}>Скруглённый</option> <option value="circle" ${shape === 'circle' ? 'selected' : ''}>Круг</option> </select> </td> <td><input type="number" step="0.001" class="expr-w" value="${w}" placeholder="W" style="width:80px;"></td> <td><input type="number" step="0.001" class="expr-l" value="${l}" placeholder="L" style="width:80px;"></td> <td><input type="number" step="0.001" class="expr-d" value="${d}" placeholder="D" style="width:80px;"></td> <td><button class="btn btn-sm" onclick="removeExpressRow(this)" style="color:var(--fail-color);padding:.2rem .5rem;">✕</button></td> `; tbody.appendChild(tr); // Setup event listeners for live update (debounced) const inputs = tr.querySelectorAll('input, select'); let _debounceTimer; inputs.forEach(inp => { inp.addEventListener('input', () => { clearTimeout(_debounceTimer); _debounceTimer = setTimeout(syncExpressToMaster, 120); }); inp.addEventListener('change', () => { clearTimeout(_debounceTimer); syncExpressToMaster(); }); }); syncExpressToMaster(); } window.addExpressRow = addExpressRow; window.removeExpressRow = function(btn) { const tr = btn.closest('tr'); if (tr) { const rowId = parseFloat(tr.dataset.rowId); state.expressRows = state.expressRows.filter(r => r.rowId !== rowId); tr.remove(); // Renumber const rows = document.querySelectorAll('#expressBody tr'); rows.forEach((r, i) => r.querySelector('td:first-child').textContent = i + 1); syncExpressToMaster(); } }; window.clearExpressRows = function() { document.getElementById('expressBody').innerHTML = ''; state.expressRows = []; // Remove express-sourced apertures from master state.apertures = state.apertures.filter(a => a.source !== 'express'); renderResults(); }; function addExpressPreset(name) { addExpressRow(name); } window.addExpressPreset = addExpressPreset; function syncExpressToMaster() { const rows = document.querySelectorAll('#expressBody tr'); const newRows = []; rows.forEach(tr => { const comp = tr.querySelector('.expr-comp').value.trim() || 'Апертура'; const shape = tr.querySelector('.expr-shape').value; const w = parseFloat(tr.querySelector('.expr-w').value); const l = parseFloat(tr.querySelector('.expr-l').value); const d = parseFloat(tr.querySelector('.expr-d').value); let width, length; if (shape === 'circle') { width = d || w || 0; length = null; } else { width = w || 0; length = l || width || 0; } if (width > 0) { newRows.push({ comp, shape, width, length }); } }); // Remove old express apertures state.apertures = state.apertures.filter(a => a.source !== 'express'); // Add current ones for (const r of newRows) { state.apertures.push({ id: state.nextId++, component: r.comp, dCode: '', shape: r.shape, width: r.width, length: r.length, source: 'express', }); } renderResults(); } function updateExpressFromMaster() { // After adding Gerber apertures, re-render all renderResults(); } // ===================== TEMPLATES TAB ===================== function renderTemplates() { const tbody = document.getElementById('templatesBody'); let html = ''; for (const tpl of COMPONENT_TEMPLATES) { html += `<tr> <td><strong>${escHtml(tpl.type)}</strong></td> <td>${tpl.w.toFixed(3)}</td> <td>${tpl.shape === 'circle' ? '—' : tpl.l.toFixed(3)}</td> <td>${shapeLabel(tpl.shape)}</td> <td style="font-size:.75rem;color:var(--text-secondary);">${escHtml(tpl.note)}</td> <td><button class="btn btn-sm btn-primary" onclick="addTemplateToExpress('${escHtml(tpl.type)}')">+ Добавить</button></td> </tr>`; } tbody.innerHTML = html; } window.addTemplateToExpress = function(name) { addExpressPreset(name); switchTab('express'); }; // ===================== TAB SWITCHING ===================== function switchTab(tab) { document.querySelectorAll('.tab-btn').forEach(b => b.classList.toggle('active', b.dataset.tab === tab)); document.querySelectorAll('.tab-panel').forEach(p => p.style.display = 'none'); const panel = document.getElementById('tab-' + tab); if (panel) panel.style.display = 'block'; } document.querySelectorAll('.tab-btn').forEach(btn => { btn.addEventListener('click', () => switchTab(btn.dataset.tab)); }); // ===================== SETTINGS ===================== window.toggleSettings = function() { state.settingsOpen = !state.settingsOpen; document.getElementById('settingsBody').style.display = state.settingsOpen ? 'block' : 'none'; document.getElementById('settingsToggle').textContent = state.settingsOpen ? '▲ Свернуть' : '▼ Развернуть'; }; // Preset buttons for thickness document.querySelectorAll('.preset-btn[data-t]').forEach(btn => { btn.addEventListener('click', function() { document.querySelectorAll('.preset-btn[data-t]').forEach(b => b.classList.remove('active')); this.classList.add('active'); state.thickness = parseInt(this.dataset.t); if (state.thicknessUnit === 'mil') { state.thickness = Math.round(milToMm(state.thickness) * 1000); state.thicknessUnit = 'um'; document.getElementById('thicknessUnit').value = 'um'; } document.getElementById('thicknessInput').value = state.thickness; onSettingsChange(); }); }); document.getElementById('thicknessInput').addEventListener('input', function() { const val = parseFloat(this.value); if (val > 0) { state.thickness = val; document.querySelectorAll('.preset-btn[data-t]').forEach(b => b.classList.remove('active')); onSettingsChange(); } }); document.getElementById('thicknessUnit').addEventListener('change', function() { state.thicknessUnit = this.value; const input = document.getElementById('thicknessInput'); if (this.value === 'mil') { // Convert current µm to mil state.thickness = Math.round(mmToMil(umToMm(state.thickness)) * 10) / 10; } else { // Convert current mil to µm state.thickness = Math.round(milToMm(state.thickness) * 1000); } input.value = state.thickness; onSettingsChange(); }); document.getElementById('techSelect').addEventListener('change', function() { state.tech = this.value; updateThresholdDisplay(); onSettingsChange(); }); document.getElementById('displayUnitSelect').addEventListener('change', function() { state.displayUnit = this.value; onSettingsChange(); }); function updateThresholdDisplay() { const thresh = getAreaRatioThreshold(); document.getElementById('thresholdDisplay').textContent = `≥ ${thresh.toFixed(2)}`; } function onSettingsChange() { renderResults(); if (state.selectedId) renderCanvas(state.selectedId); } // ===================== DARK MODE ===================== window.toggleDarkMode = function() { state.darkMode = !state.darkMode; document.documentElement.classList.toggle('dark', state.darkMode); document.getElementById('darkIcon').textContent = state.darkMode ? '☀️' : '🌙'; if (state.selectedId) renderCanvas(state.selectedId); }; // Default to light theme; user can toggle via button // ===================== EXPORT CSV ===================== window.exportCSV = function() { const results = recalculateAll(); if (results.length === 0) { alert('Нет данных для экспорта.'); return; } const T = getThicknessMm(); const Tdisp = state.thicknessUnit === 'mil' ? state.thickness + ' mil' : state.thickness + ' мкм'; const techLabels = { laser: 'Лазерная резка', electropolished: 'Электрополировка', nano: 'Нанопокрытие' }; let csv = '\uFEFF'; // BOM for Excel csv += `SMT Stencil Aperture Report;IPC-7525B/C\n`; csv += `Date;${new Date().toLocaleDateString('ru-RU')}\n`; csv += `Foil Thickness;${Tdisp}\n`; csv += `Manufacturing Tech;${techLabels[state.tech] || state.tech}\n`; csv += `Area Ratio Threshold;≥ ${getAreaRatioThreshold().toFixed(2)}\n\n`; csv += 'ID;Component;D-Code;Shape;W (mm);L (mm);Aspect Ratio;AR Status;Area Ratio;AreaR Status;Paste Volume (mm³);Recommendation\n'; for (const r of results) { const wStr = r.shape === 'circle' ? r.width.toFixed(3) : r.width.toFixed(3); const lStr = r.shape === 'circle' ? '-' : (r.length ? r.length.toFixed(3) : '-'); csv += `${r.id};${r.component};${r.dCode || ''};${r.shape};${wStr};${lStr};${r.AR.toFixed(2)};${r.arStatus};${r.areaR.toFixed(3)};${r.areaStatus};${r.volume.toFixed(4)};"${r.reduction}"\n`; } const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); const link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = `SMT_Aperture_Report_${new Date().toISOString().slice(0,10)}.csv`; link.click(); URL.revokeObjectURL(link.href); }; // ===================== PRINT ===================== // print handled by window.print() // ===================== SAMPLE GERBER ===================== function loadSampleGerber() { const sample = `%FSLAX25Y25*% %MOMM*% %ADD10C,0.500*% %ADD11R,0.800X0.400*% %ADD12O,1.200X0.600*% %ADD13C,0.350*% %ADD14R,2.000X2.000*% %ADD15R,0.600X0.300*% %ADD16C,0.450*% %ADD17R,1.600X1.200*% %ADD18O,0.900X0.450*% X500Y500D03* X1500Y500D03* X500Y1500D03* X1500Y1500D03* M02*`; document.getElementById('gerberInput').value = sample; document.getElementById('gerberResult').innerHTML = 'Пример Gerber-данных загружен. Нажмите «Разобрать».'; } // ===================== INIT ===================== function init() { // Date const now = new Date(); const dateStr = now.toLocaleDateString('ru-RU', { year: 'numeric', month: 'long', day: 'numeric' }); document.getElementById('dateDisplay').textContent = dateStr; // Threshold display updateThresholdDisplay(); // Templates renderTemplates(); // Express table initExpressTable(); // Sample data: add a few example apertures addSampleData(); // Add sample gerber button to gerber tab const gerberInput = document.getElementById('gerberInput'); const parent = gerberInput.parentElement; const loadBtn = document.createElement('button'); loadBtn.className = 'btn btn-sm'; loadBtn.textContent = '📄 Загрузить пример'; loadBtn.style.marginBottom = '.35rem'; loadBtn.onclick = loadSampleGerber; parent.insertBefore(loadBtn, parent.firstChild.nextSibling); // Results renderResults(); } function addSampleData() { const samples = [ { component: '0402 (R)', shape: 'rectangle', width: 0.50, length: 0.60 }, { component: 'BGA ⌀ 0.35', shape: 'circle', width: 0.35, length: null }, { component: 'QFN Pad 4×4', shape: 'rounded', width: 4.00, length: 4.00 }, { component: 'SOIC-8', shape: 'rounded', width: 0.45, length: 1.80 }, ]; for (const s of samples) { state.apertures.push({ id: state.nextId++, component: s.component, dCode: '', shape: s.shape, width: s.width, length: s.length, source: 'sample', }); } } // Run init(); })();