app
Adom Symbol
Public Made by Adomby adom
KiCad symbol creator with interactive viewer and delivery to KiCad/Fusion 360
← Commit history
Sync current source (open-source, up to date)
6 files changed
+694
docs/screenshot-kicad.pngadded⋯ 1 unchanged line ⋯
docs/screenshot-viewer.pngadded⋯ 1 unchanged line ⋯
service/README.mdadded+20@@ -0,0 +1,20 @@+# adom-symbol service++Node.js HTTP service for symbol creation, preview, lint, validation, and delivery.++**Port:** 8781++## Endpoints++| Method | Path | Description |+|---|---|---|+| POST | /sym/create | Create symbol from structured pin data |+| POST | /sym/preview | Generate/refresh viewer |+| POST | /sym/lint | Lint symbol |+| POST | /sym/validate | Validate symbol folder |+| POST | /sym/deliver | Deliver to KiCad/Fusion/Altium |+| GET | /sym/samples | List sample symbols |+| GET | /viewer/:name | Serve interactive viewer |+| GET | /health | Service health |+| POST | /cmd/push | AI-drivable UI command |+| GET | /cmd/poll | Poll pending UI commands |
service/cache/Device--R.kicad_symadded+128@@ -0,0 +1,128 @@+(kicad_symbol_lib+ (version 20231120)+ (generator "kicad-service")+ (symbol "R"+ (pin_numbers+ (hide yes)+ )+ (pin_names+ (offset 0)+ )+ (exclude_from_sim no)+ (in_bom yes)+ (on_board yes)+ (property "Reference" "R"+ (at 2.032 0 90)+ (effects+ (font+ (size 1.27 1.27)+ )+ )+ )+ (property "Value" "R"+ (at 0 0 90)+ (effects+ (font+ (size 1.27 1.27)+ )+ )+ )+ (property "Footprint" ""+ (at -1.778 0 90)+ (effects+ (font+ (size 1.27 1.27)+ )+ (hide yes)+ )+ )+ (property "Datasheet" "~"+ (at 0 0 0)+ (effects+ (font+ (size 1.27 1.27)+ )+ (hide yes)+ )+ )+ (property "Description" "Resistor"+ (at 0 0 0)+ (effects+ (font+ (size 1.27 1.27)+ )+ (hide yes)+ )+ )+ (property "ki_keywords" "R res resistor"+ (at 0 0 0)+ (effects+ (font+ (size 1.27 1.27)+ )+ (hide yes)+ )+ )+ (property "ki_fp_filters" "R_*"+ (at 0 0 0)+ (effects+ (font+ (size 1.27 1.27)+ )+ (hide yes)+ )+ )+ (symbol "R_0_1"+ (rectangle+ (start -1.016 -2.54)+ (end 1.016 2.54)+ (stroke+ (width 0.254)+ (type default)+ )+ (fill+ (type none)+ )+ )+ )+ (symbol "R_1_1"+ (pin passive line+ (at 0 3.81 270)+ (length 1.27)+ (name "~"+ (effects+ (font+ (size 1.27 1.27)+ )+ )+ )+ (number "1"+ (effects+ (font+ (size 1.27 1.27)+ )+ )+ )+ )+ (pin passive line+ (at 0 -3.81 90)+ (length 1.27)+ (name "~"+ (effects+ (font+ (size 1.27 1.27)+ )+ )+ )+ (number "2"+ (effects+ (font+ (size 1.27 1.27)+ )+ )+ )+ )+ )+ (embedded_fonts no)+ )+)
service/server.jsadded+536@@ -0,0 +1,536 @@+/**+ * Symbol Creator HTTP API Service+ *+ * Provides a structured API for creating KiCad/Fusion 360 schematic symbols.+ * Handles .kicad_sym generation, SVG export, branded viewer HTML, lint, validate,+ * and delivery to desktop EDA tools.+ *+ * Port: 8781+ *+ * All endpoints accept POST with JSON body.+ */++import { createServer } from 'http';+import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, readFile as readFileCb } from 'fs';+import { readFile } from 'fs/promises';+import { join, dirname } from 'path';+import { fileURLToPath } from 'url';+import { exec } from 'child_process';++const __dirname = dirname(fileURLToPath(import.meta.url));+const SAMPLES_DIR = join(__dirname, 'samples');++import { generateICSym, generateDiscreteSym, warmBaselineCache } from './lib/sym-gen.js';+import { generateBrandedViewer } from './lib/viewer-gen.js';+import { generateMetadata } from './lib/metadata-gen.js';+import { generateReadme } from './lib/readme-gen.js';+import { generateLbr } from './lib/lbr-gen.js';+import { symExportSvg, symSearch } from '../viewer/kicad-api-client.js';+import { lintKicadSymbol } from '../viewer/kicad-lint.js';+import { validateSymbolFolder } from '../viewer/kicad-validate.js';++const PORT = parseInt(process.env.PORT || '8781', 10);+const SYMBOLS_DIR = '/home/adom/project/project-content/schematics/symbols';++// In-memory cache of SymView HTML keyed by symbol name, served via GET /viewer/:name+const viewerCache = new Map();++// Build the public URL for this service using the VS Code proxy pattern+const VSCODE_PROXY_URI = process.env.VSCODE_PROXY_URI || '';++// ── Helpers ──────────────────────────────────────────────────────────++function parseBody(req) {+ return new Promise((resolve, reject) => {+ let data = '';+ req.on('data', chunk => data += chunk);+ req.on('end', () => {+ if (!data) return resolve({});+ try { resolve(JSON.parse(data)); }+ catch (e) { reject(new Error('Invalid JSON')); }+ });+ req.on('error', reject);+ });+}++function json(res, status, data) {+ res.writeHead(status, { 'Content-Type': 'application/json' });+ res.end(JSON.stringify(data));+}++async function pushToViewer(html, name) {+ // Cache the HTML so the GET /viewer/:name route can serve it+ viewerCache.set(name, html);++ // Also write it to disk at the canonical location+ const folder = join(SYMBOLS_DIR, name);+ mkdirSync(folder, { recursive: true });+ const htmlPath = join(folder, `${name}-viewer.html`);+ writeFileSync(htmlPath, html);++ // Build the proxy URL and open / refresh a Hydrogen webview tab+ if (VSCODE_PROXY_URI) {+ const base = VSCODE_PROXY_URI.replace('{{port}}', String(PORT)).replace(/\/$/, '');+ const proxyUrl = `${base}/viewer/${encodeURIComponent(name)}`;+ const cmd = `adom-cli hydrogen webview open-or-refresh --name "adom-symbol: ${name}" --url "${proxyUrl}"`;+ await new Promise((resolve) => {+ exec(cmd, (err) => {+ if (err) console.error(`[symbol-creator] hydrogen webview open-or-refresh failed: ${err.message}`);+ resolve();+ });+ });+ } else {+ console.warn('[symbol-creator] VSCODE_PROXY_URI not set — skipping Hydrogen webview open');+ }+}++function symbolDir(symbolName) {+ return join(SYMBOLS_DIR, symbolName);+}++// ── Route handlers ──────────────────────────────────────────────────++/**+ * POST /sym/create+ *+ * Create a complete symbol package from structured data.+ */+async function handleCreate(body) {+ const {+ symbolName,+ manufacturer = '',+ package: pkg = '',+ description = '',+ datasheetUrl = '',+ category = 'ic', // 'ic' or 'discrete'+ pins = [],+ groups = [],+ discreteBaseline, // { library, symbol } for discrete+ referencePrefix,+ pinOverrides = [],+ preferences = {},+ } = body;++ if (!symbolName) throw new Error('symbolName is required');+ if (pins.length === 0) throw new Error('pins array is required');++ const folder = symbolDir(symbolName);+ mkdirSync(folder, { recursive: true });++ const files = [];++ // 1. Generate .kicad_sym+ let symContent;+ if (category === 'discrete' && discreteBaseline) {+ symContent = await generateDiscreteSym({+ symbolName, manufacturer, package: pkg, description, datasheetUrl,+ referencePrefix, discreteBaseline, pinOverrides,+ });+ } else {+ // Determine reference prefix+ const refPrefix = referencePrefix || (category === 'discrete' ? 'D' : 'U');+ symContent = generateICSym({+ symbolName, manufacturer, package: pkg, description, datasheetUrl,+ referencePrefix: refPrefix, pins, groups, preferences,+ });+ }++ const symPath = join(folder, `${symbolName}.kicad_sym`);+ writeFileSync(symPath, symContent);+ files.push(symPath);++ // 2. Generate metadata JSON+ const metadata = generateMetadata({+ symbolName, manufacturer, package: pkg, description, datasheetUrl, pins, groups,+ });+ const metaPath = join(folder, `${symbolName}-metadata.json`);+ writeFileSync(metaPath, JSON.stringify(metadata, null, 2) + '\n');+ files.push(metaPath);++ // 3. Generate README+ const readme = generateReadme({+ symbolName, manufacturer, package: pkg, description, datasheetUrl, pins, groups,+ });+ const readmePath = join(folder, 'README.md');+ writeFileSync(readmePath, readme);+ files.push(readmePath);++ // 4. Export SVG via KiCad service+ let svgContent;+ try {+ svgContent = await symExportSvg(symPath, symbolName);+ const svgPath = join(folder, `${symbolName}.svg`);+ writeFileSync(svgPath, svgContent);+ files.push(svgPath);+ } catch (err) {+ // SVG export failed — continue without it+ console.error(`[symbol-creator] SVG export failed: ${err.message}`);+ }++ // 5. Generate branded viewer HTML+ let viewerHtml;+ try {+ viewerHtml = await generateBrandedViewer(symPath, symbolName, {+ pinDescriptions: metadata.pinDescriptions,+ groupDescriptions: metadata.groupDescriptions,+ datasheetUrl,+ manufacturer,+ });+ const viewerPath = join(folder, `${symbolName}-viewer.html`);+ writeFileSync(viewerPath, viewerHtml);+ files.push(viewerPath);+ } catch (err) {+ console.error(`[symbol-creator] Viewer generation failed: ${err.message}`);+ }++ // 6. Run lint+ let lintResult = { passed: true, errors: [], warnings: [] };+ try {+ lintResult = await lintKicadSymbol(symPath, symbolName);+ } catch (err) {+ lintResult = { passed: false, errors: [{ check: 'lint-error', message: err.message }], warnings: [] };+ }++ // 7. Run validate+ let validateResult = { passed: true, errors: [], warnings: [] };+ try {+ validateResult = await validateSymbolFolder(folder, symbolName);+ } catch (err) {+ validateResult = { passed: false, errors: [{ check: 'validate-error', message: err.message }], warnings: [] };+ }++ // 8. Open in Hydrogen webview+ if (viewerHtml) {+ await pushToViewer(viewerHtml, symbolName);+ }++ return {+ folder,+ files,+ lint: lintResult,+ validate: validateResult,+ };+}++/**+ * POST /sym/preview+ *+ * (Re-)preview an existing symbol in a Hydrogen webview tab.+ */+async function handlePreview(body) {+ const { symbolName, folderPath } = body;+ const folder = folderPath || symbolDir(symbolName);+ if (!symbolName) throw new Error('symbolName is required');++ const symPath = join(folder, `${symbolName}.kicad_sym`);+ if (!existsSync(symPath)) throw new Error(`Symbol file not found: ${symPath}`);++ // Load metadata+ const metaPath = join(folder, `${symbolName}-metadata.json`);+ let pinDescriptions = {};+ let groupDescriptions = {};+ let datasheetUrl = '';+ let manufacturer = '';+ if (existsSync(metaPath)) {+ const meta = JSON.parse(readFileSync(metaPath, 'utf-8'));+ pinDescriptions = meta.pinDescriptions || {};+ groupDescriptions = meta.groupDescriptions || {};+ datasheetUrl = meta.datasheetUrl || '';+ manufacturer = meta.manufacturer || '';+ }++ // Generate viewer HTML+ const viewerHtml = await generateBrandedViewer(symPath, symbolName, {+ pinDescriptions,+ groupDescriptions,+ datasheetUrl,+ manufacturer,+ });++ // Save viewer HTML+ const viewerPath = join(folder, `${symbolName}-viewer.html`);+ writeFileSync(viewerPath, viewerHtml);++ // Push to viewer+ await pushToViewer(viewerHtml, symbolName);++ return { folder, viewerPath, sent: true };+}++/**+ * POST /sym/lint+ */+async function handleLint(body) {+ const { symbolName, folderPath } = body;+ const folder = folderPath || symbolDir(symbolName);+ if (!symbolName) throw new Error('symbolName is required');++ const symPath = join(folder, `${symbolName}.kicad_sym`);+ return lintKicadSymbol(symPath, symbolName);+}++/**+ * POST /sym/validate+ */+async function handleValidate(body) {+ const { symbolName, folderPath } = body;+ const folder = folderPath || symbolDir(symbolName);+ if (!symbolName) throw new Error('symbolName is required');++ return validateSymbolFolder(folder, symbolName);+}++/**+ * POST /sym/search+ */+async function handleSearch(body) {+ const { query, limit = 20 } = body;+ if (!query) throw new Error('query is required');++ return symSearch(query, limit);+}++/**+ * POST /sym/deliver+ */+async function handleDeliver(body) {+ const { symbolName, target = 'kicad', folderPath } = body;+ const folder = folderPath || symbolDir(symbolName);+ if (!symbolName) throw new Error('symbolName is required');++ const symPath = join(folder, `${symbolName}.kicad_sym`);+ if (!existsSync(symPath)) throw new Error(`Symbol file not found: ${symPath}`);++ // Final lint + validate+ const lintResult = await lintKicadSymbol(symPath, symbolName);+ const validateResult = await validateSymbolFolder(folder, symbolName);++ if (!lintResult.passed || !validateResult.passed) {+ return {+ success: false,+ reason: 'lint or validation failed',+ lint: lintResult,+ validate: validateResult,+ };+ }++ if (target === 'fusion360') {+ // Generate .lbr+ const content = readFileSync(symPath, 'utf-8');+ const metaPath = join(folder, `${symbolName}-metadata.json`);+ const meta = existsSync(metaPath) ? JSON.parse(readFileSync(metaPath, 'utf-8')) : {};++ // Parse pins from the .kicad_sym for LBR conversion+ const pinRegex = /\(pin\s+(\w+)\s+\w+\s+\(at\s+([-\d.]+)\s+([-\d.]+)\s+(\d+)\)\s+\(length\s+([-\d.]+)\)\s+\(name\s+"([^"]*)"[^)]*\)\s+\(number\s+"([^"]*)"[^)]*\)\)/g;+ const lbrPins = [];+ let m;+ while ((m = pinRegex.exec(content)) !== null) {+ lbrPins.push({+ type: m[1], x: parseFloat(m[2]), y: parseFloat(m[3]),+ angle: parseInt(m[4]), length: parseFloat(m[5]),+ name: m[6], number: m[7],+ });+ }++ // Parse body rectangle+ const rectMatch = content.match(/\(rectangle\s+\(start\s+([-\d.]+)\s+([-\d.]+)\)\s+\(end\s+([-\d.]+)\s+([-\d.]+)\)/);+ const body = rectMatch ? {+ left: parseFloat(rectMatch[1]), top: parseFloat(rectMatch[2]),+ right: parseFloat(rectMatch[3]), bottom: parseFloat(rectMatch[4]),+ } : null;++ const lbrContent = generateLbr({+ symbolName,+ description: meta.description || '',+ referencePrefix: 'U',+ pins: lbrPins,+ body,+ });++ const lbrPath = join(folder, `${symbolName}.lbr`);+ writeFileSync(lbrPath, lbrContent);++ return {+ success: true,+ target: 'fusion360',+ lbrPath,+ lint: lintResult,+ validate: validateResult,+ };+ }++ // KiCad delivery — return the file path for Conduit+ return {+ success: true,+ target: 'kicad',+ symPath,+ lint: lintResult,+ validate: validateResult,+ };+}++/**+ * GET /sym/samples+ *+ * List available sample symbols bundled with the service.+ */+function handleSamplesList() {+ if (!existsSync(SAMPLES_DIR)) return [];+ const entries = readdirSync(SAMPLES_DIR, { withFileTypes: true });+ return entries+ .filter(e => e.isDirectory() && existsSync(join(SAMPLES_DIR, e.name, `${e.name}.kicad_sym`)))+ .map(e => {+ const metaPath = join(SAMPLES_DIR, e.name, `${e.name}-metadata.json`);+ let description = '';+ let manufacturer = '';+ if (existsSync(metaPath)) {+ const meta = JSON.parse(readFileSync(metaPath, 'utf-8'));+ description = meta.description || '';+ manufacturer = meta.manufacturer || '';+ }+ return { name: e.name, description, manufacturer };+ });+}++/**+ * POST /sym/sample+ *+ * Preview a sample symbol. Generates the viewer HTML from the bundled+ * sample artifacts and opens it in a Hydrogen webview tab.+ */+async function handleSample(body) {+ const { symbolName } = body;+ if (!symbolName) throw new Error('symbolName is required');++ const sampleFolder = join(SAMPLES_DIR, symbolName);+ const symPath = join(sampleFolder, `${symbolName}.kicad_sym`);+ if (!existsSync(symPath)) {+ const available = handleSamplesList().map(s => s.name);+ throw new Error(`Sample "${symbolName}" not found. Available: ${available.join(', ')}`);+ }++ // Load metadata+ const metaPath = join(sampleFolder, `${symbolName}-metadata.json`);+ let pinDescriptions = {};+ let groupDescriptions = {};+ let datasheetUrl = '';+ let manufacturer = '';+ if (existsSync(metaPath)) {+ const meta = JSON.parse(readFileSync(metaPath, 'utf-8'));+ pinDescriptions = meta.pinDescriptions || {};+ groupDescriptions = meta.groupDescriptions || {};+ datasheetUrl = meta.datasheetUrl || '';+ manufacturer = meta.manufacturer || '';+ }++ // Generate viewer HTML from sample files+ const viewerHtml = await generateBrandedViewer(symPath, symbolName, {+ pinDescriptions,+ groupDescriptions,+ datasheetUrl,+ manufacturer,+ });++ // Push to viewer+ await pushToViewer(viewerHtml, `${symbolName} (sample)`);++ return { symbolName, folder: sampleFolder, sent: true };+}++// ── HTTP Server ──────────────────────────────────────────────────────++const routes = {+ '/sym/create': handleCreate,+ '/sym/preview': handlePreview,+ '/sym/lint': handleLint,+ '/sym/validate': handleValidate,+ '/sym/search': handleSearch,+ '/sym/deliver': handleDeliver,+ '/sym/sample': handleSample,+};++const server = createServer(async (req, res) => {+ // CORS+ res.setHeader('Access-Control-Allow-Origin', '*');+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');++ if (req.method === 'OPTIONS') {+ res.writeHead(204);+ res.end();+ return;+ }++ // Health check+ if (req.url === '/health' || req.url === '/') {+ return json(res, 200, {+ ok: true,+ service: 'symbol-creator',+ version: '0.2.0',+ uptime: Math.floor(process.uptime()),+ });+ }++ // GET /sym/samples — list available sample symbols+ if (req.url === '/sym/samples' && req.method === 'GET') {+ return json(res, 200, handleSamplesList());+ }++ // GET /viewer/:name — serve cached SymView HTML for a symbol+ const viewerMatch = req.url.match(/^\/viewer\/([^/?]+)(\?.*)?$/);+ if (viewerMatch && req.method === 'GET') {+ const name = decodeURIComponent(viewerMatch[1]);+ const cached = viewerCache.get(name);+ if (cached) {+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });+ res.end(cached);+ return;+ }+ // Fall back to reading from disk if not in memory cache+ const htmlPath = join(SYMBOLS_DIR, name, `${name}-viewer.html`);+ if (existsSync(htmlPath)) {+ const html = readFileSync(htmlPath, 'utf-8');+ viewerCache.set(name, html);+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });+ res.end(html);+ return;+ }+ return json(res, 404, { error: `No viewer found for symbol: ${name}` });+ }++ // Route dispatch+ const handler = routes[req.url];+ if (!handler) {+ return json(res, 404, { error: `Unknown route: ${req.url}` });+ }++ if (req.method !== 'POST') {+ return json(res, 405, { error: 'POST required' });+ }++ try {+ const body = await parseBody(req);+ const result = await handler(body);+ json(res, 200, result);+ } catch (err) {+ console.error(`[symbol-creator] ${req.url} error:`, err);+ json(res, 500, { error: err.message });+ }+});++server.listen(PORT, '0.0.0.0', () => {+ console.log(`[symbol-creator] HTTP API listening on 0.0.0.0:${PORT}`);+ console.log(`[symbol-creator] Symbols directory: ${SYMBOLS_DIR}`);+ // Pre-warm baseline cache in background (non-blocking)+ warmBaselineCache().then(stats => {+ console.log(`[symbol-creator] Baseline cache: ${stats.cached} cached, ${stats.fetched} fetched, ${stats.failed} failed (${stats.total} total)`);+ }).catch(err => {+ console.error(`[symbol-creator] Cache warm failed: ${err.message}`);+ });+});++process.on('SIGINT', () => {+ console.log('[symbol-creator] Shutting down...');+ server.close();+ process.exit(0);+});
service/service.jsonadded+10@@ -0,0 +1,10 @@+{+ "name": "symbol-creator",+ "service": "local",+ "description": "Symbol Creator — generates KiCad symbols, branded viewers, and all artifacts",+ "port": 8781,+ "health": "http",+ "start": "node server.js",+ "cwd": ".",+ "log": "/tmp/symbol-creator.log"+}