From e719c9fb51ffc4f47ba90e1669f7f912b7464d5e Mon Sep 17 00:00:00 2001 From: mohan-bee Date: Sun, 6 Sep 2026 18:42:31 +0530 Subject: [PATCH 1/4] up --- README.md | 30 +++ cli/export/register.ts | 13 +- lib/shared/convert-circuit-json-to-export.ts | 186 +++++++++++++++ lib/shared/convert-circuit-json-to-gerbers.ts | 37 +++ lib/shared/export-release-preset.ts | 77 ++++++ lib/shared/export-snippet.ts | 225 ++++-------------- .../cli/export/export-release-preset.test.ts | 187 +++++++++++++++ 7 files changed, 568 insertions(+), 187 deletions(-) create mode 100644 lib/shared/convert-circuit-json-to-export.ts create mode 100644 lib/shared/convert-circuit-json-to-gerbers.ts create mode 100644 lib/shared/export-release-preset.ts create mode 100644 tests/cli/export/export-release-preset.test.ts diff --git a/README.md b/README.md index b4c8c14bf..dff653bc0 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,36 @@ The `build` command also accepts the following options: - `--ignore-placement-drc` - suppress placement DRC diagnostics - `--ignore-routing-drc` - suppress routing DRC diagnostics +### Release export + +```bash +tsci export board.tsx --preset release --output dist/release +``` + +The release preset builds once with Gerber part-orientation analysis enabled, +then uses that Circuit JSON for `circuit.json`, `schematic.svg`, `pcb.svg`, +`assembly.svg`, `board.glb`, `gerbers.zip`, `bom.csv`, `pick-and-place.csv`, +`checks.json`, and `release-manifest.json`. The standalone CSV files match the +BOM and JLCPCB pick-and-place files inside the Gerber ZIP. + +`--output` is a directory for presets, defaults to `dist/release`, and resolves +relative paths from the input file's directory, like other exports. Parent +directories are created automatically. `--preset` cannot be combined with +`--format`; `--disable-parts-engine` and `--show-courtyards` still apply. + +`checks.json` contains the build diagnostics as `errors` and `warnings` arrays, +using the same classification as `tsci check`. It does not run additional check +subcommands or fail the export on circuit diagnostics. Export failures exit +nonzero. The manifest is written last and records the schema version, CLI +version, and each artifact's relative path, byte count, and SHA-256 hash. + +To repeat conversion without rebuilding, export the saved `circuit.json` with +`--preset release`. Gerber ZIP entries use fixed timestamps, but the current +Gerber converter embeds creation dates inside Gerber and drill files, so the +ZIP and its manifest hash differ between runs. Byte-for-byte reproducibility +requires an upstream timestamp option and also depends on converter versions +and any externally fetched CAD models. + ### KiCad PCM compatibility `tsci build --kicad-pcm` uses the package license from `package.json` and diff --git a/cli/export/register.ts b/cli/export/register.ts index e30c73af1..a204ce303 100644 --- a/cli/export/register.ts +++ b/cli/export/register.ts @@ -1,4 +1,4 @@ -import type { Command } from "commander" +import { Option, type Command } from "commander" import { exportSnippet } from "lib/shared/export-snippet" import type { ExportFormat } from "lib/shared/export-snippet" import { ALLOWED_EXPORT_FORMATS } from "lib/shared/export-snippet" @@ -23,13 +23,19 @@ export const registerExport = (program: Command) => { "-f, --format ", `Output format (${ALLOWED_EXPORT_FORMATS.join(", ")})`, ) - .option("-o, --output ", "Output file path") + .addOption( + new Option("--preset ", "Export a preset bundle") + .choices(["release"]) + .conflicts("format"), + ) + .option("-o, --output ", "Output file path or preset directory") .option("--disable-parts-engine", "Disable the parts engine") .option("--show-courtyards", "Show courtyard outlines in PCB SVG output") .action( async ( file, options: { + preset?: "release" format?: string output?: string disablePartsEngine?: boolean @@ -88,11 +94,12 @@ export const registerExport = (program: Command) => { process.exit(0) } - const format = formatOption as ExportFormat + const format = options.format as ExportFormat | undefined await exportSnippet({ filePath: file, format, + preset: options.preset, outputPath: options.output, platformConfig: platformConfigWithCliDefaults, pcbSnapshotSettings: options.showCourtyards diff --git a/lib/shared/convert-circuit-json-to-export.ts b/lib/shared/convert-circuit-json-to-export.ts new file mode 100644 index 000000000..4b1d29301 --- /dev/null +++ b/lib/shared/convert-circuit-json-to-export.ts @@ -0,0 +1,186 @@ +import fs from "node:fs" +import path from "node:path" +import type { PlatformConfig } from "@tscircuit/props" +import type { AnyCircuitElement } from "circuit-json" +import { convertCircuitJsonToGltf } from "circuit-json-to-gltf" +import { + CircuitJsonToKicadPcbConverter, + CircuitJsonToKicadProConverter, + CircuitJsonToKicadSchConverter, + resolveAndLoadKicad3dModelFiles, +} from "circuit-json-to-kicad" +import { convertCircuitJsonToReadableNetlist } from "circuit-json-to-readable-netlist" +import { circuitJsonToStep } from "circuit-json-to-step" +import { circuitJsonToFdmComponentBox } from "circuit-json-to-fdm-component-box" +import { + convertCircuitJsonToAssemblySvg, + convertCircuitJsonToPcbSvg, + convertCircuitJsonToStackedSchematicSheetsSvg, +} from "circuit-to-svg" +import { convertCircuitJsonToDsnString } from "dsn-converter" +import JSZip from "jszip" +import type { PcbSnapshotSettings } from "lib/project-config/project-config-schema" +import { getCircuitJsonToGltfOptions } from "lib/shared/get-circuit-json-to-gltf-options" +import { loadLocalStepModelFsMap } from "lib/shared/load-local-step-model-fs-map" +import { convertCircuitJsonToSchematicPdf } from "./convert-circuit-json-to-schematic-pdf" +import { importFromUserLand } from "./importFromUserLand" + +import { convertCircuitJsonToGerbers } from "./convert-circuit-json-to-gerbers" +import type { ExportFormat } from "./export-snippet" + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null + +const unwrapSimpleRouteJson = (value: unknown) => { + if ( + isRecord(value) && + isRecord(value.simpleRouteJson) && + ("connections" in value.simpleRouteJson || + "obstacles" in value.simpleRouteJson || + "bounds" in value.simpleRouteJson) + ) { + return value.simpleRouteJson + } + + return value +} + +export const convertCircuitJsonToExport = async ({ + circuitJson, + format, + filePath, + platformConfig, + pcbSnapshotSettings, +}: { + circuitJson: AnyCircuitElement[] + format: ExportFormat + filePath: string + platformConfig?: PlatformConfig + pcbSnapshotSettings?: PcbSnapshotSettings +}): Promise => { + const projectDir = path.dirname(filePath) + const outputBaseName = path.basename(filePath).replace(/\.[^.]+$/, "") + let outputContent: string | Buffer + + switch (format) { + case "schematic-svg": + outputContent = convertCircuitJsonToStackedSchematicSheetsSvg(circuitJson) + break + case "schematic-pdf": + outputContent = await convertCircuitJsonToSchematicPdf(circuitJson) + break + case "pcb-svg": + outputContent = convertCircuitJsonToPcbSvg( + circuitJson, + pcbSnapshotSettings, + ) + break + case "specctra-dsn": + outputContent = convertCircuitJsonToDsnString(circuitJson) + break + case "readable-netlist": + outputContent = convertCircuitJsonToReadableNetlist(circuitJson) + break + case "gltf": + outputContent = JSON.stringify( + await convertCircuitJsonToGltf( + circuitJson, + getCircuitJsonToGltfOptions({ format: "gltf" }), + ), + null, + 2, + ) + break + case "glb": + outputContent = Buffer.from( + (await convertCircuitJsonToGltf( + circuitJson, + getCircuitJsonToGltfOptions({ format: "glb" }), + )) as ArrayBuffer, + ) + break + case "srj": + { + const userLandTscircuit = await importFromUserLand("tscircuit") + const simpleRouteJson = unwrapSimpleRouteJson( + userLandTscircuit.getSimpleRouteJsonFromCircuitJson({ + circuitJson, + }), + ) + outputContent = JSON.stringify(simpleRouteJson, null, 2) + } + break + case "kicad_sch": { + const converter = new CircuitJsonToKicadSchConverter(circuitJson) + converter.runUntilFinished() + outputContent = converter.getOutputString() + break + } + case "kicad_pcb": { + const converter = new CircuitJsonToKicadPcbConverter(circuitJson) + converter.runUntilFinished() + outputContent = converter.getOutputString() + break + } + case "kicad_zip": { + const schConverter = new CircuitJsonToKicadSchConverter(circuitJson) + schConverter.runUntilFinished() + const pcbConverter = new CircuitJsonToKicadPcbConverter(circuitJson, { + includeBuiltin3dModels: true, + projectName: outputBaseName, + }) + pcbConverter.runUntilFinished() + const proConverter = new CircuitJsonToKicadProConverter(circuitJson, { + projectName: outputBaseName, + schematicFilename: `${outputBaseName}.kicad_sch`, + pcbFilename: `${outputBaseName}.kicad_pcb`, + }) + proConverter.runUntilFinished() + + const zip = new JSZip() + zip.file(`${outputBaseName}.kicad_sch`, schConverter.getOutputString()) + zip.file(`${outputBaseName}.kicad_pcb`, pcbConverter.getOutputString()) + zip.file(`${outputBaseName}.kicad_pro`, proConverter.getOutputString()) + + await resolveAndLoadKicad3dModelFiles({ + model3dSourcePaths: pcbConverter.getModel3dSourcePaths(), + projectName: outputBaseName, + fetch: platformConfig?.platformFetch ?? globalThis.fetch, + readFile: (modelPath) => + fs.promises.readFile(path.resolve(projectDir, modelPath)), + onModelFile: ({ outputPath, content }) => { + zip.file(outputPath, content) + }, + onError: ({ sourcePath }) => { + console.warn(`Failed to load 3D model from ${sourcePath}`) + }, + }) + + outputContent = await zip.generateAsync({ type: "nodebuffer" }) + break + } + case "gerbers": + outputContent = (await convertCircuitJsonToGerbers(circuitJson)) + .gerbersZip + break + + case "step": + outputContent = await circuitJsonToStep(circuitJson, { + includeComponents: true, + includeExternalMeshes: true, + fsMap: await loadLocalStepModelFsMap(circuitJson), + }) + break + case "assembly-svg": + outputContent = convertCircuitJsonToAssemblySvg(circuitJson) + break + case "component-box-3mf": + outputContent = Buffer.from( + await circuitJsonToFdmComponentBox(circuitJson), + ) + break + default: + outputContent = JSON.stringify(circuitJson, null, 2) + } + return outputContent +} diff --git a/lib/shared/convert-circuit-json-to-gerbers.ts b/lib/shared/convert-circuit-json-to-gerbers.ts new file mode 100644 index 000000000..7fd0fabab --- /dev/null +++ b/lib/shared/convert-circuit-json-to-gerbers.ts @@ -0,0 +1,37 @@ +import type { AnyCircuitElement } from "circuit-json" +import { + convertBomRowsToCsv, + convertCircuitJsonToBomRows, +} from "circuit-json-to-bom-csv" +import { convertCircuitJsonToGerberFiles } from "circuit-json-to-gerber" +import { convertCircuitJsonToPickAndPlaceCsv } from "circuit-json-to-pnp-csv" +import JSZip from "jszip" + +export const convertCircuitJsonToGerbers = async ( + circuitJson: AnyCircuitElement[], +) => { + const bomRows = await convertCircuitJsonToBomRows({ circuitJson }) + const bomCsv = await convertBomRowsToCsv(bomRows) + const pnpCsv = await convertCircuitJsonToPickAndPlaceCsv(circuitJson, { + supplier: "jlcpcb", + }) + const gerberFiles = convertCircuitJsonToGerberFiles(circuitJson, { + flip_y_axis: false, + }) + const zip = new JSZip() + // ZIP timestamps start in 1980; a fixed date keeps archive metadata stable. + const zipDate = new Date("1980-01-01T00:00:00.000Z") + for (const [fileName, fileContents] of Object.entries({ + ...gerberFiles, + "bom.csv": bomCsv, + "pick_and_place.csv": pnpCsv, + })) { + zip.file(fileName, fileContents, { date: zipDate }) + } + + return { + gerbersZip: await zip.generateAsync({ type: "nodebuffer" }), + bomCsv, + pnpCsv, + } +} diff --git a/lib/shared/export-release-preset.ts b/lib/shared/export-release-preset.ts new file mode 100644 index 000000000..ae90f5457 --- /dev/null +++ b/lib/shared/export-release-preset.ts @@ -0,0 +1,77 @@ +import { createHash } from "node:crypto" +import { mkdir, rm, writeFile } from "node:fs/promises" +import path from "node:path" +import type { PlatformConfig } from "@tscircuit/props" +import type { AnyCircuitElement } from "circuit-json" +import type { PcbSnapshotSettings } from "lib/project-config/project-config-schema" +import { version } from "../../package.json" +import { analyzeCircuitJson } from "./circuit-json-diagnostics" +import { convertCircuitJsonToExport } from "./convert-circuit-json-to-export" +import { convertCircuitJsonToGerbers } from "./convert-circuit-json-to-gerbers" +import type { ExportFormat } from "./export-snippet" + +const RELEASE_EXPORTS = [ + { format: "circuit-json", fileName: "circuit.json" }, + { format: "schematic-svg", fileName: "schematic.svg" }, + { format: "pcb-svg", fileName: "pcb.svg" }, + { format: "assembly-svg", fileName: "assembly.svg" }, + { format: "glb", fileName: "board.glb" }, +] satisfies { format: ExportFormat; fileName: string }[] + +export const exportReleasePreset = async ({ + circuitJson, + filePath, + outputDestination, + platformConfig, + pcbSnapshotSettings, +}: { + circuitJson: AnyCircuitElement[] + filePath: string + outputDestination: string + platformConfig?: PlatformConfig + pcbSnapshotSettings?: PcbSnapshotSettings +}) => { + await mkdir(outputDestination, { recursive: true }) + const manifestPath = path.join(outputDestination, "release-manifest.json") + // A manifest marks completion, including when replacing a previous release. + await rm(manifestPath, { force: true }) + + const artifacts: Record = {} + for (const { format, fileName } of RELEASE_EXPORTS) { + artifacts[fileName] = await convertCircuitJsonToExport({ + circuitJson, + format, + filePath, + platformConfig, + pcbSnapshotSettings, + }) + } + const { gerbersZip, bomCsv, pnpCsv } = + await convertCircuitJsonToGerbers(circuitJson) + artifacts["gerbers.zip"] = gerbersZip + artifacts["bom.csv"] = bomCsv + artifacts["pick-and-place.csv"] = pnpCsv + artifacts["checks.json"] = JSON.stringify( + analyzeCircuitJson(circuitJson), + null, + 2, + ) + + const files = [] + for (const [fileName, content] of Object.entries(artifacts)) { + await writeFile(path.join(outputDestination, fileName), content) + files.push({ + path: fileName, + bytes: Buffer.byteLength(content), + sha256: createHash("sha256").update(content).digest("hex"), + }) + } + await writeFile( + manifestPath, + JSON.stringify( + { schemaVersion: 1, preset: "release", cliVersion: version, files }, + null, + 2, + ), + ) +} diff --git a/lib/shared/export-snippet.ts b/lib/shared/export-snippet.ts index 21babed8a..d795f20c2 100644 --- a/lib/shared/export-snippet.ts +++ b/lib/shared/export-snippet.ts @@ -3,40 +3,15 @@ import path from "node:path" import { promisify } from "node:util" import type { PlatformConfig } from "@tscircuit/props" import type { AnyCircuitElement } from "circuit-json" -import { - convertBomRowsToCsv, - convertCircuitJsonToBomRows, -} from "circuit-json-to-bom-csv" -import { convertCircuitJsonToGerberFiles } from "circuit-json-to-gerber" -import { convertCircuitJsonToGltf } from "circuit-json-to-gltf" -import { - CircuitJsonToKicadPcbConverter, - CircuitJsonToKicadProConverter, - CircuitJsonToKicadSchConverter, - resolveAndLoadKicad3dModelFiles, -} from "circuit-json-to-kicad" -import { convertCircuitJsonToPickAndPlaceCsv } from "circuit-json-to-pnp-csv" -import { convertCircuitJsonToReadableNetlist } from "circuit-json-to-readable-netlist" -import { circuitJsonToStep } from "circuit-json-to-step" -import { circuitJsonToFdmComponentBox } from "circuit-json-to-fdm-component-box" -import { - convertCircuitJsonToAssemblySvg, - convertCircuitJsonToPcbSvg, - convertCircuitJsonToStackedSchematicSheetsSvg, -} from "circuit-to-svg" -import { convertCircuitJsonToDsnString } from "dsn-converter" -import JSZip from "jszip" import type { PcbSnapshotSettings } from "lib/project-config/project-config-schema" import { generateCircuitJson } from "lib/shared/generate-circuit-json" -import { getCircuitJsonToGltfOptions } from "lib/shared/get-circuit-json-to-gltf-options" import { getOrGenerateCircuitJson } from "lib/shared/get-or-generate-circuit-json" import { getPlatformConfigWithCliDefaults } from "lib/shared/get-platform-config-with-cli-defaults" -import { loadLocalStepModelFsMap } from "lib/shared/load-local-step-model-fs-map" import { mergePlatformConfigs } from "lib/shared/platform-config-utils" -import { convertCircuitJsonToSchematicPdf } from "./convert-circuit-json-to-schematic-pdf" import { convertToKicadLibrary } from "./convert-to-kicad-library" -import { importFromUserLand } from "./importFromUserLand" import { isCircuitJsonFile } from "./is-circuit-json-file" +import { convertCircuitJsonToExport } from "./convert-circuit-json-to-export" +import { exportReleasePreset } from "./export-release-preset" const writeFileAsync = promisify(fs.writeFile) @@ -84,26 +59,10 @@ const OUTPUT_EXTENSIONS: Record = { "component-box-3mf": "-component-box.3mf", } -const isRecord = (value: unknown): value is Record => - typeof value === "object" && value !== null - -const unwrapSimpleRouteJson = (value: unknown) => { - if ( - isRecord(value) && - isRecord(value.simpleRouteJson) && - ("connections" in value.simpleRouteJson || - "obstacles" in value.simpleRouteJson || - "bounds" in value.simpleRouteJson) - ) { - return value.simpleRouteJson - } - - return value -} - type ExportOptions = { filePath: string - format: ExportFormat + format?: ExportFormat + preset?: "release" writeFile?: boolean outputPath?: string platformConfig?: PlatformConfig @@ -118,7 +77,8 @@ type ExportOptions = { export const exportSnippet = async ({ filePath, - format, + format: formatOption, + preset, outputPath, platformConfig, pcbSnapshotSettings, @@ -127,6 +87,11 @@ export const exportSnippet = async ({ onError = (message) => console.error(message), onSuccess = (result: unknown) => console.log(result), }: ExportOptions) => { + if (preset && formatOption) { + onError("The release preset cannot be combined with a format") + return onExit(1) + } + const format = formatOption ?? "json" if (!ALLOWED_EXPORT_FORMATS.includes(format)) { onError(`Invalid format: ${format}`) return onExit(1) @@ -134,7 +99,14 @@ export const exportSnippet = async ({ const projectDir = path.dirname(filePath) const outputBaseName = path.basename(filePath).replace(/\.[^.]+$/, "") - const outputFileName = `${outputBaseName}${OUTPUT_EXTENSIONS[format]}` + let outputFileName = `${outputBaseName}${OUTPUT_EXTENSIONS[format]}` + if (preset === "release") { + if (!writeFile) { + onError("The release preset requires writing to an output directory") + return onExit(1) + } + outputFileName = path.join("dist", "release") + } const outputDestination = outputPath && path.isAbsolute(outputPath) ? outputPath @@ -182,7 +154,8 @@ export const exportSnippet = async ({ return onExit(1) } } else { - const isJlcpcbFabricationExport = format === "gerbers" + const isJlcpcbFabricationExport = + format === "gerbers" || preset === "release" const fabricationPlatformConfig = isJlcpcbFabricationExport ? getPlatformConfigWithCliDefaults( mergePlatformConfigs(platformConfig, { @@ -195,7 +168,7 @@ export const exportSnippet = async ({ : getOrGenerateCircuitJson const circuitData = await generateCircuitData({ filePath, - saveToFile: format === "circuit-json", + saveToFile: !preset && format === "circuit-json", platformConfig: fabricationPlatformConfig, }).catch((err) => { onError(`Error generating circuit JSON: ${err}`) @@ -206,146 +179,30 @@ export const exportSnippet = async ({ circuitJson = circuitData.circuitJson } - let outputContent: string | Buffer - - switch (format) { - case "schematic-svg": - outputContent = convertCircuitJsonToStackedSchematicSheetsSvg(circuitJson) - break - case "schematic-pdf": - outputContent = await convertCircuitJsonToSchematicPdf(circuitJson) - break - case "pcb-svg": - outputContent = convertCircuitJsonToPcbSvg( + if (preset === "release") { + try { + await exportReleasePreset({ circuitJson, + filePath, + outputDestination, + platformConfig, pcbSnapshotSettings, - ) - break - case "specctra-dsn": - outputContent = convertCircuitJsonToDsnString(circuitJson) - break - case "readable-netlist": - outputContent = convertCircuitJsonToReadableNetlist(circuitJson) - break - case "gltf": - outputContent = JSON.stringify( - await convertCircuitJsonToGltf( - circuitJson, - getCircuitJsonToGltfOptions({ format: "gltf" }), - ), - null, - 2, - ) - break - case "glb": - outputContent = Buffer.from( - (await convertCircuitJsonToGltf( - circuitJson, - getCircuitJsonToGltfOptions({ format: "glb" }), - )) as ArrayBuffer, - ) - break - case "srj": - { - const userLandTscircuit = await importFromUserLand("tscircuit") - const simpleRouteJson = unwrapSimpleRouteJson( - userLandTscircuit.getSimpleRouteJsonFromCircuitJson({ - circuitJson, - }), - ) - outputContent = JSON.stringify(simpleRouteJson, null, 2) - } - break - case "kicad_sch": { - const converter = new CircuitJsonToKicadSchConverter(circuitJson) - converter.runUntilFinished() - outputContent = converter.getOutputString() - break - } - case "kicad_pcb": { - const converter = new CircuitJsonToKicadPcbConverter(circuitJson) - converter.runUntilFinished() - outputContent = converter.getOutputString() - break - } - case "kicad_zip": { - const schConverter = new CircuitJsonToKicadSchConverter(circuitJson) - schConverter.runUntilFinished() - const pcbConverter = new CircuitJsonToKicadPcbConverter(circuitJson, { - includeBuiltin3dModels: true, - projectName: outputBaseName, - }) - pcbConverter.runUntilFinished() - const proConverter = new CircuitJsonToKicadProConverter(circuitJson, { - projectName: outputBaseName, - schematicFilename: `${outputBaseName}.kicad_sch`, - pcbFilename: `${outputBaseName}.kicad_pcb`, }) - proConverter.runUntilFinished() - - const zip = new JSZip() - zip.file(`${outputBaseName}.kicad_sch`, schConverter.getOutputString()) - zip.file(`${outputBaseName}.kicad_pcb`, pcbConverter.getOutputString()) - zip.file(`${outputBaseName}.kicad_pro`, proConverter.getOutputString()) - - await resolveAndLoadKicad3dModelFiles({ - model3dSourcePaths: pcbConverter.getModel3dSourcePaths(), - projectName: outputBaseName, - fetch: platformConfig?.platformFetch ?? globalThis.fetch, - readFile: (modelPath) => - fs.promises.readFile(path.resolve(projectDir, modelPath)), - onModelFile: ({ outputPath, content }) => { - zip.file(outputPath, content) - }, - onError: ({ sourcePath }) => { - console.warn(`Failed to load 3D model from ${sourcePath}`) - }, - }) - - outputContent = await zip.generateAsync({ type: "nodebuffer" }) - break - } - case "gerbers": { - const zip = new JSZip() - - const gerberFiles = convertCircuitJsonToGerberFiles(circuitJson, { - flip_y_axis: false, - }) - for (const [fileName, fileContents] of Object.entries(gerberFiles)) { - zip.file(fileName, fileContents) - } - - const bomRows = await convertCircuitJsonToBomRows({ circuitJson }) - const bomCsv = await convertBomRowsToCsv(bomRows) - zip.file("bom.csv", bomCsv) - - const pnpCsv = await convertCircuitJsonToPickAndPlaceCsv(circuitJson, { - supplier: "jlcpcb", - }) - zip.file("pick_and_place.csv", pnpCsv) - - outputContent = await zip.generateAsync({ type: "nodebuffer" }) - break + onSuccess({ outputDestination, outputContent: "" }) + return onExit(0) + } catch (err) { + onError(`Error exporting release: ${err}`) + return onExit(1) } - - case "step": - outputContent = await circuitJsonToStep(circuitJson, { - includeComponents: true, - includeExternalMeshes: true, - fsMap: await loadLocalStepModelFsMap(circuitJson), - }) - break - case "assembly-svg": - outputContent = convertCircuitJsonToAssemblySvg(circuitJson) - break - case "component-box-3mf": - outputContent = Buffer.from( - await circuitJsonToFdmComponentBox(circuitJson), - ) - break - default: - outputContent = JSON.stringify(circuitJson, null, 2) } + + const outputContent = await convertCircuitJsonToExport({ + circuitJson, + format, + filePath, + platformConfig, + pcbSnapshotSettings, + }) if (writeFile) { await writeFileAsync(outputDestination, outputContent).catch((err) => { onError(`Error writing file: ${err}`) diff --git a/tests/cli/export/export-release-preset.test.ts b/tests/cli/export/export-release-preset.test.ts new file mode 100644 index 000000000..9a4bacd2d --- /dev/null +++ b/tests/cli/export/export-release-preset.test.ts @@ -0,0 +1,187 @@ +import { expect, test } from "bun:test" +import { createHash } from "node:crypto" +import { mkdir, readFile, readdir, writeFile } from "node:fs/promises" +import path from "node:path" +import JSZip from "jszip" +import { getCliTestFixture } from "../../fixtures/get-cli-test-fixture" + +const releaseFiles = [ + "circuit.json", + "schematic.svg", + "pcb.svg", + "assembly.svg", + "board.glb", + "gerbers.zip", + "bom.csv", + "pick-and-place.csv", + "checks.json", + "release-manifest.json", +].sort() + +test( + "release builds once and packages verifiable artifacts from the same circuit JSON", + async () => { + const { tmpDir, runCommand } = await getCliTestFixture() + const circuitPath = path.join(tmpDir, "board.tsx") + const outputDir = path.join(tmpDir, "dist", "release") + await writeFile( + circuitPath, + ` + import { appendFileSync } from "node:fs" + export default () => { + appendFileSync(${JSON.stringify(path.join(tmpDir, "builds.txt"))}, "build\\n") + return + + + } + `, + ) + + const result = await runCommand( + `tsci export ${circuitPath} --preset release --output dist/release --disable-parts-engine`, + ) + expect(result.exitCode).toBe(0) + expect(await readFile(path.join(tmpDir, "builds.txt"), "utf8")).toBe( + "build\n", + ) + expect((await readdir(outputDir)).sort()).toEqual(releaseFiles) + for (const fileName of ["schematic.svg", "pcb.svg", "assembly.svg"]) { + expect(await readFile(path.join(outputDir, fileName), "utf8")).toContain( + " file.path).sort(), + ).toEqual(releaseFiles.filter((file) => file !== "release-manifest.json")) + for (const file of manifest.files) { + const content = await readFile(path.join(outputDir, file.path)) + expect(file.bytes).toBe(content.byteLength) + expect(file.sha256).toBe( + createHash("sha256").update(content).digest("hex"), + ) + } + + const secondOutputDir = path.join(tmpDir, "repeated-release") + const repeat = await runCommand( + `tsci export ${path.join(outputDir, "circuit.json")} --preset release --output ${secondOutputDir}`, + ) + expect(repeat.exitCode).toBe(0) + for (const fileName of releaseFiles) { + // The Gerber converter embeds wall-clock dates in its file contents. + if (fileName === "gerbers.zip" || fileName === "release-manifest.json") + continue + expect(await readFile(path.join(secondOutputDir, fileName))).toEqual( + await readFile(path.join(outputDir, fileName)), + ) + } + }, + { timeout: 60_000 }, +) + +test("release includes build errors and warnings and defaults to dist/release", async () => { + const { tmpDir, runCommand } = await getCliTestFixture() + const projectDir = path.join(tmpDir, "project") + await mkdir(projectDir) + const circuitPath = path.join(projectDir, "board.circuit.json") + const board = JSON.parse( + await readFile( + path.resolve( + import.meta.dir, + "../../fixtures/export/output-path-board.circuit.json", + ), + "utf8", + ), + ) + const error = { + type: "pcb_trace_error", + error_type: "pcb_trace_error", + pcb_trace_error_id: "error_1", + message: "Unrouted trace", + } + const warning = { + type: "source_pin_missing_trace_warning", + warning_type: "source_pin_missing_trace_warning", + source_pin_missing_trace_warning_id: "warning_1", + message: "Missing trace", + source_port_id: "port_1", + } + await writeFile(circuitPath, JSON.stringify([...board, error, warning])) + const result = await runCommand(`tsci export ${circuitPath} --preset release`) + expect(result.exitCode).toBe(0) + const outputDir = path.join(projectDir, "dist", "release") + expect( + JSON.parse(await readFile(path.join(outputDir, "checks.json"), "utf8")), + ).toEqual({ errors: [error], warnings: [warning] }) +}) + +test("release rejects unknown presets, conflicting formats, and unwritable destinations", async () => { + const { tmpDir, runCommand } = await getCliTestFixture() + expect( + (await runCommand("tsci export missing.tsx --preset unknown")).exitCode, + ).not.toBe(0) + for (const format of ["json", "spice", "gerbers"]) { + const conflict = await runCommand( + `tsci export missing.tsx --preset release --format ${format}`, + ) + expect(conflict.exitCode).not.toBe(0) + expect(conflict.stderr).toContain("cannot be used with option") + } + const circuitPath = path.join(tmpDir, "board.circuit.json") + await writeFile(circuitPath, "[]") + const outputPath = path.join(tmpDir, "existing-file") + await writeFile(outputPath, "keep me") + expect( + ( + await runCommand( + `tsci export ${circuitPath} --preset release --output ${outputPath}`, + ) + ).exitCode, + ).not.toBe(0) + expect(await readFile(outputPath, "utf8")).toBe("keep me") +}) + +test("a failed release write removes the previous completion manifest", async () => { + const { tmpDir, runCommand } = await getCliTestFixture() + const circuitPath = path.join(tmpDir, "board.circuit.json") + await writeFile( + circuitPath, + await readFile( + path.resolve( + import.meta.dir, + "../../fixtures/export/output-path-board.circuit.json", + ), + ), + ) + const outputDir = path.join(tmpDir, "release") + await mkdir(path.join(outputDir, "circuit.json"), { recursive: true }) + await writeFile(path.join(outputDir, "release-manifest.json"), "old manifest") + const result = await runCommand( + `tsci export ${circuitPath} --preset release --output ${outputDir}`, + ) + expect(result.exitCode).not.toBe(0) + expect(result.stdout).not.toContain("Exported to") + expect(await readdir(outputDir)).not.toContain("release-manifest.json") +}) From 19cd671b2405e583487f54a62ad62b6430e46a01 Mon Sep 17 00:00:00 2001 From: mohan-bee Date: Sun, 6 Sep 2026 18:55:11 +0530 Subject: [PATCH 2/4] up --- README.md | 30 +-- lib/shared/convert-circuit-json-to-export.ts | 186 -------------- lib/shared/convert-circuit-json-to-gerbers.ts | 37 --- lib/shared/export-release-preset.ts | 77 ------ lib/shared/export-snippet.ts | 234 +++++++++++++++++- .../cli/export/export-release-preset.test.ts | 117 +-------- 6 files changed, 238 insertions(+), 443 deletions(-) delete mode 100644 lib/shared/convert-circuit-json-to-export.ts delete mode 100644 lib/shared/convert-circuit-json-to-gerbers.ts delete mode 100644 lib/shared/export-release-preset.ts diff --git a/README.md b/README.md index dff653bc0..c22f0a32a 100644 --- a/README.md +++ b/README.md @@ -87,29 +87,13 @@ The `build` command also accepts the following options: tsci export board.tsx --preset release --output dist/release ``` -The release preset builds once with Gerber part-orientation analysis enabled, -then uses that Circuit JSON for `circuit.json`, `schematic.svg`, `pcb.svg`, -`assembly.svg`, `board.glb`, `gerbers.zip`, `bom.csv`, `pick-and-place.csv`, -`checks.json`, and `release-manifest.json`. The standalone CSV files match the -BOM and JLCPCB pick-and-place files inside the Gerber ZIP. - -`--output` is a directory for presets, defaults to `dist/release`, and resolves -relative paths from the input file's directory, like other exports. Parent -directories are created automatically. `--preset` cannot be combined with -`--format`; `--disable-parts-engine` and `--show-courtyards` still apply. - -`checks.json` contains the build diagnostics as `errors` and `warnings` arrays, -using the same classification as `tsci check`. It does not run additional check -subcommands or fail the export on circuit diagnostics. Export failures exit -nonzero. The manifest is written last and records the schema version, CLI -version, and each artifact's relative path, byte count, and SHA-256 hash. - -To repeat conversion without rebuilding, export the saved `circuit.json` with -`--preset release`. Gerber ZIP entries use fixed timestamps, but the current -Gerber converter embeds creation dates inside Gerber and drill files, so the -ZIP and its manifest hash differ between runs. Byte-for-byte reproducibility -requires an upstream timestamp option and also depends on converter versions -and any externally fetched CAD models. +The release preset builds once and reuses that Circuit JSON to write +`circuit.json`, `schematic.svg`, `pcb.svg`, and `gerbers.zip`. The Gerber ZIP +includes the existing BOM and JLCPCB pick-and-place files. + +`--output` is a directory for presets and defaults to `dist/release`, relative +to the input file's directory. It is created automatically. `--preset` cannot +be combined with `--format`. ### KiCad PCM compatibility diff --git a/lib/shared/convert-circuit-json-to-export.ts b/lib/shared/convert-circuit-json-to-export.ts deleted file mode 100644 index 4b1d29301..000000000 --- a/lib/shared/convert-circuit-json-to-export.ts +++ /dev/null @@ -1,186 +0,0 @@ -import fs from "node:fs" -import path from "node:path" -import type { PlatformConfig } from "@tscircuit/props" -import type { AnyCircuitElement } from "circuit-json" -import { convertCircuitJsonToGltf } from "circuit-json-to-gltf" -import { - CircuitJsonToKicadPcbConverter, - CircuitJsonToKicadProConverter, - CircuitJsonToKicadSchConverter, - resolveAndLoadKicad3dModelFiles, -} from "circuit-json-to-kicad" -import { convertCircuitJsonToReadableNetlist } from "circuit-json-to-readable-netlist" -import { circuitJsonToStep } from "circuit-json-to-step" -import { circuitJsonToFdmComponentBox } from "circuit-json-to-fdm-component-box" -import { - convertCircuitJsonToAssemblySvg, - convertCircuitJsonToPcbSvg, - convertCircuitJsonToStackedSchematicSheetsSvg, -} from "circuit-to-svg" -import { convertCircuitJsonToDsnString } from "dsn-converter" -import JSZip from "jszip" -import type { PcbSnapshotSettings } from "lib/project-config/project-config-schema" -import { getCircuitJsonToGltfOptions } from "lib/shared/get-circuit-json-to-gltf-options" -import { loadLocalStepModelFsMap } from "lib/shared/load-local-step-model-fs-map" -import { convertCircuitJsonToSchematicPdf } from "./convert-circuit-json-to-schematic-pdf" -import { importFromUserLand } from "./importFromUserLand" - -import { convertCircuitJsonToGerbers } from "./convert-circuit-json-to-gerbers" -import type { ExportFormat } from "./export-snippet" - -const isRecord = (value: unknown): value is Record => - typeof value === "object" && value !== null - -const unwrapSimpleRouteJson = (value: unknown) => { - if ( - isRecord(value) && - isRecord(value.simpleRouteJson) && - ("connections" in value.simpleRouteJson || - "obstacles" in value.simpleRouteJson || - "bounds" in value.simpleRouteJson) - ) { - return value.simpleRouteJson - } - - return value -} - -export const convertCircuitJsonToExport = async ({ - circuitJson, - format, - filePath, - platformConfig, - pcbSnapshotSettings, -}: { - circuitJson: AnyCircuitElement[] - format: ExportFormat - filePath: string - platformConfig?: PlatformConfig - pcbSnapshotSettings?: PcbSnapshotSettings -}): Promise => { - const projectDir = path.dirname(filePath) - const outputBaseName = path.basename(filePath).replace(/\.[^.]+$/, "") - let outputContent: string | Buffer - - switch (format) { - case "schematic-svg": - outputContent = convertCircuitJsonToStackedSchematicSheetsSvg(circuitJson) - break - case "schematic-pdf": - outputContent = await convertCircuitJsonToSchematicPdf(circuitJson) - break - case "pcb-svg": - outputContent = convertCircuitJsonToPcbSvg( - circuitJson, - pcbSnapshotSettings, - ) - break - case "specctra-dsn": - outputContent = convertCircuitJsonToDsnString(circuitJson) - break - case "readable-netlist": - outputContent = convertCircuitJsonToReadableNetlist(circuitJson) - break - case "gltf": - outputContent = JSON.stringify( - await convertCircuitJsonToGltf( - circuitJson, - getCircuitJsonToGltfOptions({ format: "gltf" }), - ), - null, - 2, - ) - break - case "glb": - outputContent = Buffer.from( - (await convertCircuitJsonToGltf( - circuitJson, - getCircuitJsonToGltfOptions({ format: "glb" }), - )) as ArrayBuffer, - ) - break - case "srj": - { - const userLandTscircuit = await importFromUserLand("tscircuit") - const simpleRouteJson = unwrapSimpleRouteJson( - userLandTscircuit.getSimpleRouteJsonFromCircuitJson({ - circuitJson, - }), - ) - outputContent = JSON.stringify(simpleRouteJson, null, 2) - } - break - case "kicad_sch": { - const converter = new CircuitJsonToKicadSchConverter(circuitJson) - converter.runUntilFinished() - outputContent = converter.getOutputString() - break - } - case "kicad_pcb": { - const converter = new CircuitJsonToKicadPcbConverter(circuitJson) - converter.runUntilFinished() - outputContent = converter.getOutputString() - break - } - case "kicad_zip": { - const schConverter = new CircuitJsonToKicadSchConverter(circuitJson) - schConverter.runUntilFinished() - const pcbConverter = new CircuitJsonToKicadPcbConverter(circuitJson, { - includeBuiltin3dModels: true, - projectName: outputBaseName, - }) - pcbConverter.runUntilFinished() - const proConverter = new CircuitJsonToKicadProConverter(circuitJson, { - projectName: outputBaseName, - schematicFilename: `${outputBaseName}.kicad_sch`, - pcbFilename: `${outputBaseName}.kicad_pcb`, - }) - proConverter.runUntilFinished() - - const zip = new JSZip() - zip.file(`${outputBaseName}.kicad_sch`, schConverter.getOutputString()) - zip.file(`${outputBaseName}.kicad_pcb`, pcbConverter.getOutputString()) - zip.file(`${outputBaseName}.kicad_pro`, proConverter.getOutputString()) - - await resolveAndLoadKicad3dModelFiles({ - model3dSourcePaths: pcbConverter.getModel3dSourcePaths(), - projectName: outputBaseName, - fetch: platformConfig?.platformFetch ?? globalThis.fetch, - readFile: (modelPath) => - fs.promises.readFile(path.resolve(projectDir, modelPath)), - onModelFile: ({ outputPath, content }) => { - zip.file(outputPath, content) - }, - onError: ({ sourcePath }) => { - console.warn(`Failed to load 3D model from ${sourcePath}`) - }, - }) - - outputContent = await zip.generateAsync({ type: "nodebuffer" }) - break - } - case "gerbers": - outputContent = (await convertCircuitJsonToGerbers(circuitJson)) - .gerbersZip - break - - case "step": - outputContent = await circuitJsonToStep(circuitJson, { - includeComponents: true, - includeExternalMeshes: true, - fsMap: await loadLocalStepModelFsMap(circuitJson), - }) - break - case "assembly-svg": - outputContent = convertCircuitJsonToAssemblySvg(circuitJson) - break - case "component-box-3mf": - outputContent = Buffer.from( - await circuitJsonToFdmComponentBox(circuitJson), - ) - break - default: - outputContent = JSON.stringify(circuitJson, null, 2) - } - return outputContent -} diff --git a/lib/shared/convert-circuit-json-to-gerbers.ts b/lib/shared/convert-circuit-json-to-gerbers.ts deleted file mode 100644 index 7fd0fabab..000000000 --- a/lib/shared/convert-circuit-json-to-gerbers.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { AnyCircuitElement } from "circuit-json" -import { - convertBomRowsToCsv, - convertCircuitJsonToBomRows, -} from "circuit-json-to-bom-csv" -import { convertCircuitJsonToGerberFiles } from "circuit-json-to-gerber" -import { convertCircuitJsonToPickAndPlaceCsv } from "circuit-json-to-pnp-csv" -import JSZip from "jszip" - -export const convertCircuitJsonToGerbers = async ( - circuitJson: AnyCircuitElement[], -) => { - const bomRows = await convertCircuitJsonToBomRows({ circuitJson }) - const bomCsv = await convertBomRowsToCsv(bomRows) - const pnpCsv = await convertCircuitJsonToPickAndPlaceCsv(circuitJson, { - supplier: "jlcpcb", - }) - const gerberFiles = convertCircuitJsonToGerberFiles(circuitJson, { - flip_y_axis: false, - }) - const zip = new JSZip() - // ZIP timestamps start in 1980; a fixed date keeps archive metadata stable. - const zipDate = new Date("1980-01-01T00:00:00.000Z") - for (const [fileName, fileContents] of Object.entries({ - ...gerberFiles, - "bom.csv": bomCsv, - "pick_and_place.csv": pnpCsv, - })) { - zip.file(fileName, fileContents, { date: zipDate }) - } - - return { - gerbersZip: await zip.generateAsync({ type: "nodebuffer" }), - bomCsv, - pnpCsv, - } -} diff --git a/lib/shared/export-release-preset.ts b/lib/shared/export-release-preset.ts deleted file mode 100644 index ae90f5457..000000000 --- a/lib/shared/export-release-preset.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { createHash } from "node:crypto" -import { mkdir, rm, writeFile } from "node:fs/promises" -import path from "node:path" -import type { PlatformConfig } from "@tscircuit/props" -import type { AnyCircuitElement } from "circuit-json" -import type { PcbSnapshotSettings } from "lib/project-config/project-config-schema" -import { version } from "../../package.json" -import { analyzeCircuitJson } from "./circuit-json-diagnostics" -import { convertCircuitJsonToExport } from "./convert-circuit-json-to-export" -import { convertCircuitJsonToGerbers } from "./convert-circuit-json-to-gerbers" -import type { ExportFormat } from "./export-snippet" - -const RELEASE_EXPORTS = [ - { format: "circuit-json", fileName: "circuit.json" }, - { format: "schematic-svg", fileName: "schematic.svg" }, - { format: "pcb-svg", fileName: "pcb.svg" }, - { format: "assembly-svg", fileName: "assembly.svg" }, - { format: "glb", fileName: "board.glb" }, -] satisfies { format: ExportFormat; fileName: string }[] - -export const exportReleasePreset = async ({ - circuitJson, - filePath, - outputDestination, - platformConfig, - pcbSnapshotSettings, -}: { - circuitJson: AnyCircuitElement[] - filePath: string - outputDestination: string - platformConfig?: PlatformConfig - pcbSnapshotSettings?: PcbSnapshotSettings -}) => { - await mkdir(outputDestination, { recursive: true }) - const manifestPath = path.join(outputDestination, "release-manifest.json") - // A manifest marks completion, including when replacing a previous release. - await rm(manifestPath, { force: true }) - - const artifacts: Record = {} - for (const { format, fileName } of RELEASE_EXPORTS) { - artifacts[fileName] = await convertCircuitJsonToExport({ - circuitJson, - format, - filePath, - platformConfig, - pcbSnapshotSettings, - }) - } - const { gerbersZip, bomCsv, pnpCsv } = - await convertCircuitJsonToGerbers(circuitJson) - artifacts["gerbers.zip"] = gerbersZip - artifacts["bom.csv"] = bomCsv - artifacts["pick-and-place.csv"] = pnpCsv - artifacts["checks.json"] = JSON.stringify( - analyzeCircuitJson(circuitJson), - null, - 2, - ) - - const files = [] - for (const [fileName, content] of Object.entries(artifacts)) { - await writeFile(path.join(outputDestination, fileName), content) - files.push({ - path: fileName, - bytes: Buffer.byteLength(content), - sha256: createHash("sha256").update(content).digest("hex"), - }) - } - await writeFile( - manifestPath, - JSON.stringify( - { schemaVersion: 1, preset: "release", cliVersion: version, files }, - null, - 2, - ), - ) -} diff --git a/lib/shared/export-snippet.ts b/lib/shared/export-snippet.ts index d795f20c2..f26b1ee04 100644 --- a/lib/shared/export-snippet.ts +++ b/lib/shared/export-snippet.ts @@ -3,15 +3,40 @@ import path from "node:path" import { promisify } from "node:util" import type { PlatformConfig } from "@tscircuit/props" import type { AnyCircuitElement } from "circuit-json" +import { + convertBomRowsToCsv, + convertCircuitJsonToBomRows, +} from "circuit-json-to-bom-csv" +import { convertCircuitJsonToGerberFiles } from "circuit-json-to-gerber" +import { convertCircuitJsonToGltf } from "circuit-json-to-gltf" +import { + CircuitJsonToKicadPcbConverter, + CircuitJsonToKicadProConverter, + CircuitJsonToKicadSchConverter, + resolveAndLoadKicad3dModelFiles, +} from "circuit-json-to-kicad" +import { convertCircuitJsonToPickAndPlaceCsv } from "circuit-json-to-pnp-csv" +import { convertCircuitJsonToReadableNetlist } from "circuit-json-to-readable-netlist" +import { circuitJsonToStep } from "circuit-json-to-step" +import { circuitJsonToFdmComponentBox } from "circuit-json-to-fdm-component-box" +import { + convertCircuitJsonToAssemblySvg, + convertCircuitJsonToPcbSvg, + convertCircuitJsonToStackedSchematicSheetsSvg, +} from "circuit-to-svg" +import { convertCircuitJsonToDsnString } from "dsn-converter" +import JSZip from "jszip" import type { PcbSnapshotSettings } from "lib/project-config/project-config-schema" import { generateCircuitJson } from "lib/shared/generate-circuit-json" +import { getCircuitJsonToGltfOptions } from "lib/shared/get-circuit-json-to-gltf-options" import { getOrGenerateCircuitJson } from "lib/shared/get-or-generate-circuit-json" import { getPlatformConfigWithCliDefaults } from "lib/shared/get-platform-config-with-cli-defaults" +import { loadLocalStepModelFsMap } from "lib/shared/load-local-step-model-fs-map" import { mergePlatformConfigs } from "lib/shared/platform-config-utils" +import { convertCircuitJsonToSchematicPdf } from "./convert-circuit-json-to-schematic-pdf" import { convertToKicadLibrary } from "./convert-to-kicad-library" +import { importFromUserLand } from "./importFromUserLand" import { isCircuitJsonFile } from "./is-circuit-json-file" -import { convertCircuitJsonToExport } from "./convert-circuit-json-to-export" -import { exportReleasePreset } from "./export-release-preset" const writeFileAsync = promisify(fs.writeFile) @@ -59,6 +84,30 @@ const OUTPUT_EXTENSIONS: Record = { "component-box-3mf": "-component-box.3mf", } +const RELEASE_EXPORTS = [ + { format: "circuit-json", fileName: "circuit.json" }, + { format: "schematic-svg", fileName: "schematic.svg" }, + { format: "pcb-svg", fileName: "pcb.svg" }, + { format: "gerbers", fileName: "gerbers.zip" }, +] satisfies { format: ExportFormat; fileName: string }[] + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null + +const unwrapSimpleRouteJson = (value: unknown) => { + if ( + isRecord(value) && + isRecord(value.simpleRouteJson) && + ("connections" in value.simpleRouteJson || + "obstacles" in value.simpleRouteJson || + "bounds" in value.simpleRouteJson) + ) { + return value.simpleRouteJson + } + + return value +} + type ExportOptions = { filePath: string format?: ExportFormat @@ -168,7 +217,7 @@ export const exportSnippet = async ({ : getOrGenerateCircuitJson const circuitData = await generateCircuitData({ filePath, - saveToFile: !preset && format === "circuit-json", + saveToFile: format === "circuit-json", platformConfig: fabricationPlatformConfig, }).catch((err) => { onError(`Error generating circuit JSON: ${err}`) @@ -181,13 +230,20 @@ export const exportSnippet = async ({ if (preset === "release") { try { - await exportReleasePreset({ - circuitJson, - filePath, - outputDestination, - platformConfig, - pcbSnapshotSettings, - }) + await fs.promises.mkdir(outputDestination, { recursive: true }) + for (const { format, fileName } of RELEASE_EXPORTS) { + const outputContent = await convertCircuitJsonToExport({ + circuitJson, + format, + filePath, + platformConfig, + pcbSnapshotSettings, + }) + await writeFileAsync( + path.join(outputDestination, fileName), + outputContent, + ) + } onSuccess({ outputDestination, outputContent: "" }) return onExit(0) } catch (err) { @@ -217,3 +273,161 @@ export const exportSnippet = async ({ onExit(0) } + +const convertCircuitJsonToExport = async ({ + circuitJson, + format, + filePath, + platformConfig, + pcbSnapshotSettings, +}: { + circuitJson: AnyCircuitElement[] + format: ExportFormat + filePath: string + platformConfig?: PlatformConfig + pcbSnapshotSettings?: PcbSnapshotSettings +}): Promise => { + const projectDir = path.dirname(filePath) + const outputBaseName = path.basename(filePath).replace(/\.[^.]+$/, "") + let outputContent: string | Buffer + + switch (format) { + case "schematic-svg": + outputContent = convertCircuitJsonToStackedSchematicSheetsSvg(circuitJson) + break + case "schematic-pdf": + outputContent = await convertCircuitJsonToSchematicPdf(circuitJson) + break + case "pcb-svg": + outputContent = convertCircuitJsonToPcbSvg( + circuitJson, + pcbSnapshotSettings, + ) + break + case "specctra-dsn": + outputContent = convertCircuitJsonToDsnString(circuitJson) + break + case "readable-netlist": + outputContent = convertCircuitJsonToReadableNetlist(circuitJson) + break + case "gltf": + outputContent = JSON.stringify( + await convertCircuitJsonToGltf( + circuitJson, + getCircuitJsonToGltfOptions({ format: "gltf" }), + ), + null, + 2, + ) + break + case "glb": + outputContent = Buffer.from( + (await convertCircuitJsonToGltf( + circuitJson, + getCircuitJsonToGltfOptions({ format: "glb" }), + )) as ArrayBuffer, + ) + break + case "srj": + { + const userLandTscircuit = await importFromUserLand("tscircuit") + const simpleRouteJson = unwrapSimpleRouteJson( + userLandTscircuit.getSimpleRouteJsonFromCircuitJson({ + circuitJson, + }), + ) + outputContent = JSON.stringify(simpleRouteJson, null, 2) + } + break + case "kicad_sch": { + const converter = new CircuitJsonToKicadSchConverter(circuitJson) + converter.runUntilFinished() + outputContent = converter.getOutputString() + break + } + case "kicad_pcb": { + const converter = new CircuitJsonToKicadPcbConverter(circuitJson) + converter.runUntilFinished() + outputContent = converter.getOutputString() + break + } + case "kicad_zip": { + const schConverter = new CircuitJsonToKicadSchConverter(circuitJson) + schConverter.runUntilFinished() + const pcbConverter = new CircuitJsonToKicadPcbConverter(circuitJson, { + includeBuiltin3dModels: true, + projectName: outputBaseName, + }) + pcbConverter.runUntilFinished() + const proConverter = new CircuitJsonToKicadProConverter(circuitJson, { + projectName: outputBaseName, + schematicFilename: `${outputBaseName}.kicad_sch`, + pcbFilename: `${outputBaseName}.kicad_pcb`, + }) + proConverter.runUntilFinished() + + const zip = new JSZip() + zip.file(`${outputBaseName}.kicad_sch`, schConverter.getOutputString()) + zip.file(`${outputBaseName}.kicad_pcb`, pcbConverter.getOutputString()) + zip.file(`${outputBaseName}.kicad_pro`, proConverter.getOutputString()) + + await resolveAndLoadKicad3dModelFiles({ + model3dSourcePaths: pcbConverter.getModel3dSourcePaths(), + projectName: outputBaseName, + fetch: platformConfig?.platformFetch ?? globalThis.fetch, + readFile: (modelPath) => + fs.promises.readFile(path.resolve(projectDir, modelPath)), + onModelFile: ({ outputPath, content }) => { + zip.file(outputPath, content) + }, + onError: ({ sourcePath }) => { + console.warn(`Failed to load 3D model from ${sourcePath}`) + }, + }) + + outputContent = await zip.generateAsync({ type: "nodebuffer" }) + break + } + case "gerbers": { + const zip = new JSZip() + + const gerberFiles = convertCircuitJsonToGerberFiles(circuitJson, { + flip_y_axis: false, + }) + for (const [fileName, fileContents] of Object.entries(gerberFiles)) { + zip.file(fileName, fileContents) + } + + const bomRows = await convertCircuitJsonToBomRows({ circuitJson }) + const bomCsv = await convertBomRowsToCsv(bomRows) + zip.file("bom.csv", bomCsv) + + const pnpCsv = await convertCircuitJsonToPickAndPlaceCsv(circuitJson, { + supplier: "jlcpcb", + }) + zip.file("pick_and_place.csv", pnpCsv) + + outputContent = await zip.generateAsync({ type: "nodebuffer" }) + break + } + + case "step": + outputContent = await circuitJsonToStep(circuitJson, { + includeComponents: true, + includeExternalMeshes: true, + fsMap: await loadLocalStepModelFsMap(circuitJson), + }) + break + case "assembly-svg": + outputContent = convertCircuitJsonToAssemblySvg(circuitJson) + break + case "component-box-3mf": + outputContent = Buffer.from( + await circuitJsonToFdmComponentBox(circuitJson), + ) + break + default: + outputContent = JSON.stringify(circuitJson, null, 2) + } + return outputContent +} diff --git a/tests/cli/export/export-release-preset.test.ts b/tests/cli/export/export-release-preset.test.ts index 9a4bacd2d..3c5b261a3 100644 --- a/tests/cli/export/export-release-preset.test.ts +++ b/tests/cli/export/export-release-preset.test.ts @@ -1,6 +1,5 @@ import { expect, test } from "bun:test" -import { createHash } from "node:crypto" -import { mkdir, readFile, readdir, writeFile } from "node:fs/promises" +import { readFile, readdir, writeFile } from "node:fs/promises" import path from "node:path" import JSZip from "jszip" import { getCliTestFixture } from "../../fixtures/get-cli-test-fixture" @@ -9,17 +8,11 @@ const releaseFiles = [ "circuit.json", "schematic.svg", "pcb.svg", - "assembly.svg", - "board.glb", "gerbers.zip", - "bom.csv", - "pick-and-place.csv", - "checks.json", - "release-manifest.json", ].sort() test( - "release builds once and packages verifiable artifacts from the same circuit JSON", + "release builds once and exports circuit JSON, SVGs, and Gerbers", async () => { const { tmpDir, runCommand } = await getCliTestFixture() const circuitPath = path.join(tmpDir, "board.tsx") @@ -45,98 +38,25 @@ test( "build\n", ) expect((await readdir(outputDir)).sort()).toEqual(releaseFiles) - for (const fileName of ["schematic.svg", "pcb.svg", "assembly.svg"]) { + for (const fileName of ["schematic.svg", "pcb.svg"]) { expect(await readFile(path.join(outputDir, fileName), "utf8")).toContain( " file.path).sort(), - ).toEqual(releaseFiles.filter((file) => file !== "release-manifest.json")) - for (const file of manifest.files) { - const content = await readFile(path.join(outputDir, file.path)) - expect(file.bytes).toBe(content.byteLength) - expect(file.sha256).toBe( - createHash("sha256").update(content).digest("hex"), - ) - } - - const secondOutputDir = path.join(tmpDir, "repeated-release") - const repeat = await runCommand( - `tsci export ${path.join(outputDir, "circuit.json")} --preset release --output ${secondOutputDir}`, - ) - expect(repeat.exitCode).toBe(0) - for (const fileName of releaseFiles) { - // The Gerber converter embeds wall-clock dates in its file contents. - if (fileName === "gerbers.zip" || fileName === "release-manifest.json") - continue - expect(await readFile(path.join(secondOutputDir, fileName))).toEqual( - await readFile(path.join(outputDir, fileName)), - ) - } + JSON.parse(await readFile(path.join(outputDir, "circuit.json"), "utf8")), + ).toBeArray() }, { timeout: 60_000 }, ) -test("release includes build errors and warnings and defaults to dist/release", async () => { - const { tmpDir, runCommand } = await getCliTestFixture() - const projectDir = path.join(tmpDir, "project") - await mkdir(projectDir) - const circuitPath = path.join(projectDir, "board.circuit.json") - const board = JSON.parse( - await readFile( - path.resolve( - import.meta.dir, - "../../fixtures/export/output-path-board.circuit.json", - ), - "utf8", - ), - ) - const error = { - type: "pcb_trace_error", - error_type: "pcb_trace_error", - pcb_trace_error_id: "error_1", - message: "Unrouted trace", - } - const warning = { - type: "source_pin_missing_trace_warning", - warning_type: "source_pin_missing_trace_warning", - source_pin_missing_trace_warning_id: "warning_1", - message: "Missing trace", - source_port_id: "port_1", - } - await writeFile(circuitPath, JSON.stringify([...board, error, warning])) - const result = await runCommand(`tsci export ${circuitPath} --preset release`) - expect(result.exitCode).toBe(0) - const outputDir = path.join(projectDir, "dist", "release") - expect( - JSON.parse(await readFile(path.join(outputDir, "checks.json"), "utf8")), - ).toEqual({ errors: [error], warnings: [warning] }) -}) - test("release rejects unknown presets, conflicting formats, and unwritable destinations", async () => { const { tmpDir, runCommand } = await getCliTestFixture() expect( @@ -162,26 +82,3 @@ test("release rejects unknown presets, conflicting formats, and unwritable desti ).not.toBe(0) expect(await readFile(outputPath, "utf8")).toBe("keep me") }) - -test("a failed release write removes the previous completion manifest", async () => { - const { tmpDir, runCommand } = await getCliTestFixture() - const circuitPath = path.join(tmpDir, "board.circuit.json") - await writeFile( - circuitPath, - await readFile( - path.resolve( - import.meta.dir, - "../../fixtures/export/output-path-board.circuit.json", - ), - ), - ) - const outputDir = path.join(tmpDir, "release") - await mkdir(path.join(outputDir, "circuit.json"), { recursive: true }) - await writeFile(path.join(outputDir, "release-manifest.json"), "old manifest") - const result = await runCommand( - `tsci export ${circuitPath} --preset release --output ${outputDir}`, - ) - expect(result.exitCode).not.toBe(0) - expect(result.stdout).not.toContain("Exported to") - expect(await readdir(outputDir)).not.toContain("release-manifest.json") -}) From 6aeee0fd5872fbee425ea72f57f329270d351096 Mon Sep 17 00:00:00 2001 From: mohan-bee Date: Sun, 6 Sep 2026 18:58:31 +0530 Subject: [PATCH 3/4] up --- cli/export/register.ts | 18 +++++++++++------- tests/cli/export/export-release-preset.test.ts | 10 ++++++---- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/cli/export/register.ts b/cli/export/register.ts index a204ce303..d04ac59d9 100644 --- a/cli/export/register.ts +++ b/cli/export/register.ts @@ -1,4 +1,4 @@ -import { Option, type Command } from "commander" +import type { Command } from "commander" import { exportSnippet } from "lib/shared/export-snippet" import type { ExportFormat } from "lib/shared/export-snippet" import { ALLOWED_EXPORT_FORMATS } from "lib/shared/export-snippet" @@ -23,11 +23,7 @@ export const registerExport = (program: Command) => { "-f, --format ", `Output format (${ALLOWED_EXPORT_FORMATS.join(", ")})`, ) - .addOption( - new Option("--preset ", "Export a preset bundle") - .choices(["release"]) - .conflicts("format"), - ) + .option("--preset ", "Export a preset bundle (release)") .option("-o, --output ", "Output file path or preset directory") .option("--disable-parts-engine", "Disable the parts engine") .option("--show-courtyards", "Show courtyard outlines in PCB SVG output") @@ -35,13 +31,21 @@ export const registerExport = (program: Command) => { async ( file, options: { - preset?: "release" + preset?: string format?: string output?: string disablePartsEngine?: boolean showCourtyards?: boolean }, ) => { + if (options.preset !== undefined && options.preset !== "release") { + console.error(`Invalid preset: ${options.preset}. Expected release.`) + process.exit(1) + } + if (options.preset !== undefined && options.format !== undefined) { + console.error("The release preset cannot be combined with a format") + process.exit(1) + } const formatOption = options.format ?? "json" const projectConfig = await loadRuntimeProjectConfig(process.cwd()) diff --git a/tests/cli/export/export-release-preset.test.ts b/tests/cli/export/export-release-preset.test.ts index 3c5b261a3..f3da562b6 100644 --- a/tests/cli/export/export-release-preset.test.ts +++ b/tests/cli/export/export-release-preset.test.ts @@ -59,15 +59,17 @@ test( test("release rejects unknown presets, conflicting formats, and unwritable destinations", async () => { const { tmpDir, runCommand } = await getCliTestFixture() - expect( - (await runCommand("tsci export missing.tsx --preset unknown")).exitCode, - ).not.toBe(0) + const invalidPreset = await runCommand( + "tsci export missing.tsx --preset unknown", + ) + expect(invalidPreset.exitCode).not.toBe(0) + expect(invalidPreset.stderr).toContain("Invalid preset: unknown") for (const format of ["json", "spice", "gerbers"]) { const conflict = await runCommand( `tsci export missing.tsx --preset release --format ${format}`, ) expect(conflict.exitCode).not.toBe(0) - expect(conflict.stderr).toContain("cannot be used with option") + expect(conflict.stderr).toContain("cannot be combined with a format") } const circuitPath = path.join(tmpDir, "board.circuit.json") await writeFile(circuitPath, "[]") From 3283168ccad469fb8237da6adfab3e5d9a35d938 Mon Sep 17 00:00:00 2001 From: mohan-bee Date: Sun, 6 Sep 2026 19:01:22 +0530 Subject: [PATCH 4/4] up --- README.md | 8 ++++---- cli/export/register.ts | 19 +++++++++---------- lib/shared/export-snippet.ts | 17 ++++++++--------- ...-preset.test.ts => export-release.test.ts} | 17 +++++++---------- 4 files changed, 28 insertions(+), 33 deletions(-) rename tests/cli/export/{export-release-preset.test.ts => export-release.test.ts} (78%) diff --git a/README.md b/README.md index c22f0a32a..ec2b4db47 100644 --- a/README.md +++ b/README.md @@ -84,15 +84,15 @@ The `build` command also accepts the following options: ### Release export ```bash -tsci export board.tsx --preset release --output dist/release +tsci export board.tsx --release --output dist/release ``` -The release preset builds once and reuses that Circuit JSON to write +`--release` builds once and reuses that Circuit JSON to write `circuit.json`, `schematic.svg`, `pcb.svg`, and `gerbers.zip`. The Gerber ZIP includes the existing BOM and JLCPCB pick-and-place files. -`--output` is a directory for presets and defaults to `dist/release`, relative -to the input file's directory. It is created automatically. `--preset` cannot +`--output` is a directory for release exports and defaults to `dist/release`, relative +to the input file's directory. It is created automatically. `--release` cannot be combined with `--format`. ### KiCad PCM compatibility diff --git a/cli/export/register.ts b/cli/export/register.ts index d04ac59d9..61d8fd50a 100644 --- a/cli/export/register.ts +++ b/cli/export/register.ts @@ -23,27 +23,26 @@ export const registerExport = (program: Command) => { "-f, --format ", `Output format (${ALLOWED_EXPORT_FORMATS.join(", ")})`, ) - .option("--preset ", "Export a preset bundle (release)") - .option("-o, --output ", "Output file path or preset directory") + .option( + "--release", + "Export circuit JSON, schematic SVG, PCB SVG, and Gerbers", + ) + .option("-o, --output ", "Output file path or release directory") .option("--disable-parts-engine", "Disable the parts engine") .option("--show-courtyards", "Show courtyard outlines in PCB SVG output") .action( async ( file, options: { - preset?: string + release?: boolean format?: string output?: string disablePartsEngine?: boolean showCourtyards?: boolean }, ) => { - if (options.preset !== undefined && options.preset !== "release") { - console.error(`Invalid preset: ${options.preset}. Expected release.`) - process.exit(1) - } - if (options.preset !== undefined && options.format !== undefined) { - console.error("The release preset cannot be combined with a format") + if (options.release && options.format !== undefined) { + console.error("--release cannot be combined with --format") process.exit(1) } const formatOption = options.format ?? "json" @@ -103,7 +102,7 @@ export const registerExport = (program: Command) => { await exportSnippet({ filePath: file, format, - preset: options.preset, + release: options.release, outputPath: options.output, platformConfig: platformConfigWithCliDefaults, pcbSnapshotSettings: options.showCourtyards diff --git a/lib/shared/export-snippet.ts b/lib/shared/export-snippet.ts index f26b1ee04..e9cc0f8c3 100644 --- a/lib/shared/export-snippet.ts +++ b/lib/shared/export-snippet.ts @@ -111,7 +111,7 @@ const unwrapSimpleRouteJson = (value: unknown) => { type ExportOptions = { filePath: string format?: ExportFormat - preset?: "release" + release?: boolean writeFile?: boolean outputPath?: string platformConfig?: PlatformConfig @@ -127,7 +127,7 @@ type ExportOptions = { export const exportSnippet = async ({ filePath, format: formatOption, - preset, + release = false, outputPath, platformConfig, pcbSnapshotSettings, @@ -136,8 +136,8 @@ export const exportSnippet = async ({ onError = (message) => console.error(message), onSuccess = (result: unknown) => console.log(result), }: ExportOptions) => { - if (preset && formatOption) { - onError("The release preset cannot be combined with a format") + if (release && formatOption) { + onError("--release cannot be combined with --format") return onExit(1) } const format = formatOption ?? "json" @@ -149,9 +149,9 @@ export const exportSnippet = async ({ const projectDir = path.dirname(filePath) const outputBaseName = path.basename(filePath).replace(/\.[^.]+$/, "") let outputFileName = `${outputBaseName}${OUTPUT_EXTENSIONS[format]}` - if (preset === "release") { + if (release) { if (!writeFile) { - onError("The release preset requires writing to an output directory") + onError("Release export requires writing to an output directory") return onExit(1) } outputFileName = path.join("dist", "release") @@ -203,8 +203,7 @@ export const exportSnippet = async ({ return onExit(1) } } else { - const isJlcpcbFabricationExport = - format === "gerbers" || preset === "release" + const isJlcpcbFabricationExport = format === "gerbers" || release const fabricationPlatformConfig = isJlcpcbFabricationExport ? getPlatformConfigWithCliDefaults( mergePlatformConfigs(platformConfig, { @@ -228,7 +227,7 @@ export const exportSnippet = async ({ circuitJson = circuitData.circuitJson } - if (preset === "release") { + if (release) { try { await fs.promises.mkdir(outputDestination, { recursive: true }) for (const { format, fileName } of RELEASE_EXPORTS) { diff --git a/tests/cli/export/export-release-preset.test.ts b/tests/cli/export/export-release.test.ts similarity index 78% rename from tests/cli/export/export-release-preset.test.ts rename to tests/cli/export/export-release.test.ts index f3da562b6..c91ba62f9 100644 --- a/tests/cli/export/export-release-preset.test.ts +++ b/tests/cli/export/export-release.test.ts @@ -31,7 +31,7 @@ test( ) const result = await runCommand( - `tsci export ${circuitPath} --preset release --output dist/release --disable-parts-engine`, + `tsci export ${circuitPath} --release --output dist/release --disable-parts-engine`, ) expect(result.exitCode).toBe(0) expect(await readFile(path.join(tmpDir, "builds.txt"), "utf8")).toBe( @@ -57,19 +57,16 @@ test( { timeout: 60_000 }, ) -test("release rejects unknown presets, conflicting formats, and unwritable destinations", async () => { +test("release rejects conflicting formats and unwritable destinations", async () => { const { tmpDir, runCommand } = await getCliTestFixture() - const invalidPreset = await runCommand( - "tsci export missing.tsx --preset unknown", - ) - expect(invalidPreset.exitCode).not.toBe(0) - expect(invalidPreset.stderr).toContain("Invalid preset: unknown") for (const format of ["json", "spice", "gerbers"]) { const conflict = await runCommand( - `tsci export missing.tsx --preset release --format ${format}`, + `tsci export missing.tsx --release --format ${format}`, ) expect(conflict.exitCode).not.toBe(0) - expect(conflict.stderr).toContain("cannot be combined with a format") + expect(conflict.stderr).toContain( + "--release cannot be combined with --format", + ) } const circuitPath = path.join(tmpDir, "board.circuit.json") await writeFile(circuitPath, "[]") @@ -78,7 +75,7 @@ test("release rejects unknown presets, conflicting formats, and unwritable desti expect( ( await runCommand( - `tsci export ${circuitPath} --preset release --output ${outputPath}`, + `tsci export ${circuitPath} --release --output ${outputPath}`, ) ).exitCode, ).not.toBe(0)