rspace-online/lib/folk-kicad.ts

497 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { FolkShape } from "./folk-shape";
import { css, html } from "./tags";
const styles = css`
:host {
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
min-width: 380px;
min-height: 460px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: linear-gradient(135deg, #059669, #34d399);
color: white;
border-radius: 8px 8px 0 0;
font-size: 12px;
font-weight: 600;
cursor: move;
}
.header-title {
display: flex;
align-items: center;
gap: 6px;
}
.header-actions {
display: flex;
gap: 4px;
}
.header-actions button {
background: transparent;
border: none;
color: white;
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
font-size: 14px;
}
.header-actions button:hover {
background: rgba(255, 255, 255, 0.2);
}
.content {
display: flex;
flex-direction: column;
height: calc(100% - 36px);
overflow: hidden;
}
.prompt-area {
padding: 12px;
border-bottom: 1px solid #e2e8f0;
}
.prompt-input {
width: 100%;
padding: 10px 12px;
border: 2px solid #e2e8f0;
border-radius: 8px;
font-size: 13px;
resize: none;
outline: none;
font-family: inherit;
}
.prompt-input:focus {
border-color: #059669;
}
.component-input {
width: 100%;
padding: 8px 10px;
border: 2px solid #e2e8f0;
border-radius: 6px;
font-size: 12px;
outline: none;
margin-top: 8px;
font-family: inherit;
}
.component-input:focus {
border-color: #059669;
}
.controls {
display: flex;
gap: 8px;
margin-top: 8px;
}
.generate-btn {
flex: 1;
padding: 8px 16px;
background: linear-gradient(135deg, #059669, #34d399);
color: white;
border: none;
border-radius: 6px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s;
}
.generate-btn:hover {
opacity: 0.9;
}
.generate-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.tabs {
display: flex;
border-bottom: 1px solid #e2e8f0;
}
.tab {
flex: 1;
padding: 8px;
text-align: center;
font-size: 12px;
font-weight: 600;
color: #64748b;
border: none;
background: none;
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all 0.2s;
}
.tab:hover {
color: #059669;
}
.tab.active {
color: #059669;
border-bottom-color: #059669;
}
.preview-area {
flex: 1;
overflow: auto;
padding: 12px;
}
.preview-area img {
max-width: 100%;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.drc-results {
font-size: 12px;
color: #334155;
}
.drc-results .pass {
color: #059669;
font-weight: 600;
}
.drc-results .fail {
color: #ef4444;
font-weight: 600;
}
.export-row {
display: flex;
gap: 8px;
padding: 8px 12px;
border-top: 1px solid #e2e8f0;
flex-wrap: wrap;
}
.export-btn {
padding: 6px 12px;
background: #334155;
color: white;
border: none;
border-radius: 6px;
font-size: 12px;
cursor: pointer;
text-decoration: none;
}
.export-btn:hover {
background: #475569;
}
.export-btn.primary {
background: #059669;
}
.export-btn.primary:hover {
background: #047857;
}
.placeholder {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #94a3b8;
text-align: center;
gap: 8px;
}
.placeholder-icon {
font-size: 48px;
opacity: 0.5;
}
.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24px;
gap: 12px;
}
.spinner {
width: 32px;
height: 32px;
border: 3px solid #e2e8f0;
border-top-color: #059669;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.error {
color: #ef4444;
padding: 12px;
background: #fef2f2;
border-radius: 6px;
font-size: 13px;
}
`;
declare global {
interface HTMLElementTagNameMap {
"folk-kicad": FolkKiCAD;
}
}
export class FolkKiCAD extends FolkShape {
static override tagName = "folk-kicad";
static {
const sheet = new CSSStyleSheet();
const parentRules = Array.from(FolkShape.styles.cssRules)
.map((r) => r.cssText)
.join("\n");
const childRules = Array.from(styles.cssRules)
.map((r) => r.cssText)
.join("\n");
sheet.replaceSync(`${parentRules}\n${childRules}`);
this.styles = sheet;
}
#isLoading = false;
#error: string | null = null;
#schematicSvg: string | null = null;
#boardSvg: string | null = null;
#gerberUrl: string | null = null;
#bomUrl: string | null = null;
#pdfUrl: string | null = null;
#drcResults: any = null;
#activeTab: "schematic" | "board" | "drc" = "schematic";
#promptInput: HTMLTextAreaElement | null = null;
#componentInput: HTMLInputElement | null = null;
#generateBtn: HTMLButtonElement | null = null;
#previewArea: HTMLElement | null = null;
#exportRow: HTMLElement | null = null;
override createRenderRoot() {
const root = super.createRenderRoot();
const wrapper = document.createElement("div");
wrapper.innerHTML = html`
<div class="header">
<span class="header-title">
<span>🔌</span>
<span>KiCAD PCB</span>
</span>
<div class="header-actions">
<button class="close-btn" title="Close">×</button>
</div>
</div>
<div class="content">
<div class="prompt-area">
<textarea class="prompt-input" placeholder="Describe the PCB design...\ne.g. 'ESP32-based sensor board with BME280, OLED display, and USB-C'" rows="2"></textarea>
<input class="component-input" type="text" placeholder="Components (comma-separated): ESP32-WROOM, BME280, SSD1306..." />
<div class="controls">
<button class="generate-btn">🔌 Generate PCB</button>
</div>
</div>
<div class="tabs">
<button class="tab active" data-tab="schematic">📋 Schematic</button>
<button class="tab" data-tab="board">📟 Board</button>
<button class="tab" data-tab="drc">✅ DRC</button>
</div>
<div class="preview-area">
<div class="placeholder">
<span class="placeholder-icon">🔌</span>
<span>Describe a PCB design and click Generate</span>
</div>
</div>
<div class="export-row" style="display:none">
<a class="export-btn primary gerber-dl" href="#" download>⬇️ Gerber</a>
<a class="export-btn bom-dl" href="#" download>📋 BOM</a>
<a class="export-btn pdf-dl" href="#" download>📄 PDF</a>
</div>
</div>
`;
const slot = root.querySelector("slot");
const containerDiv = slot?.parentElement as HTMLElement;
if (containerDiv) {
containerDiv.replaceWith(wrapper);
}
this.#promptInput = wrapper.querySelector(".prompt-input");
this.#componentInput = wrapper.querySelector(".component-input");
this.#generateBtn = wrapper.querySelector(".generate-btn");
this.#previewArea = wrapper.querySelector(".preview-area");
this.#exportRow = wrapper.querySelector(".export-row");
const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement;
// Tab switching
wrapper.querySelectorAll(".tab").forEach((tab) => {
tab.addEventListener("click", (e) => {
e.stopPropagation();
const tabName = (tab as HTMLElement).dataset.tab as "schematic" | "board" | "drc";
this.#activeTab = tabName;
wrapper.querySelectorAll(".tab").forEach((t) => t.classList.remove("active"));
tab.classList.add("active");
this.#renderPreview();
});
});
this.#generateBtn?.addEventListener("click", (e) => {
e.stopPropagation();
this.#generate();
});
this.#promptInput?.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
this.#generate();
}
});
this.#promptInput?.addEventListener("pointerdown", (e) => e.stopPropagation());
this.#componentInput?.addEventListener("pointerdown", (e) => e.stopPropagation());
closeBtn.addEventListener("click", (e) => {
e.stopPropagation();
this.dispatchEvent(new CustomEvent("close"));
});
return root;
}
async #generate() {
const prompt = this.#promptInput?.value.trim();
if (!prompt || this.#isLoading) return;
const components = this.#componentInput?.value
.split(",")
.map((c) => c.trim())
.filter(Boolean) || [];
this.#isLoading = true;
this.#error = null;
if (this.#generateBtn) this.#generateBtn.disabled = true;
if (this.#previewArea) {
this.#previewArea.innerHTML = '<div class="loading"><div class="spinner"></div><span>Generating PCB design...</span></div>';
}
try {
// Step 1: Create project
const createRes = await fetch("/api/kicad/create_project", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt, components }),
});
if (!createRes.ok) {
const err = await createRes.json().catch(() => ({ error: createRes.statusText }));
throw new Error(err.error || "PCB generation failed");
}
const data = await createRes.json();
this.#schematicSvg = data.schematic_svg || null;
this.#boardSvg = data.board_svg || null;
this.#gerberUrl = data.gerber_url || null;
this.#bomUrl = data.bom_url || null;
this.#pdfUrl = data.pdf_url || null;
this.#drcResults = data.drc || null;
this.#renderPreview();
this.#showExports();
} catch (err) {
this.#error = err instanceof Error ? err.message : "Generation failed";
if (this.#previewArea) {
this.#previewArea.innerHTML = `<div class="error">${this.#escapeHtml(this.#error)}</div>`;
}
} finally {
this.#isLoading = false;
if (this.#generateBtn) this.#generateBtn.disabled = false;
}
}
#renderPreview() {
if (!this.#previewArea) return;
switch (this.#activeTab) {
case "schematic":
if (this.#schematicSvg) {
this.#previewArea.innerHTML = `<img src="${this.#escapeHtml(this.#schematicSvg)}" alt="Schematic" />`;
} else {
this.#previewArea.innerHTML = '<div class="placeholder"><span class="placeholder-icon">📋</span><span>Schematic will appear here</span></div>';
}
break;
case "board":
if (this.#boardSvg) {
this.#previewArea.innerHTML = `<img src="${this.#escapeHtml(this.#boardSvg)}" alt="Board Layout" />`;
} else {
this.#previewArea.innerHTML = '<div class="placeholder"><span class="placeholder-icon">📟</span><span>Board layout will appear here</span></div>';
}
break;
case "drc":
if (this.#drcResults) {
const violations = this.#drcResults.violations || [];
const passed = violations.length === 0;
this.#previewArea.innerHTML = `
<div class="drc-results" style="padding:12px">
<h4 style="margin:0 0 8px">${passed
? '<span class="pass">✅ DRC Passed</span>'
: `<span class="fail">❌ ${violations.length} Violation(s)</span>`
}</h4>
${violations.map((v: any) => `<p style="margin:4px 0;font-size:12px">• ${this.#escapeHtml(v.message || v)}</p>`).join("")}
</div>
`;
} else {
this.#previewArea.innerHTML = '<div class="placeholder"><span class="placeholder-icon">✅</span><span>DRC results will appear here</span></div>';
}
break;
}
}
#showExports() {
if (this.#exportRow && (this.#gerberUrl || this.#bomUrl || this.#pdfUrl)) {
this.#exportRow.style.display = "flex";
const gerberLink = this.#exportRow.querySelector(".gerber-dl") as HTMLAnchorElement;
const bomLink = this.#exportRow.querySelector(".bom-dl") as HTMLAnchorElement;
const pdfLink = this.#exportRow.querySelector(".pdf-dl") as HTMLAnchorElement;
if (gerberLink) gerberLink.href = this.#gerberUrl || "#";
if (bomLink) bomLink.href = this.#bomUrl || "#";
if (pdfLink) pdfLink.href = this.#pdfUrl || "#";
}
}
#escapeHtml(text: string): string {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
override toJSON() {
return {
...super.toJSON(),
type: "folk-kicad",
schematicSvg: this.#schematicSvg,
boardSvg: this.#boardSvg,
gerberUrl: this.#gerberUrl,
bomUrl: this.#bomUrl,
pdfUrl: this.#pdfUrl,
};
}
}