Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
63 changes: 53 additions & 10 deletions cli/build/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -494,14 +497,22 @@ export const registerBuild = (program: Command) => {
outputPath: string,
buildOutcome: {
ok: boolean
circuitJson?: unknown[]
circuitJson?: AnyCircuitElement[]
hasErrors?: boolean
ignoredDrcByCategory?: DrcIgnoreCounts
isFatalError?: { errorType: string; message: string }
},
) => {
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,
Expand Down Expand Up @@ -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,
Expand All @@ -566,7 +601,7 @@ export const registerBuild = (program: Command) => {
resolvedOptions?.kicadProjectZip,
)
const project = await generateKicadProject({
circuitJson,
circuitJson: builtCircuitJson,
outputDir: projectOutputDir,
projectName,
writeFiles: shouldWriteKicadFiles,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(", "))}`)
}
Expand All @@ -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")
Expand Down
171 changes: 171 additions & 0 deletions lib/shared/analyze-pcb-routing-completeness.ts
Original file line number Diff line number Diff line change
@@ -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 = <T>(values: T[]): T[] => [...new Set(values)]

const PCB_LAYER_OFFSETS: Record<string, number> = {
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<string, string[]>()

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,
}
}
46 changes: 46 additions & 0 deletions tests/cli/build/build-fail-on-unrouted.test.ts
Original file line number Diff line number Diff line change
@@ -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 () => (
<board width="20mm" height="12mm">
<resistor name="R1" resistance="1k" footprint="0402" pcbX={-5} pcbY={0} />
<resistor name="R2" resistance="1k" footprint="0402" pcbX={5} pcbY={0} />
<trace name="T1" from=".R1 > .pin2" to=".R2 > .pin1" />
</board>
)`

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)
Loading
Loading