ducki
Version:
Simple CLI for working with KiCad projects.
291 lines (217 loc) • 8.74 kB
text/typescript
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());
}
}