Add board lip feature for automatic PCB alignment

Adds an optional board lip — walls that hang below the stencil and
wrap around the PCB edges so the stencil seats itself without manual
alignment.

- New edge cuts layer file input (Gerber and SVG)
- Gerber edge cuts parser: stroke-based (D01/D02), chained segments
  into closed contours, arc interpolation (G02/G03 with I/J)
- SVG edge cuts parser using existing path parser
- Automatic board outline selection (largest closed contour wins,
  so interior cutouts and keychain holes are ignored)
- Polygon offset (vertex-normal bisector) for inner/outer lip edges
- When lip is enabled the stencil shape follows the board outline
  instead of a rectangle — no overhang beyond the PCB edge
- STL export merges stencil + lip into a single solid geometry
- Fix: strip trailing near-duplicate point from chained contours to
  prevent zero-length edge at vertex 0 causing missing wall at one corner

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Will
2026-03-18 17:18:52 -04:00
parent f4be91ef2d
commit 59fe8cbbd8
2 changed files with 419 additions and 25 deletions
+23 -3
View File
@@ -2,6 +2,8 @@
3D print a solder paste stencil for any circuit board. Export the paste layer from KiCad as a Gerber or SVG file and get a custom STL file you can 3D print.
Optionally add a **board lip** — a wall around the edge of the PCB that lets the stencil seat itself automatically without manual alignment.
![Example](example.png)
## Usage
@@ -10,25 +12,42 @@
2. Export your paste layer from KiCad:
- **Gerber (recommended):** File > Fabrication Outputs > Gerbers, enable F.Paste
- **SVG:** File > Plot > F.Paste, select SVG format
3. Drag and drop the file onto the drop zone (or click to browse)
3. Drag and drop the file onto the paste layer drop zone (or click to browse)
4. Adjust stencil dimensions:
- **Width/Height**: Overall stencil size (auto-sized to fit with 20mm margin)
- **Thickness**: Stencil thickness (default 0.12mm)
5. Preview the stencil in the 3D viewer (drag to rotate, scroll to zoom)
6. Click **Download STL** to save
### Adding a board lip (auto-alignment)
A board lip adds walls that hang below the stencil and wrap around the PCB edges, so the stencil seats itself on the board without needing to be manually aligned.
1. Export the edge cuts layer from KiCad:
- **Gerber:** File > Fabrication Outputs > Gerbers, enable Edge.Cuts — produces `*Edge_Cuts.gbr` or similar (`.gm1`, `.gko`)
- **SVG:** File > Plot > Edge.Cuts, select SVG format
2. Drag and drop the edge cuts file onto the **Edge Cuts Layer** drop zone
3. The **Board Lip** section will appear with three controls:
- **Lip Height**: How far the wall extends below the stencil (default 1.2mm)
- **Wall Thickness**: Thickness of the lip wall (default 1.2mm)
- **Fit Clearance**: Gap between the lip's inner face and the PCB edge (default 0.15mm) — increase if the fit is too tight
4. When the lip is enabled the stencil is automatically sized to the board outline — it won't extend beyond the edge of the PCB
If your board has interior cutouts (keychain holes, slots, etc.) the outermost contour is automatically selected as the board outline.
## Supported File Formats
### Gerber (RS-274X)
Accepts `.gbr`, `.ger`, `.gtp`, `.gbp`, and other common Gerber extensions. Supports:
Accepts `.gbr`, `.ger`, `.gtp`, `.gbp`, `.gm1`, `.gko`, and other common Gerber extensions. Supports:
- Standard aperture types: circle (C), rectangle (R), obround (O), polygon (P)
- KiCad's `RoundRect` aperture macro for rounded rectangle pads
- Pre-instantiated aperture macros (KiCad 6+)
- Region fills (G36/G37) for polygon-defined apertures
- Stroke-based edge cuts with linear and arc segments (G01/G02/G03)
- Both metric (mm) and imperial (inch) units
### SVG
Accepts `.svg` files exported from KiCad's plot function. Parses filled paths as apertures.
Accepts `.svg` files exported from KiCad's plot function. Parses filled paths as paste apertures and stroke-only paths as edge cuts outlines.
## Requirements
@@ -38,4 +57,5 @@ Accepts `.svg` files exported from KiCad's plot function. Parses filled paths as
## Notes
- Apertures smaller than 0.1mm are filtered out
- When the board lip is enabled the STL contains the stencil and lip as a single merged solid
- STL files may need minor repair for 3D printing (most slicers handle this automatically)
+396 -22
View File
@@ -19,14 +19,16 @@
padding: 20px;
display: flex;
flex-direction: column;
gap: 20px;
gap: 16px;
overflow-y: auto;
max-height: 100vh;
}
h1 { font-size: 1.4rem; color: #e94560; }
h2 { font-size: 0.8rem; color: #888; text-transform: uppercase; margin-bottom: 10px; }
.drop-zone {
border: 2px dashed #444;
border-radius: 8px;
padding: 30px 20px;
padding: 20px 16px;
text-align: center;
cursor: pointer;
}
@@ -34,13 +36,16 @@
.drop-zone.loaded { border-color: #4ade80; background: rgba(74,222,128,0.1); }
.drop-zone-text { font-size: 0.9rem; color: #888; }
.filename { font-size: 0.8rem; color: #4ade80; margin-top: 8px; word-break: break-all; }
.input-group { margin-bottom: 12px; }
.input-group { margin-bottom: 10px; }
.input-group label { display: block; font-size: 0.8rem; color: #888; margin-bottom: 4px; }
.input-row { display: flex; gap: 8px; align-items: center; }
.toggle-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
.toggle-row label { font-size: 0.8rem; color: #888; }
input[type="number"] {
flex: 1; padding: 8px; border: 1px solid #333; border-radius: 4px;
background: #1a1a2e; color: #eee; font-size: 0.9rem;
}
input[type="checkbox"] { width: 16px; height: 16px; cursor: pointer; accent-color: #e94560; }
input:focus { outline: none; border-color: #e94560; }
.unit { font-size: 0.8rem; color: #666; min-width: 30px; }
button {
@@ -50,17 +55,23 @@
.btn-primary { background: #e94560; color: white; }
.btn-primary:hover:not(:disabled) { background: #d13652; }
.btn-primary:disabled { background: #444; cursor: not-allowed; }
.info { font-size: 0.85rem; color: #888; margin-top: auto; }
.info { font-size: 0.85rem; color: #888; }
.info-value { color: #eee; }
.preview { flex: 1; position: relative; }
#canvas3d { width: 100%; height: 100%; display: block; }
.preview-hint { position: absolute; bottom: 10px; left: 10px; font-size: 0.75rem; color: #666; }
input[type="file"] { display: none; }
.hint { font-size: 0.75rem; color: #555; margin-top: 4px; }
#lipSection { display: none; }
#lipControls { transition: opacity 0.2s; }
#stencilSizeSection { transition: opacity 0.2s; }
.size-note { font-size: 0.75rem; color: #888; margin-bottom: 8px; display: none; }
</style>
</head>
<body>
<div class="sidebar">
<h1>SVG2Stencil</h1>
<div>
<h2>Paste Layer File</h2>
<div class="drop-zone" id="dropZone">
@@ -69,8 +80,20 @@
</div>
<input type="file" id="fileInput" accept=".svg,.gbr,.ger,.gtp,.gbp,.gm1,.gtl,.gbl">
</div>
<div>
<h2>Edge Cuts Layer <span style="color:#555;font-size:0.7rem;text-transform:none">(optional)</span></h2>
<div class="drop-zone" id="edgeCutsDropZone">
<div class="drop-zone-text">Drop Gerber or SVG edge cuts</div>
<div class="filename" id="edgeCutsFilename"></div>
</div>
<input type="file" id="edgeCutsFileInput" accept=".svg,.gbr,.ger,.gm1,.gm2,.gm3,.gko">
<div class="hint">e.g. Edge_Cuts.gbr, .gm1, .gko</div>
</div>
<div id="stencilSizeSection">
<h2>Stencil Size</h2>
<div class="size-note" id="sizeNote">Bounded by edge cuts when lip is enabled</div>
<div class="input-group">
<label>Width</label>
<div class="input-row">
@@ -93,6 +116,38 @@
</div>
</div>
</div>
<div id="lipSection">
<h2>Board Lip</h2>
<div class="toggle-row">
<label for="lipEnabled">Enable lip</label>
<input type="checkbox" id="lipEnabled" checked>
</div>
<div id="lipControls">
<div class="input-group">
<label>Lip Height</label>
<div class="input-row">
<input type="number" id="lipHeight" value="1.2" step="0.1" min="0.5" max="10">
<span class="unit">mm</span>
</div>
</div>
<div class="input-group">
<label>Wall Thickness</label>
<div class="input-row">
<input type="number" id="lipWall" value="1.2" step="0.1" min="0.3" max="5">
<span class="unit">mm</span>
</div>
</div>
<div class="input-group">
<label>Fit Clearance</label>
<div class="input-row">
<input type="number" id="lipClearance" value="0.15" step="0.05" min="0" max="2">
<span class="unit">mm</span>
</div>
</div>
</div>
</div>
<button class="btn-primary" id="downloadBtn" disabled>Download STL</button>
<div class="info"><span id="infoText">Load a paste layer SVG or Gerber</span></div>
</div>
@@ -109,18 +164,29 @@
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { STLExporter } from 'three/addons/exporters/STLExporter.js';
import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
let apertures = [], svgBounds = null;
let scene, camera, renderer, controls, stencilMesh;
let edgeCutsContour = null;
let scene, camera, renderer, controls, stencilMesh, lipMesh;
const dropZone = document.getElementById('dropZone');
const fileInput = document.getElementById('fileInput');
const filenameEl = document.getElementById('filename');
const edgeCutsDropZone = document.getElementById('edgeCutsDropZone');
const edgeCutsFileInput = document.getElementById('edgeCutsFileInput');
const edgeCutsFilenameEl = document.getElementById('edgeCutsFilename');
const downloadBtn = document.getElementById('downloadBtn');
const infoText = document.getElementById('infoText');
const widthInput = document.getElementById('stencilWidth');
const heightInput = document.getElementById('stencilHeight');
const thicknessInput = document.getElementById('stencilThickness');
const lipSection = document.getElementById('lipSection');
const lipEnabledInput = document.getElementById('lipEnabled');
const lipHeightInput = document.getElementById('lipHeight');
const lipWallInput = document.getElementById('lipWall');
const lipClearanceInput = document.getElementById('lipClearance');
const lipControls = document.getElementById('lipControls');
function initThreeJS() {
const canvas = document.getElementById('canvas3d');
@@ -182,6 +248,21 @@
return results;
}
function parseSVGEdgeCuts(content) {
const doc = new DOMParser().parseFromString(content, 'image/svg+xml');
const contours = [];
for (const path of doc.querySelectorAll('path')) {
const d = path.getAttribute('d');
if (!d) continue;
const style = path.getAttribute('style') || '';
// Accept stroke paths (fill:none) — typical for edge cuts
if (style.includes('fill:') && !style.includes('fill:none')) continue;
const pts = parsePath(d);
if (pts.length >= 3) contours.push(pts);
}
return contours;
}
function parsePath(d) {
const points = [];
const regex = /([MmLlHhVvCcZz])\s*([^MmLlHhVvCcZz]*)/g;
@@ -408,6 +489,178 @@
return results;
}
// Parse edge cuts from a Gerber file (stroke-based, not region-based).
// Returns an array of closed contours [{x,y}[]].
function parseEdgeCuts(content) {
let xDec = 6, yDec = 6;
const fmtMatch = content.match(/%FSL[AI]X\d(\d)Y\d(\d)\*%/);
if (fmtMatch) { xDec = parseInt(fmtMatch[1]); yDec = parseInt(fmtMatch[2]); }
const toMM = content.includes('%MOIN*%') ? 25.4 : 1;
function toVal(s, dec) { return parseInt(s) / Math.pow(10, dec) * toMM; }
const segments = [];
let curX = 0, curY = 0;
let interpMode = 1; // 1=linear, 2=CW (G02), 3=CCW (G03)
const stripped = content.replace(/%[^%]*%/g, '');
const commands = stripped.split('*').map(s => s.trim()).filter(Boolean);
for (const cmd of commands) {
if (/^G0*4/.test(cmd)) continue;
if (/^M0*2$/.test(cmd)) break;
if (/^G0*36$/.test(cmd) || /^G0*37$/.test(cmd)) continue; // skip region mode
if (/^G0*1$/.test(cmd)) { interpMode = 1; continue; }
if (/^G0*2$/.test(cmd)) { interpMode = 2; continue; }
if (/^G0*3$/.test(cmd)) { interpMode = 3; continue; }
const coordMatch = cmd.match(/^(?:G0*([123]))?(?:X(-?\d+))?(?:Y(-?\d+))?(?:I(-?\d+))?(?:J(-?\d+))?D0*(\d+)$/);
if (!coordMatch) continue;
if (coordMatch[1]) interpMode = parseInt(coordMatch[1]);
const nx = coordMatch[2] !== undefined ? toVal(coordMatch[2], xDec) : curX;
const ny = coordMatch[3] !== undefined ? -toVal(coordMatch[3], yDec) : curY; // Y inverted same as paste layer
const dCode = parseInt(coordMatch[6]);
if (dCode >= 10) continue; // aperture select
if (dCode === 2) {
// Pen up — move to new position
curX = nx; curY = ny;
} else if (dCode === 1) {
// Pen down — draw from current to new position
if (Math.abs(nx - curX) > 0.001 || Math.abs(ny - curY) > 0.001) {
if (interpMode === 1) {
segments.push({ x1: curX, y1: curY, x2: nx, y2: ny });
} else {
// Arc: I/J are Gerber-space offset from current point to center.
// After Y-inversion: arcCY = curY - jVal (J is in raw Gerber Y).
const iVal = coordMatch[4] !== undefined ? toVal(coordMatch[4], xDec) : 0;
const jVal = coordMatch[5] !== undefined ? toVal(coordMatch[5], yDec) : 0;
const arcCX = curX + iVal;
const arcCY = curY - jVal;
const r = Math.sqrt((curX - arcCX) ** 2 + (curY - arcCY) ** 2);
if (r > 0.001) {
let startAngle = Math.atan2(curY - arcCY, curX - arcCX);
let endAngle = Math.atan2(ny - arcCY, nx - arcCX);
// G02=CW in Gerber → CCW in Y-inverted space → increasing angle
if (interpMode === 2) { if (endAngle < startAngle) endAngle += 2 * Math.PI; }
// G03=CCW in Gerber → CW in Y-inverted space → decreasing angle
else { if (endAngle > startAngle) endAngle -= 2 * Math.PI; }
const arcSegs = Math.max(4, Math.ceil(Math.abs(endAngle - startAngle) * r * 4));
let px = curX, py = curY;
for (let i = 1; i <= arcSegs; i++) {
const angle = startAngle + (endAngle - startAngle) * i / arcSegs;
const ax = arcCX + r * Math.cos(angle);
const ay = arcCY + r * Math.sin(angle);
segments.push({ x1: px, y1: py, x2: ax, y2: ay });
px = ax; py = ay;
}
}
}
}
curX = nx; curY = ny;
}
}
return chainSegmentsToContours(segments);
}
// Connect a flat list of line segments into closed contours.
function chainSegmentsToContours(segments) {
if (!segments.length) return [];
const EPS = 0.05; // mm tolerance for endpoint matching
const used = new Set();
const contours = [];
const endMap = new Map();
function addEnd(x, y, idx, end) {
const k = `${Math.round(x / EPS)}_${Math.round(y / EPS)}`;
if (!endMap.has(k)) endMap.set(k, []);
endMap.get(k).push({ idx, end });
}
function getAt(x, y) {
const k = `${Math.round(x / EPS)}_${Math.round(y / EPS)}`;
return (endMap.get(k) || []).filter(e => !used.has(e.idx));
}
for (let i = 0; i < segments.length; i++) {
addEnd(segments[i].x1, segments[i].y1, i, 'start');
addEnd(segments[i].x2, segments[i].y2, i, 'end');
}
for (let si = 0; si < segments.length; si++) {
if (used.has(si)) continue;
used.add(si);
const startX = segments[si].x1, startY = segments[si].y1;
const pts = [{ x: startX, y: startY }];
let cx = segments[si].x2, cy = segments[si].y2;
pts.push({ x: cx, y: cy });
let closed = false;
for (let iter = 0; iter < segments.length; iter++) {
if (Math.abs(cx - startX) < EPS && Math.abs(cy - startY) < EPS) { closed = true; break; }
const next = getAt(cx, cy);
if (!next.length) break;
const { idx, end } = next[0];
used.add(idx);
const seg = segments[idx];
if (end === 'start') { cx = seg.x2; cy = seg.y2; }
else { cx = seg.x1; cy = seg.y1; }
pts.push({ x: cx, y: cy });
}
// The closing iteration pushes a near-duplicate of startPt; remove it so
// offsetPolygon sees a clean polygon with no zero-length edges at vertex 0.
if (closed) {
const last = pts[pts.length - 1];
if (Math.abs(last.x - startX) < EPS && Math.abs(last.y - startY) < EPS) pts.pop();
}
if (closed && pts.length >= 3) contours.push(pts);
}
return contours;
}
// Offset a polygon outward by distance d using the vertex-normal bisector method.
// Input polygon should have at least 3 points; winding is normalized to CCW internally.
function offsetPolygon(points, d) {
if (Math.abs(d) < 0.0001) return points.map(p => ({ ...p }));
let pts = points;
// Normalize to CCW so right-normals point outward
if (signedArea(pts) < 0) pts = pts.slice().reverse();
const n = pts.length;
const result = [];
for (let i = 0; i < n; i++) {
const prev = pts[(i - 1 + n) % n];
const curr = pts[i];
const next = pts[(i + 1) % n];
const e1x = curr.x - prev.x, e1y = curr.y - prev.y;
const l1 = Math.sqrt(e1x * e1x + e1y * e1y);
const e2x = next.x - curr.x, e2y = next.y - curr.y;
const l2 = Math.sqrt(e2x * e2x + e2y * e2y);
if (l1 < 0.0001 || l2 < 0.0001) { result.push({ x: curr.x, y: curr.y }); continue; }
// Right normals of each edge (outward for CCW polygon)
const n1x = e1y / l1, n1y = -e1x / l1;
const n2x = e2y / l2, n2y = -e2x / l2;
// Bisector of the two outward normals
const bx = n1x + n2x, by = n1y + n2y;
const bl = Math.sqrt(bx * bx + by * by);
if (bl < 0.0001) {
// Antiparallel edges — straight line, use edge normal directly
result.push({ x: curr.x + n1x * d, y: curr.y + n1y * d });
continue;
}
const bux = bx / bl, buy = by / bl;
const dot = n1x * bux + n1y * buy; // always > 0 for well-formed polygon
// Cap at 10x to avoid extreme spike at very sharp corners
const scale = dot > 0.1 ? d / dot : d * 10;
result.push({ x: curr.x + bux * scale, y: curr.y + buy * scale });
}
return result;
}
function signedArea(pts) {
let sum = 0;
for (let i = 0; i < pts.length; i++) {
@@ -431,17 +684,43 @@
const centerX = svgBounds.minX + svgBounds.width / 2;
const centerY = svgBounds.minY + svgBounds.height / 2;
// Create stencil shape (rectangle)
// Clean up old meshes
if (stencilMesh) { scene.remove(stencilMesh); stencilMesh.geometry.dispose(); stencilMesh.material.dispose(); stencilMesh = null; }
if (lipMesh) { scene.remove(lipMesh); lipMesh.geometry.dispose(); lipMesh.material.dispose(); lipMesh = null; }
const useLip = edgeCutsContour && lipEnabledInput.checked;
// Pre-compute offset edges when lip is active (reused for both stencil shape and lip mesh)
let contourWorld = null, innerEdge = null, outerEdge = null;
if (edgeCutsContour) {
contourWorld = edgeCutsContour.map(p => ({ x: p.x - centerX, y: -(p.y - centerY) }));
}
if (useLip) {
const lipClearance = parseFloat(lipClearanceInput.value);
const lipWall = parseFloat(lipWallInput.value);
innerEdge = offsetPolygon(contourWorld, lipClearance);
outerEdge = offsetPolygon(contourWorld, lipClearance + lipWall);
}
// Create stencil shape: board outline when lip is on, rectangle otherwise
const shape = new THREE.Shape();
shape.moveTo(-width/2, -height/2);
shape.lineTo(width/2, -height/2);
shape.lineTo(width/2, height/2);
shape.lineTo(-width/2, height/2);
shape.closePath();
if (useLip && outerEdge) {
// Shape follows the outer edge of the lip — stencil doesn't extend beyond the board
const outerCCW = signedArea(outerEdge) < 0 ? outerEdge.slice().reverse() : outerEdge;
shape.moveTo(outerCCW[0].x, outerCCW[0].y);
for (let i = 1; i < outerCCW.length; i++) shape.lineTo(outerCCW[i].x, outerCCW[i].y);
shape.closePath();
} else {
shape.moveTo(-width/2, -height/2);
shape.lineTo(width/2, -height/2);
shape.lineTo(width/2, height/2);
shape.lineTo(-width/2, height/2);
shape.closePath();
}
// Add apertures as holes
for (const ap of apertures) {
// Transform and flip Y
// Transform: center and flip Y (second flip corrects the Y-inversion from parsing)
let pts = ap.map(p => ({ x: p.x - centerX, y: -(p.y - centerY) }));
pts = ensureCW(pts);
@@ -456,22 +735,45 @@
}
}
// Create geometry
// Create geometry — stencil spans Z: [-thickness/2, +thickness/2] after position offset
const geometry = new THREE.ExtrudeGeometry(shape, {
depth: thickness,
bevelEnabled: false
});
if (stencilMesh) {
scene.remove(stencilMesh);
stencilMesh.geometry.dispose();
stencilMesh.material.dispose();
}
stencilMesh = new THREE.Mesh(geometry, new THREE.MeshPhongMaterial({ color: 0x4a90d9, side: THREE.DoubleSide }));
stencilMesh.position.z = -thickness / 2;
scene.add(stencilMesh);
// Generate lip mesh (annular wall hanging below stencil)
if (useLip && innerEdge && outerEdge) {
const lipHeight = parseFloat(lipHeightInput.value);
// Build annular shape: outer boundary with inner boundary as hole
const lipShape = new THREE.Shape();
const outerCCW = signedArea(outerEdge) < 0 ? outerEdge.slice().reverse() : outerEdge;
lipShape.moveTo(outerCCW[0].x, outerCCW[0].y);
for (let i = 1; i < outerCCW.length; i++) lipShape.lineTo(outerCCW[i].x, outerCCW[i].y);
lipShape.closePath();
const lipHole = new THREE.Path();
const innerCW = ensureCW(innerEdge);
lipHole.moveTo(innerCW[0].x, innerCW[0].y);
for (let i = 1; i < innerCW.length; i++) lipHole.lineTo(innerCW[i].x, innerCW[i].y);
lipHole.closePath();
lipShape.holes.push(lipHole);
const lipGeometry = new THREE.ExtrudeGeometry(lipShape, {
depth: lipHeight,
bevelEnabled: false
});
// Lip spans Z: [-(thickness/2 + lipHeight), -thickness/2], flush with stencil bottom
lipMesh = new THREE.Mesh(lipGeometry, new THREE.MeshPhongMaterial({ color: 0xe94560, side: THREE.DoubleSide }));
lipMesh.position.z = -(thickness / 2 + lipHeight);
scene.add(lipMesh);
}
camera.position.set(0, 0, Math.max(width, height) * 1.5);
controls.target.set(0, 0, 0);
controls.update();
@@ -482,7 +784,22 @@
function downloadSTL() {
if (!stencilMesh) return;
const exporter = new STLExporter();
const buffer = exporter.parse(stencilMesh, { binary: true });
let buffer;
if (lipMesh) {
// Merge stencil and lip into a single geometry for a clean STL
const stencilGeo = stencilMesh.geometry.clone().applyMatrix4(stencilMesh.matrixWorld);
const lipGeo = lipMesh.geometry.clone().applyMatrix4(lipMesh.matrixWorld);
const merged = mergeGeometries([stencilGeo, lipGeo]);
const tempMesh = new THREE.Mesh(merged);
buffer = exporter.parse(tempMesh, { binary: true });
merged.dispose();
stencilGeo.dispose();
lipGeo.dispose();
} else {
buffer = exporter.parse(stencilMesh, { binary: true });
}
const blob = new Blob([buffer], { type: 'application/octet-stream' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
@@ -491,7 +808,7 @@
}
function isGerber(name) {
return /\.(gbr|ger|gtp|gbp|gm\d+|gtl|gbl)$/i.test(name);
return /\.(gbr|ger|gtp|gbp|gm\d+|gtl|gbl|gko)$/i.test(name);
}
function loadFile(file) {
@@ -508,15 +825,72 @@
reader.readAsText(file);
}
function loadEdgeCuts(file) {
const reader = new FileReader();
reader.onload = (e) => {
const content = e.target.result;
const contours = isGerber(file.name) ? parseEdgeCuts(content) : parseSVGEdgeCuts(content);
if (!contours.length) {
edgeCutsFilenameEl.textContent = 'No closed contours found';
edgeCutsFilenameEl.style.color = '#e94560';
return;
}
// Select the largest closed contour as the board outline
// (inner cutouts / holes have smaller area than the board boundary)
edgeCutsContour = contours.reduce((best, c) =>
Math.abs(signedArea(c)) > Math.abs(signedArea(best)) ? c : best
);
edgeCutsFilenameEl.textContent = file.name;
edgeCutsFilenameEl.style.color = '';
edgeCutsDropZone.classList.add('loaded');
lipSection.style.display = 'block';
updateLipUI();
if (apertures.length) generateStencil();
};
reader.readAsText(file);
}
// Wire up paste layer drop zone
dropZone.addEventListener('click', () => fileInput.click());
dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag-over'); });
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));
dropZone.addEventListener('drop', e => { e.preventDefault(); dropZone.classList.remove('drag-over'); const f = e.dataTransfer.files[0]; if (f && (f.name.endsWith('.svg') || isGerber(f.name))) loadFile(f); });
dropZone.addEventListener('drop', e => {
e.preventDefault(); dropZone.classList.remove('drag-over');
const f = e.dataTransfer.files[0];
if (f && (f.name.endsWith('.svg') || isGerber(f.name))) loadFile(f);
});
fileInput.addEventListener('change', e => { if (e.target.files[0]) loadFile(e.target.files[0]); });
// Wire up edge cuts drop zone
edgeCutsDropZone.addEventListener('click', () => edgeCutsFileInput.click());
edgeCutsDropZone.addEventListener('dragover', e => { e.preventDefault(); edgeCutsDropZone.classList.add('drag-over'); });
edgeCutsDropZone.addEventListener('dragleave', () => edgeCutsDropZone.classList.remove('drag-over'));
edgeCutsDropZone.addEventListener('drop', e => {
e.preventDefault(); edgeCutsDropZone.classList.remove('drag-over');
const f = e.dataTransfer.files[0];
if (f) loadEdgeCuts(f);
});
edgeCutsFileInput.addEventListener('change', e => { if (e.target.files[0]) loadEdgeCuts(e.target.files[0]); });
// Regenerate on any parameter change
downloadBtn.addEventListener('click', downloadSTL);
widthInput.addEventListener('change', generateStencil);
heightInput.addEventListener('change', generateStencil);
thicknessInput.addEventListener('change', generateStencil);
function updateLipUI() {
const on = lipEnabledInput.checked;
lipControls.style.opacity = on ? '1' : '0.4';
document.getElementById('stencilSizeSection').style.opacity = on ? '0.4' : '1';
document.getElementById('sizeNote').style.display = on ? 'block' : 'none';
}
lipEnabledInput.addEventListener('change', () => { updateLipUI(); generateStencil(); });
lipHeightInput.addEventListener('change', generateStencil);
lipWallInput.addEventListener('change', generateStencil);
lipClearanceInput.addEventListener('change', generateStencil);
initThreeJS();
</script>