astra-abse/t113-s3
Allwinner T113-S3 / JLCPCB C5197687: native saved fanout, LCD top, storage right, 127 perimeter exits, 30 decoupling capacitors, connected GND planes; clean DRC and shorts, all 29 vias connected.
- Version
- 1.2.0
- License
- MIT
- Stars
- 0
scripts/copper-clearance.ts
import type { AnyCircuitElement } from "circuit-json";
import { elements } from "./audit";
export type Point = { x: number; y: number };
export type Copper = { id: string; net: string; layers: string[] } & (
| { kind: "segment"; a: Point; b: Point; r: number }
| { kind: "rect"; x: number; y: number; w: number; h: number }
| { kind: "circle"; x: number; y: number; r: number }
| { kind: "polygon"; rings: Point[][] }
);
const pointSegment = (p: Point, a: Point, b: Point) => {
const dx = b.x - a.x,
dy = b.y - a.y;
const t = Math.max(
0,
Math.min(
1,
((p.x - a.x) * dx + (p.y - a.y) * dy) / (dx * dx + dy * dy || 1),
),
);
return Math.hypot(p.x - a.x - t * dx, p.y - a.y - t * dy);
};
const cross = (a: Point, b: Point, c: Point) =>
(b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);
function segmentDistance(a: Point, b: Point, c: Point, d: Point) {
const s1 = cross(a, b, c),
s2 = cross(a, b, d),
s3 = cross(c, d, a),
s4 = cross(c, d, b);
const boundsOverlap =
Math.max(Math.min(a.x, b.x), Math.min(c.x, d.x)) <=
Math.min(Math.max(a.x, b.x), Math.max(c.x, d.x)) + 1e-10 &&
Math.max(Math.min(a.y, b.y), Math.min(c.y, d.y)) <=
Math.min(Math.max(a.y, b.y), Math.max(c.y, d.y)) + 1e-10;
if (boundsOverlap && s1 * s2 <= 0 && s3 * s4 <= 0) return 0;
return Math.min(
pointSegment(a, c, d),
pointSegment(b, c, d),
pointSegment(c, a, b),
pointSegment(d, a, b),
);
}
type Rect = Extract<Copper, { kind: "rect" }>;
function segmentRect(a: Point, b: Point, r: Rect) {
const inside = (p: Point) =>
Math.abs(p.x - r.x) <= r.w / 2 && Math.abs(p.y - r.y) <= r.h / 2;
if (inside(a) || inside(b)) return 0;
const corners = [
{ x: r.x - r.w / 2, y: r.y - r.h / 2 },
{ x: r.x + r.w / 2, y: r.y - r.h / 2 },
{ x: r.x + r.w / 2, y: r.y + r.h / 2 },
{ x: r.x - r.w / 2, y: r.y + r.h / 2 },
];
return Math.min(
...corners.map((c, i) => segmentDistance(a, b, c, corners[(i + 1) % 4]!)),
);
}
function inRing(p: Point, ring: Point[]) {
let inside = false;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
const a = ring[i]!,
b = ring[j]!;
if (
a.y > p.y !== b.y > p.y &&
p.x < ((b.x - a.x) * (p.y - a.y)) / (b.y - a.y) + a.x
)
inside = !inside;
}
return inside;
}
function inPour(p: Point, rings: Point[][]) {
return inRing(p, rings[0]!) && !rings.slice(1).some((r) => inRing(p, r));
}
export function gap(a: Copper, b: Copper): number {
if (a.kind === "polygon") {
if (b.kind === "polygon")
throw new Error(
"Different-net overlapping pours need a polygon-polygon checker",
);
let distance = Infinity;
const points =
b.kind === "segment"
? [b.a, b.b]
: b.kind === "circle"
? [b]
: [
{ x: b.x - b.w / 2, y: b.y - b.h / 2 },
{ x: b.x + b.w / 2, y: b.y - b.h / 2 },
{ x: b.x + b.w / 2, y: b.y + b.h / 2 },
{ x: b.x - b.w / 2, y: b.y + b.h / 2 },
];
if (points.some((p) => inPour(p, a.rings))) return -("r" in b ? b.r : 0);
for (const ring of a.rings)
for (let i = 0; i < ring.length; i++) {
const c = ring[i]!,
d = ring[(i + 1) % ring.length]!;
const v =
b.kind === "segment"
? segmentDistance(b.a, b.b, c, d) - b.r
: b.kind === "circle"
? pointSegment(b, c, d) - b.r
: segmentRect(c, d, b);
distance = Math.min(distance, v);
}
return distance;
}
if (b.kind === "polygon") return gap(b, a);
if (a.kind === "segment") {
if (b.kind === "segment")
return segmentDistance(a.a, a.b, b.a, b.b) - a.r - b.r;
if (b.kind === "circle") return pointSegment(b, a.a, a.b) - a.r - b.r;
return segmentRect(a.a, a.b, b) - a.r;
}
if (b.kind === "segment") return gap(b, a);
if (a.kind === "circle") {
if (b.kind === "circle")
return Math.hypot(a.x - b.x, a.y - b.y) - a.r - b.r;
return (
Math.hypot(
Math.max(0, Math.abs(a.x - b.x) - b.w / 2),
Math.max(0, Math.abs(a.y - b.y) - b.h / 2),
) - a.r
);
}
if (b.kind === "circle") return gap(b, a);
return Math.hypot(
Math.max(0, Math.abs(a.x - b.x) - (a.w + b.w) / 2),
Math.max(0, Math.abs(a.y - b.y) - (a.h + b.h) / 2),
);
}
/** Independent analytic clearance check for this module's rectangular pads,
* circular vias and straight copper segments. Intended nets come only from
* source connectivity, never inferred from touching copper/endpoints.
* Board-world millimeters; +X right, +Y top. Includes wire-to-via segments.
*/
export function extractCopper(json: AnyCircuitElement[]) {
const ports = elements(json, "source_port"),
pcbPorts = elements(json, "pcb_port");
const sourceTraces = elements(json, "source_trace"),
traces = elements(json, "pcb_trace");
const copper: Copper[] = [];
const traceNet = (id: string | undefined) => {
const source = sourceTraces.find((t) => t.source_trace_id === id);
if (!source?.subcircuit_connectivity_map_key)
throw new Error(`Trace ${id} lacks intended source connectivity`);
return source.subcircuit_connectivity_map_key;
};
for (const pad of elements(json, "pcb_smtpad")) {
if (pad.shape !== "rect") throw new Error("Unsupported pad geometry");
const port = ports.find(
(p) =>
p.source_port_id ===
pcbPorts.find((p) => p.pcb_port_id === pad.pcb_port_id)?.source_port_id,
)!;
copper.push({
id: pad.pcb_smtpad_id,
net:
port.subcircuit_connectivity_map_key ??
`unconnected:${port.source_port_id}`,
layers: [pad.layer],
kind: "rect",
x: pad.x,
y: pad.y,
w: pad.width,
h: pad.height,
});
}
for (const trace of traces) {
const net = traceNet(trace.source_trace_id);
let width = 0.1;
for (let i = 0; i < trace.route.length - 1; i++) {
const a = trace.route[i]!,
b = trace.route[i + 1]!;
if (
(a.route_type !== "wire" && a.route_type !== "via") ||
(b.route_type !== "wire" && b.route_type !== "via")
)
throw new Error("Unsupported route geometry");
if (a.route_type === "wire") width = a.width;
const layer = a.route_type === "via" ? a.to_layer : a.layer;
const destinationLayer = b.route_type === "via" ? b.from_layer : b.layer;
if (layer !== destinationLayer)
throw new Error("Unbridged layer transition");
copper.push({
id: `${trace.pcb_trace_id}:${i}`,
net,
layers: [layer],
kind: "segment",
a,
b,
r: width / 2,
});
}
}
for (const via of elements(json, "pcb_via")) {
const trace = traces.find((t) => t.pcb_trace_id === via.pcb_trace_id)!;
copper.push({
id: via.pcb_via_id,
net: traceNet(trace.source_trace_id),
layers: via.layers,
kind: "circle",
x: via.x,
y: via.y,
r: via.outer_diameter / 2,
});
}
for (const pour of elements(json, "pcb_copper_pour")) {
if (pour.shape !== "brep") throw new Error("Unsupported pour geometry");
const net = elements(json, "source_net").find(
(n) => n.source_net_id === pour.source_net_id,
);
if (!net?.subcircuit_connectivity_map_key)
throw new Error("Pour lacks source net identity");
const rings = [pour.brep_shape.outer_ring, ...pour.brep_shape.inner_rings];
if (
rings.some(
(r) =>
r.vertices.length < 3 ||
r.vertices.some((p) =>
Object.keys(p).some((k) => k !== "x" && k !== "y"),
),
)
)
throw new Error("Unsupported pour vertices");
copper.push({
id: pour.pcb_copper_pour_id,
net: net.subcircuit_connectivity_map_key,
layers: [pour.layer],
kind: "polygon",
rings: rings.map((r) => r.vertices),
});
}
return copper;
}
export function checkCopperClearance(json: AnyCircuitElement[], minimum = 0.1) {
const copper = extractCopper(json);
const violations: { first: string; second: string; gapMm: number }[] = [];
let minGap = Infinity,
pairs = 0;
for (let i = 0; i < copper.length; i++)
for (let k = i + 1; k < copper.length; k++) {
const a = copper[i]!,
b = copper[k]!;
if (a.net === b.net || !a.layers.some((l) => b.layers.includes(l)))
continue;
pairs++;
const distance = gap(a, b);
minGap = Math.min(minGap, distance);
if (distance < minimum - 1e-6)
violations.push({ first: a.id, second: b.id, gapMm: distance });
}
return {
minimumRequiredMm: minimum,
minimumMeasuredMm: minGap,
pairsChecked: pairs,
violations,
};
}