Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
16 changes: 16 additions & 0 deletions .github/workflows/pr-preview.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
name: PR package preview
on:
pull_request:
permissions:
contents: read
jobs:
preview:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.4.0
- run: bun install
- run: bun run build
- run: bunx pkg-pr-new@0.0.37 publish --comment=off
351 changes: 351 additions & 0 deletions docs/bus-lanes-integrated-dogbones-plan.md

Large diffs are not rendered by default.

19 changes: 17 additions & 2 deletions lib/IsolatedCircuit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,8 +225,23 @@ export class IsolatedCircuit {
this.render()

while (!this.isDoneRendering()) {
await new Promise((resolve) => setTimeout(resolve, 100))
this.render()
await new Promise<void>((resolve) => {
const resume = () => {
clearTimeout(timer)
this.removeListener("asyncEffect:end", resume)
resolve()
}
const timer = setTimeout(resume, 100)
this.on("asyncEffect:end", resume)
if (this._hasUnrenderedUpdatesFromAsyncEffects) resume()
})
// A running effect blocks its dependent phases. Revisit the tree when
// an effect completes, rather than traversing every component on idle polls.
if (
this._hasUnrenderedUpdatesFromAsyncEffects ||
this._asyncEffectPhaseById.size === 0
)
this.render()
}

this.emit("renderComplete")
Expand Down
6 changes: 5 additions & 1 deletion lib/solvers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { BusLanesSolver } from "@tscircuit/bus-lanes-solver"
import {
BusLanesSolver,
BusLanesPipelineSolver,
} from "@tscircuit/bus-lanes-solver"
import {
AssignableAutoroutingPipeline2,
AssignableAutoroutingPipeline3,
Expand Down Expand Up @@ -27,6 +30,7 @@ import { DogboneFanoutSolver } from "@tscircuit/dogbone-solver"
export const SOLVERS = {
DogboneFanoutSolver,
BusLanesSolver,
BusLanesPipelineSolver,
PackSolver2,
LayoutPipelineSolver,
AutoroutingPipelineSolver,
Expand Down
25 changes: 18 additions & 7 deletions lib/utils/autorouting/BusLanesAutorouter.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {
BusLanesSolver,
BusLanesPipelineSolver,
type SimpleRouteJson as BusLanesInput,
} from "@tscircuit/bus-lanes-solver"
import type {
Expand All @@ -14,15 +14,15 @@ import type { SimpleRouteJson, SimplifiedPcbTrace } from "./SimpleRouteJson"
* solve emits an error; it never falls back to a router that can insert vias. */
export class BusLanesAutorouter implements GenericLocalAutorouter {
isRouting = false
private solver: BusLanesSolver
private timer?: ReturnType<typeof setTimeout>
private solver: BusLanesPipelineSolver
private cancelScheduledTick?: () => void
private listeners: Array<{
event: AutorouterEvent["type"]
callback: (event: AutorouterEvent) => void
}> = []
constructor(public input: SimpleRouteJson) {
// The solver validates unsupported SRJ route primitives at its input boundary.
this.solver = new BusLanesSolver(input as unknown as BusLanesInput)
this.solver = new BusLanesPipelineSolver(input as unknown as BusLanesInput)
}
on(
event: "complete",
Expand Down Expand Up @@ -81,7 +81,7 @@ export class BusLanesAutorouter implements GenericLocalAutorouter {
phase: this.solver.phase,
debugGraphics: this.solver.visualize(),
})
this.timer = setTimeout(tick, 0)
this.scheduleTick(tick)
} catch (error) {
this.isRouting = false
this.emit({
Expand All @@ -90,11 +90,22 @@ export class BusLanesAutorouter implements GenericLocalAutorouter {
})
}
}
this.timer = setTimeout(tick, 0)
this.scheduleTick(tick)
}
private scheduleTick(tick: () => void) {
// Node/Bun can yield to I/O without imposing the timer's minimum delay.
// Browsers retain their normal task scheduling and cancellation behavior.
if (typeof globalThis.setImmediate === "function") {
const timer = globalThis.setImmediate(tick)
this.cancelScheduledTick = () => globalThis.clearImmediate(timer)
} else {
const timer = setTimeout(tick, 0)
this.cancelScheduledTick = () => clearTimeout(timer)
}
}
stop() {
this.isRouting = false
if (this.timer) clearTimeout(this.timer)
this.cancelScheduledTick?.()
}
solveSync(): SimplifiedPcbTrace[] {
this.solver.solve()
Expand Down
7 changes: 6 additions & 1 deletion lib/utils/autorouting/getPresetAutoroutingConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,12 @@ export function getPresetAutoroutingConfig(
preset: "single_layer_fanout",
}
case "bus-lanes":
return { local: true, groupMode: "subcircuit", preset: "bus_lanes" }
return {
...providedConfig,
local: true,
groupMode: "subcircuit",
preset: "bus_lanes",
}
case "dogbone":
return { local: true, groupMode: "subcircuit", preset: "dogbone" }
case "fanout":
Expand Down
4 changes: 2 additions & 2 deletions lib/utils/autorouting/local-autorouter-strategies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,10 +142,10 @@ const localAutorouterStrategies = new Map<string, LocalAutorouterStrategy>([
{
name: "bus_lanes",
cacheable: false,
getSolverName: () => "BusLanesSolver",
getSolverName: () => "BusLanesPipelineSolver",
create: ({ simpleRouteJson, onSolverStarted }) => {
onSolverStarted?.({
solverName: "BusLanesSolver",
solverName: "BusLanesPipelineSolver",
solverParams: simpleRouteJson,
solverConstructorArgs: [simpleRouteJson],
})
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

This file was deleted.

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
103 changes: 103 additions & 0 deletions tests/features/autorouter-bus-lanes-am3352.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { expect, test } from "bun:test"
import { getTestFixture } from "tests/fixtures/get-test-fixture"
import {
measureRoutingQuality,
measurePairInteriorGaps,
} from "tests/fixtures/am3352-ram-bus-lanes/measure-routing-quality"
import Board from "tests/fixtures/am3352-ram-bus-lanes"

test("bus_lanes routes AM3352 DDR3 from original pads without a custom algorithm", async () => {
const { circuit } = getTestFixture()
circuit.add(<Board />)
await circuit.renderUntilSettled()
const json = circuit.getCircuitJson()
expect(json.filter((e) => e.type.endsWith("_error"))).toEqual([])
expect(json.filter((e) => e.type === "source_trace")).toHaveLength(47)
expect(json.filter((e) => e.type === "pcb_trace")).toHaveLength(47)
const traces = json.filter((e) => e.type === "pcb_trace")
expect(new Set(traces.map((t) => t.source_trace_id)).size).toBe(47)
for (const trace of traces) {
const vias = trace.route.flatMap((p, i) =>
p.route_type === "via" ? [i] : [],
)
expect(vias).toHaveLength(2)
const carrier = trace.route.slice(vias[0] + 1, vias[1])
expect(carrier.every((p) => p.route_type === "wire")).toBe(true)
expect(
new Set(
carrier.map((p) => (p.route_type === "wire" ? p.layer : undefined)),
).size,
).toBe(1)
}
// Differential pairs also export source_bus membership in Circuit JSON.
expect(
json
.filter((e) => e.type === "source_bus")
.map((e) => e.name)
.sort(),
).toEqual([
"DDR_BYTE0",
"DDR_BYTE1",
"DDR_CK_PAIR",
"DDR_DQS_PAIR0",
"DDR_DQS_PAIR1",
])
const scores = measureRoutingQuality(traces)
expect(scores.reduce((n, s) => n + s.acuteCorners, 0)).toBe(0)
// Guard the improved compact routing while allowing numerical roundoff.
// These ceilings also accept the preceding 1549.71 mm / 540-turn result.
expect(scores.reduce((n, s) => n + s.planarLength, 0)).toBeLessThanOrEqual(
1550,
)
expect(Math.max(...scores.map((s) => s.detourRatio))).toBeLessThanOrEqual(
2.05,
)
expect(
scores.reduce((n, s) => n + s.detourRatio, 0) / scores.length,
).toBeLessThanOrEqual(1.55)
expect(scores.reduce((n, s) => n + s.ordinaryTurns, 0)).toBeLessThanOrEqual(
540,
)
expect(scores.reduce((n, s) => n + s.shortJogs, 0)).toBeLessThanOrEqual(145)
for (const bus of json.filter((e) => e.type === "source_bus")) {
const lengths = bus.source_trace_ids.map(
(id) => scores.find((s) => s.sourceTraceId === id)!.planarLength,
)
expect(Math.max(...lengths) - Math.min(...lengths)).toBeLessThanOrEqual(
bus.max_length_skew! + 1e-8,
)
}
const namedTrace = (name: string) => {
const source = json.find(
(e) => e.type === "source_trace" && e.name === name,
)
if (!source || source.type !== "source_trace")
throw Error(`Missing ${name}`)
return traces.find((t) => t.source_trace_id === source.source_trace_id)!
}
for (const [positive, negative] of [
["DDR_DQS0", "DDR_DQSn0"],
["DDR_DQS1", "DDR_DQSn1"],
["DDR_CK", "DDR_CKn"],
]) {
const pair = [namedTrace(positive), namedTrace(negative)]
const lengths = measureRoutingQuality(pair).map((s) => s.planarLength)
expect(Math.abs(lengths[0] - lengths[1])).toBeLessThanOrEqual(0.127 + 1e-8)
const gaps = measurePairInteriorGaps(pair[0], pair[1])
expect(gaps.min).toBeGreaterThanOrEqual(0.0999)
expect(gaps.max).toBeLessThanOrEqual(0.155)
}
// Signal layers are explicit: the top layer contains only local dogbones.
// Write snapshots only after every connectivity, DRC, and quality gate passes.
for (const layer of ["inner1", "inner2", "bottom"] as const)
await expect(circuit).toMatchPcbSnapshot(
import.meta.path.replace(".test.tsx", `-${layer}.test.tsx`),
{
layer,
hiddenLayerOpacity: 0.08,
width: 900,
height: 1400,
viewport: { minX: -10, maxX: 10, minY: -35, maxY: -2 },
},
)
}, 900_000)
92 changes: 57 additions & 35 deletions tests/features/autorouter-bus-lanes-layer-error.test.tsx
Original file line number Diff line number Diff line change
@@ -1,42 +1,60 @@
import { expect, test } from "bun:test"
import { getTestFixture } from "tests/fixtures/get-test-fixture"
test("bus_lanes does not fall back to a multilayer router", async () => {

test("bus_lanes rejects incompatible existing fanout exits without dogboning them again", async () => {
const { circuit } = getTestFixture()
const inputs: any[] = []
circuit.on("autorouting:start", (event: any) =>
inputs.push(event.simpleRouteJson),
)
circuit.add(
<board width={14} height={10} doubleSidedAssembly>
<autoroutingphase name="NO_VIAS" phaseIndex={0} autorouter="bus_lanes" />
<resistor name="TOP" resistance="1k" footprint="0402" pcbX={-4} />
<resistor
name="BOTTOM"
resistance="1k"
footprint="0402"
pcbX={4}
layer="bottom"
/>
<board width={16} height={10} doubleSidedAssembly routeRemaining={false}>
{(["top", "bottom"] as const).map((layer, i) => {
const name = i ? "B" : "A"
return (
<fanout
key={name}
name={`${name}_FANOUT`}
pcbRelative
pcbX={i ? 4 : -4}
pcbY={0}
pcbTracePaths={[
{
connection: `${name}.pin1`,
route: [
{ route_type: "wire", x: 0, y: 0, layer, width: 0.1 },
{
route_type: "wire",
x: i ? -2 : 2,
y: 0,
layer,
width: 0.1,
},
],
},
]}
>
<chip name={name} layer={layer} pinLabels={{ pin1: "DATA" }}>
<footprint>
<smtpad
shape="circle"
radius={0.2}
pcbX={0}
pcbY={0}
portHints={["pin1"]}
/>
</footprint>
</chip>
</fanout>
)
})}
<autoroutingphase autorouter="bus_lanes" phaseIndex={0} />
<trace
name="DATA"
from=".TOP > .pin2"
to=".BOTTOM > .pin1"
from=".A > .pin1"
to=".B > .pin1"
routingPhaseIndex={0}
/>
<pcbnotetext
pcbX={0}
pcbY={-2.8}
fontSize={0.32}
text="DATA connects TOP.pin2 (top) to BOTTOM.pin1 (bottom)."
/>
<pcbnotetext
pcbX={0}
pcbY={-3.45}
fontSize={0.32}
text="bus_lanes rejects endpoints without a common fixed layer."
/>
<pcbnotetext
pcbX={0}
pcbY={-4.1}
fontSize={0.32}
text="Expected routing error: no traces, vias, or router fallback."
/>
</board>,
)
await circuit.renderUntilSettled()
Expand All @@ -45,11 +63,15 @@ test("bus_lanes does not fall back to a multilayer router", async () => {
json.some(
(e) =>
e.type === "pcb_autorouting_error" &&
e.message.includes("common fixed layer"),
e.message.includes("existing fanout handoffs cannot be dogboned again"),
),
).toBe(true)
expect(json.filter((e) => e.type === "pcb_via")).toHaveLength(0)
const input = inputs.at(-1)
expect(input.traces).toHaveLength(2)
expect(
json.filter((e) => e.type === "pcb_via" || e.type === "pcb_trace"),
).toHaveLength(0)
expect(circuit).toMatchPcbSnapshot(import.meta.path)
input.connections[0].pointsToConnect.map((p: any) => p.layer).sort(),
).toEqual(["bottom", "top"])
// The failed phase publishes no partial or layer-changing route.
expect(json.filter((e) => e.type === "pcb_trace")).toHaveLength(0)
})
Loading
Loading