ShiboSoftwareDev/i-wan

This code defines a PCB and schematic for a microcontroller development board, incorporating various surface-mount components (resistors, capacitors, headers, connectors, crystals, and an IC), with detailed footprints, electrical nets, and routing instructions.

Version
1.0.10
License
unset
Stars
0

routing/ThroughViaFanoutAutorouter.ts

import {
	AutoroutingPipelineSolver,
	type Obstacle,
	type SimpleRouteBus,
	type SimpleRouteConnection,
	type SimpleRouteJson,
	type SimplifiedPcbTrace,
} from "@tscircuit/capacity-autorouter";
import { FanoutSolver, type FanoutBusSpec } from "@tscircuit/fanout-solver";

type FanoutDirection = "left" | "right" | "up" | "down";
type Point = { x: number; y: number };
type Bounds = { minX: number; maxX: number; minY: number; maxY: number };
type RoutePoint = SimplifiedPcbTrace["route"][number];
type AutorouterEventMap = {
	complete: { type: "complete"; traces: SimplifiedPcbTrace[] };
	error: { type: "error"; error: Error };
	progress: {
		type: "progress";
		steps: number;
		progress: number;
		phase?: string;
		iterationsPerSecond?: number;
	};
};

const OUTER_LAYERS = ["top", "bottom"] as const;
const FANOUT_PADDING_MM = 1;
const LOCAL_FANOUT_PADDING_MM = 0.5;
const ROUTING_CLEARANCE_MM = 0.15;
const VIA_TO_PAD_ROUTING_CLEARANCE_MM = 0.155;

const getOuterLayerRoutingProblem = (
	input: SimpleRouteJson,
): SimpleRouteJson => ({
	...input,
	// Route with guard-band above the board's 0.10 mm DRC rule.
	// This compensates for the autorouter/core geometry-grid mismatch without
	// changing the physical board constraint or biasing any path/via location.
	defaultObstacleMargin: Math.max(
		input.defaultObstacleMargin ?? 0,
		ROUTING_CLEARANCE_MM,
	),
	minTraceToPadEdgeClearance: Math.max(
		input.minTraceToPadEdgeClearance ?? 0,
		ROUTING_CLEARANCE_MM,
	),
	minViaEdgeToPadEdgeClearance: Math.max(
		input.minViaEdgeToPadEdgeClearance ?? 0,
		VIA_TO_PAD_ROUTING_CLEARANCE_MM,
	),
	// The physical board remains four-layer. Presenting only the two outer
	// copper layers to the routing solvers means every emitted top/bottom
	// transition is interpreted by tscircuit as a full-stack L1-L4 via.
	layerCount: 2,
	buses: input.buses?.map((bus) => ({
		...bus,
		allowedLayers: bus.allowedLayers?.filter((layer) =>
			OUTER_LAYERS.includes(layer as (typeof OUTER_LAYERS)[number]),
		),
	})),
	obstacles: input.obstacles.flatMap((obstacle): Obstacle[] => {
		const layers = OUTER_LAYERS.filter((layer) =>
			obstacle.layers.includes(layer),
		);
		if (layers.length === 0) return [];
		const zLayers = layers.map((layer) => (layer === "top" ? 0 : 1));
		return [{ ...obstacle, layers: [...layers], zLayers, __zLayers: zLayers }];
	}),
});

const getObstacleGroups = (obstacles: Obstacle[]) => {
	const groups = new Map<string, Obstacle[]>();
	for (const obstacle of obstacles) {
		if (!obstacle.componentId) continue;
		const group = groups.get(obstacle.componentId) ?? [];
		group.push(obstacle);
		groups.set(obstacle.componentId, group);
	}
	return groups;
};

const getBounds = (obstacles: Obstacle[]): Bounds => ({
	minX: Math.min(
		...obstacles.map((obstacle) => obstacle.center.x - obstacle.width / 2),
	),
	maxX: Math.max(
		...obstacles.map((obstacle) => obstacle.center.x + obstacle.width / 2),
	),
	minY: Math.min(
		...obstacles.map((obstacle) => obstacle.center.y - obstacle.height / 2),
	),
	maxY: Math.max(
		...obstacles.map((obstacle) => obstacle.center.y + obstacle.height / 2),
	),
});

const expandBounds = (bounds: Bounds, padding: number): Bounds => ({
	minX: bounds.minX - padding,
	maxX: bounds.maxX + padding,
	minY: bounds.minY - padding,
	maxY: bounds.maxY + padding,
});

const pointIsInBounds = (point: Point, bounds: Bounds) =>
	point.x >= bounds.minX &&
	point.x <= bounds.maxX &&
	point.y >= bounds.minY &&
	point.y <= bounds.maxY;

const getOutwardDirection = (point: Point, center: Point): FanoutDirection => {
	const dx = point.x - center.x;
	const dy = point.y - center.y;
	if (Math.abs(dx) >= Math.abs(dy)) return dx < 0 ? "left" : "right";
	return dy < 0 ? "down" : "up";
};

const getQfnSupplyRailTokens = (input: SimpleRouteJson) => {
	const qfnBounds = [...getObstacleGroups(input.obstacles).values()]
		.filter((componentObstacles) => componentObstacles.length >= 20)
		.map(getBounds);
	const supplyRailTokens = new Set<string>();
	for (const connection of input.connections) {
		if (connection.pointsToConnect.length < 2) continue;
		const qfnPadCounts = qfnBounds.map((bounds) => {
			const padLocations = new Set(
				connection.pointsToConnect
					.filter((point) => pointIsInBounds(point, bounds))
					.map((point) => `${point.x.toFixed(6)}:${point.y.toFixed(6)}`),
			);
			return padLocations.size;
		});
		// Supply rails usually feed a small group of QFN supply pads. The ground
		// return has many more package pads and is already handled by the board's
		// inner ground plane, so keep it in the global same-net tree.
		const totalQfnPadCount = qfnPadCounts.reduce(
			(total, padCount) => total + padCount,
			0,
		);
		if (totalQfnPadCount >= 2 && totalQfnPadCount <= 4) {
			supplyRailTokens.add(connection.name);
		}
	}
	return supplyRailTokens;
};

const connectionMatchesNetToken = (
	connection: SimpleRouteConnection,
	netTokens: Set<string>,
) =>
	[
		connection.name,
		connection.rootConnectionName,
		connection.netConnectionName,
		connection.__netConnectionName,
		...(connection.mergedConnectionNames ?? []),
		...(connection.__rootConnectionNames ?? []),
	].some((token) => token !== undefined && netTokens.has(token));

/**
 * Apply one coordinated fanout-solver operation to each QFN-like device. The
 * buses and their directions come entirely from package geometry and
 * connectivity; there are no authored via coordinates, route points, or
 * per-pin direction overrides.
 */
const addQfnFanout = (
	input: SimpleRouteJson,
	supplyRailTokens: Set<string>,
) => {
	let output = input;
	const fanoutPrefixes = new Map<string, SimplifiedPcbTrace>();
	const obstacleGroups = getObstacleGroups(input.obstacles);
	const applyFanout = ({
		componentId,
		candidates,
		sharedBoundary,
		escapeLayers,
		busLabel,
		termination = { type: "boundary" },
	}: {
		componentId: string;
		candidates: Array<{
			direction: FanoutDirection;
			connection: SimpleRouteConnection;
		}>;
		sharedBoundary: Bounds;
		escapeLayers: Array<(typeof OUTER_LAYERS)[number]>;
		busLabel: string;
		termination?: NonNullable<FanoutBusSpec["termination"]>;
	}) => {
		if (candidates.length === 0) return;
		const fanoutConnections = candidates.map(({ connection }) => connection);
		const buses: FanoutBusSpec[] = (["left", "right", "up", "down"] as const)
			.map((direction) => ({
				busId: `${componentId}-${busLabel}-${direction}`,
				connectionNames: candidates
					.filter((candidate) => candidate.direction === direction)
					.map(({ connection }) => connection.name),
				direction,
				termination,
			}))
			.filter((bus) => bus.connectionNames.length > 0);

		const fanoutSolver = new FanoutSolver(
			{
				...output,
				connections: fanoutConnections,
				buses: buses as SimpleRouteBus[],
			},
			{
				buses,
				sourceComponentId: componentId,
				escapeLayers,
				sharedBoundary,
				allowSameNetMerges: true,
				compactBusTracks: true,
				singleLayerPushAndShove: true,
				singleLayerAdaptiveExits: true,
				borderDistribution: "preserve",
			},
		);
		fanoutSolver.solve();
		if (fanoutSolver.failed) {
			throw new Error(
				`Coordinated QFN ${busLabel} fanout failed for ${componentId}: ${fanoutSolver.error}`,
			);
		}

		const fanoutOutput = fanoutSolver.getOutputSimpleRouteJson();
		const priorTraceIds = new Set(
			(output.traces ?? []).map((trace) => trace.pcb_trace_id),
		);
		for (const connection of fanoutConnections) {
			const prefix = fanoutOutput.traces?.find(
				(trace) =>
					trace.connection_name === connection.name &&
					!priorTraceIds.has(trace.pcb_trace_id),
			);
			if (!prefix) {
				throw new Error(
					`Coordinated QFN ${busLabel} fanout did not emit a prefix for ${connection.name}`,
				);
			}
			fanoutPrefixes.set(connection.name, prefix);
		}
		const routedNames = new Set(
			fanoutConnections.map(({ name }) => name),
		);
		const untouchedConnections = output.connections.filter(
			({ name }) => !routedNames.has(name),
		);
		output = {
			...fanoutOutput,
			connections: [...fanoutOutput.connections, ...untouchedConnections],
			buses: output.buses,
		};
	};

	for (const [componentId, componentObstacles] of obstacleGroups) {
		// On this board the two QFNs are the only top-side packages with at least
		// twenty component-tagged copper obstacles. Escape every connected pad on
		// each QFN in one coordinated, geometry-derived operation.
		if (componentObstacles.length < 20) continue;

		const componentBounds = getBounds(componentObstacles);
		const fanoutBounds = expandBounds(componentBounds, FANOUT_PADDING_MM);
		const componentCenter = {
			x: (componentBounds.minX + componentBounds.maxX) / 2,
			y: (componentBounds.minY + componentBounds.maxY) / 2,
		};
		const allCandidates: Array<{
			direction: FanoutDirection;
			connection: SimpleRouteConnection;
			destinationLayer: string;
			isLocal: boolean;
		}> = [];
		const candidatePadLocations = new Set<string>();

		for (const connection of output.connections) {
			// Multi-point nets are collected separately below; this pass handles
			// ordinary point-to-point package connections only.
			if (connection.pointsToConnect.length !== 2) continue;
			const sourcePointIndex = connection.pointsToConnect.findIndex((point) =>
				pointIsInBounds(point, componentBounds),
			);
			if (sourcePointIndex < 0) continue;
			const sourcePoint = connection.pointsToConnect[sourcePointIndex];
			const destinationPoint = connection.pointsToConnect[1 - sourcePointIndex];
			if (!sourcePoint || !destinationPoint) continue;
			const padLocationKey = `${sourcePoint.x.toFixed(6)}:${sourcePoint.y.toFixed(6)}`;
			// A single physical pad may appear in several logical two-point traces.
			// Escape that copper location once; Pipeline 9 keeps the other same-net
			// branches connected to the preloaded trace.
			if (candidatePadLocations.has(padLocationKey)) continue;
			candidatePadLocations.add(padLocationKey);
			const direction = getOutwardDirection(sourcePoint, componentCenter);
			const destinationLayer =
				"layer" in destinationPoint
					? destinationPoint.layer
					: (destinationPoint.layers.find((layer) =>
							OUTER_LAYERS.includes(
								layer as (typeof OUTER_LAYERS)[number],
							),
						) ?? "top");
			allCandidates.push({
				direction,
				connection,
				destinationLayer,
				// A compact secondary escape is useful for the cluster of adjacent
				// bypass parts beside the package's left edge. Other directions
				// retain the wider coordinated boundary, which gives their global
				// continuations more room to meet the preloaded prefix.
				isLocal:
					direction === "left" &&
					destinationLayer === "bottom" &&
					Math.hypot(
						destinationPoint.x - sourcePoint.x,
						destinationPoint.y - sourcePoint.y,
					) <= 3,
			});
		}

		const localFanoutBounds = expandBounds(
			componentBounds,
			LOCAL_FANOUT_PADDING_MM,
		);
		for (const layer of OUTER_LAYERS) {
			applyFanout({
				componentId,
				candidates: allCandidates.filter(
					(candidate) =>
						candidate.isLocal && candidate.destinationLayer === layer,
				),
				sharedBoundary: localFanoutBounds,
				escapeLayers: [layer],
				busLabel: `local-${layer}`,
			});
		}
		// Split a merged QFN supply tree into one solver-owned branch per supply
		// pad. Each branch is escaped to the shared top-layer boundary before the
		// global same-net solver runs, keeping its vias outside the fine-pitch pad
		// field without authoring any route point or via coordinate.
		const supplyCandidates: Array<{
			direction: FanoutDirection;
			connection: SimpleRouteConnection;
		}> = [];
		const rewrittenConnections: SimpleRouteConnection[] = [];
		for (const connection of output.connections) {
			if (!connectionMatchesNetToken(connection, supplyRailTokens)) {
				rewrittenConnections.push(connection);
				continue;
			}
			const componentPoints = connection.pointsToConnect.filter((point) =>
				pointIsInBounds(point, componentBounds),
			);
			if (componentPoints.length === 0) {
				rewrittenConnections.push(connection);
				continue;
			}
			const componentPointSet = new Set(componentPoints);
			const remainingPoints = connection.pointsToConnect.filter(
				(point) => !componentPointSet.has(point),
			);
			const anchorPoint = remainingPoints[0];
			if (!anchorPoint) {
				rewrittenConnections.push(connection);
				continue;
			}
			if (remainingPoints.length >= 2) {
				rewrittenConnections.push({
					...connection,
					pointsToConnect: remainingPoints,
				});
			}
			for (const [pointIndex, point] of componentPoints.entries()) {
				const supplyConnection: SimpleRouteConnection = {
					...connection,
					name: `${connection.name}__supply_${componentId}_${pointIndex}`,
					rootConnectionName:
						connection.rootConnectionName ?? connection.name,
					netConnectionName:
						connection.netConnectionName ??
						connection.rootConnectionName ??
						connection.name,
					pointsToConnect: [point, anchorPoint],
				};
				rewrittenConnections.push(supplyConnection);
				supplyCandidates.push({
					direction: getOutwardDirection(point, componentCenter),
					connection: supplyConnection,
				});
			}
		}
		output = { ...output, connections: rewrittenConnections };
		applyFanout({
			componentId,
			candidates: supplyCandidates,
			sharedBoundary: fanoutBounds,
			escapeLayers: ["top"],
			busLabel: "supply",
		});
		applyFanout({
			componentId,
			candidates: allCandidates.filter((candidate) => !candidate.isLocal),
			sharedBoundary: fanoutBounds,
			escapeLayers: ["top"],
			busLabel: "external",
		});
	}

	return { routingProblem: output, fanoutPrefixes };
};

const reverseRoute = (route: RoutePoint[]): RoutePoint[] =>
	route.toReversed().map((point) => {
		if (point.route_type === "via") {
			return {
				...point,
				from_layer: point.to_layer,
				to_layer: point.from_layer,
			};
		}
		if (point.route_type !== "wire") return point;
		const { start_pcb_port_id, end_pcb_port_id, ...rest } = point;
		return {
			...rest,
			...(end_pcb_port_id ? { start_pcb_port_id: end_pcb_port_id } : {}),
			...(start_pcb_port_id ? { end_pcb_port_id: start_pcb_port_id } : {}),
		};
	});

const routeEndsTouch = (first: RoutePoint, second: RoutePoint) => {
	// The downstream DRC improver snaps fanout exits to its 1 µm geometry grid.
	const endpointToleranceMm = 0.01;
	if (
		(first.route_type !== "wire" && first.route_type !== "via") ||
		(second.route_type !== "wire" && second.route_type !== "via")
	) {
		return false;
	}
	return (
		Math.abs(first.x - second.x) <= endpointToleranceMm &&
		Math.abs(first.y - second.y) <= endpointToleranceMm &&
		(first.route_type === "via" ||
			second.route_type === "via" ||
			first.layer === second.layer)
	);
};

const joinFanoutPrefix = (
	completion: SimplifiedPcbTrace,
	prefix: SimplifiedPcbTrace,
): SimplifiedPcbTrace => {
	const completionStart = completion.route[0];
	const completionEnd = completion.route.at(-1);
	const prefixStart = prefix.route[0];
	const prefixEnd = prefix.route.at(-1);
	if (!completionStart || !completionEnd || !prefixStart || !prefixEnd) {
		throw new Error(
			`Cannot join empty fanout route ${completion.connection_name}`,
		);
	}

	if (routeEndsTouch(prefixEnd, completionStart)) {
		return {
			...completion,
			route: [...prefix.route, ...completion.route.slice(1)],
		};
	}
	if (routeEndsTouch(prefixEnd, completionEnd)) {
		return {
			...completion,
			route: [...completion.route, ...reverseRoute(prefix.route).slice(1)],
		};
	}
	// Pipeline 9 can absorb a short preloaded local fanout into its final route
	// and return a complete source-to-destination trace instead of a route that
	// begins at the fanout exit. In that case the original pad endpoint proves
	// that the prefix is already represented and must not be concatenated again.
	if (
		routeEndsTouch(prefixStart, completionStart) ||
		routeEndsTouch(prefixStart, completionEnd)
	) {
		return completion;
	}
	throw new Error(
		`Global route for ${completion.connection_name} does not meet its fanout prefix`,
	);
};

const fanoutPrefixTouchesTrace = (
	completion: SimplifiedPcbTrace,
	prefix: SimplifiedPcbTrace,
) => {
	const prefixEnd = prefix.route.at(-1);
	const completionStart = completion.route[0];
	const completionEnd = completion.route.at(-1);
	return Boolean(
		prefixEnd &&
			((completionStart && routeEndsTouch(prefixEnd, completionStart)) ||
				(completionEnd && routeEndsTouch(prefixEnd, completionEnd))),
	);
};

class ThroughViaFanoutAutorouter {
	readonly input: SimpleRouteJson;
	isRouting = false;

	private readonly solver: AutoroutingPipelineSolver;
	private readonly fanoutPrefixes: Map<string, SimplifiedPcbTrace>;
	private readonly eventHandlers: {
		[K in keyof AutorouterEventMap]: Array<
			(event: AutorouterEventMap[K]) => void
		>;
	} = { complete: [], error: [], progress: [] };
	private timeoutId: ReturnType<typeof setTimeout> | undefined;
	private cycleCount = 0;

	constructor(input: SimpleRouteJson) {
		this.input = input;
		const supplyRailTokens = getQfnSupplyRailTokens(input);
		const outerLayerProblem = getOuterLayerRoutingProblem(input);
		const { routingProblem, fanoutPrefixes } = addQfnFanout(
			outerLayerProblem,
			supplyRailTokens,
		);
		this.fanoutPrefixes = fanoutPrefixes;
		this.solver = new AutoroutingPipelineSolver(routingProblem, {
			// The optional expander only widens already-routed power nets. It can
			// exhaust its fixed iteration budget on this board; the trace-width
			// solver has already applied the requested per-net widths.
			powerTraceExpansion: { onlyConnectionNames: [] },
		});
	}

	on<K extends keyof AutorouterEventMap>(
		eventName: K,
		callback: (event: AutorouterEventMap[K]) => void,
	) {
		this.eventHandlers[eventName].push(callback as never);
	}

	start() {
		if (this.isRouting) return;
		this.isRouting = true;
		this.cycleCount = 0;
		this.timeoutId = setTimeout(() => this.runCycle(), 0);
	}

	stop() {
		this.isRouting = false;
		if (this.timeoutId !== undefined) clearTimeout(this.timeoutId);
		this.timeoutId = undefined;
	}

	solveSync(): SimplifiedPcbTrace[] {
		this.solver.solve();
		if (this.solver.failed) {
			throw new Error(this.solver.error ?? "Outer-layer autorouting failed");
		}
		return this.getFinalTraces();
	}

	private getFinalTraces() {
		const completions = this.solver.getOutputSimplifiedPcbTraces();
		const unmatchedPrefixes = new Map(this.fanoutPrefixes);
		const traces = completions.map((completion) => {
			const directPrefix = unmatchedPrefixes.get(completion.connection_name);
			const matchingPrefix = directPrefix
				? ([completion.connection_name, directPrefix] as const)
				: [...unmatchedPrefixes].find(([, prefix]) =>
						fanoutPrefixTouchesTrace(completion, prefix),
					);
			if (!matchingPrefix) return completion;
			unmatchedPrefixes.delete(matchingPrefix[0]);
			return joinFanoutPrefix(completion, matchingPrefix[1]);
		});
		if (unmatchedPrefixes.size > 0) {
			throw new Error(
				`Global routes did not meet ${unmatchedPrefixes.size} fanout prefix(es): ${[
					...unmatchedPrefixes.keys(),
				].join(", ")}`,
			);
		}
		return traces;
	}

	private emit<K extends keyof AutorouterEventMap>(
		eventName: K,
		event: AutorouterEventMap[K],
	) {
		for (const callback of this.eventHandlers[eventName]) callback(event);
	}

	private runCycle() {
		if (!this.isRouting) return;
		try {
			if (this.solver.failed) {
				this.isRouting = false;
				this.emit("error", {
					type: "error",
					error: new Error(
						this.solver.error ?? "Outer-layer autorouting failed",
					),
				});
				return;
			}
			if (this.solver.solved) {
				this.isRouting = false;
				this.emit("complete", {
					type: "complete",
					traces: this.getFinalTraces(),
				});
				return;
			}

			const startedAt = Date.now();
			const startingIterations = this.solver.iterations;
			while (
				Date.now() - startedAt < 200 &&
				!this.solver.solved &&
				!this.solver.failed
			) {
				this.solver.step();
			}
			const elapsedMs = Math.max(1, Date.now() - startedAt);
			this.cycleCount++;
			this.emit("progress", {
				type: "progress",
				steps: this.cycleCount,
				progress: this.solver.progress,
				phase: this.solver.getCurrentPhase(),
				iterationsPerSecond:
					((this.solver.iterations - startingIterations) / elapsedMs) * 1000,
			});
			this.timeoutId = setTimeout(() => this.runCycle(), 0);
		} catch (error) {
			this.isRouting = false;
			this.emit("error", {
				type: "error",
				error: error instanceof Error ? error : new Error(String(error)),
			});
		}
	}
}

export const throughViaFanoutAutorouter = {
	local: true,
	groupMode: "subcircuit" as const,
	algorithmFn: async (simpleRouteJson: SimpleRouteJson) =>
		new ThroughViaFanoutAutorouter(simpleRouteJson),
};