diff --git a/README.md b/README.md index a4d78e205..dd5d72e51 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,8 @@ The `build` command also accepts the following options: - `--ignore-placement-drc` - suppress placement DRC diagnostics - `--ignore-routing-drc` - suppress routing DRC diagnostics +The build exits with code 1 when traced PCB nets are not fully routed. + ### KiCad PCM compatibility `tsci build --kicad-pcm` uses the package license from `package.json` and diff --git a/cli/build/register.ts b/cli/build/register.ts index aede927b0..ff25d586f 100644 --- a/cli/build/register.ts +++ b/cli/build/register.ts @@ -2,8 +2,10 @@ import fs from "node:fs" import path from "node:path" import JSZip from "jszip" import type { PlatformConfig } from "@tscircuit/props" +import type { AnyCircuitElement } from "circuit-json" import type { Command } from "commander" import kleur from "kleur" +import { analyzePcbRoutingCompleteness } from "lib/shared/analyze-pcb-routing-completeness" import { getCircuitJsonOutputDirName } from "lib/shared/circuit-json-build-cache" import { loadRuntimeProjectConfig } from "lib/project-config" import { @@ -409,6 +411,7 @@ export const registerBuild = (program: Command) => { let hasErrors = false let hasFatalErrors = false + let unroutedPcbNetCount = 0 const ignoredDrcByCategory: DrcIgnoreCounts = { netlist: 0, pin_specification: 0, @@ -494,7 +497,7 @@ export const registerBuild = (program: Command) => { outputPath: string, buildOutcome: { ok: boolean - circuitJson?: unknown[] + circuitJson?: AnyCircuitElement[] hasErrors?: boolean ignoredDrcByCategory?: DrcIgnoreCounts isFatalError?: { errorType: string; message: string } @@ -502,6 +505,14 @@ export const registerBuild = (program: Command) => { ) => { const relative = path.relative(projectDir, filePath) const outputDirName = getCircuitJsonOutputDirName(relative) + let circuitJson = buildOutcome.circuitJson + const getCircuitJson = (): AnyCircuitElement[] | undefined => { + if (!circuitJson && fs.existsSync(outputPath)) { + const parsed = JSON.parse(fs.readFileSync(outputPath, "utf-8")) + circuitJson = Array.isArray(parsed) ? parsed : undefined + } + return circuitJson + } builtFiles.push({ sourcePath: filePath, @@ -547,14 +558,38 @@ export const registerBuild = (program: Command) => { }) } + if (buildOutcome.ok) { + const builtCircuitJson = getCircuitJson() + if (builtCircuitJson) { + const routingCompleteness = + analyzePcbRoutingCompleteness(builtCircuitJson) + const fileUnroutedNetCount = + routingCompleteness.unroutedNets.length + unroutedPcbNetCount += fileUnroutedNetCount + + if (fileUnroutedNetCount > 0) { + hasErrors = true + console.error( + kleur.red( + `Unrouted PCB net${fileUnroutedNetCount === 1 ? "" : "s"} in ${relative}: ${fileUnroutedNetCount}`, + ), + ) + for (const unroutedNet of routingCompleteness.unroutedNets) { + console.error( + kleur.red( + ` - ${unroutedNet.label} (${unroutedNet.disconnectedGroupCount} disconnected groups)`, + ), + ) + } + } + } + } + if (buildOutcome.ok && shouldGenerateKicadProject) { // Read circuit JSON from file if not provided (worker mode doesn't pass it through IPC) - let circuitJson = buildOutcome.circuitJson - if (!circuitJson && fs.existsSync(outputPath)) { - circuitJson = JSON.parse(fs.readFileSync(outputPath, "utf-8")) - } + const builtCircuitJson = getCircuitJson() - if (circuitJson) { + if (builtCircuitJson) { const projectOutputDir = path.join( distDir, outputDirName, @@ -566,7 +601,7 @@ export const registerBuild = (program: Command) => { resolvedOptions?.kicadProjectZip, ) const project = await generateKicadProject({ - circuitJson, + circuitJson: builtCircuitJson, outputDir: projectOutputDir, projectName, writeFiles: shouldWriteKicadFiles, @@ -1036,8 +1071,8 @@ export const registerBuild = (program: Command) => { } } - // Fatal errors (e.g., circuit generation exceptions) always cause exit code 1. - const shouldExitNonZero = hasFatalErrors + // Fatal generation errors and incomplete PCB routing always fail. + const shouldExitNonZero = hasFatalErrors || unroutedPcbNetCount > 0 const successCount = builtFiles.filter((f) => f.ok).length const failCount = builtFiles.length - successCount @@ -1096,6 +1131,9 @@ export const registerBuild = (program: Command) => { console.log( ` Circuits ${kleur.green(`${successCount} passed`)}${failCount > 0 ? kleur.red(` ${failCount} failed`) : ""}`, ) + console.log( + ` Routing ${unroutedPcbNetCount === 0 ? kleur.green("0 unrouted") : kleur.red(`${unroutedPcbNetCount} unrouted`)}`, + ) if (enabledOpts.length > 0) { console.log(` Options ${kleur.cyan(enabledOpts.join(", "))}`) } @@ -1122,7 +1160,12 @@ export const registerBuild = (program: Command) => { : kleur.green("\n✓ Done"), ) if (shouldExitNonZero) { - exitBuild(1, "fatal circuit build errors occurred") + exitBuild( + 1, + hasFatalErrors + ? "fatal circuit build errors occurred" + : "unrouted PCB nets found", + ) } exitBuild(0, "build finished successfully") diff --git a/lib/shared/analyze-pcb-routing-completeness.ts b/lib/shared/analyze-pcb-routing-completeness.ts new file mode 100644 index 000000000..417df2a42 --- /dev/null +++ b/lib/shared/analyze-pcb-routing-completeness.ts @@ -0,0 +1,171 @@ +import type { AnyCircuitElement } from "circuit-json" +import { + getSourcePortConnectivityMapFromCircuitJson, + PcbConnectivityMap, +} from "circuit-json-to-connectivity-map" + +export type UnroutedPcbNet = { + label: string + sourcePortIds: string[] + pcbPortIds: string[] + disconnectedGroupCount: number +} + +export type PcbRoutingCompleteness = { + checkedNetCount: number + routedNetCount: number + unroutedNets: UnroutedPcbNet[] +} + +const unique = (values: T[]): T[] => [...new Set(values)] + +const PCB_LAYER_OFFSETS: Record = { + top: 0, + inner1: 1, + inner2: 2, + inner3: 3, + inner4: 4, + inner5: 5, + inner6: 6, + inner7: 7, + inner8: 8, + bottom: 9, +} + +const separatePcbTraceLayers = ( + circuitJson: AnyCircuitElement[], +): AnyCircuitElement[] => + circuitJson.map((element) => { + if (element.type !== "pcb_trace") return element + + return { + ...element, + route: element.route.map((routePoint) => + routePoint.route_type === "wire" + ? { + ...routePoint, + y: + routePoint.y + + (PCB_LAYER_OFFSETS[routePoint.layer] ?? 0) * 1_000_000, + } + : routePoint, + ), + } + }) + +export const analyzePcbRoutingCompleteness = ( + circuitJson: AnyCircuitElement[], +): PcbRoutingCompleteness => { + const sourceTraces = circuitJson.filter( + (element) => element.type === "source_trace", + ) + const sourceNetsById = new Map( + circuitJson + .filter((element) => element.type === "source_net") + .map((sourceNet) => [sourceNet.source_net_id, sourceNet]), + ) + const directlyTracedSourcePortIds = new Set( + sourceTraces.flatMap((trace) => trace.connected_source_port_ids ?? []), + ) + const sourcePortToPcbPortIds = new Map() + + for (const element of circuitJson) { + if (element.type !== "pcb_port" || !element.source_port_id) continue + const pcbPortIds = sourcePortToPcbPortIds.get(element.source_port_id) ?? [] + pcbPortIds.push(element.pcb_port_id) + sourcePortToPcbPortIds.set(element.source_port_id, pcbPortIds) + } + + const expectedConnectivity = + getSourcePortConnectivityMapFromCircuitJson(circuitJson) + // PcbConnectivityMap detects geometric trace intersections without checking + // layers. Separate layer coordinates before analysis so an ordinary + // top/bottom crossing is not mistaken for a copper connection. + const pcbConnectivity = new PcbConnectivityMap( + separatePcbTraceLayers(circuitJson), + ) + + // PCB ports that are electrically joined inside a component do not require + // an external copper connection, so include those links in the physical map. + for (const element of circuitJson) { + const internalSourcePortGroups = + element.type === "source_component" + ? (element.internally_connected_source_port_ids ?? []) + : element.type === "source_component_internal_connection" + ? [element.source_port_ids] + : [] + + for (const sourcePortGroup of internalSourcePortGroups) { + const pcbPortIds = unique( + sourcePortGroup.flatMap( + (sourcePortId) => sourcePortToPcbPortIds.get(sourcePortId) ?? [], + ), + ) + if (pcbPortIds.length > 1) { + pcbConnectivity.connMap.addConnections([pcbPortIds]) + } + } + } + + const unroutedNets: UnroutedPcbNet[] = [] + let checkedNetCount = 0 + + for (const expectedIds of Object.values(expectedConnectivity.netMap)) { + // Internal-only component ports are intentionally omitted. A PCB net is + // only required for ports that participate directly in a source trace. + const sourcePortIds = unique( + expectedIds.filter( + (id) => + directlyTracedSourcePortIds.has(id) && sourcePortToPcbPortIds.has(id), + ), + ) + const pcbPortIds = unique( + sourcePortIds.flatMap( + (sourcePortId) => sourcePortToPcbPortIds.get(sourcePortId) ?? [], + ), + ) + + if (pcbPortIds.length < 2) continue + checkedNetCount += 1 + + const physicalGroups = new Set( + pcbPortIds.map( + (pcbPortId) => + pcbConnectivity.connMap.getNetConnectedToId(pcbPortId) ?? + `unconnected:${pcbPortId}`, + ), + ) + if (physicalGroups.size <= 1) continue + + const sourceNetNames = expectedIds.flatMap((id) => { + const sourceNet = sourceNetsById.get(id) + return sourceNet?.name ? [sourceNet.name] : [] + }) + const sourcePortIdSet = new Set(sourcePortIds) + const representativeTrace = sourceTraces.find((trace) => + trace.connected_source_port_ids?.some((sourcePortId) => + sourcePortIdSet.has(sourcePortId), + ), + ) + const label = + sourceNetNames.length > 0 + ? sourceNetNames.map((name) => `net.${name}`).join(", ") + : (representativeTrace?.display_name ?? + representativeTrace?.name ?? + representativeTrace?.source_trace_id ?? + "unnamed net") + + unroutedNets.push({ + label, + sourcePortIds, + pcbPortIds, + disconnectedGroupCount: physicalGroups.size, + }) + } + + return { + checkedNetCount, + routedNetCount: checkedNetCount - unroutedNets.length, + unroutedNets, + } +} diff --git a/tests/cli/build/build-fail-on-unrouted.test.ts b/tests/cli/build/build-fail-on-unrouted.test.ts new file mode 100644 index 000000000..3f59c350b --- /dev/null +++ b/tests/cli/build/build-fail-on-unrouted.test.ts @@ -0,0 +1,46 @@ +import { expect, test } from "bun:test" +import { writeFile } from "node:fs/promises" +import path from "node:path" +import { getCliTestFixture } from "../../fixtures/get-cli-test-fixture" + +const circuitCode = ` +export default () => ( + + + + + +)` + +const setupCircuit = async (tmpDir: string) => { + await writeFile(path.join(tmpDir, "board.circuit.tsx"), circuitCode) + await writeFile(path.join(tmpDir, "package.json"), "{}") +} + +test("build succeeds when every PCB net is routed", async () => { + const { tmpDir, runCommand } = await getCliTestFixture() + await setupCircuit(tmpDir) + + const { exitCode, stdout, stderr } = await runCommand( + "tsci build board.circuit.tsx --disable-parts-engine", + ) + + expect(exitCode).toBe(0) + expect(stderr).toBe("") + expect(stdout).toContain("Routing 0 unrouted") +}, 60_000) + +test("build fails on unrouted PCB nets even with --ignore-errors", async () => { + const { tmpDir, runCommand } = await getCliTestFixture() + await setupCircuit(tmpDir) + + const { exitCode, stdout, stderr } = await runCommand( + "tsci build board.circuit.tsx --disable-parts-engine --routing-disabled --ignore-errors", + ) + + expect(exitCode).toBe(1) + expect(stdout).toContain("Routing 1 unrouted") + expect(stdout).toContain("Build completed with errors") + expect(stderr).toContain("Unrouted PCB net") + expect(stderr).toContain(".R1 > .pin2 to .R2 > .pin1") +}, 60_000) diff --git a/tests/shared/analyze-pcb-routing-completeness.test.ts b/tests/shared/analyze-pcb-routing-completeness.test.ts new file mode 100644 index 000000000..1c62323a1 --- /dev/null +++ b/tests/shared/analyze-pcb-routing-completeness.test.ts @@ -0,0 +1,155 @@ +import { expect, test } from "bun:test" +import type { AnyCircuitElement } from "circuit-json" +import { analyzePcbRoutingCompleteness } from "lib/shared/analyze-pcb-routing-completeness" + +const getCircuitJson = ({ + includeRoute, + includeInternalConnection = false, +}: { + includeRoute: boolean + includeInternalConnection?: boolean +}): AnyCircuitElement[] => { + const circuitJson: any[] = [ + { + type: "source_trace", + source_trace_id: "source_trace_0", + connected_source_port_ids: ["source_port_0", "source_port_1"], + connected_source_net_ids: [], + display_name: ".R1 > .pin2 to .R2 > .pin1", + }, + { + type: "pcb_port", + pcb_port_id: "pcb_port_0", + source_port_id: "source_port_0", + pcb_component_id: "pcb_component_0", + layers: ["top"], + x: -5, + y: 0, + }, + { + type: "pcb_port", + pcb_port_id: "pcb_port_1", + source_port_id: "source_port_1", + pcb_component_id: "pcb_component_1", + layers: ["top"], + x: 5, + y: 0, + }, + ] + + if (includeRoute) { + circuitJson.push({ + type: "pcb_trace", + pcb_trace_id: "pcb_trace_0", + source_trace_id: "source_trace_0", + route: [ + { + route_type: "wire", + x: -5, + y: 0, + width: 0.2, + layer: "top", + start_pcb_port_id: "pcb_port_0", + }, + { + route_type: "wire", + x: 5, + y: 0, + width: 0.2, + layer: "top", + end_pcb_port_id: "pcb_port_1", + }, + ], + }) + } + + if (includeInternalConnection) { + circuitJson.push({ + type: "source_component_internal_connection", + source_component_internal_connection_id: "internal_connection_0", + source_component_id: "source_component_0", + source_port_ids: ["source_port_0", "source_port_1"], + }) + } + + return circuitJson as AnyCircuitElement[] +} + +test("reports a directly traced PCB net without copper as unrouted", () => { + const result = analyzePcbRoutingCompleteness( + getCircuitJson({ includeRoute: false }), + ) + + expect(result.checkedNetCount).toBe(1) + expect(result.routedNetCount).toBe(0) + expect(result.unroutedNets).toEqual([ + { + label: ".R1 > .pin2 to .R2 > .pin1", + sourcePortIds: ["source_port_0", "source_port_1"], + pcbPortIds: ["pcb_port_0", "pcb_port_1"], + disconnectedGroupCount: 2, + }, + ]) +}) + +test("accepts routed and internally connected PCB nets", () => { + const routed = analyzePcbRoutingCompleteness( + getCircuitJson({ includeRoute: true }), + ) + const internallyConnected = analyzePcbRoutingCompleteness( + getCircuitJson({ + includeRoute: false, + includeInternalConnection: true, + }), + ) + + expect(routed.routedNetCount).toBe(1) + expect(routed.unroutedNets).toHaveLength(0) + expect(internallyConnected.routedNetCount).toBe(1) + expect(internallyConnected.unroutedNets).toHaveLength(0) +}) + +test("does not connect traces that cross on different PCB layers", () => { + const circuitJson = getCircuitJson({ includeRoute: false }) as any[] + circuitJson.push( + { + type: "pcb_trace", + pcb_trace_id: "pcb_trace_0", + source_trace_id: "source_trace_0", + route: [ + { + route_type: "wire", + x: -5, + y: 0, + width: 0.2, + layer: "top", + start_pcb_port_id: "pcb_port_0", + }, + { route_type: "wire", x: 0, y: 0, width: 0.2, layer: "top" }, + ], + }, + { + type: "pcb_trace", + pcb_trace_id: "pcb_trace_1", + source_trace_id: "source_trace_0", + route: [ + { + route_type: "wire", + x: 0, + y: -5, + width: 0.2, + layer: "bottom", + start_pcb_port_id: "pcb_port_1", + }, + { route_type: "wire", x: 0, y: 0, width: 0.2, layer: "bottom" }, + ], + }, + ) + + const result = analyzePcbRoutingCompleteness( + circuitJson as AnyCircuitElement[], + ) + + expect(result.unroutedNets).toHaveLength(1) + expect(result.unroutedNets[0].disconnectedGroupCount).toBe(2) +})