Initial implementation of kicad-query helper
Generic S-expression parser feeding into KiCad-specific queries. Supports .kicad_sch (summary, components, component, nets), .kicad_pcb (summary, nets, net, footprints, footprint, traces), and .kicad_pro (summary). The 'traces' command does BFS through segments and vias to show the physical copper path from a specific pad. All queries support --format=json for structured LLM output. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
970
kicad-query.mjs
Normal file
970
kicad-query.mjs
Normal file
@@ -0,0 +1,970 @@
|
||||
#!/usr/bin/env node
|
||||
// kicad-query.mjs — query KiCad files for Claude-readable summaries
|
||||
// Usage: node kicad-query.mjs <file> <query> [args...]
|
||||
|
||||
import { readFileSync } from 'fs';
|
||||
import { extname } from 'path';
|
||||
|
||||
// ── S-expression tokenizer ──────────────────────────────────────────────────
|
||||
|
||||
function tokenize(text) {
|
||||
const tokens = [];
|
||||
let i = 0;
|
||||
const n = text.length;
|
||||
|
||||
while (i < n) {
|
||||
const ch = text[i];
|
||||
|
||||
// whitespace
|
||||
if (ch === ' ' || ch === '\t' || ch === '\r' || ch === '\n') {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// line comment
|
||||
if (ch === ';') {
|
||||
while (i < n && text[i] !== '\n') i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '(') { tokens.push('('); i++; continue; }
|
||||
if (ch === ')') { tokens.push(')'); i++; continue; }
|
||||
|
||||
// quoted string
|
||||
if (ch === '"') {
|
||||
let s = '';
|
||||
i++; // skip opening quote
|
||||
while (i < n && text[i] !== '"') {
|
||||
if (text[i] === '\\') {
|
||||
i++;
|
||||
const esc = text[i] ?? '';
|
||||
s += esc === 'n' ? '\n' : esc === 't' ? '\t' : esc;
|
||||
} else {
|
||||
s += text[i];
|
||||
}
|
||||
i++;
|
||||
}
|
||||
i++; // skip closing quote
|
||||
tokens.push({ s });
|
||||
continue;
|
||||
}
|
||||
|
||||
// atom (number, identifier, etc.)
|
||||
let atom = '';
|
||||
while (i < n && text[i] !== '(' && text[i] !== ')' && text[i] !== '"'
|
||||
&& text[i] !== ' ' && text[i] !== '\t' && text[i] !== '\r' && text[i] !== '\n') {
|
||||
atom += text[i++];
|
||||
}
|
||||
if (atom) tokens.push(atom);
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
// ── S-expression parser ─────────────────────────────────────────────────────
|
||||
// Returns { tag: string, items: Array<node|string> } for lists,
|
||||
// or a plain string for atoms/quoted strings.
|
||||
|
||||
function parse_sexp(text) {
|
||||
const tokens = tokenize(text);
|
||||
let pos = 0;
|
||||
|
||||
function next() { return tokens[pos++]; }
|
||||
function peek() { return tokens[pos]; }
|
||||
|
||||
function parse_expr() {
|
||||
const tok = next();
|
||||
|
||||
if (tok === '(') {
|
||||
// first item is the tag
|
||||
const tag_tok = next();
|
||||
const tag = typeof tag_tok === 'string' ? tag_tok : (tag_tok?.s ?? '');
|
||||
const items = [];
|
||||
while (pos < tokens.length && peek() !== ')') {
|
||||
items.push(parse_expr());
|
||||
}
|
||||
next(); // consume ')'
|
||||
return { tag, items };
|
||||
}
|
||||
|
||||
// atom or quoted string
|
||||
return typeof tok === 'string' ? tok : (tok?.s ?? '');
|
||||
}
|
||||
|
||||
return parse_expr();
|
||||
}
|
||||
|
||||
// ── Tree helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
function find_child(node, tag) {
|
||||
if (!node?.items) return null;
|
||||
return node.items.find(n => n?.tag === tag) ?? null;
|
||||
}
|
||||
|
||||
function find_children(node, tag) {
|
||||
if (!node?.items) return [];
|
||||
return node.items.filter(n => n?.tag === tag);
|
||||
}
|
||||
|
||||
// Get value of first atom/string child of a node
|
||||
function first_str(node) {
|
||||
if (!node?.items) return null;
|
||||
for (const item of node.items) {
|
||||
if (typeof item === 'string') return item;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get a (property "Key" "Value") property from a symbol node
|
||||
function get_prop(node, key) {
|
||||
for (const item of (node?.items ?? [])) {
|
||||
if (item?.tag === 'property') {
|
||||
const [k, v] = item.items ?? [];
|
||||
if (k === key) return typeof v === 'string' ? v : (v?.s ?? null);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Collect all nodes recursively matching tag
|
||||
function collect(node, tag, acc = []) {
|
||||
if (!node?.items) return acc;
|
||||
if (node.tag === tag) acc.push(node);
|
||||
for (const item of node.items) {
|
||||
if (item?.items) collect(item, tag, acc);
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
// ── Output format ───────────────────────────────────────────────────────────
|
||||
// Set by --format=json flag; queries emit via output_*() helpers.
|
||||
|
||||
let OUTPUT_FORMAT = 'human';
|
||||
|
||||
function output_json(data) {
|
||||
console.log(JSON.stringify(data, null, 2));
|
||||
}
|
||||
|
||||
// ── Output helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
function pad_table(rows, sep = ' ') {
|
||||
if (rows.length === 0) return;
|
||||
const widths = rows[0].map((_, ci) =>
|
||||
Math.max(...rows.map(r => String(r[ci] ?? '').length))
|
||||
);
|
||||
for (const row of rows) {
|
||||
console.log(row.map((cell, ci) =>
|
||||
ci === row.length - 1 ? cell : String(cell ?? '').padEnd(widths[ci])
|
||||
).join(sep));
|
||||
}
|
||||
}
|
||||
|
||||
function glob_match(pattern, str) {
|
||||
if (!pattern) return true;
|
||||
const re = new RegExp('^' + pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.') + '$', 'i');
|
||||
return re.test(str);
|
||||
}
|
||||
|
||||
// Parse "key=value" filter args
|
||||
function parse_filters(args) {
|
||||
const filters = {};
|
||||
for (const arg of args) {
|
||||
const m = arg.match(/^(\w+)=(.*)$/);
|
||||
if (m) filters[m[1].toLowerCase()] = m[2];
|
||||
}
|
||||
return filters;
|
||||
}
|
||||
|
||||
// ── Schematic queries ───────────────────────────────────────────────────────
|
||||
|
||||
// Placed symbols (not lib_symbols definitions, not power-only, not unit>1 duplicates)
|
||||
function get_placed_symbols(root) {
|
||||
const lib_syms = find_child(root, 'lib_symbols');
|
||||
const lib_sym_set = new Set(lib_syms?.items?.map(n => n?.tag === 'symbol' ? first_str(n) : null).filter(Boolean));
|
||||
|
||||
// Top-level symbols that are not inside lib_symbols
|
||||
const placed = [];
|
||||
for (const item of (root?.items ?? [])) {
|
||||
if (item?.tag !== 'symbol') continue;
|
||||
// lib_id distinguishes placed symbols from sub-unit definitions
|
||||
const lib_id_node = find_child(item, 'lib_id');
|
||||
if (!lib_id_node) continue;
|
||||
placed.push(item);
|
||||
}
|
||||
|
||||
// Filter to unit 1 only (avoid duplicates for multi-unit ICs)
|
||||
return placed.filter(s => {
|
||||
const unit = find_child(s, 'unit');
|
||||
return !unit || first_str(unit) === '1';
|
||||
});
|
||||
}
|
||||
|
||||
function sch_summary(root) {
|
||||
const all_placed = get_placed_symbols(root);
|
||||
const real = all_placed.filter(s => {
|
||||
const lib_id = first_str(find_child(s, 'lib_id')) ?? '';
|
||||
return !lib_id.startsWith('power:');
|
||||
});
|
||||
const power_syms = all_placed.filter(s => {
|
||||
const lib_id = first_str(find_child(s, 'lib_id')) ?? '';
|
||||
return lib_id.startsWith('power:');
|
||||
});
|
||||
|
||||
const net_labels = collect(root, 'net_label');
|
||||
const global_labels = collect(root, 'global_label');
|
||||
const wires = collect(root, 'wire');
|
||||
|
||||
console.log('=== Schematic Summary ===');
|
||||
console.log(`Components : ${real.length}`);
|
||||
console.log(`Power nets : ${power_syms.length} power symbols`);
|
||||
console.log(`Net labels : ${net_labels.length}`);
|
||||
console.log(`Global labels: ${global_labels.length}`);
|
||||
console.log(`Wires : ${wires.length}`);
|
||||
|
||||
// Library breakdown
|
||||
const libs = {};
|
||||
for (const s of real) {
|
||||
const lib_id = first_str(find_child(s, 'lib_id')) ?? 'unknown';
|
||||
const lib = lib_id.split(':')[0];
|
||||
libs[lib] = (libs[lib] ?? 0) + 1;
|
||||
}
|
||||
if (Object.keys(libs).length) {
|
||||
console.log('\nLibraries:');
|
||||
for (const [lib, count] of Object.entries(libs).sort()) {
|
||||
console.log(` ${lib}: ${count}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sch_components(root, filters = {}) {
|
||||
const placed = get_placed_symbols(root);
|
||||
let comps = placed
|
||||
.filter(s => {
|
||||
const lib_id = first_str(find_child(s, 'lib_id')) ?? '';
|
||||
return !lib_id.startsWith('power:');
|
||||
})
|
||||
.map(s => ({
|
||||
ref : get_prop(s, 'Reference') ?? '?',
|
||||
value : get_prop(s, 'Value') ?? '?',
|
||||
footprint : get_prop(s, 'Footprint') ?? '',
|
||||
lib_id : first_str(find_child(s, 'lib_id')) ?? '',
|
||||
datasheet : get_prop(s, 'Datasheet') ?? '',
|
||||
dnp : first_str(find_child(s, 'dnp')) === 'yes',
|
||||
}));
|
||||
|
||||
// Apply filters
|
||||
if (filters.ref) comps = comps.filter(c => glob_match(filters.ref, c.ref));
|
||||
if (filters.value) comps = comps.filter(c => glob_match(filters.value, c.value));
|
||||
if (filters.lib) comps = comps.filter(c => glob_match(filters.lib, c.lib_id.split(':')[0]));
|
||||
if (filters.footprint) comps = comps.filter(c => glob_match(filters.footprint, c.footprint));
|
||||
|
||||
// Sort by reference (alphanumeric)
|
||||
comps.sort((a, b) => a.ref.localeCompare(b.ref, undefined, { numeric: true }));
|
||||
|
||||
if (comps.length === 0) {
|
||||
console.log('No components match.');
|
||||
return;
|
||||
}
|
||||
|
||||
const header = ['Reference', 'Value', 'Library:Symbol', 'Footprint'];
|
||||
const rows = [header, header.map(h => '-'.repeat(h.length))];
|
||||
for (const c of comps) {
|
||||
rows.push([
|
||||
c.dnp ? `${c.ref}*` : c.ref,
|
||||
c.value,
|
||||
c.lib_id,
|
||||
c.footprint,
|
||||
]);
|
||||
}
|
||||
pad_table(rows);
|
||||
console.log(`\n${comps.length} component(s)${comps.some(c => c.dnp) ? ' (* = DNP)' : ''}`);
|
||||
}
|
||||
|
||||
function sch_component(root, ref) {
|
||||
const placed = get_placed_symbols(root);
|
||||
// Include all units for this ref
|
||||
const all_placed_all_units = (root?.items ?? []).filter(item => {
|
||||
if (item?.tag !== 'symbol') return false;
|
||||
if (!find_child(item, 'lib_id')) return false;
|
||||
return get_prop(item, 'Reference') === ref;
|
||||
});
|
||||
|
||||
const s = placed.find(s => get_prop(s, 'Reference') === ref);
|
||||
if (!s && all_placed_all_units.length === 0) {
|
||||
console.log(`Component '${ref}' not found.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const target = s ?? all_placed_all_units[0];
|
||||
const lib_id = first_str(find_child(target, 'lib_id')) ?? '';
|
||||
const at = find_child(target, 'at');
|
||||
const pos = at?.items?.slice(0, 2).join(', ') ?? '?';
|
||||
|
||||
console.log(`=== Component: ${ref} ===`);
|
||||
console.log(`Library/Symbol : ${lib_id}`);
|
||||
console.log(`Position : (${pos})`);
|
||||
|
||||
// Collect all properties from all units
|
||||
const props = {};
|
||||
for (const unit_sym of all_placed_all_units) {
|
||||
for (const item of (unit_sym?.items ?? [])) {
|
||||
if (item?.tag === 'property') {
|
||||
const k = item.items?.[0];
|
||||
const v = item.items?.[1];
|
||||
if (typeof k === 'string' && typeof v === 'string') {
|
||||
if (!(k in props)) props[k] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\nProperties:');
|
||||
for (const [k, v] of Object.entries(props)) {
|
||||
if (v && v !== '~') console.log(` ${k}: ${v}`);
|
||||
}
|
||||
|
||||
// Pins
|
||||
const pins = all_placed_all_units.flatMap(u => find_children(u, 'pin'));
|
||||
if (pins.length) {
|
||||
console.log(`\nPins (${pins.length}):`);
|
||||
for (const pin of pins) {
|
||||
const num = first_str(pin);
|
||||
const net = find_child(pin, 'net');
|
||||
const net_name = net ? first_str(find_child(net, 'name')) ?? first_str(net) : null;
|
||||
console.log(` pin ${num}${net_name ? ` → ${net_name}` : ''}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sch_nets(root) {
|
||||
const net_labels = collect(root, 'net_label');
|
||||
const global_labels = collect(root, 'global_label');
|
||||
const power_syms = (root?.items ?? []).filter(item => {
|
||||
if (item?.tag !== 'symbol') return false;
|
||||
const lib_id = first_str(find_child(item, 'lib_id')) ?? '';
|
||||
return lib_id.startsWith('power:');
|
||||
});
|
||||
|
||||
const locals = new Set(net_labels.map(n => first_str(n)).filter(Boolean));
|
||||
const globals = new Set(global_labels.map(n => first_str(n)).filter(Boolean));
|
||||
const power = new Set(power_syms.map(s => get_prop(s, 'Value')).filter(Boolean));
|
||||
|
||||
const all_nets = new Set([...locals, ...globals, ...power]);
|
||||
const sorted = [...all_nets].sort((a, b) => a.localeCompare(b));
|
||||
|
||||
console.log(`=== Net Names (${sorted.length} total) ===`);
|
||||
for (const name of sorted) {
|
||||
const tags = [];
|
||||
if (globals.has(name)) tags.push('global');
|
||||
if (power.has(name)) tags.push('power');
|
||||
console.log(` ${name}${tags.length ? ' [' + tags.join(', ') + ']' : ''}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── PCB queries ─────────────────────────────────────────────────────────────
|
||||
|
||||
function pcb_summary(root) {
|
||||
const nets = find_children(root, 'net');
|
||||
const footprints = collect(root, 'footprint');
|
||||
const segments = collect(root, 'segment');
|
||||
const vias = collect(root, 'via');
|
||||
const zones = collect(root, 'zone');
|
||||
|
||||
// Exclude net 0 (unconnected sentinel)
|
||||
const real_nets = nets.filter(n => first_str(n) !== '0');
|
||||
|
||||
console.log('=== PCB Summary ===');
|
||||
console.log(`Nets : ${real_nets.length}`);
|
||||
console.log(`Footprints : ${footprints.length}`);
|
||||
console.log(`Segments : ${segments.length}`);
|
||||
console.log(`Vias : ${vias.length}`);
|
||||
console.log(`Zones : ${zones.length}`);
|
||||
}
|
||||
|
||||
function pcb_nets(root) {
|
||||
const nets = find_children(root, 'net')
|
||||
.filter(n => first_str(n) !== '0')
|
||||
.map(n => ({ id: first_str(n), name: n.items?.[1] ?? '' }))
|
||||
.sort((a, b) => Number(a.id) - Number(b.id));
|
||||
|
||||
if (nets.length === 0) { console.log('No nets found.'); return; }
|
||||
|
||||
console.log(`=== PCB Nets (${nets.length}) ===`);
|
||||
pad_table([
|
||||
['ID', 'Net Name'],
|
||||
['--', '--------'],
|
||||
...nets.map(n => [n.id, n.name]),
|
||||
]);
|
||||
}
|
||||
|
||||
function pcb_footprints(root, filters = {}) {
|
||||
let fps = collect(root, 'footprint').map(fp => {
|
||||
const ref = get_prop(fp, 'Reference') ?? '?';
|
||||
const value = get_prop(fp, 'Value') ?? '?';
|
||||
const lib = first_str(fp) ?? '';
|
||||
const layer = first_str(find_child(fp, 'layer')) ?? '';
|
||||
return { ref, value, lib, layer };
|
||||
});
|
||||
|
||||
if (filters.ref) fps = fps.filter(f => glob_match(filters.ref, f.ref));
|
||||
if (filters.value) fps = fps.filter(f => glob_match(filters.value, f.value));
|
||||
|
||||
fps.sort((a, b) => a.ref.localeCompare(b.ref, undefined, { numeric: true }));
|
||||
|
||||
if (fps.length === 0) { console.log('No footprints match.'); return; }
|
||||
|
||||
pad_table([
|
||||
['Reference', 'Value', 'Layer', 'Footprint'],
|
||||
['---------', '-----', '-----', '---------'],
|
||||
...fps.map(f => [f.ref, f.value, f.layer, f.lib]),
|
||||
]);
|
||||
console.log(`\n${fps.length} footprint(s)`);
|
||||
}
|
||||
|
||||
function pcb_footprint(root, ref) {
|
||||
const fps = collect(root, 'footprint').filter(fp => get_prop(fp, 'Reference') === ref);
|
||||
if (fps.length === 0) { console.log(`Footprint '${ref}' not found.`); return; }
|
||||
|
||||
const fp = fps[0];
|
||||
const lib = first_str(fp) ?? '';
|
||||
const layer = first_str(find_child(fp, 'layer')) ?? '';
|
||||
const at = find_child(fp, 'at');
|
||||
const pos = at?.items?.slice(0, 2).join(', ') ?? '?';
|
||||
|
||||
console.log(`=== Footprint: ${ref} ===`);
|
||||
console.log(`Library : ${lib}`);
|
||||
console.log(`Layer : ${layer}`);
|
||||
console.log(`Position : (${pos})`);
|
||||
console.log(`Value : ${get_prop(fp, 'Value') ?? '?'}`);
|
||||
|
||||
// Pads and their nets
|
||||
const pads = find_children(fp, 'pad');
|
||||
if (pads.length) {
|
||||
console.log(`\nPads (${pads.length}):`);
|
||||
pad_table([
|
||||
['Pad', 'Type', 'Net'],
|
||||
['---', '----', '---'],
|
||||
...pads.map(p => {
|
||||
const num = p.items?.[0] ?? '?';
|
||||
const type = p.items?.[1] ?? '?';
|
||||
const net = find_child(p, 'net');
|
||||
const net_name = net?.items?.[1] ?? '';
|
||||
return [num, type, net_name];
|
||||
}),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// ── PCB net detail ──────────────────────────────────────────────────────────
|
||||
|
||||
function pcb_net(root, name) {
|
||||
// Find net id
|
||||
const net_node = find_children(root, 'net').find(n => n.items?.[1] === name);
|
||||
if (!net_node) { console.log(`Net '${name}' not found.`); return; }
|
||||
|
||||
const net_id = first_str(net_node);
|
||||
console.log(`=== Net: ${name} (id=${net_id}) ===`);
|
||||
|
||||
// Pads on this net
|
||||
const fps = collect(root, 'footprint');
|
||||
const connected_pads = [];
|
||||
for (const fp of fps) {
|
||||
const ref = get_prop(fp, 'Reference') ?? '?';
|
||||
for (const pad of find_children(fp, 'pad')) {
|
||||
const pad_net = find_child(pad, 'net');
|
||||
if (pad_net && first_str(pad_net) === net_id) {
|
||||
connected_pads.push({ ref, pad: pad.items?.[0] ?? '?' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (connected_pads.length) {
|
||||
console.log(`\nConnected pads (${connected_pads.length}):`);
|
||||
for (const { ref, pad } of connected_pads.sort((a, b) => a.ref.localeCompare(b.ref, undefined, { numeric: true }))) {
|
||||
console.log(` ${ref} pad ${pad}`);
|
||||
}
|
||||
} else {
|
||||
console.log('No pads connected.');
|
||||
}
|
||||
}
|
||||
|
||||
// ── PCB geometry helpers ─────────────────────────────────────────────────────
|
||||
|
||||
const COORD_TOL = 0.005; // mm tolerance for point equality
|
||||
|
||||
function pt_eq(ax, ay, bx, by) {
|
||||
return Math.abs(ax - bx) <= COORD_TOL && Math.abs(ay - by) <= COORD_TOL;
|
||||
}
|
||||
|
||||
// Rotate point (px, py) by angle degrees and translate by (ox, oy)
|
||||
function rotate_translate(px, py, angle_deg, ox, oy) {
|
||||
const rad = (angle_deg * Math.PI) / 180;
|
||||
const cos = Math.cos(rad);
|
||||
const sin = Math.sin(rad);
|
||||
return [ox + px * cos - py * sin, oy + px * sin + py * cos];
|
||||
}
|
||||
|
||||
// Get absolute pad position given footprint node
|
||||
function pad_abs_pos(fp_node, pad_node) {
|
||||
const fp_at = find_child(fp_node, 'at');
|
||||
const fx = Number(fp_at?.items?.[0] ?? 0);
|
||||
const fy = Number(fp_at?.items?.[1] ?? 0);
|
||||
const fr = Number(fp_at?.items?.[2] ?? 0); // footprint rotation
|
||||
|
||||
const pad_at = find_child(pad_node, 'at');
|
||||
const px = Number(pad_at?.items?.[0] ?? 0);
|
||||
const py = Number(pad_at?.items?.[1] ?? 0);
|
||||
|
||||
return rotate_translate(px, py, fr, fx, fy);
|
||||
}
|
||||
|
||||
// Collect all segments keyed by net id
|
||||
function build_segment_index(root) {
|
||||
const segs = collect(root, 'segment').map(seg => {
|
||||
const s = find_child(seg, 'start');
|
||||
const e = find_child(seg, 'end');
|
||||
const net = find_child(seg, 'net');
|
||||
const lay = find_child(seg, 'layer');
|
||||
const w = find_child(seg, 'width');
|
||||
return {
|
||||
sx : Number(s?.items?.[0] ?? 0),
|
||||
sy : Number(s?.items?.[1] ?? 0),
|
||||
ex : Number(e?.items?.[0] ?? 0),
|
||||
ey : Number(e?.items?.[1] ?? 0),
|
||||
net : first_str(net) ?? '',
|
||||
layer: first_str(lay) ?? '',
|
||||
width: Number(first_str(w) ?? 0),
|
||||
};
|
||||
});
|
||||
|
||||
const vias = collect(root, 'via').map(v => {
|
||||
const at = find_child(v, 'at');
|
||||
const net = find_child(v, 'net');
|
||||
const layers_node = find_child(v, 'layers');
|
||||
return {
|
||||
x : Number(at?.items?.[0] ?? 0),
|
||||
y : Number(at?.items?.[1] ?? 0),
|
||||
net : first_str(net) ?? '',
|
||||
layers: layers_node?.items?.filter(i => typeof i === 'string') ?? [],
|
||||
};
|
||||
});
|
||||
|
||||
return { segs, vias };
|
||||
}
|
||||
|
||||
// BFS from a set of seed {x, y, layer} points through segments and vias on net_id
|
||||
function trace_from(seed_points, net_id, segs, vias) {
|
||||
// frontier: Set of "x,y,layer" strings
|
||||
const visited_points = new Set();
|
||||
const visited_segs = new Set();
|
||||
const visited_vias = new Set();
|
||||
const queue = [...seed_points];
|
||||
|
||||
for (const p of queue) {
|
||||
visited_points.add(`${p.x.toFixed(4)},${p.y.toFixed(4)},${p.layer}`);
|
||||
}
|
||||
|
||||
const result_segs = [];
|
||||
const result_vias = [];
|
||||
|
||||
while (queue.length) {
|
||||
const { x, y, layer } = queue.shift();
|
||||
|
||||
// Find segments starting or ending here on same layer + net
|
||||
for (let i = 0; i < segs.length; i++) {
|
||||
const seg = segs[i];
|
||||
if (seg.net !== net_id || seg.layer !== layer) continue;
|
||||
if (visited_segs.has(i)) continue;
|
||||
|
||||
let other_x, other_y;
|
||||
if (pt_eq(seg.sx, seg.sy, x, y)) {
|
||||
other_x = seg.ex; other_y = seg.ey;
|
||||
} else if (pt_eq(seg.ex, seg.ey, x, y)) {
|
||||
other_x = seg.sx; other_y = seg.sy;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
|
||||
visited_segs.add(i);
|
||||
result_segs.push(seg);
|
||||
|
||||
const key = `${other_x.toFixed(4)},${other_y.toFixed(4)},${layer}`;
|
||||
if (!visited_points.has(key)) {
|
||||
visited_points.add(key);
|
||||
queue.push({ x: other_x, y: other_y, layer });
|
||||
}
|
||||
}
|
||||
|
||||
// Find vias at this point that carry the same net
|
||||
for (let i = 0; i < vias.length; i++) {
|
||||
const via = vias[i];
|
||||
if (via.net !== net_id) continue;
|
||||
if (!pt_eq(via.x, via.y, x, y)) continue;
|
||||
if (visited_vias.has(i)) continue;
|
||||
|
||||
visited_vias.add(i);
|
||||
result_vias.push(via);
|
||||
|
||||
// Expand to all layers the via connects
|
||||
for (const vl of via.layers) {
|
||||
const key = `${via.x.toFixed(4)},${via.y.toFixed(4)},${vl}`;
|
||||
if (!visited_points.has(key)) {
|
||||
visited_points.add(key);
|
||||
queue.push({ x: via.x, y: via.y, layer: vl });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { segs: result_segs, vias: result_vias };
|
||||
}
|
||||
|
||||
function pcb_pin_traces(root, ref, pad_num) {
|
||||
// Find the footprint
|
||||
const fps = collect(root, 'footprint').filter(fp => get_prop(fp, 'Reference') === ref);
|
||||
if (fps.length === 0) { console.log(`Footprint '${ref}' not found.`); return; }
|
||||
const fp = fps[0];
|
||||
|
||||
// Find the pad
|
||||
const pads = find_children(fp, 'pad');
|
||||
const pad = pad_num
|
||||
? pads.find(p => p.items?.[0] === String(pad_num))
|
||||
: pads[0];
|
||||
|
||||
if (!pad) {
|
||||
console.log(`Pad '${pad_num}' not found on '${ref}'.`);
|
||||
if (pads.length) console.log(`Available pads: ${pads.map(p => p.items?.[0]).join(', ')}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const [abs_x, abs_y] = pad_abs_pos(fp, pad);
|
||||
const net_node = find_child(pad, 'net');
|
||||
const net_id = first_str(net_node) ?? '';
|
||||
const net_name = net_node?.items?.[1] ?? '(unconnected)';
|
||||
|
||||
// Pad layer(s)
|
||||
const layers_node = find_child(pad, 'layers');
|
||||
const pad_layers = layers_node?.items?.filter(i => typeof i === 'string').filter(l => l.endsWith('.Cu')) ?? ['F.Cu'];
|
||||
|
||||
console.log(`=== Traces from ${ref} pad ${pad.items?.[0]} ===`);
|
||||
console.log(`Net : ${net_name} (id=${net_id})`);
|
||||
console.log(`Position : (${abs_x.toFixed(3)}, ${abs_y.toFixed(3)})`);
|
||||
console.log(`Pad layers : ${pad_layers.join(', ')}`);
|
||||
|
||||
if (!net_id || net_id === '0') { console.log('Pad is unconnected.'); return; }
|
||||
|
||||
const { segs, vias } = build_segment_index(root);
|
||||
const seeds = pad_layers.map(l => ({ x: abs_x, y: abs_y, layer: l }));
|
||||
const { segs: found_segs, vias: found_vias } = trace_from(seeds, net_id, segs, vias);
|
||||
|
||||
// Group segments by layer
|
||||
const by_layer = {};
|
||||
for (const seg of found_segs) {
|
||||
(by_layer[seg.layer] ??= []).push(seg);
|
||||
}
|
||||
|
||||
if (found_segs.length === 0 && found_vias.length === 0) {
|
||||
console.log('\nNo traces found connected to this pad.');
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [layer, layer_segs] of Object.entries(by_layer).sort()) {
|
||||
console.log(`\nLayer ${layer} — ${layer_segs.length} segment(s):`);
|
||||
pad_table([
|
||||
[' From (x, y)', '', 'To (x, y)', '', 'Width'],
|
||||
[' -----------', '', '---------', '', '-----'],
|
||||
...layer_segs.map(s => [
|
||||
` (${s.sx.toFixed(3)},`, `${s.sy.toFixed(3)})`,
|
||||
`→ (${s.ex.toFixed(3)},`, `${s.ey.toFixed(3)})`,
|
||||
`${s.width}mm`,
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
if (found_vias.length) {
|
||||
console.log(`\nVias (${found_vias.length}):`);
|
||||
for (const v of found_vias) {
|
||||
console.log(` (${v.x.toFixed(3)}, ${v.y.toFixed(3)}) ${v.layers.join(' ↔ ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (OUTPUT_FORMAT === 'json') {
|
||||
output_json({
|
||||
ref,
|
||||
pad: pad.items?.[0],
|
||||
net: { id: net_id, name: net_name },
|
||||
position: { x: abs_x, y: abs_y },
|
||||
traces: found_segs.map(s => ({
|
||||
layer: s.layer,
|
||||
start: { x: s.sx, y: s.sy },
|
||||
end : { x: s.ex, y: s.ey },
|
||||
width: s.width,
|
||||
})),
|
||||
vias: found_vias.map(v => ({
|
||||
position: { x: v.x, y: v.y },
|
||||
layers : v.layers,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── JSON output variants ─────────────────────────────────────────────────────
|
||||
|
||||
function sch_components_json(root, filters = {}) {
|
||||
const placed = get_placed_symbols(root);
|
||||
let comps = placed
|
||||
.filter(s => !(first_str(find_child(s, 'lib_id')) ?? '').startsWith('power:'))
|
||||
.map(s => ({
|
||||
ref : get_prop(s, 'Reference') ?? '?',
|
||||
value : get_prop(s, 'Value') ?? '?',
|
||||
footprint: get_prop(s, 'Footprint') ?? '',
|
||||
lib_id : first_str(find_child(s, 'lib_id')) ?? '',
|
||||
datasheet: get_prop(s, 'Datasheet') ?? '',
|
||||
dnp : first_str(find_child(s, 'dnp')) === 'yes',
|
||||
}));
|
||||
|
||||
if (filters.ref) comps = comps.filter(c => glob_match(filters.ref, c.ref));
|
||||
if (filters.value) comps = comps.filter(c => glob_match(filters.value, c.value));
|
||||
comps.sort((a, b) => a.ref.localeCompare(b.ref, undefined, { numeric: true }));
|
||||
output_json({ components: comps });
|
||||
}
|
||||
|
||||
function sch_nets_json(root) {
|
||||
const net_labels = collect(root, 'net_label');
|
||||
const global_labels = collect(root, 'global_label');
|
||||
const power_syms = (root?.items ?? []).filter(item =>
|
||||
item?.tag === 'symbol' && (first_str(find_child(item, 'lib_id')) ?? '').startsWith('power:')
|
||||
);
|
||||
|
||||
const locals = new Set(net_labels.map(n => first_str(n)).filter(Boolean));
|
||||
const globals = new Set(global_labels.map(n => first_str(n)).filter(Boolean));
|
||||
const power = new Set(power_syms.map(s => get_prop(s, 'Value')).filter(Boolean));
|
||||
const all = new Set([...locals, ...globals, ...power]);
|
||||
|
||||
output_json({
|
||||
nets: [...all].sort().map(name => ({
|
||||
name,
|
||||
global: globals.has(name),
|
||||
power : power.has(name),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
function pcb_nets_json(root) {
|
||||
const nets = find_children(root, 'net')
|
||||
.filter(n => first_str(n) !== '0')
|
||||
.map(n => ({ id: Number(first_str(n)), name: n.items?.[1] ?? '' }))
|
||||
.sort((a, b) => a.id - b.id);
|
||||
output_json({ nets });
|
||||
}
|
||||
|
||||
function pcb_footprints_json(root, filters = {}) {
|
||||
let fps = collect(root, 'footprint').map(fp => ({
|
||||
ref : get_prop(fp, 'Reference') ?? '?',
|
||||
value : get_prop(fp, 'Value') ?? '?',
|
||||
lib : first_str(fp) ?? '',
|
||||
layer : first_str(find_child(fp, 'layer')) ?? '',
|
||||
}));
|
||||
if (filters.ref) fps = fps.filter(f => glob_match(filters.ref, f.ref));
|
||||
if (filters.value) fps = fps.filter(f => glob_match(filters.value, f.value));
|
||||
fps.sort((a, b) => a.ref.localeCompare(b.ref, undefined, { numeric: true }));
|
||||
output_json({ footprints: fps });
|
||||
}
|
||||
|
||||
// ── Generic / debug queries ─────────────────────────────────────────────────
|
||||
|
||||
function query_tags(root) {
|
||||
const counts = {};
|
||||
function walk(node) {
|
||||
if (!node?.items) return;
|
||||
counts[node.tag] = (counts[node.tag] ?? 0) + 1;
|
||||
for (const item of node.items) walk(item);
|
||||
}
|
||||
walk(root);
|
||||
const sorted = Object.entries(counts).sort((a, b) => b[1] - a[1]);
|
||||
console.log('=== All tags (by frequency) ===');
|
||||
pad_table([
|
||||
['Tag', 'Count'],
|
||||
['---', '-----'],
|
||||
...sorted.map(([tag, count]) => [tag, count]),
|
||||
]);
|
||||
}
|
||||
|
||||
function query_raw(root, tag) {
|
||||
const nodes = collect(root, tag);
|
||||
if (nodes.length === 0) { console.log(`No nodes with tag '${tag}'.`); return; }
|
||||
console.log(`=== Raw nodes: ${tag} (${nodes.length}) ===`);
|
||||
for (const n of nodes) {
|
||||
// Print a compact one-line representation
|
||||
const parts = (n.items ?? []).map(item =>
|
||||
typeof item === 'string'
|
||||
? item
|
||||
: item?.items
|
||||
? `(${item.tag} ...)`
|
||||
: String(item)
|
||||
);
|
||||
console.log(` (${n.tag} ${parts.join(' ')})`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Project file (.kicad_pro is JSON) ───────────────────────────────────────
|
||||
|
||||
function query_pro(text, query, args) {
|
||||
const proj = JSON.parse(text);
|
||||
|
||||
switch (query) {
|
||||
case 'summary':
|
||||
case undefined: {
|
||||
const meta = proj.metadata ?? {};
|
||||
const board = proj.board ?? {};
|
||||
const sch = proj.schematic ?? {};
|
||||
console.log('=== KiCad Project ===');
|
||||
if (meta.filename) console.log(`File : ${meta.filename}`);
|
||||
if (meta.version) console.log(`Version : ${meta.version}`);
|
||||
const design_settings = board.design_settings ?? {};
|
||||
if (design_settings.rules?.min_clearance) {
|
||||
console.log(`Min clearance: ${design_settings.rules.min_clearance}`);
|
||||
}
|
||||
console.log('\nRaw sections:', Object.keys(proj).join(', '));
|
||||
break;
|
||||
}
|
||||
default:
|
||||
console.log('Project file is JSON. Available queries: summary');
|
||||
console.log('Raw JSON keys:', Object.keys(proj).join(', '));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Dispatch ────────────────────────────────────────────────────────────────
|
||||
|
||||
function usage() {
|
||||
console.log(`Usage: node kicad-query.mjs [--format=json] <file> <query> [args...]
|
||||
|
||||
Options:
|
||||
--format=json Emit structured JSON (for LLM consumption)
|
||||
|
||||
Queries for .kicad_sch:
|
||||
summary High-level statistics
|
||||
components [filters] List placed components (filters: ref=U* value=10k lib=Device)
|
||||
component <ref> Details for one component
|
||||
nets All net/label names
|
||||
|
||||
Queries for .kicad_pcb:
|
||||
summary High-level statistics
|
||||
nets List all nets with IDs
|
||||
net <name> Show which pads connect to a net
|
||||
footprints [filters] List footprints (filters: ref=R* value=10k)
|
||||
footprint <ref> Details for one footprint
|
||||
traces <ref> [pad] Physical trace path from a pad (BFS through segments/vias)
|
||||
|
||||
Queries for .kicad_pro:
|
||||
summary Project overview
|
||||
|
||||
Generic queries (any file):
|
||||
tags All S-expression tags and their counts
|
||||
raw <tag> Dump all nodes with a given tag`);
|
||||
}
|
||||
|
||||
function main() {
|
||||
let argv = process.argv.slice(2);
|
||||
|
||||
// Strip --format= option
|
||||
for (let i = argv.length - 1; i >= 0; i--) {
|
||||
const m = argv[i].match(/^--format=(\w+)$/);
|
||||
if (m) {
|
||||
OUTPUT_FORMAT = m[1];
|
||||
argv.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
const [file_arg, query_arg, ...rest_args] = argv;
|
||||
|
||||
if (!file_arg || file_arg === '--help' || file_arg === '-h') {
|
||||
usage();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
let text;
|
||||
try {
|
||||
text = readFileSync(file_arg, 'utf8');
|
||||
} catch (e) {
|
||||
console.error(`Error reading file: ${e.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const ext = extname(file_arg).toLowerCase();
|
||||
|
||||
// .kicad_pro is JSON
|
||||
if (ext === '.kicad_pro') {
|
||||
query_pro(text, query_arg ?? 'summary', rest_args);
|
||||
return;
|
||||
}
|
||||
|
||||
// All others are S-expressions
|
||||
let root;
|
||||
try {
|
||||
root = parse_sexp(text);
|
||||
} catch (e) {
|
||||
console.error(`Parse error: ${e.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const query = query_arg ?? 'summary';
|
||||
|
||||
// Generic queries work on any file
|
||||
if (query === 'tags') { query_tags(root); return; }
|
||||
if (query === 'raw') { query_raw(root, rest_args[0]); return; }
|
||||
|
||||
// File-type specific
|
||||
if (ext === '.kicad_sch') {
|
||||
const filters = parse_filters(rest_args);
|
||||
switch (query) {
|
||||
case 'summary': sch_summary(root); break;
|
||||
case 'components':
|
||||
OUTPUT_FORMAT === 'json'
|
||||
? sch_components_json(root, filters)
|
||||
: sch_components(root, filters);
|
||||
break;
|
||||
case 'component': sch_component(root, rest_args[0]); break;
|
||||
case 'nets':
|
||||
OUTPUT_FORMAT === 'json'
|
||||
? sch_nets_json(root)
|
||||
: sch_nets(root);
|
||||
break;
|
||||
default:
|
||||
console.error(`Unknown query '${query}' for .kicad_sch`);
|
||||
usage();
|
||||
process.exit(1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (ext === '.kicad_pcb') {
|
||||
const filters = parse_filters(rest_args);
|
||||
switch (query) {
|
||||
case 'summary': pcb_summary(root); break;
|
||||
case 'nets':
|
||||
OUTPUT_FORMAT === 'json'
|
||||
? pcb_nets_json(root)
|
||||
: pcb_nets(root);
|
||||
break;
|
||||
case 'net': pcb_net(root, rest_args[0]); break;
|
||||
case 'footprints':
|
||||
OUTPUT_FORMAT === 'json'
|
||||
? pcb_footprints_json(root, filters)
|
||||
: pcb_footprints(root, filters);
|
||||
break;
|
||||
case 'footprint': pcb_footprint(root, rest_args[0]); break;
|
||||
case 'traces': pcb_pin_traces(root, rest_args[0], rest_args[1]); break;
|
||||
default:
|
||||
console.error(`Unknown query '${query}' for .kicad_pcb`);
|
||||
usage();
|
||||
process.exit(1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(`Unknown file type '${ext}'. Supported: .kicad_sch, .kicad_pcb, .kicad_pro`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
main();
|
||||
Reference in New Issue
Block a user