UNPKG

ducki

Version:

Simple CLI for working with KiCad projects.

291 lines (217 loc) 8.74 kB
import fs from "fs"; import { dirname, join, resolve as _resolve } from "path"; import { randomBytes } from "crypto"; import GIFEncoder from "gif-encoder-2"; import { PNG } from "pngjs"; import { generateSpinFrames } from "./quat.js"; import { run } from "./run.js"; import { fileURLToPath } from 'node:url'; const __dirname = dirname(fileURLToPath(import.meta.url)); import type { XYZ } from "./quat.js"; export function resolve(...path: Array<string>): string { return _resolve(__dirname, "..", ...path); } export interface RenderXYZ { x?: number, y?: number, z?: number } export interface RenderSize { height?: number, width?: number } export interface FlyAroundOptions { perspective?: boolean; zoom?: number, rotate?: RenderXYZ, size?: RenderSize } export interface PlotOptions { //format?: "pdf" | "svg"; // "ps" // } export interface RenderOptions { perspective?: boolean, zoom?: number, // pan?: RenderXYZ, rotate?: RenderXYZ, // pivot?: RenderXYZ, size?: RenderSize } function flatXYZ(xyz?: RenderXYZ): string { if (xyz == null) { return "0,0,0"; } return "'" + [ (xyz.x || 0).toFixed(2), (xyz.y || 0).toFixed(2), (xyz.z || 0).toFixed(2), ].join(",") + "'"; } function normalizeSize(size?: RenderSize): { width: number, height: number } { if (size == null) { return { width: 512, height: 512 }; } let { width, height } = size; if (width != null) { if (height == null) { height = width; } } else if (height == null) { if (width == null) { width = height; } } if (width == null) { width = 512; } if (height == null) { height = 512; } return { width, height }; } function searchDir(folder: string, ext: string): Array<string> { ext = "." + ext; const result: Array<string> = [ ]; for (const filename of fs.readdirSync(folder)) { if (filename[0] !== "_" && filename[0] !== "." && filename.endsWith(ext)) { result.push(filename); } } return result; } export class Project { readonly cmd: string; readonly projectFolder: string; constructor(cmd: string, projectFolder: string) { this.cmd = resolve(cmd); this.projectFolder = projectFolder; } _searchDir(ext: string): Array<string> { return searchDir(this.projectFolder, ext); } get projectName(): string { const filename = this.projectFilename; return filename.substring(0, filename.length - ".kicad_pro".length); } get projectFilename(): string { const files = this._searchDir("kicad_pro"); if (files.length === 0 ) { throw new Error(`No project file found in ${ JSON.stringify(this.projectFolder)}`); } if (files.length > 1) { throw new Error(`Too many project files found in ${ JSON.stringify(this.projectFolder)}`); } return join(this.projectFolder, files[0]);; } get pcbFilename(): string { const files = this._searchDir("kicad_pcb"); if (files.length === 0 ) { throw new Error(`No PCB file found in ${ JSON.stringify(this.projectFolder)}`); } if (files.length > 1) { throw new Error(`Too many PCB files found in ${ JSON.stringify(this.projectFolder)}`); } return join(this.projectFolder, files[0]);; } get schFilename(): string { const filename = `${ this.projectName }.kicad_sch`; if (this._searchDir("kicad_sch").indexOf(filename) >= 0) { return filename; } throw new Error("could not find root schematic?"); } mv(src: string, dst: string): void { src = join(this.projectFolder, src); dst = join(this.projectFolder, dst); fs.renameSync(src, dst); } plot(folder: string, options?: PlotOptions): void { if (options == null) { options = { }; } console.log("GENERATING:", folder); const args: Array<string> = [ "sch", "export", "svg", "--output", ".ducki-tmp" ]; args.push(this.schFilename); fs.mkdirSync(folder, { recursive: true }); const result = run(this.cmd, args); for (const line of result.stdout.split("\n")) { const prefix = "Plotted to '"; const suffix = "'."; if (!line.startsWith(prefix)) { continue; } const filename = line.substring(prefix.length, line.length - suffix.length); // Get the real filename (generally more concise and unique) const data = fs.readFileSync(filename).toString(); const match = data.match(/<desc>File: (.*?).kicad_sch<\/desc>/i); if (!match) { throw new Error(`missing desc`); } let name = match[1]; name = (name === this.projectName) ? "sch.svg": (`sch-${ name }.svg`); console.log("GENERATING:", join(folder, name)); fs.renameSync(filename, join(this.projectFolder, folder, name)); } } _render(filename: string, options: RenderOptions): void { filename = _resolve(this.projectFolder, filename); if (!filename.endsWith(".png") && !filename.endsWith(".jpg")) { throw new Error("Invalid image filename ${ JSON.stringify(filename) }"); } const args: Array<string> = [ "pcb", "render", "--output", filename ]; if (options.perspective) { args.push("--perspective"); } if (options.zoom) { args.push("--zoom", String(options.zoom)); } // if (options.pivot) { args.push("--pivot", flatXYZ(options.pivot)); } if (options.rotate) { args.push("--rotate", flatXYZ(options.rotate)); } // if (options.pan) { args.push("--pan", flatXYZ(options.pan)); } // @TODO: Why does Kicad always render images a bit pixels shy? const size = normalizeSize(options.size); args.push("--width", String(size.width)); args.push("--height", String(size.height)); args.push(this.pcbFilename); fs.mkdirSync(dirname(filename), { recursive: true }); const result = run(this.cmd, args); if (result.stdout.indexOf("Successfully created 3D render image") === -1) { console.log({ args, status: result.status, stdout: result.stdout, stderr: result.stderr }); throw new Error("Failed to render PCB"); } } render(filename: string, options?: RenderOptions): void { console.log("GENERATING:", filename); return this._render(filename, options ?? { }) } flyaround(filename: string, options?: FlyAroundOptions): void { if (options == null) { options = { }; } console.log("GENERATING:", filename); const perspective = options.perspective ?? false; const zoom = options.zoom ?? 0.8; filename = _resolve(this.projectFolder, filename); const dr = 5; const size = normalizeSize(options.size); const tmp = `.ducki-tmp/temp-${ randomBytes(16).toString("hex") }.png`; const getImage = (rotate: XYZ) => { this._render(tmp, { perspective, rotate, size, zoom }); const data = fs.readFileSync(tmp); const img = PNG.sync.read(data); const bg = [ 0xb1, 0xb1, 0xc4 ]; // @TODO: expose in CLI for (let i = 0; i < img.data.length; i += 4) { const a = img.data[i + 3] / 255; if (a == 0) { img.data[i + 0] = bg[0]; img.data[i + 1] = bg[1]; img.data[i + 2] = bg[2]; } else if (a < 1) { img.data[i + 0] = Math.trunc((a * img.data[i + 0]) + ((1 - a) * bg[0])); img.data[i + 1] = Math.trunc((a * img.data[i + 1]) + ((1 - a) * bg[1])); img.data[i + 2] = Math.trunc((a * img.data[i + 2]) + ((1 - a) * bg[2])); } } return img; }; const rot = Object.assign({ x: 0, y: 0, z: 0 }, options.rotate); const rots = generateSpinFrames(rot, 360, 360 / dr); const img = getImage(rots[0]); const encoder = new GIFEncoder(img.width, img.height, 'neuquant', true); encoder.start(); encoder.setRepeat(0); encoder.setDelay(35); encoder.setQuality(10); encoder.addFrame(img.data); for (let i = 1; i < rots.length; i++) { encoder.addFrame(getImage(rots[i]).data); } encoder.finish(); fs.writeFileSync(filename, encoder.out.getData()); } }