1032 lines
39 KiB
HTML
1032 lines
39 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Enclosure Preview — PCB Tools</title>
|
||
<link rel="stylesheet" href="/static/style.css">
|
||
<style>
|
||
.preview-container {
|
||
max-width: 800px;
|
||
}
|
||
|
||
.board-canvas-wrap {
|
||
background: #1a1a2e;
|
||
border-radius: 8px;
|
||
padding: 12px;
|
||
margin-bottom: 1rem;
|
||
text-align: center;
|
||
}
|
||
|
||
.board-canvas-wrap canvas {
|
||
max-width: 100%;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.option-group {
|
||
margin-bottom: 1rem;
|
||
padding: 0.75rem;
|
||
background: #f9fafb;
|
||
border-radius: 6px;
|
||
border: 1px solid var(--border);
|
||
}
|
||
|
||
.option-group label {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
cursor: pointer;
|
||
font-weight: 500;
|
||
font-size: 0.9rem;
|
||
margin: 0;
|
||
}
|
||
|
||
.option-group input[type="checkbox"] {
|
||
width: 18px;
|
||
height: 18px;
|
||
accent-color: var(--primary);
|
||
}
|
||
|
||
.option-hint {
|
||
font-size: 0.75rem;
|
||
color: #6b7280;
|
||
margin-left: 26px;
|
||
margin-top: 0.25rem;
|
||
}
|
||
|
||
.side-editor {
|
||
display: none;
|
||
margin-top: 1rem;
|
||
padding: 1rem;
|
||
background: #f0f1f5;
|
||
border-radius: 8px;
|
||
border: 1px solid var(--border);
|
||
}
|
||
|
||
.side-editor.active {
|
||
display: block;
|
||
}
|
||
|
||
.face-tabs {
|
||
display: flex;
|
||
gap: 0;
|
||
margin-bottom: 0.75rem;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.face-tab {
|
||
flex: 1;
|
||
min-width: 60px;
|
||
padding: 0.4rem;
|
||
text-align: center;
|
||
border: 1px solid var(--border);
|
||
background: white;
|
||
cursor: pointer;
|
||
font-size: 0.8rem;
|
||
}
|
||
|
||
.face-tab:first-child {
|
||
border-radius: 4px 0 0 4px;
|
||
}
|
||
|
||
.face-tab:last-child {
|
||
border-radius: 0 4px 4px 0;
|
||
}
|
||
|
||
.face-tab.active {
|
||
background: var(--primary);
|
||
color: white;
|
||
border-color: var(--primary);
|
||
}
|
||
|
||
.side-canvas-wrap {
|
||
background: #e5e7eb;
|
||
border-radius: 6px;
|
||
padding: 8px;
|
||
margin-bottom: 0.75rem;
|
||
position: relative;
|
||
cursor: crosshair;
|
||
}
|
||
|
||
.side-canvas-wrap canvas {
|
||
width: 100%;
|
||
display: block;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.coord-row {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr 1fr 1fr;
|
||
gap: 0.5rem;
|
||
margin-bottom: 0.5rem;
|
||
}
|
||
|
||
.coord-row label {
|
||
font-size: 0.75rem;
|
||
margin-bottom: 0.2rem;
|
||
}
|
||
|
||
.coord-row input {
|
||
font-size: 0.85rem;
|
||
padding: 0.3rem;
|
||
}
|
||
|
||
.cutout-list {
|
||
max-height: 120px;
|
||
overflow-y: auto;
|
||
margin-bottom: 0.5rem;
|
||
}
|
||
|
||
.cutout-item {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 0.3rem 0.5rem;
|
||
background: white;
|
||
border-radius: 4px;
|
||
margin-bottom: 0.3rem;
|
||
font-size: 0.8rem;
|
||
}
|
||
|
||
.cutout-item button {
|
||
background: #ef4444;
|
||
color: white;
|
||
border: none;
|
||
border-radius: 4px;
|
||
padding: 0.15rem 0.4rem;
|
||
cursor: pointer;
|
||
font-size: 0.75rem;
|
||
}
|
||
|
||
.btn-row {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
.btn-small {
|
||
padding: 0.4rem 0.8rem;
|
||
border: 1px solid var(--border);
|
||
border-radius: 4px;
|
||
background: white;
|
||
cursor: pointer;
|
||
font-size: 0.8rem;
|
||
}
|
||
|
||
.btn-small:hover {
|
||
background: #f3f4f6;
|
||
}
|
||
|
||
.preset-row {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
margin-bottom: 0.75rem;
|
||
}
|
||
|
||
.btn-preset {
|
||
padding: 0.35rem 0.7rem;
|
||
border: 1px solid #3b82f6;
|
||
border-radius: 4px;
|
||
background: #eff6ff;
|
||
color: #1d4ed8;
|
||
cursor: pointer;
|
||
font-size: 0.75rem;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.btn-preset:hover {
|
||
background: #dbeafe;
|
||
}
|
||
|
||
.coord-field {
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.coord-label-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.3rem;
|
||
margin-bottom: 0.2rem;
|
||
}
|
||
|
||
.btn-center {
|
||
padding: 0.1rem 0.35rem;
|
||
border: 1px solid #d1d5db;
|
||
border-radius: 3px;
|
||
background: #f9fafb;
|
||
cursor: pointer;
|
||
font-size: 0.65rem;
|
||
color: #6b7280;
|
||
line-height: 1;
|
||
}
|
||
|
||
.btn-center:hover {
|
||
background: #e5e7eb;
|
||
color: #374151;
|
||
}
|
||
|
||
.unit-note {
|
||
font-size: 0.7rem;
|
||
color: #9ca3af;
|
||
text-align: right;
|
||
margin-top: 0.2rem;
|
||
}
|
||
|
||
/* Auto-Align Modal */
|
||
.modal {
|
||
display: none;
|
||
position: fixed;
|
||
z-index: 1000;
|
||
left: 0;
|
||
top: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
background-color: rgba(0, 0, 0, 0.5);
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.modal-content {
|
||
background-color: #fefefe;
|
||
padding: 24px;
|
||
border-radius: 8px;
|
||
width: 90%;
|
||
max-width: 500px;
|
||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||
}
|
||
|
||
.close-modal {
|
||
color: #aaa;
|
||
float: right;
|
||
font-size: 28px;
|
||
font-weight: bold;
|
||
cursor: pointer;
|
||
line-height: 1;
|
||
margin-top: -5px;
|
||
}
|
||
|
||
.close-modal:hover {
|
||
color: black;
|
||
}
|
||
|
||
.fp-item {
|
||
padding: 10px;
|
||
border: 1px solid #e5e7eb;
|
||
margin-bottom: 5px;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
font-size: 0.85rem;
|
||
}
|
||
|
||
.fp-item:hover {
|
||
background-color: #f9fafb;
|
||
}
|
||
|
||
.fp-item.selected {
|
||
background-color: #eff6ff;
|
||
border-color: #3b82f6;
|
||
}
|
||
</style>
|
||
</head>
|
||
|
||
<body>
|
||
<div class="container preview-container">
|
||
<h1>Enclosure Preview</h1>
|
||
|
||
<!-- Top-down board view with numbered side labels -->
|
||
<div class="board-canvas-wrap">
|
||
<canvas id="boardCanvas" width="600" height="400"></canvas>
|
||
</div>
|
||
|
||
<!-- Options -->
|
||
<div class="option-group">
|
||
<label>
|
||
<input type="checkbox" id="optConform" checked>
|
||
Conform to edge cuts
|
||
</label>
|
||
<div class="option-hint">Enclosure walls follow the board outline shape instead of a rectangular box.</div>
|
||
</div>
|
||
|
||
<div class="option-group">
|
||
<label>
|
||
<input type="checkbox" id="optSideCutout">
|
||
Add side cutout (USB-C, connectors)
|
||
</label>
|
||
<div class="option-hint">Place rounded-rectangle cutouts on enclosure side walls.</div>
|
||
</div>
|
||
|
||
<!-- Side cutout editor -->
|
||
<div class="side-editor" id="sideEditor">
|
||
<div class="face-tabs" id="faceTabs"></div>
|
||
|
||
<div class="side-canvas-wrap" id="sideCanvasWrap">
|
||
<canvas id="sideCanvas" width="700" height="200"></canvas>
|
||
</div>
|
||
|
||
<div class="preset-row">
|
||
<button type="button" class="btn-preset" id="btnPresetUSBC">⚡ USB-C (9 × 3.26mm r=1.3)</button>
|
||
</div>
|
||
|
||
<div class="coord-row">
|
||
<div class="coord-field">
|
||
<div class="coord-label-row">
|
||
<label for="cutX">X (mm)</label>
|
||
<button type="button" class="btn-center" id="btnCenterX" title="Center horizontally">⟷
|
||
center</button>
|
||
</div>
|
||
<input type="number" id="cutX" value="0" step="0.01">
|
||
</div>
|
||
<div class="coord-field">
|
||
<div class="coord-label-row">
|
||
<label for="cutY">Y (mm)</label>
|
||
<button type="button" class="btn-center" id="btnCenterY" title="Center vertically">⟷
|
||
center</button>
|
||
</div>
|
||
<input type="number" id="cutY" value="0" step="0.01">
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="cutW">Width (mm)</label>
|
||
<input type="number" id="cutW" value="9.0" step="0.01">
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="cutH">Height (mm)</label>
|
||
<input type="number" id="cutH" value="3.5" step="0.01">
|
||
</div>
|
||
</div>
|
||
<div class="coord-row">
|
||
<div class="form-group">
|
||
<label for="cutR">Corner Radius (mm)</label>
|
||
<input type="number" id="cutR" value="1.3" step="0.01">
|
||
</div>
|
||
<div class="form-group"></div>
|
||
<div class="form-group"></div>
|
||
<div class="form-group">
|
||
<div class="unit-note">All values in mm (0.01mm precision)</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="btn-row">
|
||
<button class="btn-small" id="btnAddCutout">+ Add Cutout</button>
|
||
</div>
|
||
|
||
<div class="cutout-list" id="cutoutList"></div>
|
||
|
||
<hr style="margin: 1.5rem 0 1rem; border: 0; border-top: 1px solid #e5e7eb;">
|
||
<div style="text-align: center;">
|
||
<button type="button" class="btn-preset" id="btnOpenAutoAlign"
|
||
style="font-size: 0.85rem; padding: 0.5rem 1rem; border-color: #8b5cf6; color: #7c3aed; background: #f5f3ff;">✨
|
||
Auto-Align USB Port</button>
|
||
</div>
|
||
</div>
|
||
|
||
<form id="generateForm" method="POST" action="/generate-enclosure">
|
||
<input type="hidden" name="sessionId" id="sessionId">
|
||
<input type="hidden" name="sideCutouts" id="sideCutoutsInput">
|
||
<input type="hidden" name="conformToEdge" id="conformInput" value="true">
|
||
<div style="display: flex; gap: 10px; margin-top: 15px;">
|
||
<a href="/" class="btn secondary"
|
||
style="flex: 1; text-align: center; text-decoration: none; padding: 10px; border-radius: 4px; border: 1px solid var(--border-color); color: var(--text-color);">Go
|
||
Back</a>
|
||
<button type="submit" class="submit-btn" style="flex: 1; margin-top: 0;">Generate</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
|
||
<!-- Auto-Align Modal -->
|
||
<div id="autoAlignModal" class="modal">
|
||
<div class="modal-content">
|
||
<span class="close-modal" id="closeModal">×</span>
|
||
<h2 style="margin-top:0; font-size: 1.25rem; font-weight: 600; color: #111827;">Auto-Align USB Port</h2>
|
||
<p style="font-size:0.85rem; color:#6b7280; margin-bottom: 1rem;">Upload your **Fabrication** Gerbers (e.g.,
|
||
F.Fab,
|
||
B.Fab) to visually select your USB-C footprint on the board canvas.<br><strong
|
||
style="color:#ef4444">Note: You must upload the Fab layer.</strong> Copper, Mask, and Edge Cuts do
|
||
not contain footprint component data!</p>
|
||
<form id="autoAlignForm">
|
||
<div style="margin-bottom: 1rem;">
|
||
<input type="file" id="autoAlignGerbers" name="gerbers" multiple accept=".gbr" required
|
||
style="font-size: 0.8rem;">
|
||
</div>
|
||
<button type="button" id="btnUploadFootprints" class="btn-small">Upload & Align</button>
|
||
</form>
|
||
<div id="autoAlignStatus" style="margin-top:0.75rem; font-size:0.85rem; color:#4b5563;"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Floating Align Instructions -->
|
||
<div id="alignInstructions"
|
||
style="display:none; position:fixed; top:20px; left:50%; transform:translateX(-50%); background:#2563eb; color:white; padding:12px 24px; border-radius:30px; font-weight:600; box-shadow:0 10px 15px -3px rgba(0,0,0,0.1); z-index:1000; align-items:center; gap:12px;">
|
||
<span id="alignInstructionsText">Select the USB-C footprint</span>
|
||
<button id="btnCancelAlign"
|
||
style="background:rgba(255,255,255,0.2); border:none; color:white; padding:4px 10px; border-radius:4px; cursor:pointer;">Cancel</button>
|
||
</div>
|
||
|
||
<script>
|
||
var sideCutouts = [];
|
||
var currentSide = 1;
|
||
var dragStart = null;
|
||
var dragCurrent = null;
|
||
|
||
// Visual Alignment Globals
|
||
var alignMode = null; // 'SELECT_FOOTPRINT' | 'SELECT_EDGE'
|
||
var footprintsData = [];
|
||
var hoverFootprint = null;
|
||
var selectedFootprint = null;
|
||
var hoverEdge = null; // 'top'|'bottom'|'left'|'right'
|
||
var fabImg = new Image();
|
||
var imgLoaded = false;
|
||
|
||
fabImg.onload = function () {
|
||
imgLoaded = true;
|
||
drawBoardWithLabels();
|
||
};
|
||
|
||
// Board dimensions from server
|
||
var boardInfo = {{.BoardInfoJSON }};
|
||
var sessionId = '{{.SessionID}}';
|
||
|
||
document.getElementById('sessionId').value = sessionId;
|
||
|
||
var sides = boardInfo.sides || [];
|
||
if (sides.length === 0) {
|
||
sides = [
|
||
{ num: 1, label: 'Side 1 (Top)', length: boardInfo.boardW, pos: 'top' },
|
||
{ num: 2, label: 'Side 2 (Right)', length: boardInfo.boardH, pos: 'right' },
|
||
{ num: 3, label: 'Side 3 (Bottom)', length: boardInfo.boardW, pos: 'bottom' },
|
||
{ num: 4, label: 'Side 4 (Left)', length: boardInfo.boardH, pos: 'left' }
|
||
];
|
||
}
|
||
|
||
// Build side tabs dynamically
|
||
var tabsContainer = document.getElementById('faceTabs');
|
||
sides.forEach(function (side, i) {
|
||
var tab = document.createElement('div');
|
||
tab.className = 'face-tab' + (i === 0 ? ' active' : '');
|
||
tab.dataset.side = side.num;
|
||
tab.textContent = 'Side ' + side.num;
|
||
tab.addEventListener('click', function () {
|
||
document.querySelectorAll('.face-tab').forEach(function (t) { t.classList.remove('active'); });
|
||
tab.classList.add('active');
|
||
currentSide = side.num;
|
||
drawSideFace();
|
||
});
|
||
tabsContainer.appendChild(tab);
|
||
});
|
||
|
||
// Colors for side labels
|
||
var sideColors = ['#ef4444', '#3b82f6', '#22c55e', '#f59e0b', '#8b5cf6', '#ec4899', '#14b8a6', '#f97316'];
|
||
|
||
// Initialize board canvas
|
||
var boardCanvas = document.getElementById('boardCanvas');
|
||
var boardCtx = boardCanvas.getContext('2d');
|
||
|
||
// Board image position (set after load for label drawing)
|
||
var boardRect = { x: 0, y: 0, w: 0, h: 0 };
|
||
|
||
var boardImg = new Image();
|
||
boardImg.onload = function () {
|
||
drawBoardWithLabels();
|
||
};
|
||
boardImg.src = '/preview-image/' + sessionId;
|
||
|
||
function drawBoardWithLabels() {
|
||
var ctx = boardCtx;
|
||
var scale = Math.min(boardCanvas.width / boardImg.width, boardCanvas.height / boardImg.height) * 0.75;
|
||
var w = boardImg.width * scale;
|
||
var h = boardImg.height * scale;
|
||
var x = (boardCanvas.width - w) / 2;
|
||
var y = (boardCanvas.height - h) / 2;
|
||
boardRect = { x: x, y: y, w: w, h: h, scale: scale };
|
||
|
||
ctx.fillStyle = '#1a1a2e';
|
||
ctx.fillRect(0, 0, boardCanvas.width, boardCanvas.height);
|
||
ctx.drawImage(boardImg, x, y, w, h);
|
||
|
||
if (alignMode && imgLoaded) {
|
||
ctx.globalAlpha = 0.5;
|
||
ctx.drawImage(fabImg, x, y, w, h);
|
||
ctx.globalAlpha = 1.0;
|
||
}
|
||
|
||
// Draw visual alignment overlays
|
||
if (alignMode) {
|
||
var pxFromMmX = function (mmX) { return x + (mmX - boardInfo.minX) * boardInfo.dpi / 25.4 * scale; };
|
||
var pxFromMmY = function (mmY) { return y + (boardInfo.maxY - mmY) * boardInfo.dpi / 25.4 * scale; };
|
||
|
||
if (alignMode === 'SELECT_FOOTPRINT') {
|
||
footprintsData.forEach(function (fp) {
|
||
var px1 = pxFromMmX(fp.minX);
|
||
var py1 = pxFromMmY(fp.maxY); // Y is flipped
|
||
var px2 = pxFromMmX(fp.maxX);
|
||
var py2 = pxFromMmY(fp.minY);
|
||
var fw = px2 - px1;
|
||
var fh = py2 - py1;
|
||
|
||
ctx.beginPath();
|
||
ctx.rect(px1, py1, fw, fh);
|
||
if (hoverFootprint && hoverFootprint.name === fp.name && hoverFootprint.centerX === fp.centerX) {
|
||
ctx.fillStyle = 'rgba(59, 130, 246, 0.4)';
|
||
ctx.fill();
|
||
ctx.strokeStyle = '#3b82f6';
|
||
ctx.lineWidth = 2;
|
||
} else {
|
||
ctx.fillStyle = 'rgba(255, 255, 255, 0.1)';
|
||
ctx.fill();
|
||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.5)';
|
||
ctx.lineWidth = 1;
|
||
}
|
||
ctx.stroke();
|
||
});
|
||
} else if (alignMode === 'SELECT_EDGE' && selectedFootprint) {
|
||
var px1 = pxFromMmX(selectedFootprint.minX);
|
||
var py1 = pxFromMmY(selectedFootprint.maxY);
|
||
var px2 = pxFromMmX(selectedFootprint.maxX);
|
||
var py2 = pxFromMmY(selectedFootprint.minY);
|
||
|
||
// Draw base box
|
||
ctx.strokeStyle = 'rgba(59, 130, 246, 0.5)';
|
||
ctx.lineWidth = 1;
|
||
ctx.strokeRect(px1, py1, px2 - px1, py2 - py1);
|
||
|
||
// Draw 4 edges
|
||
var edges = [
|
||
{ id: 'top', x1: px1, y1: py1, x2: px2, y2: py1 },
|
||
{ id: 'bottom', x1: px1, y1: py2, x2: px2, y2: py2 },
|
||
{ id: 'left', x1: px1, y1: py1, x2: px1, y2: py2 },
|
||
{ id: 'right', x1: px2, y1: py1, x2: px2, y2: py2 }
|
||
];
|
||
|
||
edges.forEach(function (e) {
|
||
ctx.beginPath();
|
||
ctx.moveTo(e.x1, e.y1);
|
||
ctx.lineTo(e.x2, e.y2);
|
||
if (hoverEdge === e.id) {
|
||
ctx.strokeStyle = '#ef4444';
|
||
ctx.lineWidth = 4;
|
||
} else {
|
||
ctx.strokeStyle = '#3b82f6';
|
||
ctx.lineWidth = 2;
|
||
}
|
||
ctx.stroke();
|
||
});
|
||
}
|
||
}
|
||
|
||
// Draw numbered side labels around the board
|
||
ctx.font = 'bold 13px sans-serif';
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = 'middle';
|
||
|
||
var labelPad = 18;
|
||
|
||
sides.forEach(function (side) {
|
||
var color = sideColors[(side.num - 1) % sideColors.length];
|
||
ctx.fillStyle = color;
|
||
ctx.strokeStyle = color;
|
||
ctx.lineWidth = 2;
|
||
|
||
var lx, ly;
|
||
if (side.startX !== undefined) {
|
||
var px1 = x + (side.startX - boardInfo.minX) * boardInfo.dpi / 25.4 * scale;
|
||
var py1 = y + (boardInfo.maxY - side.startY) * boardInfo.dpi / 25.4 * scale;
|
||
var px2 = x + (side.endX - boardInfo.minX) * boardInfo.dpi / 25.4 * scale;
|
||
var py2 = y + (boardInfo.maxY - side.endY) * boardInfo.dpi / 25.4 * scale;
|
||
|
||
ctx.beginPath();
|
||
ctx.moveTo(px1, py1);
|
||
ctx.lineTo(px2, py2);
|
||
ctx.stroke();
|
||
|
||
var midX_mm = (side.startX + side.endX) / 2;
|
||
var midY_mm = (side.startY + side.endY) / 2;
|
||
var imgPxX = (midX_mm - boardInfo.minX) * boardInfo.dpi / 25.4;
|
||
var imgPxY = (boardInfo.maxY - midY_mm) * boardInfo.dpi / 25.4;
|
||
|
||
var cx = x + imgPxX * scale;
|
||
var cy = y + imgPxY * scale;
|
||
|
||
var nx = Math.cos(side.angle);
|
||
var ny = -Math.sin(side.angle); // -sin because canvas Y is flipped
|
||
|
||
lx = cx + nx * labelPad;
|
||
ly = cy + ny * labelPad;
|
||
} else {
|
||
switch (side.pos) {
|
||
case 'top':
|
||
lx = x + w / 2; ly = y - labelPad;
|
||
ctx.beginPath(); ctx.moveTo(x, y - 1); ctx.lineTo(x + w, y - 1); ctx.stroke();
|
||
break;
|
||
case 'right':
|
||
lx = x + w + labelPad; ly = y + h / 2;
|
||
ctx.beginPath(); ctx.moveTo(x + w + 1, y); ctx.lineTo(x + w + 1, y + h); ctx.stroke();
|
||
break;
|
||
case 'bottom':
|
||
lx = x + w / 2; ly = y + h + labelPad;
|
||
ctx.beginPath(); ctx.moveTo(x, y + h + 1); ctx.lineTo(x + w, y + h + 1); ctx.stroke();
|
||
break;
|
||
case 'left':
|
||
lx = x - labelPad; ly = y + h / 2;
|
||
ctx.beginPath(); ctx.moveTo(x - 1, y); ctx.lineTo(x - 1, y + h); ctx.stroke();
|
||
break;
|
||
}
|
||
}
|
||
|
||
// Draw circled number
|
||
ctx.beginPath();
|
||
ctx.arc(lx, ly, 12, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
ctx.fillStyle = 'white';
|
||
ctx.fillText(side.num, lx, ly + 1);
|
||
});
|
||
}
|
||
|
||
// Side cutout checkbox toggle
|
||
document.getElementById('optSideCutout').addEventListener('change', function () {
|
||
document.getElementById('sideEditor').classList.toggle('active', this.checked);
|
||
if (this.checked) drawSideFace();
|
||
});
|
||
|
||
// Conform checkbox
|
||
document.getElementById('optConform').addEventListener('change', function () {
|
||
document.getElementById('conformInput').value = this.checked ? 'true' : 'false';
|
||
});
|
||
|
||
// Get face dimensions in mm for current side
|
||
function getFaceDims() {
|
||
var side = sides.find(function (s) { return s.num === currentSide; });
|
||
return { width: side ? side.length : boardInfo.boardW, height: boardInfo.totalH };
|
||
}
|
||
|
||
// Draw side face
|
||
function drawSideFace() {
|
||
var canvas = document.getElementById('sideCanvas');
|
||
var ctx = canvas.getContext('2d');
|
||
var dims = getFaceDims();
|
||
var side = sides.find(function (s) { return s.num === currentSide; });
|
||
|
||
var scaleX = (canvas.width - 40) / dims.width;
|
||
var scaleY = (canvas.height - 30) / dims.height;
|
||
var scale = Math.min(scaleX, scaleY);
|
||
|
||
var offX = (canvas.width - dims.width * scale) / 2;
|
||
var offY = 10;
|
||
|
||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||
|
||
// Draw wall face
|
||
ctx.fillStyle = '#d1d5db';
|
||
ctx.strokeStyle = sideColors[(currentSide - 1) % sideColors.length];
|
||
ctx.lineWidth = 2;
|
||
ctx.fillRect(offX, offY, dims.width * scale, dims.height * scale);
|
||
ctx.strokeRect(offX, offY, dims.width * scale, dims.height * scale);
|
||
|
||
// Side label
|
||
ctx.fillStyle = sideColors[(currentSide - 1) % sideColors.length];
|
||
ctx.font = 'bold 11px sans-serif';
|
||
ctx.textAlign = 'left';
|
||
ctx.fillText(side ? side.label : 'Side ' + currentSide, offX, offY - 2);
|
||
|
||
// Draw existing cutouts for this side
|
||
ctx.fillStyle = '#1a1a2e';
|
||
sideCutouts.forEach(function (c) {
|
||
if (c.side !== currentSide) return;
|
||
drawRoundedRect(ctx, offX + c.x * scale, offY + (dims.height - c.y - c.h) * scale,
|
||
c.w * scale, c.h * scale, c.r * scale);
|
||
});
|
||
|
||
// Draw drag preview
|
||
if (dragStart && dragCurrent) {
|
||
ctx.fillStyle = 'rgba(37, 99, 235, 0.3)';
|
||
ctx.strokeStyle = '#2563eb';
|
||
ctx.lineWidth = 1;
|
||
var x1 = Math.min(dragStart.x, dragCurrent.x);
|
||
var y1 = Math.min(dragStart.y, dragCurrent.y);
|
||
var dw = Math.abs(dragCurrent.x - dragStart.x);
|
||
var dh = Math.abs(dragCurrent.y - dragStart.y);
|
||
ctx.fillRect(x1, y1, dw, dh);
|
||
ctx.strokeRect(x1, y1, dw, dh);
|
||
}
|
||
|
||
// Draw mm grid labels
|
||
ctx.fillStyle = '#9ca3af';
|
||
ctx.font = '10px sans-serif';
|
||
ctx.textAlign = 'center';
|
||
var step = Math.ceil(dims.width / 10);
|
||
for (var mm = 0; mm <= dims.width; mm += step) {
|
||
var px = offX + mm * scale;
|
||
ctx.fillText(mm + '', px, offY + dims.height * scale + 14);
|
||
}
|
||
}
|
||
|
||
function drawRoundedRect(ctx, x, y, w, h, r) {
|
||
r = Math.min(r, w / 2, h / 2);
|
||
ctx.beginPath();
|
||
ctx.moveTo(x + r, y);
|
||
ctx.lineTo(x + w - r, y);
|
||
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
|
||
ctx.lineTo(x + w, y + h - r);
|
||
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
|
||
ctx.lineTo(x + r, y + h);
|
||
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
|
||
ctx.lineTo(x, y + r);
|
||
ctx.quadraticCurveTo(x, y, x + r, y);
|
||
ctx.closePath();
|
||
ctx.fill();
|
||
}
|
||
|
||
// Mouse drag on side canvas
|
||
var sideCanvas = document.getElementById('sideCanvas');
|
||
sideCanvas.addEventListener('mousedown', function (e) {
|
||
var rect = sideCanvas.getBoundingClientRect();
|
||
var sx = (e.clientX - rect.left) * (sideCanvas.width / rect.width);
|
||
var sy = (e.clientY - rect.top) * (sideCanvas.height / rect.height);
|
||
dragStart = { x: sx, y: sy };
|
||
dragCurrent = null;
|
||
});
|
||
sideCanvas.addEventListener('mousemove', function (e) {
|
||
if (!dragStart) return;
|
||
var rect = sideCanvas.getBoundingClientRect();
|
||
dragCurrent = {
|
||
x: (e.clientX - rect.left) * (sideCanvas.width / rect.width),
|
||
y: (e.clientY - rect.top) * (sideCanvas.height / rect.height)
|
||
};
|
||
drawSideFace();
|
||
});
|
||
sideCanvas.addEventListener('mouseup', function (e) {
|
||
if (!dragStart || !dragCurrent) { dragStart = null; return; }
|
||
var rect = sideCanvas.getBoundingClientRect();
|
||
dragCurrent = {
|
||
x: (e.clientX - rect.left) * (sideCanvas.width / rect.width),
|
||
y: (e.clientY - rect.top) * (sideCanvas.height / rect.height)
|
||
};
|
||
|
||
var dims = getFaceDims();
|
||
var scaleX = (sideCanvas.width - 40) / dims.width;
|
||
var scaleY = (sideCanvas.height - 30) / dims.height;
|
||
var scale = Math.min(scaleX, scaleY);
|
||
var offX = (sideCanvas.width - dims.width * scale) / 2;
|
||
var offY = 10;
|
||
|
||
var x1 = Math.min(dragStart.x, dragCurrent.x);
|
||
var y1 = Math.min(dragStart.y, dragCurrent.y);
|
||
var dw = Math.abs(dragCurrent.x - dragStart.x);
|
||
var dh = Math.abs(dragCurrent.y - dragStart.y);
|
||
|
||
var mmX = (x1 - offX) / scale;
|
||
var mmY = dims.height - (y1 + dh - offY) / scale;
|
||
var mmW = dw / scale;
|
||
var mmH = dh / scale;
|
||
|
||
if (mmW > 0.5 && mmH > 0.5) {
|
||
document.getElementById('cutX').value = mmX.toFixed(2);
|
||
document.getElementById('cutY').value = mmY.toFixed(2);
|
||
document.getElementById('cutW').value = mmW.toFixed(2);
|
||
document.getElementById('cutH').value = mmH.toFixed(2);
|
||
}
|
||
|
||
dragStart = null;
|
||
dragCurrent = null;
|
||
drawSideFace();
|
||
});
|
||
|
||
// USB-C Preset button
|
||
document.getElementById('btnPresetUSBC').addEventListener('click', function () {
|
||
document.getElementById('cutW').value = '9';
|
||
document.getElementById('cutH').value = '3.26';
|
||
document.getElementById('cutR').value = '1.3';
|
||
drawSideFace();
|
||
});
|
||
|
||
// Horizontal center button (centers X along face width)
|
||
document.getElementById('btnCenterX').addEventListener('click', function () {
|
||
var dims = getFaceDims();
|
||
var w = parseFloat(document.getElementById('cutW').value) || 0;
|
||
var x = (dims.width - w) / 2;
|
||
document.getElementById('cutX').value = x.toFixed(2);
|
||
drawSideFace();
|
||
});
|
||
|
||
// Vertical center button (centers Y along face height)
|
||
document.getElementById('btnCenterY').addEventListener('click', function () {
|
||
var dims = getFaceDims();
|
||
var h = parseFloat(document.getElementById('cutH').value) || 0;
|
||
var y = (dims.height - h) / 2;
|
||
document.getElementById('cutY').value = y.toFixed(2);
|
||
drawSideFace();
|
||
});
|
||
|
||
// Add cutout button
|
||
document.getElementById('btnAddCutout').addEventListener('click', function () {
|
||
var c = {
|
||
side: currentSide,
|
||
x: parseFloat(document.getElementById('cutX').value) || 0,
|
||
y: parseFloat(document.getElementById('cutY').value) || 0,
|
||
w: parseFloat(document.getElementById('cutW').value) || 9,
|
||
h: parseFloat(document.getElementById('cutH').value) || 3.5,
|
||
r: parseFloat(document.getElementById('cutR').value) || 1.3
|
||
};
|
||
sideCutouts.push(c);
|
||
updateCutoutList();
|
||
drawSideFace();
|
||
});
|
||
|
||
function updateCutoutList() {
|
||
var list = document.getElementById('cutoutList');
|
||
list.innerHTML = '';
|
||
sideCutouts.forEach(function (c, i) {
|
||
var div = document.createElement('div');
|
||
div.className = 'cutout-item';
|
||
var color = sideColors[(c.side - 1) % sideColors.length];
|
||
div.innerHTML = '<span style="color:' + color + ';font-weight:600;">Side ' + c.side + '</span> ' +
|
||
c.w.toFixed(1) + '×' + c.h.toFixed(1) + 'mm at (' +
|
||
c.x.toFixed(1) + ',' + c.y.toFixed(1) + ') r=' + c.r.toFixed(1) +
|
||
'<button onclick="removeCutout(' + i + ')">✕</button>';
|
||
list.appendChild(div);
|
||
});
|
||
document.getElementById('sideCutoutsInput').value = JSON.stringify(sideCutouts);
|
||
}
|
||
|
||
window.removeCutout = function (i) {
|
||
sideCutouts.splice(i, 1);
|
||
updateCutoutList();
|
||
drawSideFace();
|
||
};
|
||
|
||
// Form submit
|
||
document.getElementById('generateForm').addEventListener('submit', function () {
|
||
document.getElementById('sideCutoutsInput').value = JSON.stringify(sideCutouts);
|
||
var btn = this.querySelector('.submit-btn');
|
||
btn.disabled = true;
|
||
btn.innerText = 'Generating...';
|
||
});
|
||
|
||
// --- Auto-Align Logic ---
|
||
var autoAlignModal = document.getElementById('autoAlignModal');
|
||
|
||
document.getElementById('btnOpenAutoAlign').addEventListener('click', function () {
|
||
autoAlignModal.style.display = 'flex';
|
||
});
|
||
|
||
document.getElementById('closeModal').addEventListener('click', function () {
|
||
autoAlignModal.style.display = 'none';
|
||
});
|
||
|
||
document.getElementById('btnCancelAlign').addEventListener('click', function () {
|
||
alignMode = null;
|
||
document.getElementById('alignInstructions').style.display = 'none';
|
||
drawBoardWithLabels();
|
||
});
|
||
|
||
document.getElementById('btnUploadFootprints').addEventListener('click', function () {
|
||
var files = document.getElementById('autoAlignGerbers').files;
|
||
if (files.length === 0) return;
|
||
|
||
var fd = new FormData();
|
||
for (var i = 0; i < files.length; i++) {
|
||
fd.append('gerbers', files[i]);
|
||
}
|
||
fd.append('sessionId', sessionId);
|
||
|
||
document.getElementById('autoAlignStatus').textContent = 'Processing files...';
|
||
|
||
fetch('/upload-footprints', {
|
||
method: 'POST',
|
||
body: fd
|
||
}).then(r => r.json()).then(data => {
|
||
if (!data || data.length === 0) {
|
||
document.getElementById('autoAlignStatus').textContent = 'No matching footprints found.';
|
||
return;
|
||
}
|
||
|
||
footprintsData = data;
|
||
autoAlignModal.style.display = 'none';
|
||
|
||
// Switch to visual selection mode
|
||
alignMode = 'SELECT_FOOTPRINT';
|
||
hoverFootprint = null;
|
||
selectedFootprint = null;
|
||
hoverEdge = null;
|
||
|
||
document.getElementById('alignInstructions').style.display = 'flex';
|
||
document.getElementById('alignInstructionsText').textContent = 'Select the USB-C footprint on the board';
|
||
|
||
// Fetch the rendered composite fab layer
|
||
fabImg.src = '/fab-image/' + sessionId + '?t=' + Date.now();
|
||
drawBoardWithLabels();
|
||
}).catch(e => {
|
||
document.getElementById('autoAlignStatus').textContent = 'Error: ' + e;
|
||
});
|
||
});
|
||
|
||
// Interactive Canvas Event Listeners
|
||
boardCanvas.addEventListener('mousemove', function (e) {
|
||
if (!alignMode) return;
|
||
var rect = boardCanvas.getBoundingClientRect();
|
||
var pxX = (e.clientX - rect.left) * (boardCanvas.width / rect.width);
|
||
var pxY = (e.clientY - rect.top) * (boardCanvas.height / rect.height);
|
||
|
||
// Convert canvas px to mm
|
||
var scale = boardRect.scale;
|
||
var mmX = boardInfo.minX + (pxX - boardRect.x) / scale * 25.4 / boardInfo.dpi;
|
||
var mmY = boardInfo.maxY - (pxY - boardRect.y) / scale * 25.4 / boardInfo.dpi;
|
||
|
||
if (alignMode === 'SELECT_FOOTPRINT') {
|
||
hoverFootprint = null;
|
||
for (var i = 0; i < footprintsData.length; i++) {
|
||
var fp = footprintsData[i];
|
||
if (mmX >= fp.minX && mmX <= fp.maxX && mmY >= fp.minY && mmY <= fp.maxY) {
|
||
hoverFootprint = fp;
|
||
break;
|
||
}
|
||
}
|
||
drawBoardWithLabels();
|
||
} else if (alignMode === 'SELECT_EDGE' && selectedFootprint) {
|
||
hoverEdge = null;
|
||
var dists = [
|
||
{ id: 'top', d: Math.abs(mmY - selectedFootprint.maxY) },
|
||
{ id: 'bottom', d: Math.abs(mmY - selectedFootprint.minY) },
|
||
{ id: 'left', d: Math.abs(mmX - selectedFootprint.minX) },
|
||
{ id: 'right', d: Math.abs(mmX - selectedFootprint.maxX) }
|
||
];
|
||
dists.sort(function (a, b) { return a.d - b.d; });
|
||
if (dists[0].d < 3.0) { // snap tolerance 3mm
|
||
hoverEdge = dists[0].id;
|
||
}
|
||
drawBoardWithLabels();
|
||
}
|
||
});
|
||
|
||
boardCanvas.addEventListener('click', function (e) {
|
||
if (!alignMode) return;
|
||
|
||
if (alignMode === 'SELECT_FOOTPRINT' && hoverFootprint) {
|
||
selectedFootprint = hoverFootprint;
|
||
alignMode = 'SELECT_EDGE';
|
||
document.getElementById('alignInstructionsText').textContent = 'Select the outermost edge of the connector lip';
|
||
drawBoardWithLabels();
|
||
} else if (alignMode === 'SELECT_EDGE' && hoverEdge && selectedFootprint) {
|
||
applyAlignment(selectedFootprint, hoverEdge);
|
||
alignMode = null;
|
||
document.getElementById('alignInstructions').style.display = 'none';
|
||
drawBoardWithLabels();
|
||
}
|
||
});
|
||
|
||
function applyAlignment(fp, lip) {
|
||
var bx = fp.centerX;
|
||
var by = fp.centerY;
|
||
|
||
if (lip === 'top') by = fp.maxY;
|
||
else if (lip === 'bottom') by = fp.minY;
|
||
else if (lip === 'left') bx = fp.minX;
|
||
else if (lip === 'right') bx = fp.maxX;
|
||
|
||
// Find closest board side
|
||
var closestSide = null;
|
||
var minDist = Infinity;
|
||
var bestPosX = 0;
|
||
|
||
sides.forEach(function (s) {
|
||
if (s.startX !== undefined) {
|
||
var dx = s.endX - s.startX;
|
||
var dy = s.endY - s.startY;
|
||
var lenSq = dx * dx + dy * dy;
|
||
if (lenSq > 0) {
|
||
var t = ((bx - s.startX) * dx + (by - s.startY) * dy) / lenSq;
|
||
t = Math.max(0, Math.min(1, t));
|
||
var rx = s.startX + t * dx;
|
||
var ry = s.startY + t * dy;
|
||
var dist = Math.sqrt(Math.pow(bx - rx, 2) + Math.pow(by - ry, 2));
|
||
if (dist < minDist) {
|
||
minDist = dist;
|
||
closestSide = s;
|
||
bestPosX = t * s.length;
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
if (closestSide) {
|
||
// Set typical USB-C parameters
|
||
document.getElementById('cutW').value = '9.00';
|
||
document.getElementById('cutH').value = '3.50';
|
||
document.getElementById('cutR').value = '1.30';
|
||
|
||
// Center based on projected posX
|
||
var cutX = bestPosX - (9.0 / 2);
|
||
document.getElementById('cutX').value = cutX.toFixed(2);
|
||
document.getElementById('cutY').value = '0.00';
|
||
|
||
currentSide = closestSide.num;
|
||
document.getElementById('btnAddCutout').click();
|
||
|
||
// Activate the correct side tab
|
||
document.querySelector('.face-tab[data-side="' + closestSide.num + '"]').click();
|
||
}
|
||
}
|
||
|
||
</script>
|
||
</body>
|
||
|
||
</html> |