Files
Will 59fe8cbbd8 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>
2026-03-18 17:18:52 -04:00

899 lines
44 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SVG2Stencil</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, sans-serif;
background: #1a1a2e;
color: #eee;
min-height: 100vh;
display: flex;
}
.sidebar {
width: 280px;
background: #16213e;
padding: 20px;
display: flex;
flex-direction: column;
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: 20px 16px;
text-align: center;
cursor: pointer;
}
.drop-zone:hover, .drop-zone.drag-over { border-color: #e94560; background: rgba(233,69,96,0.1); }
.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: 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 {
width: 100%; padding: 12px; border: none; border-radius: 6px;
font-size: 0.9rem; font-weight: 600; cursor: pointer;
}
.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; }
.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">
<div class="drop-zone-text">Drop SVG or Gerber file</div>
<div class="filename" id="filename"></div>
</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">
<input type="number" id="stencilWidth" value="100" min="10" max="500">
<span class="unit">mm</span>
</div>
</div>
<div class="input-group">
<label>Height</label>
<div class="input-row">
<input type="number" id="stencilHeight" value="100" min="10" max="500">
<span class="unit">mm</span>
</div>
</div>
<div class="input-group">
<label>Thickness</label>
<div class="input-row">
<input type="number" id="stencilThickness" value="0.12" step="0.01" min="0.05" max="1">
<span class="unit">mm</span>
</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>
<div class="preview">
<canvas id="canvas3d"></canvas>
<div class="preview-hint">Drag to rotate, scroll to zoom</div>
</div>
<script type="importmap">
{ "imports": { "three": "https://cdn.jsdelivr.net/npm/three@0.162.0/build/three.module.js", "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.162.0/examples/jsm/" } }
</script>
<script type="module">
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 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');
scene = new THREE.Scene();
scene.background = new THREE.Color(0x1a1a2e);
camera = new THREE.PerspectiveCamera(45, canvas.parentElement.clientWidth / canvas.parentElement.clientHeight, 0.1, 10000);
camera.position.set(0, 0, 200);
renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setSize(canvas.parentElement.clientWidth, canvas.parentElement.clientHeight);
renderer.setPixelRatio(window.devicePixelRatio);
controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
scene.add(new THREE.AmbientLight(0xffffff, 0.6));
const light = new THREE.DirectionalLight(0xffffff, 0.8);
light.position.set(50, 100, 50);
scene.add(light);
const grid = new THREE.GridHelper(200, 20, 0x444444, 0x222222);
grid.rotation.x = Math.PI / 2;
scene.add(grid);
animate();
window.addEventListener('resize', () => {
camera.aspect = canvas.parentElement.clientWidth / canvas.parentElement.clientHeight;
camera.updateProjectionMatrix();
renderer.setSize(canvas.parentElement.clientWidth, canvas.parentElement.clientHeight);
});
}
function animate() {
requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
}
function parseSVG(content) {
const doc = new DOMParser().parseFromString(content, 'image/svg+xml');
const results = [];
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const path of doc.querySelectorAll('path')) {
const d = path.getAttribute('d');
const style = path.getAttribute('style') || '';
if (!d || !style.includes('fill:') || style.includes('fill:none')) continue;
const points = parsePath(d);
if (points.length < 3) continue;
let pMinX = Infinity, pMinY = Infinity, pMaxX = -Infinity, pMaxY = -Infinity;
for (const p of points) {
pMinX = Math.min(pMinX, p.x); pMinY = Math.min(pMinY, p.y);
pMaxX = Math.max(pMaxX, p.x); pMaxY = Math.max(pMaxY, p.y);
}
if (pMaxX - pMinX >= 0.1 && pMaxY - pMinY >= 0.1) {
results.push(points);
minX = Math.min(minX, pMinX); minY = Math.min(minY, pMinY);
maxX = Math.max(maxX, pMaxX); maxY = Math.max(maxY, pMaxY);
}
}
svgBounds = { minX, minY, maxX, maxY, width: maxX - minX, height: maxY - minY };
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;
let x = 0, y = 0, startX = 0, startY = 0, match;
while ((match = regex.exec(d)) !== null) {
const cmd = match[1];
const args = match[2].trim() ? match[2].trim().split(/[\s,]+/).map(parseFloat).filter(n => !isNaN(n)) : [];
switch (cmd) {
case 'M': for (let i = 0; i < args.length; i += 2) { x = args[i]; y = args[i+1]; if (i === 0) { startX = x; startY = y; } points.push({x, y}); } break;
case 'm': for (let i = 0; i < args.length; i += 2) { x += args[i]; y += args[i+1]; if (i === 0) { startX = x; startY = y; } points.push({x, y}); } break;
case 'L': for (let i = 0; i < args.length; i += 2) { x = args[i]; y = args[i+1]; points.push({x, y}); } break;
case 'l': for (let i = 0; i < args.length; i += 2) { x += args[i]; y += args[i+1]; points.push({x, y}); } break;
case 'H': x = args[0]; points.push({x, y}); break;
case 'h': x += args[0]; points.push({x, y}); break;
case 'V': y = args[0]; points.push({x, y}); break;
case 'v': y += args[0]; points.push({x, y}); break;
case 'C': for (let i = 0; i < args.length; i += 6) { x = args[i+4]; y = args[i+5]; points.push({x, y}); } break;
case 'c': for (let i = 0; i < args.length; i += 6) { x += args[i+4]; y += args[i+5]; points.push({x, y}); } break;
}
}
// Remove duplicates
const clean = [];
for (const p of points) {
if (clean.length === 0 || Math.abs(p.x - clean[clean.length-1].x) > 0.005 || Math.abs(p.y - clean[clean.length-1].y) > 0.005) {
clean.push(p);
}
}
return clean;
}
function parseGerber(content) {
// Parse coordinate format: %FSLAX<int><dec>Y<int><dec>*%
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]); }
// Parse units
const toMM = content.includes('%MOIN*%') ? 25.4 : 1;
// Parse aperture macro definitions (%AM...%) to compute bounding boxes.
// Newer KiCad bakes absolute values into unique per-shape macro definitions,
// so parsing the primitives gives us the actual shape size.
const macroShapes = {};
let m;
const amRe = /%AM([A-Za-z][A-Za-z0-9]*)\*([\s\S]*?)%/g;
while ((m = amRe.exec(content)) !== null) {
const name = m[1];
const prims = m[2].split('*').map(s => s.trim()).filter(Boolean);
let bx0 = Infinity, by0 = Infinity, bx1 = -Infinity, by1 = -Infinity, ok = false;
for (const prim of prims) {
const p = prim.split(',').map(parseFloat);
if (isNaN(p[0])) continue; // comment primitive (0 with text)
if (p[0] === 1) { // circle: 1,exp,diam,x,y
const r = p[2]/2, cx = p[3]||0, cy = p[4]||0;
if (isNaN(r)) continue;
bx0=Math.min(bx0,cx-r); bx1=Math.max(bx1,cx+r); by0=Math.min(by0,cy-r); by1=Math.max(by1,cy+r); ok=true;
} else if (p[0] === 21) { // rectangle: 21,exp,w,h,cx,cy,rot
const w=p[2], h=p[3], cx=p[4]||0, cy=p[5]||0, rot=((p[6]||0)*Math.PI/180);
if (isNaN(w)||isNaN(h)) continue;
for (const [dx,dy] of [[-w/2,-h/2],[w/2,-h/2],[w/2,h/2],[-w/2,h/2]]) {
const rx=cx+dx*Math.cos(rot)-dy*Math.sin(rot), ry=cy+dx*Math.sin(rot)+dy*Math.cos(rot);
bx0=Math.min(bx0,rx); bx1=Math.max(bx1,rx); by0=Math.min(by0,ry); by1=Math.max(by1,ry);
}
ok=true;
} else if (p[0] === 20) { // line: 20,exp,w,x1,y1,x2,y2,rot
const hw=p[2]/2, x1=p[3], y1=p[4], x2=p[5], y2=p[6];
if (isNaN(hw)) continue;
bx0=Math.min(bx0,x1-hw,x2-hw); bx1=Math.max(bx1,x1+hw,x2+hw);
by0=Math.min(by0,y1-hw,y2-hw); by1=Math.max(by1,y1+hw,y2+hw); ok=true;
}
}
if (ok && bx1>bx0 && by1>by0) macroShapes[name] = { hw:(bx1-bx0)/2, hh:(by1-by0)/2 };
}
// Parse aperture definitions — params are optional (newer KiCad omits them for pre-instantiated macros)
const apertureMap = {};
const addRe = /%ADD(\d+)([A-Za-z][A-Za-z0-9]*)(?:,([^*]*))?\*%/g;
while ((m = addRe.exec(content)) !== null) {
const id = parseInt(m[1]);
const type = m[2];
const params = m[3] ? m[3].split('X').map(parseFloat) : [];
apertureMap[id] = { type, params };
}
function toVal(s, dec) { return parseInt(s) / Math.pow(10, dec) * toMM; }
function circlePoints(cx, cy, r, n = 32) {
const pts = [];
for (let i = 0; i < n; i++) {
const a = (i / n) * Math.PI * 2;
pts.push({ x: cx + r * Math.cos(a), y: cy + r * Math.sin(a) });
}
return pts;
}
function obroundPoints(cx, cy, w, h) {
const pts = [], n = 16;
if (w >= h) {
const r = h / 2, dx = w / 2 - r;
for (let i = 0; i <= n; i++) { const a = -Math.PI/2 + (i/n)*Math.PI; pts.push({ x: cx+dx+r*Math.cos(a), y: cy+r*Math.sin(a) }); }
for (let i = 0; i <= n; i++) { const a = Math.PI/2 + (i/n)*Math.PI; pts.push({ x: cx-dx+r*Math.cos(a), y: cy+r*Math.sin(a) }); }
} else {
const r = w / 2, dy = h / 2 - r;
for (let i = 0; i <= n; i++) { const a = (i/n)*Math.PI; pts.push({ x: cx+r*Math.cos(a), y: cy+dy+r*Math.sin(a) }); }
for (let i = 0; i <= n; i++) { const a = Math.PI+(i/n)*Math.PI; pts.push({ x: cx+r*Math.cos(a), y: cy-dy+r*Math.sin(a) }); }
}
return pts;
}
function rectPoints(cx, cy, hw, hh) {
return [{ x: cx-hw, y: cy-hh }, { x: cx+hw, y: cy-hh }, { x: cx+hw, y: cy+hh }, { x: cx-hw, y: cy+hh }];
}
function roundedRectPoints(cx, cy, hw, hh, r) {
if (r <= 0 || r >= Math.min(hw, hh)) return rectPoints(cx, cy, hw, hh);
const n = 8, pts = [], dx = hw - r, dy = hh - r;
for (let i = 0; i <= n; i++) { const a = (i/n)*Math.PI/2; pts.push({x: cx+dx+r*Math.cos(a), y: cy+dy+r*Math.sin(a)}); }
for (let i = 0; i <= n; i++) { const a = Math.PI/2+(i/n)*Math.PI/2; pts.push({x: cx-dx+r*Math.cos(a), y: cy+dy+r*Math.sin(a)}); }
for (let i = 0; i <= n; i++) { const a = Math.PI+(i/n)*Math.PI/2; pts.push({x: cx-dx+r*Math.cos(a), y: cy-dy+r*Math.sin(a)}); }
for (let i = 0; i <= n; i++) { const a = 3*Math.PI/2+(i/n)*Math.PI/2; pts.push({x: cx+dx+r*Math.cos(a), y: cy-dy+r*Math.sin(a)}); }
return pts;
}
function aperturePoints(ap, cx, cy) {
const { type, params } = ap;
// Standard single-character types
if (type === 'C' || type === 'P') {
return (params[0] > 0) ? circlePoints(cx, cy, params[0] / 2) : [];
}
if (type === 'R' || type === 'Rect') {
const hw = params[0]/2, hh = (params[1] ?? params[0])/2;
return (hw > 0) ? rectPoints(cx, cy, hw, hh) : [];
}
if (type === 'O' || type === 'Oblong') {
return (params[0] > 0) ? obroundPoints(cx, cy, params[0], params[1] ?? params[0]) : [];
}
// KiCad RoundRect macro:
// params[0] = corner radius
// params[1..N-1] = corner arc CENTER coordinates as (x,y) pairs
// Actual pad extent = max(|corner_x|) + radius (same for y)
// The last param is often a rotation (0) and is skipped since N is odd.
if (/^RoundRect/i.test(type) && params.length >= 3) {
const r = params[0];
let maxAbsX = 0, maxAbsY = 0;
for (let i = 1; i + 1 < params.length; i += 2) {
maxAbsX = Math.max(maxAbsX, Math.abs(params[i]));
maxAbsY = Math.max(maxAbsY, Math.abs(params[i+1]));
}
const hw = maxAbsX + r, hh = maxAbsY + r;
if (hw > 0 && hh > 0) return roundedRectPoints(cx, cy, hw, hh, r);
}
// For any macro: try the bounding box computed from the %AM...% block
const ms = macroShapes[type];
if (ms && ms.hw > 0) return rectPoints(cx, cy, ms.hw, ms.hh);
// Generic fallback: first two params as full w/h, or first as diameter
if (params.length >= 2 && params[0] > 0 && params[1] > 0) {
return rectPoints(cx, cy, params[0]/2, params[1]/2);
}
if (params.length >= 1 && params[0] > 0) {
return circlePoints(cx, cy, params[0]/2);
}
return [];
}
const results = [];
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
let curAp = null, curX = 0, curY = 0;
let inRegion = false, regionPts = [];
// Remove %...% extended command blocks, then split by * into individual commands
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; // comment
if (/^G0*36$/.test(cmd)) { inRegion = true; regionPts = []; continue; }
if (/^G0*37$/.test(cmd)) {
inRegion = false;
if (regionPts.length >= 3) {
for (const p of regionPts) { minX = Math.min(minX, p.x); minY = Math.min(minY, p.y); maxX = Math.max(maxX, p.x); maxY = Math.max(maxY, p.y); }
results.push(regionPts);
}
regionPts = [];
continue;
}
if (/^M0*2$/.test(cmd)) break; // end of file
// Standalone aperture select: D10+
const dSelect = cmd.match(/^D(\d+)$/);
if (dSelect) {
const n = parseInt(dSelect[1]);
if (n >= 10) curAp = apertureMap[n] || null;
continue;
}
// Coordinate command with optional G-prefix, X, Y, I, J, and required D-code
const coordMatch = cmd.match(/^(?:G\d+)?(?:X(-?\d+))?(?:Y(-?\d+))?(?:I(-?\d+))?(?:J(-?\d+))?D0*(\d+)$/);
if (coordMatch) {
if (coordMatch[1] !== undefined) curX = toVal(coordMatch[1], xDec);
if (coordMatch[2] !== undefined) curY = -toVal(coordMatch[2], yDec);
const dCode = parseInt(coordMatch[5]);
if (dCode >= 10) {
curAp = apertureMap[dCode] || null; // G54D<n> old-style aperture select
} else if (dCode === 2) {
if (inRegion) regionPts = [{ x: curX, y: curY }];
} else if (dCode === 1) {
if (inRegion) regionPts.push({ x: curX, y: curY });
} else if (dCode === 3 && !inRegion && curAp) {
const pts = aperturePoints(curAp, curX, curY);
if (pts.length >= 3) {
for (const p of pts) { minX = Math.min(minX, p.x); minY = Math.min(minY, p.y); maxX = Math.max(maxX, p.x); maxY = Math.max(maxY, p.y); }
results.push(pts);
}
}
}
}
svgBounds = { minX, minY, maxX, maxY, width: maxX - minX, height: maxY - minY };
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++) {
const j = (i + 1) % pts.length;
sum += pts[i].x * pts[j].y - pts[j].x * pts[i].y;
}
return sum / 2;
}
function ensureCW(pts) {
return signedArea(pts) > 0 ? pts.slice().reverse() : pts;
}
function generateStencil() {
if (!apertures.length || !svgBounds) return;
const width = parseFloat(widthInput.value);
const height = parseFloat(heightInput.value);
const thickness = parseFloat(thicknessInput.value);
const centerX = svgBounds.minX + svgBounds.width / 2;
const centerY = svgBounds.minY + svgBounds.height / 2;
// 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();
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: 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);
if (pts.length >= 3) {
const hole = new THREE.Path();
hole.moveTo(pts[0].x, pts[0].y);
for (let i = 1; i < pts.length; i++) {
hole.lineTo(pts[i].x, pts[i].y);
}
hole.closePath();
shape.holes.push(hole);
}
}
// Create geometry — stencil spans Z: [-thickness/2, +thickness/2] after position offset
const geometry = new THREE.ExtrudeGeometry(shape, {
depth: thickness,
bevelEnabled: false
});
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();
downloadBtn.disabled = false;
}
function downloadSTL() {
if (!stencilMesh) return;
const exporter = new STLExporter();
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);
a.download = 'stencil.stl';
a.click();
}
function isGerber(name) {
return /\.(gbr|ger|gtp|gbp|gm\d+|gtl|gbl|gko)$/i.test(name);
}
function loadFile(file) {
const reader = new FileReader();
reader.onload = (e) => {
apertures = isGerber(file.name) ? parseGerber(e.target.result) : parseSVG(e.target.result);
filenameEl.textContent = file.name;
dropZone.classList.add('loaded');
infoText.innerHTML = `Apertures: <span class="info-value">${apertures.length}</span> | Size: <span class="info-value">${svgBounds.width.toFixed(1)} x ${svgBounds.height.toFixed(1)} mm</span>`;
widthInput.value = Math.ceil(svgBounds.width + 20);
heightInput.value = Math.ceil(svgBounds.height + 20);
generateStencil();
};
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);
});
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>
</body>
</html>