59fe8cbbd8
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>
899 lines
44 KiB
HTML
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>
|