astra/am3352-sbc

A tscircuit package created by astra.

Version
0.1.28
License
unset
Stars
0

routing/saved-autorouter.ts

import type {
  GenericLocalAutorouter,
  SimpleRouteJson,
  SimplifiedPcbTrace,
} from "@tscircuit/core"

export type SavedPoint =
  | { route_type: "wire"; x: number; y: number; width: number; layer: string; width_interpolation_mode?: "linear" | "quadratic" }
  | {
      route_type: "via"
      x: number
      y: number
      from_layer: string
      to_layer: string
      layers?: readonly string[]
      via_diameter?: number
      via_hole_diameter?: number
    }

export type SavedRouteGroup = {
  name: string
  ports: readonly string[]
  netNames: readonly string[]
  traces: readonly { id: string; route: readonly SavedPoint[] }[]
  anchors?: readonly {
    selector: string
    x: number
    y: number
    layers: readonly string[]
  }[]
  /** Saved explicit vias, including stitching terminals that have no component selector. */
  viaAnchors?: readonly { x: number; y: number; layers: readonly string[] }[]
}

const tolerance = 1e-4
const fail = (message: string): never => {
  throw new Error(`Saved autorouting: ${message}`)
}

/** Physical component/pin selectors only; no generated Circuit JSON identifiers. */
function portKey(selector: string) {
  const value = selector.trim()
  const css = /^\.?([^\s.>]+)\s*>\s*(?:port)?\.([^\s.>]+)$/.exec(value)
  const dotted = /^\.?([^\s.>]+)\.([^\s.>]+)$/.exec(value)
  const match = css ?? dotted
  if (!match) return fail(`unsupported physical port selector ${selector}`)
  return `${match[1]}.${match[2]}`
}

function touchesRoute(point: { x: number; y: number; layer: string }, route: readonly SavedPoint[]) {
  for (let i = 0; i < route.length; i++) {
    const current = route[i]!
    if (current.route_type === "via") {
      const layers = current.layers ?? [current.from_layer, current.to_layer]
      if (layers.includes(point.layer) && Math.hypot(point.x - current.x, point.y - current.y) < tolerance) return true
      continue
    }
    if (current.layer !== point.layer) continue
    if (Math.hypot(point.x - current.x, point.y - current.y) < tolerance) return true
    const previous = route[i - 1]
    if (!previous || previous.route_type !== "wire" || previous.layer !== point.layer) continue
    const dx = current.x - previous.x, dy = current.y - previous.y
    const lengthSquared = dx * dx + dy * dy
    const fraction = lengthSquared === 0 ? 0 : Math.max(0, Math.min(1,
      ((point.x - previous.x) * dx + (point.y - previous.y) * dy) / lengthSquared))
    if (Math.hypot(point.x - previous.x - fraction * dx, point.y - previous.y - fraction * dy) < tolerance) return true
  }
  return false
}

function touchesAnchor(point: { x: number; y: number; layer: string }, anchor: { x: number; y: number; layers: readonly string[] }) {
  return Math.hypot(point.x - anchor.x, point.y - anchor.y) < tolerance && anchor.layers.includes(point.layer)
}

function sameConnection(first: SimpleRouteJson["connections"][number], second: SimpleRouteJson["connections"][number]) {
  const identities = (connection: SimpleRouteJson["connections"][number]) =>
    [connection.name, connection.netConnectionName, connection.rootConnectionName,
      connection.source_trace_id, ...(connection.mergedConnectionNames ?? [])]
      .filter((value): value is string => Boolean(value))
  const firstIdentities = new Set(identities(first))
  const firstPorts = new Set(first.pointsToConnect.map(point => point.pcb_port_id).filter((id): id is string => Boolean(id)))
  return identities(second).some(identity => firstIdentities.has(identity)) ||
    second.pointsToConnect.some(point => Boolean(point.pcb_port_id && firstPorts.has(point.pcb_port_id)))
}

/** Create once per board render, and share this same function across its phases.
 * Replays only saved autorouter output; never creates coordinates or runs a solver.
 * Routes remain separate so junctions and branches retain their original copper.
 */
export function savedAutorouter(groups: readonly SavedRouteGroup[]) {
  const byPort = new Map<string, number>()
  const emitted = new Map<number, string[]>()
  const emittedOwners = new Map<number, SimpleRouteJson["connections"][number]>()
  const names = new Set<string>()
  const ids = new Set<string>()
  for (const [index, group] of groups.entries()) {
    if (!group.name || names.has(group.name) || !group.ports.length || !group.traces.length) fail("groups require unique names, symbolic ports, and saved routes")
    names.add(group.name)
    for (const selector of group.ports) {
      const key = portKey(selector), previous = byPort.get(key)
      if (previous !== undefined && previous !== index) fail(`port ${key} occurs in multiple saved groups`)
      byPort.set(key, index)
    }
    for (const trace of group.traces) {
      const id = `${index}:${trace.id}`
      if (!trace.id || ids.has(id) || trace.route.length < 2) fail(`invalid or duplicate saved route in ${group.name}`)
      ids.add(id)
      for (const point of trace.route) {
        if (!Number.isFinite(point.x) || !Number.isFinite(point.y)) fail(`nonfinite saved coordinate in ${group.name}`)
        if (point.route_type === "wire") {
          if (!Number.isFinite(point.width) || point.width <= 0 || !point.layer) fail(`invalid saved wire in ${group.name}`)
        } else if (point.route_type === "via") {
          if (!point.from_layer || !point.to_layer || [point.via_diameter, point.via_hole_diameter].some(value => value !== undefined && (!Number.isFinite(value) || value <= 0))) fail(`invalid saved via in ${group.name}`)
        } else fail(`saved routes must contain wire/via points in ${group.name}`)
      }
    }
  }

  return async (input: SimpleRouteJson): Promise<GenericLocalAutorouter> => {
    if (!Number.isSafeInteger(input.layerCount) || input.layerCount < 1) fail("invalid current board layer count")
    const owners = new Map<number, SimpleRouteJson["connections"][number]>()
    const layers = input.layerCount === 1 ? ["top"] : ["top", ...Array.from({ length: input.layerCount - 2 }, (_, i) => `inner${i + 1}`), "bottom"]
    for (const connection of input.connections) {
      const groupIndices = new Set<number>()
      const anonymous = [] as typeof connection.pointsToConnect
      if (!connection.pointsToConnect.length) fail(`connection ${connection.name} has no physical terminals`)
      for (const point of connection.pointsToConnect) {
        if (!point.port_selector) { anonymous.push(point); continue }
        const key = portKey(point.port_selector!), index = byPort.get(key)
        if (index === undefined) fail(`no saved group covers ${key} in ${connection.name}`)
        groupIndices.add(index!)
        const group = groups[index!]!
        if (group.anchors?.length) {
          const anchors = group.anchors.filter(anchor => portKey(anchor.selector) === key)
          if (!anchors.length || !anchors.some(anchor => Math.hypot(point.x - anchor.x, point.y - anchor.y) < tolerance && anchor.layers.includes(point.layer))) fail(`placement/layer changed for ${key} in ${group.name}`)
        } else if (!group.traces.some(trace => touchesRoute(point, trace.route))) fail(`saved copper does not reach ${key} at its current placement/layer`)
      }
      if (!groupIndices.size) {
        // No port identity is guessed from nearby copper. Only an exact saved physical anchor can identify an anonymous-only connection.
        let candidates = new Set(groups.map((_, index) => index))
        for (const point of anonymous) candidates = new Set([...candidates].filter(index =>
          [...(groups[index]!.anchors ?? []), ...(groups[index]!.viaAnchors ?? [])].some(anchor => touchesAnchor(point, anchor))))
        for (const index of candidates) groupIndices.add(index)
      }
      if (groupIndices.size !== 1) fail(`connection ${connection.name} joins incompatible saved groups: ${[...groupIndices].map(index => groups[index]!.name).join(", ")}; selectors=${connection.pointsToConnect.map(point => point.port_selector ?? "anonymous").join(", ")}`)
      const index = [...groupIndices][0]!
      const group = groups[index]!
      for (const point of anonymous) {
        if (![...(group.anchors ?? []), ...(group.viaAnchors ?? [])].some(anchor => touchesAnchor(point, anchor)) &&
          !group.traces.some(trace => touchesRoute(point, trace.route))) fail(`anonymous terminal in ${connection.name} lacks a saved physical anchor/copper in ${group.name}`)
      }
      const existing = owners.get(index)
      if (existing && !sameConnection(existing, connection)) fail(`current connections ${existing.name} and ${connection.name} split saved group ${group.name}`)
      if (!existing) owners.set(index, connection)
    }
    for (const index of owners.keys()) {
      const previous = emitted.get(index)
      if (previous && !sameConnection(emittedOwners.get(index)!, owners.get(index)!)) {
        fail(`later phase changes the electrical identity of ${groups[index]!.name}`)
      }
      if (previous && !previous.every(id => input.traces?.some(trace => trace.pcb_trace_id === id))) {
        fail(`previously replayed ${groups[index]!.name} is absent; create a fresh adapter per board render`)
      }
    }
    const pending = [...owners.keys()].filter(index => !emitted.has(index))
    function currentSourceTraceId(owner: SimpleRouteJson["connections"][number]) {
      if (owner.source_trace_id) return owner.source_trace_id
      const terminalIds = new Set(owner.pointsToConnect.map(point => point.pcb_port_id).filter(Boolean))
      const identities = new Set([owner.name, owner.netConnectionName, owner.rootConnectionName, ...(owner.mergedConnectionNames ?? [])].filter(Boolean))
      // A named-net SRJ connection has no source_trace_id. Its current terminal
      // obstacles carry the authoritative net membership and current trace IDs.
      for (const obstacle of input.obstacles) {
        if (!terminalIds.has(obstacle.circuitJsonMetadata?.pcb_port_id)) continue
        if (!obstacle.connectedTo.some(id => identities.has(id))) continue
        const sourceTraceId = obstacle.connectedTo.find(id => id.startsWith("source_trace_"))
        if (sourceTraceId) return sourceTraceId
      }
      return fail(`current connection ${owner.name} has no authoritative source trace`)
    }
    const traces: SimplifiedPcbTrace[] = pending.flatMap(index => {
      const owner = owners.get(index)!, group = groups[index]!
      const sourceTraceId = currentSourceTraceId(owner)
      return group.traces.map(trace => {
        const route = trace.route.map(point => {
          const usedLayers = point.route_type === "wire" ? [point.layer] : [...(point.layers ?? []), point.from_layer, point.to_layer]
          if (usedLayers.some(layer => !layers.includes(layer))) fail(`saved copper in ${group.name} uses an unavailable board layer`)
          // Whitelist geometric fields: old port, source, group, and pour IDs never enter the new circuit.
          if (point.route_type === "wire") return { route_type: "wire" as const, x: point.x, y: point.y, width: point.width, layer: point.layer,
            ...(point.width_interpolation_mode ? { width_interpolation_mode: point.width_interpolation_mode } : {}) }
          return { route_type: "via" as const, x: point.x, y: point.y, from_layer: point.from_layer, to_layer: point.to_layer,
            ...(point.layers ? { layers: [...point.layers] } : {}),
            ...(point.via_diameter !== undefined ? { via_diameter: point.via_diameter } : {}),
            ...(point.via_hole_diameter !== undefined ? { via_hole_diameter: point.via_hole_diameter } : {}) }
        })
        return { type: "pcb_trace" as const, pcb_trace_id: `saved_${index}_${trace.id}`, connection_name: owner.name,
          // Bind every branch to a real current source trace on this electrical net.
          source_trace_id: sourceTraceId, route }
      })
    })
    const handlers: Record<string, ((event: any) => void)[]> = { complete: [], error: [], progress: [] }
    const autorouter: GenericLocalAutorouter = {
      input, isRouting: false,
      on(event, handler) { handlers[event]!.push(handler) },
      start() {
        if (autorouter.isRouting) return
        autorouter.isRouting = true
        queueMicrotask(() => {
          if (!autorouter.isRouting) return
          for (const index of pending) {
            emitted.set(index, groups[index]!.traces.map(trace => `saved_${index}_${trace.id}`))
            const owner = owners.get(index)!
            emittedOwners.set(index, { ...owner, pointsToConnect: [...owner.pointsToConnect] })
          }
          autorouter.isRouting = false
          for (const handler of handlers.complete!) handler({ type: "complete", traces })
        })
      },
      stop() { autorouter.isRouting = false },
      solveSync() { return fail("this adapter requires the standard asynchronous phase renderer") },
    }
    return autorouter
  }
}

export const createSavedAutorouter = savedAutorouter