muse/book-reading-clip-lamp

A 60‑mm ESP32‑S3 lamp-head PCB combining USB‑C battery charging/power regulation, OV5640 autofocus camera interfacing, I²S microphone and differential speaker audio, six‑LED boost-driven reading light, buttons, status indicators, and battery monitoring.

Version
0.2.5
License
unset
Stars
0

saved-routing.ts

SchematicSchematic preview for saved-routing.ts
import type { SimpleRouteJson, SimplifiedPcbTrace } from "@tscircuit/capacity-autorouter"
import capturedInput from "./saved-routing-input.json"
import capturedTraces from "./saved-routing-traces.json"
import capturedHoles from "./saved-routing-holes.json"
import provenance from "./saved-routing-provenance.json"
import corrections from "./routing-corrections.json"
import replacements from "./routing-replacements.json"

/**
 * Reproducible source-level routing replay through tscircuit's supported
 * autorouter.algorithmFn interface. Core still imports copper, expands
 * through-hole vias and executes normal routing/short/clearance checks.
 *
 * These files preserve an ENGINEERING route, not a fabrication approval.
 * Reviewed via moves belong in routing-corrections.json. Full route replacements
 * belong in routing-replacements.json. Neither mechanism suppresses normal DRC.
 *
 * To regenerate after any component/net/footprint/board change: select the
 * normal autorouter (algorithmFn: undefined), reroute, review its results,
 * then recapture the three saved-routing-*.json files from autorouter-debug.
 * Never weaken the input guard just to reuse routes for a changed placement.
 */
export const capturedRoutingCacheKey = (provenance.phases[0] as {cacheKey?:string}).cacheKey ?? "custom pipeline9 / fresh-routing.ts"

export type ViaRelocation = {
  traceId: string
  from: { x: number; y: number }
  to: { x: number; y: number }
}

export type TraceReplacement = {
  traceId: string
  originalRoute: SimplifiedPcbTrace["route"]
  replacementRoute: SimplifiedPcbTrace["route"]
  reason: string
}

/** Full paths are reviewed against the original capture before via moves. */
export const traceReplacements = replacements as unknown as TraceReplacement[]

/** Explicit via moves change the drill and both adjoining layer contacts. */
export const viaRelocations = corrections as ViaRelocation[]

// Stable object-key ordering removes serialization-only differences while
// retaining geometry and ordered paths; unordered connection sets are sorted. Coordinates are rounded to 9 decimal
// places (1 picometre in mm units) to tolerate Bun/Chromium trig noise. Actual
// layout changes still fail. No Node or crypto APIs are required.
function normalizedJson(value: unknown): string {
  return JSON.stringify(value, (key, item: unknown) => {
    // connectedTo is an unordered connectivity set, not a copper path.
    if (key === "connectedTo" && Array.isArray(item)) return [...new Set(item)].sort()
    if (typeof item === "number" && Number.isFinite(item)) return Number(item.toFixed(9))
    if (item && typeof item === "object" && !Array.isArray(item)) {
      const record = item as Record<string, unknown>
      return Object.fromEntries(Object.keys(record).sort().map(key => [key, record[key]]))
    }
    return item
  })
}
function fingerprint(text: string): string {
  let hash = 2166136261
  for (let i = 0; i < text.length; i++) {
    hash ^= text.charCodeAt(i)
    hash = Math.imul(hash, 16777619)
  }
  return (hash >>> 0).toString(16).padStart(8, "0")
}
function firstDifference(a: unknown, b: unknown, path = "input"): string | undefined {
 if (normalizedJson(a) === normalizedJson(b)) return undefined
 if (a && b && typeof a === "object" && typeof b === "object") {
  const left = a as Record<string,unknown>; const right = b as Record<string,unknown>
  for (const key of Array.from(new Set([...Object.keys(left),...Object.keys(right)])).sort()) {
   const diff = firstDifference(left[key], right[key], `${path}.${key}`)
   if (diff) return diff
  }
 }
 return `${path}: ${JSON.stringify(a)?.slice(0,160)} → ${JSON.stringify(b)?.slice(0,160)}`
}
// The browser serializer explicitly emits this default; the CLI serializer
// omits it. Preserve the rule value rather than dropping the field from review.
function comparableInput(input: unknown) {
 const data = input as SimpleRouteJson
 // Core versions differ in which aliases of a plated via enter connectedTo.
 // Compare the actual named-net binding, whose exact pad IDs and coordinates
 // are independently preserved in connections[].pointsToConnect. Retain raw
 // identities for unconnected obstacles, which have no named-net binding.
 const netsByPort = new Map<string,Set<string>>()
 for (const connection of data.connections) {
  for (const point of connection.pointsToConnect) {
   for (const id of [point.pointId, point.pcb_port_id]) {
    if (!id) continue
    const nets = netsByPort.get(id) ?? new Set<string>()
    nets.add(connection.name); netsByPort.set(id,nets)
   }
  }
 }
 const obstacles = data.obstacles.map(obstacle => {
  const names = new Set<string>()
  for (const id of obstacle.connectedTo) {
   for (const net of netsByPort.get(id) ?? []) names.add(net)
  }
  // Browser core marks non-plated holes explicitly; this CLI omits the flag.
  // Add its value only for independently captured physical drill geometry.
  // An explicit false, changed diameter, location or layer set still fails.
  const knownHole = obstacle.type === "rect" && obstacle.connectedTo.length === 0 &&
   obstacle.layers.length === data.layerCount && capturedHoles.find(h =>
    normalizedJson([h.x,h.y,h.width,h.height]) === normalizedJson([
     obstacle.center.x,obstacle.center.y,obstacle.width,obstacle.height]))
  const withHole = knownHole ? { isNonPlatedHole: true, obstacleId: knownHole.obstacleId, shape: "circle", ...obstacle } : obstacle
  return names.size ? { ...withHole, connectedTo:[...names].map(n=>`net:${n}`).sort() } : withHole
 })
 return { minTraceToHoleEdgeClearance: 0.2, ...data, obstacles }
}
const expectedInput = normalizedJson(comparableInput(capturedInput))

function validateReplacementRoute(route: SimplifiedPcbTrace["route"], traceId: string) {
  const fail = (message: string): never => { throw new Error(`Saved route replacement ${traceId}: ${message}`) }
  if (!Array.isArray(route) || route.length < 2) fail("route needs at least two points")
  const layers = new Set(["top", ...Array.from({ length: capturedInput.layerCount - 2 }, (_, i) => `inner${i + 1}`), "bottom"])
  let currentLayer: string | undefined
  for (let index = 0; index < route.length; index++) {
    const point = route[index]
    if (!point || (point.route_type !== "wire" && point.route_type !== "via")) fail("only explicit wire/via route points are supported")
    if (point.route_type !== "wire" && point.route_type !== "via") continue // narrows the library union
    if (![point.x, point.y].every(Number.isFinite)) fail(`non-finite coordinate at point ${index}`)
    if (point.route_type === "wire") {
      if (!layers.has(point.layer) || !Number.isFinite(point.width) || point.width <= 0) fail(`invalid layer/width at point ${index}`)
      if (currentLayer !== undefined && point.layer !== currentLayer) fail(`layer change without via at point ${index}`)
      currentLayer = point.layer
      continue
    }
    if (!layers.has(point.from_layer) || !layers.has(point.to_layer) || point.from_layer === point.to_layer) fail(`invalid via layers at point ${index}`)
    if (currentLayer !== point.from_layer) fail(`via does not continue its incoming layer at point ${index}`)
    if (point.via_diameter !== undefined && (!Number.isFinite(point.via_diameter) || point.via_diameter <= 0)) fail(`invalid via pad diameter at point ${index}`)
    if (point.via_hole_diameter !== undefined && (!Number.isFinite(point.via_hole_diameter) || point.via_hole_diameter <= 0)) fail(`invalid via drill at point ${index}`)
    const previous = route[index - 1]
    const next = route[index + 1]
    if (!previous || !next || previous.route_type !== "wire" || next.route_type !== "wire" ||
        previous.x !== point.x || previous.y !== point.y || next.x !== point.x || next.y !== point.y ||
        previous.layer !== point.from_layer || next.layer !== point.to_layer) {
      fail(`via requires coincident wire contacts on both layers at point ${index}`)
    }
    currentLayer = point.to_layer
  }
}

function correctedTraces(): SimplifiedPcbTrace[] {
  const traces = JSON.parse(JSON.stringify(capturedTraces)) as SimplifiedPcbTrace[]
  const replacedIds = new Set<string>()
  for (const patch of traceReplacements) {
    if (typeof patch.traceId !== "string" || replacedIds.has(patch.traceId)) throw new Error(`Saved route replacement: invalid or duplicate target ${patch.traceId}`)
    const matches = traces.filter(candidate => candidate.pcb_trace_id === patch.traceId)
    if (matches.length !== 1) throw new Error(`Saved route replacement: expected one target ${patch.traceId}, got ${matches.length}`)
    const trace = matches[0]
    if (typeof patch.reason !== "string" || !patch.reason.trim()) throw new Error(`Saved route replacement ${patch.traceId}: a review reason is required`)
    if (normalizedJson(trace.route) !== normalizedJson(patch.originalRoute)) throw new Error(`Saved route replacement ${patch.traceId}: original route does not match the captured copper`)
    validateReplacementRoute(patch.replacementRoute, patch.traceId)
    if (normalizedJson(trace.route[0]) !== normalizedJson(patch.replacementRoute[0]) ||
        normalizedJson(trace.route[trace.route.length - 1]) !== normalizedJson(patch.replacementRoute[patch.replacementRoute.length - 1])) {
      throw new Error(`Saved route replacement ${patch.traceId}: first and last terminal points must remain exactly unchanged`)
    }
    // Only copper geometry is assigned. pcb_trace_id, connection_name,
    // connectsTo and all other identity/terminal metadata remain unchanged.
    trace.route = JSON.parse(JSON.stringify(patch.replacementRoute)) as SimplifiedPcbTrace["route"]
    replacedIds.add(patch.traceId)
  }
  for (const patch of viaRelocations) {
    const trace = traces.find(candidate => candidate.pcb_trace_id === patch.traceId)
    if (!trace) throw new Error(`Saved route correction: unknown trace ${patch.traceId}`)
    const samePoint = (point: { x: number; y: number }) =>
      Math.abs(point.x - patch.from.x) < 1e-8 && Math.abs(point.y - patch.from.y) < 1e-8
    const indices = trace.route.flatMap((point, index) =>
      point.route_type === "via" && samePoint(point) ? [index] : [])
    if (indices.length !== 1) throw new Error(`Saved route correction: expected one via in ${patch.traceId}, got ${indices.length}`)
    const index = indices[0]
    const previous = trace.route[index - 1]
    const next = trace.route[index + 1]
    if (!previous || !next || previous.route_type !== "wire" || next.route_type !== "wire" || !samePoint(previous) || !samePoint(next)) {
      throw new Error(`Saved route correction: ${patch.traceId} via must have coincident wire contacts on both sides`)
    }
    if (![patch.to.x, patch.to.y].every(Number.isFinite)) throw new Error("Saved route correction must use finite coordinates")
    // Move the drill and both adjacent copper contacts together. Neighbouring
    // segments automatically end at the corrected location on each layer.
    for (const point of [previous, trace.route[index], next]) Object.assign(point, patch.to)
  }
  return traces
}

/** Return the small event-router contract consumed by core's custom strategy. */
export async function replaySavedRoutes(input: SimpleRouteJson) {
  const actualInput = normalizedJson(comparableInput(input))
  if (actualInput !== expectedInput) {
    throw new Error(
      `Saved routing input changed (expected ${fingerprint(expectedInput)}, actual ${fingerprint(actualInput)}). ` +
      `Run bun run reroute to generate new routes; review and recapture the routing snapshot. ` +
      `Original capture: ${capturedRoutingCacheKey}. First difference: ${firstDifference(JSON.parse(expectedInput), JSON.parse(actualInput))}`,
    )
  }
  const traces = correctedTraces()
  type RouteEvent = { type: "complete"; traces: SimplifiedPcbTrace[] }
  const listeners = new Map<string, Array<(event: RouteEvent) => void>>()
  let stopped = false
  return {
    on(eventName: string, callback: (event: RouteEvent) => void) {
      const handlers = listeners.get(eventName) ?? []
      handlers.push(callback)
      listeners.set(eventName, handlers)
    },
    start() {
      if (stopped) return
      for (const handler of listeners.get("complete") ?? []) handler({ type: "complete", traces })
    },
    stop() { stopped = true },
  }
}