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

scripts/generate-assembly-files.mjs

import { mkdirSync, readFileSync, writeFileSync } from "node:fs"
import { dirname, resolve } from "node:path"

const inputPath = resolve(process.argv[2] ?? "dist/index/circuit.json")
const outputDir = resolve(process.argv[3] ?? "fabrication")
const circuit = JSON.parse(readFileSync(inputPath, "utf8"))

const sourceComponents = new Map(
  circuit
    .filter((element) => element.type === "source_component")
    .map((component) => [component.source_component_id, component]),
)
const pcbComponents = circuit.filter(
  (element) => element.type === "pcb_component",
)

const footprintByReference = {
  J_USB: "Micro-USB-B MicroXNJ",
  U_ESD: "SOT-23-6",
  U_LDO: "SOT-23-5",
  U_DBG: "VQFN-64 RGC (9x9mm + exposed pad)",
  Y_DBG: "Murata CSTCR 3-pin resonator",
  J_DBG_PROG: "P2.54mm 1x7 THT",
  U_TARGET: "VQFN-24 RGE (4x4mm + exposed pad)",
  SW_RESET: "TS342A2P-WZ",
  SW1: "TS342A2P-WZ",
  SW2: "TS342A2P-WZ",
  Y_TARGET: "SC-32S 32.768kHz crystal",
  J1: "P2.54mm 1x10 THT female",
  J2: "P2.54mm 1x10 THT female",
  J_EXT_PWR: "P2.54mm 1x3 THT male",
  J_SUPERCAP: "P2.54mm 1x3 THT male",
  C_SUPERCAP: "Radial 2.00mm pitch, 7mm body",
}

const footprintFor = (component) => {
  const reference = component.name
  if (footprintByReference[reference]) return footprintByReference[reference]
  if (reference.startsWith("J_ISO_")) return "P2.54mm 1x2 THT + shunt"
  if (reference.startsWith("TP_")) return "1.2mm PCB test pad"
  if (reference.startsWith("LED")) return "0603"
  if (["R_SHIELD", "R_LED_PWR", "R_DBG_RED", "R_DBG_GREEN", "R_LED1_EN", "R_LED1", "R_LED2_EN", "R_LED2", "R_SUPERCAP"].includes(reference)) return "0603"
  if (["C_SHIELD", "C_LDO_IN", "C_LDO_OUT"].includes(reference)) return "0603"
  if (["C_DBG_BULK", "C_TARGET_BULK"].includes(reference)) return "0805"
  if (reference.startsWith("R") || reference.startsWith("C")) return "0402"
  return component.ftype ?? "unspecified"
}

const valueFor = (component) =>
  component.display_resistance ??
  component.display_capacitance ??
  component.symbol_display_value ??
  component.manufacturer_part_number ??
  component.name

const csvCell = (value) => {
  const text = value == null ? "" : String(value)
  return /[",\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text
}
const csv = (rows) => rows.map((row) => row.map(csvCell).join(",")).join("\n") + "\n"

const assemblyRows = []
for (const pcbComponent of pcbComponents) {
  const source = sourceComponents.get(pcbComponent.source_component_id)
  if (!source || source.ftype === "simple_test_point") continue

  const lcscCandidates = source.supplier_part_numbers?.jlcpcb ?? []
  const dnp = Boolean(pcbComponent.do_not_place)
  let notes = ""
  if (source.name.startsWith("J_ISO_")) {
    notes = "Fit C492401 header; install C100114 removable shunt after assembly."
  } else if (source.name === "C_SUPERCAP") {
    notes = "Optional Panasonic EEC-S0HD224H; DNP because no matching live JLCPCB/LCSC listing was found."
  } else if (!source.manufacturer_part_number && lcscCandidates.length === 0) {
    notes = "Select an equivalent part matching the footprint and electrical value."
  }

  assemblyRows.push({
    reference: source.name,
    value: valueFor(source),
    footprint: footprintFor(source),
    mpn: source.manufacturer_part_number ?? "",
    lcsc: lcscCandidates.join(";"),
    dnp,
    notes,
    x: pcbComponent.center.x,
    y: pcbComponent.center.y,
    layer: pcbComponent.layer,
    rotation: pcbComponent.rotation ?? 0,
  })
}

const fittedRows = assemblyRows.filter((row) => !row.dnp)
const unlockedRows = fittedRows.filter(
  (row) => !row.mpn || row.lcsc.split(";").filter(Boolean).length !== 1,
)
if (unlockedRows.length > 0) {
  throw new Error(
    `Every fitted component must have one exact MPN and one exact LCSC number. Unlocked: ${unlockedRows
      .map((row) => row.reference)
      .join(", ")}`,
  )
}

const grouped = new Map()
for (const row of assemblyRows) {
  const key = JSON.stringify([
    row.value,
    row.footprint,
    row.mpn,
    row.lcsc,
    row.dnp,
    row.notes,
  ])
  const group = grouped.get(key) ?? { ...row, references: [] }
  group.references.push(row.reference)
  grouped.set(key, group)
}

const bomRows = [
  ["Quantity", "References", "Value", "Footprint", "Manufacturer Part Number", "LCSC Part Number", "DNP", "Notes"],
  ...[...grouped.values()]
    .sort((a, b) => a.references[0].localeCompare(b.references[0], undefined, { numeric: true }))
    .map((row) => [
      row.references.length,
      row.references.join(" "),
      row.value,
      row.footprint,
      row.mpn,
      row.lcsc,
      row.dnp ? "YES" : "NO",
      row.notes,
    ]),
]

const jlcGrouped = new Map()
for (const row of fittedRows) {
  const key = JSON.stringify([row.value, row.footprint, row.lcsc])
  const group = jlcGrouped.get(key) ?? { ...row, references: [] }
  group.references.push(row.reference)
  jlcGrouped.set(key, group)
}
const jlcBomRows = [
  ["Comment", "Designator", "Footprint", "LCSC Part #"],
  ...[...jlcGrouped.values()]
    .sort((a, b) => a.references[0].localeCompare(b.references[0], undefined, { numeric: true }))
    .map((row) => [
      row.value,
      row.references.join(","),
      row.footprint,
      row.lcsc,
    ]),
]

const pnpHeader = ["Designator", "Mid X (mm)", "Mid Y (mm)", "Layer", "Rotation", "DNP"]
const pnpData = assemblyRows
  .sort((a, b) => a.reference.localeCompare(b.reference, undefined, { numeric: true }))
const pnpRowsLocal = [
  pnpHeader,
  ...pnpData.map((row) => [
    row.reference,
    row.x.toFixed(4),
    row.y.toFixed(4),
    row.layer === "bottom" ? "Bottom" : "Top",
    ((row.rotation % 360) + 360) % 360,
    row.dnp ? "YES" : "NO",
  ]),
]

const jlcCplRows = [
  ["Designator", "Mid X", "Mid Y", "Layer", "Rotation"],
  ...fittedRows
    .sort((a, b) => a.reference.localeCompare(b.reference, undefined, { numeric: true }))
    .map((row) => [
      row.reference,
      `${row.x.toFixed(4)}mm`,
      `${row.y.toFixed(4)}mm`,
      row.layer === "bottom" ? "Bottom" : "Top",
      ((row.rotation % 360) + 360) % 360,
    ]),
]

const accessoryRows = [
  ["Quantity", "Description", "Manufacturer Part Number", "LCSC Part Number", "Install"],
  [7, "2.54 mm removable shunt for J_ISO_* headers", "2.54 shorting cap, closed top", "C100114", "After PCBA"],
  [1, "Optional 0.22 F 5.5 V radial supercapacitor", "EEC-S0HD224H", "", "DNP / optional hand fit"],
]

// The optional KiCad export translates the board by (+100,+100) and KiCad's
// position exporter expresses Y with the opposite sign. Keep this as a
// separate reference file; the production Gerbers use tscircuit coordinates.
const pnpRowsKicad = [
  ["Designator", "Mid X (mm)", "Mid Y (mm)", "Layer", "Rotation", "DNP"],
  ...pnpData.map((row) => [
    row.reference,
    (row.x + 100).toFixed(4),
    (row.y - 100).toFixed(4),
    row.layer === "bottom" ? "Bottom" : "Top",
    ((row.rotation % 360) + 360) % 360,
    row.dnp ? "YES" : "NO",
  ]),
]

mkdirSync(outputDir, { recursive: true })
writeFileSync(resolve(outputDir, "bom.csv"), csv(bomRows))
writeFileSync(resolve(outputDir, "pick-and-place.csv"), csv(pnpRowsLocal))
writeFileSync(resolve(outputDir, "pick-and-place-tscircuit-local.csv"), csv(pnpRowsLocal))
writeFileSync(resolve(outputDir, "pick-and-place-kicad.csv"), csv(pnpRowsKicad))
writeFileSync(resolve(outputDir, "jlcpcb-bom.csv"), csv(jlcBomRows))
writeFileSync(resolve(outputDir, "jlcpcb-cpl.csv"), csv(jlcCplRows))
writeFileSync(resolve(outputDir, "assembly-accessories.csv"), csv(accessoryRows))

console.log(
  `Wrote ${bomRows.length - 1} engineering BOM groups, ${jlcBomRows.length - 1} JLCPCB BOM groups, and ${jlcCplRows.length - 1} fitted placements to ${outputDir}`,
)