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,
  };
}