Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
3aa468eadThis file is pinned to the selected release. Download the original to open it in your design tool.
import { existsSync } from 'node:fs';
import { writeFile } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import path from 'node:path';
import { configPath, loadConfig, type CopperheadConfig } from '../config.js';
/**
* The create pipeline starts from a brief with no KiCad files, but the agent
* cannot create them: `write_file` refuses KiCad files and `edit_file` only
* edits existing ones. Without a project on disk the schematic stage can never
* satisfy its contract (config.schematic stays null), so the pipeline stalls
* indefinitely. This module scaffolds a minimal, kicad-cli-loadable empty
* project (schematic ERC-clean, board DRC-clean with a default outline) and
* wires it into .copperhead/config.json, giving the agent a file to populate.
*/
/** Slug for the project filename, taken from the brief's first H1 (a leading
* "Brief:" label is dropped). Falls back to "board". */
export function projectSlug(brief: string): string {
const m = brief.match(/^#\s+(.+?)\s*$/m);
const title = (m?.[1] ?? 'board').replace(/^brief\s*:\s*/i, '');
const slug = title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
return slug || 'board';
}
/** A stable, valid-shaped v4 UUID derived from a seed, so a given project
* bootstraps to the same UUIDs on every run (no Date/random — runs stay
* reproducible). */
function uuidFrom(seed: string): string {
const h = createHash('sha256').update(seed).digest('hex');
return `${h.slice(0, 8)}-${h.slice(8, 12)}-4${h.slice(13, 16)}-8${h.slice(17, 20)}-${h.slice(20, 32)}`;
}
function emptySchematic(rootUuid: string): string {
return `(kicad_sch
(version 20231120)
(generator "eeschema")
(generator_version "8.0")
(uuid "${rootUuid}")
(paper "A4")
(lib_symbols)
(sheet_instances
(path "/" (page "1"))
)
)
`;
}
function emptyBoard(outlineUuid: string): string {
// A default 30x20mm outline on Edge.Cuts so the blank board is DRC-clean out
// of the gate; the layout stage resizes/replaces it with the real outline.
return `(kicad_pcb
(version 20240108)
(generator "pcbnew")
(generator_version "8.0")
(general
(thickness 1.6)
(legacy_teardrops no)
)
(paper "A4")
(layers
(0 "F.Cu" signal)
(31 "B.Cu" signal)
(32 "B.Adhes" user "B.Adhesive")
(33 "F.Adhes" user "F.Adhesive")
(34 "B.Paste" user)
(35 "F.Paste" user)
(36 "B.SilkS" user "B.Silkscreen")
(37 "F.SilkS" user "F.Silkscreen")
(38 "B.Mask" user)
(39 "F.Mask" user)
(40 "Dwgs.User" user "User.Drawings")
(41 "Cmts.User" user "User.Comments")
(42 "Eco1.User" user "User.Eco1")
(43 "Eco2.User" user "User.Eco2")
(44 "Edge.Cuts" user)
(45 "Margin" user)
(46 "B.CrtYd" user "B.Courtyard")
(47 "F.CrtYd" user "F.Courtyard")
(48 "B.Fab" user)
(49 "F.Fab" user)
)
(setup
(pad_to_mask_clearance 0)
(allow_soldermask_bridges_in_footprints no)
)
(net 0 "")
(gr_rect (start 100 100) (end 130 120)
(stroke (width 0.1) (type default))
(layer "Edge.Cuts")
(uuid "${outlineUuid}")
)
)
`;
}
function projectFile(slug: string, rootUuid: string): string {
return (
JSON.stringify(
{
board: { design_settings: { defaults: {}, rules: {} } },
erc: {
erc_exclusions: [],
meta: { version: 0 },
rule_severities: { footprint_link_issues: 'ignore', lib_symbol_issues: 'ignore' },
},
meta: { filename: `${slug}.kicad_pro`, version: 1 },
net_settings: {
classes: [
{
bus_width: 12,
clearance: 0.2,
diff_pair_gap: 0.25,
diff_pair_via_gap: 0.25,
diff_pair_width: 0.2,
line_style: 0,
microvia_diameter: 0.3,
microvia_drill: 0.1,
name: 'Default',
pcb_color: 'rgba(0, 0, 0, 0.000)',
schematic_color: 'rgba(0, 0, 0, 0.000)',
track_width: 0.25,
via_diameter: 0.6,
via_drill: 0.3,
wire_width: 6,
},
],
meta: { version: 3 },
},
schematic: {
annotate_start_num: 0,
drawing: { default_line_thickness: 6.0, default_text_size: 50.0 },
legacy_lib_dir: '',
legacy_lib_list: [],
meta: { version: 1 },
},
sheets: [[rootUuid, 'Root']],
text_variables: {},
},
null,
2,
) + '\n'
);
}
async function persist(repoRoot: string, config: CopperheadConfig): Promise<void> {
await writeFile(configPath(repoRoot), JSON.stringify(config, null, 2) + '\n', 'utf8');
}
/**
* Ensure a KiCad project exists and is wired into config. No-op (returns null)
* when config already points at a schematic on disk. If project files exist but
* config doesn't reference them, just wires config. Otherwise scaffolds an empty
* project. Returns the schematic's repo-relative path when it created or wired
* one, else null.
*/
export async function bootstrapKicadProject(repoRoot: string, brief: string): Promise<string | null> {
const config = await loadConfig(repoRoot);
if (config.schematic && existsSync(path.join(repoRoot, config.schematic))) return null;
const slug = projectSlug(brief);
const schRel = `${slug}.kicad_sch`;
const pcbRel = `${slug}.kicad_pcb`;
const proRel = `${slug}.kicad_pro`;
const schAbs = path.join(repoRoot, schRel);
if (!existsSync(schAbs)) {
const rootUuid = uuidFrom(slug);
await writeFile(schAbs, emptySchematic(rootUuid), 'utf8');
await writeFile(path.join(repoRoot, pcbRel), emptyBoard(uuidFrom(`${slug}:edge`)), 'utf8');
await writeFile(path.join(repoRoot, proRel), projectFile(slug, rootUuid), 'utf8');
}
config.schematic = schRel;
config.board = existsSync(path.join(repoRoot, pcbRel)) ? pcbRel : null;
await persist(repoRoot, config);
return schRel;
}