imrishabh18/rp2040-motor-controller

A library of PCB component definitions covering MOSFETs, motor drivers, USB-PD control, sensors, current monitors, transistors, diodes, resistors, speakers, connectors, and mirrored bottom-side logos, with pin mappings, footprints, and 3D models.

Version
1.0.55
License
unset
Stars
5

panel.circuit.tsx

PCBPCB preview for panel.circuit.tsx
SchematicSchematic preview for panel.circuit.tsx
import type { Panel } from "@tscircuit/core";
import type { AnyCircuitElement, PcbBoard, SourceBoard } from "circuit-json";
// Refresh this input with `tsci build index.circuit.tsx` after editing the single board.
import singleBoardJson from "./dist/index/circuit.json";

const boardBuild = singleBoardJson as AnyCircuitElement[];
function getBoardDefinition() {
  const board = boardBuild.find((element): element is PcbBoard => element.type === "pcb_board");
  const source = boardBuild.find((element): element is SourceBoard => element.type === "source_board");
  const isRouted = boardBuild.some((element) => element.type === "pcb_trace");
  const hasErrors = boardBuild.some(({ type }) => type.endsWith("_error"));
  if (!isRouted || hasErrors || !board?.outline || !board.subcircuit_id ||
    !board.width || !board.height || !source?.source_group_id) {
    throw new Error("Build index.circuit.tsx before building the panel. The panel needs its routed board and outline.");
  }
  return {
    board,
    width: board.width,
    height: board.height,
    sourceId: source.source_board_id,
    groupId: source.source_group_id,
    subcircuitId: board.subcircuit_id,
  };
}

const { board, width, height, sourceId, groupId, subcircuitId } = getBoardDefinition();

const copies = ["A", "B", "C", "D"];
const boardGap = 5;
const edgePadding = 5;
const routingWidth = 2;
const tabLength = 5;
const rightTabOffset = 5;
const mouseBiteDiameter = 0.6;
const mouseBitePitch = 0.95;
const mouseBiteOffsets = [-2, -1, 0, 1, 2].map((index) => index * mouseBitePitch);
const mouseBiteEdgeOffset = 0.22;
const boardRecords = boardBuild.filter(({ type }) =>
  /^(pcb|source|cad)_/.test(type) &&
  !/_(warning|error)$/.test(type) &&
  !["pcb_board", "source_board", "source_project_metadata"].includes(type),
).filter((record) => record.type !== "source_group" || record.source_group_id !== groupId);

type Point = { x: number; y: number };

function copyRecord(
  record: AnyCircuitElement,
  offset: Point,
  suffix: string,
  aliases: Map<string, string>,
): AnyCircuitElement {
  const isPcbGeometry = record.type.startsWith("pcb_") || record.type === "cad_component";
  const clone = (value: unknown, field: string): unknown => {
    if (typeof value === "string" && value !== "" &&
      (/_ids?$/.test(field) ||
        field === "subcircuit_connectivity_map_key" ||
        field === "connectsTo" || field === "connection_name")) {
      return aliases.get(value) ?? `${value}_${suffix}`;
    }
    if (Array.isArray(value)) return value.map((item) => clone(item, field));
    if (value === null || typeof value !== "object") return value;

    const copied = Object.fromEntries(
      Object.entries(value).map(([key, item]) => [key, clone(item, key)]),
    );
    // Model origin and orientation are local to the 3D model. All other x/y
    // pairs in the board's PCB records are in board coordinates.
    if (isPcbGeometry && field !== "rotation" && field !== "model_origin_position" &&
      typeof copied.x === "number" && typeof copied.y === "number") {
      copied.x += offset.x;
      copied.y += offset.y;
    }
    return copied;
  };

  const copy = clone(record, "") as AnyCircuitElement;
  if (copy.type === "source_component") copy.name = `${copy.name}_${suffix}`;
  return copy;
}

const installed = new WeakSet<Panel>();

function populatePanel(panel: Panel | null) {
  if (!panel || installed.has(panel)) return;
  installed.add(panel);

  // Native layout creates the board outlines and routed slots. Attach the
  // already-checked copper after the mechanical panel has finished rendering.
  const layout = panel.doInitialPanelLayout.bind(panel);
  panel.doInitialPanelLayout = () => {
    layout();
    const root = panel.root;
    if (root && panel.pcb_panel_id) {
      const complete = () => {
        root.removeListener("renderComplete", complete);
        populatePlacedBoards(root.db);
      };
      root.on("renderComplete", complete);
    }
  };
}

function populatePlacedBoards(db: NonNullable<Panel["root"]>["db"]) {
  const placedBoards = db.pcb_board.list();
  if (placedBoards.length !== copies.length) throw new Error("Expected four panel boards.");
  for (const [index, placed] of placedBoards.entries()) {
    const placedGroup = db.source_group.getWhere({ subcircuit_id: placed.subcircuit_id });
    const placedSource = placedGroup && db.source_board.getWhere({ source_group_id: placedGroup.source_group_id });
    if (!placed.subcircuit_id || !placedGroup || !placedSource) {
      throw new Error("A panel board is missing its source scope.");
    }
    const suffix = copies[index];
    const offset = {
      x: placed.center.x - board.center.x,
      y: placed.center.y - board.center.y,
    };
    const aliases = new Map([
      [board.pcb_board_id, placed.pcb_board_id],
      [subcircuitId, placed.subcircuit_id],
      [sourceId, placedSource.source_board_id],
      [groupId, placedGroup.source_group_id],
    ]);

    db.pcb_board.update(placed.pcb_board_id, {
      ...board,
      center: placed.center,
      outline: placed.outline,
      position_mode: placed.position_mode,
      display_offset_x: placed.display_offset_x,
      display_offset_y: placed.display_offset_y,
      pcb_board_id: placed.pcb_board_id,
      source_board_id: placedSource.source_board_id,
      subcircuit_id: placed.subcircuit_id,
    } as PcbBoard);
    // Bulk insertion assigns new IDs. Preserve the copied IDs so the ports,
    // components, traces, and net references all resolve within this board.
    for (const record of boardRecords) {
      const copy = copyRecord(record, offset, suffix, aliases);
      const table = db[copy.type] as { insert: (element: AnyCircuitElement) => unknown };
      table.insert(copy);
    }
  }

  for (const placed of placedBoards) {
    arrangeBreakawayTabs(db, placed);
  }
}

function arrangeBreakawayTabs(db: NonNullable<Panel["root"]>["db"], placed: PcbBoard) {
  const { x, y } = placed.center;
  const right = x + width / 2;
  const top = y + height / 2;
  const halfTab = tabLength / 2;
  // The sampled outline is a few microns off-axis at some straight edges.
  const tolerance = 0.001;
  const slots = db.pcb_cutout.list().filter((slot) => slot.shape === "polygon");

  const near = (a: number, b: number) => Math.abs(a - b) < tolerance;
  const tabEnd = (matches: (point: Point) => boolean) => {
    const slot = slots.find(({ points }) => points.filter(matches).length === 2);
    if (!slot) throw new Error("Could not locate the routed end of a panel tab.");
    return slot;
  };

  // Join the two top slots across the default tab to clear both USB-C shells.
  const topEnd = (dx: number) => (point: Point) =>
    near(point.x, x + dx) && point.y >= top - tolerance && point.y <= top + routingWidth + tolerance;
  const topLeft = tabEnd(topEnd(-halfTab));
  const topRight = tabEnd(topEnd(halfTab));
  const rightEnds = [-halfTab, halfTab].map((dy) => {
    const matches = (point: Point) => near(point.y, y + dy) &&
      point.x >= right - tolerance && point.x <= right + routingWidth + tolerance;
    return { slotId: tabEnd(matches).pcb_cutout_id, matches };
  });
  const aroundSlot = (points: Point[], matches: (point: Point) => boolean, fromInner: boolean) => {
    const [a, b] = points.filter(matches).sort((a, b) => a.y - b.y);
    const start = points.indexOf(fromInner ? a : b);
    const end = points.indexOf(fromInner ? b : a);
    const step = (start + 1) % points.length === end ? -1 : 1;
    if ((start - step + points.length) % points.length !== end) {
      throw new Error("The panel tab end is not a straight edge.");
    }
    return points.map((_, index) => points[(start + step * index + points.length) % points.length]);
  };
  db.pcb_cutout.update(topLeft.pcb_cutout_id, {
    points: [...aroundSlot(topLeft.points, topEnd(-halfTab), true), ...aroundSlot(topRight.points, topEnd(halfTab), false)],
  });
  db.pcb_cutout.delete(topRight.pcb_cutout_id);

  // Shift the right tab up the straight edge, clear of the side-entry JST.
  for (const { slotId, matches } of rightEnds) {
    // The top-right slot was merged above, so read the current polygon.
    const currentId = slotId === topRight.pcb_cutout_id ? topLeft.pcb_cutout_id : slotId;
    const current = db.pcb_cutout.get(currentId);
    if (current?.shape !== "polygon") throw new Error("Missing right panel slot.");
    db.pcb_cutout.update(currentId, {
      points: current.points.map((point) => matches(point) ? { ...point, y: point.y + rightTabOffset } : point),
    });
  }

  // Five 0.6 mm drills per tab, with 0.35 mm of material between holes.
  // Bias the drills toward the waste to clear the board's edge copper.
  for (const offset of mouseBiteOffsets) {
    const points = [
      { x: x - width / 2 - mouseBiteEdgeOffset, y: y + offset },
      { x: right + mouseBiteEdgeOffset, y: y + rightTabOffset + offset },
      { x: x + offset, y: y - height / 2 - mouseBiteEdgeOffset },
    ];
    for (const point of points) {
      db.pcb_hole.insert({
        hole_shape: "circle",
        hole_diameter: mouseBiteDiameter,
        ...point,
      });
    }
  }
}

export default function Rp2040MotorControllerPanel() {
  return (
    <panel
      {...{ ref: populatePanel }}
      layoutMode="grid"
      row={2}
      col={2}
      boardGap={boardGap}
      edgePadding={edgePadding}
      panelizationMethod="tab-routing"
      tabWidth={routingWidth}
      tabLength={tabLength}
      mouseBites={false}
    >
      {copies.map((suffix) => (
        <board
          key={suffix}
          name={`Controller-${suffix}`}
          outline={board.outline}
          layers={4}
          thickness={board.thickness}
          routingDisabled
        />
      ))}
    </panel>
  );
}