Download

adom-usbc — making USB-C receptacles actually work

Read this before touching a USB-C receptacle on an Adom tscircuit board. Written because Claude got USB-C wrong ~20 times before we wrote the rules down. If you skip this skill and try to wing it, you will repeat every one of those failures.

THE CORE INVARIANT — DO NOT VIOLATE

Footprint (pads + holes) and cadModel (3D body) are tied together. Whatever rotation you apply to one MUST be applied identically to the other. Same rotation AND same translation. They are one physical object. Treating them as independently rotatable is the root cause of every USB-C alignment bug we've hit.

Ways to satisfy this invariant:

  1. Bundle them in one component with a single pcbRotation prop that applies to BOTH the <chip>'s footprint children AND the cadModel's positionOffset. The wrapper in §"The workaround" below does this — it computes positionOffset as rotate(native_offset, pcbRotation) in userland so the body rotates with the footprint.
  2. Compute body placement in the SAME local frame as the footprint. Never express body offset in world coords while the footprint is in local coords. If you set positionOffset directly without rotating it by pcbRotation, the body will drift off its pads on every non-zero rotation.

The common violation: "I'll rotate the chip 270° for east edge, and I'll add 2.5 mm positionOffset to shift the body forward." The first rotation applies to pads/holes (tscircuit auto-rotates them in the footprint local frame). The second shift applies to the body (in world frame, no rotation). They decouple. The body ends up on the wrong side of the pads, and the anchor holes end up OUTSIDE the body footprint. I rebuilt this ~15 times before realising this was the bug, not a tuning issue.

Test for the invariant: the distance between the body centroid and each anchor hole must be the same at pcbRotation=0 as it is at pcbRotation=90/180/270. If that distance changes with rotation, the invariant is broken, the build is wrong, and you must NOT ship the board.

The fundamental problem

@tsci/seveibar.smd-usb-c and @tsci/ArnavK-09.smd-usb-c both hardcode a cadModel with:

cadModel: {
  objUrl: "…easyeda_models/download?…pn=C165948",
  rotationOffset: { x: 0, y: 0, z: 180 },
  positionOffset: { x: 0, y: -2.5, z: 0 },
}

This works at the default pcbRotation=0 (mouth facing south) because (0, -2.5) puts the body 2.5 mm south of the pad centroid, which is where the receptacle's body sits relative to its back-row of SMT pads.

It breaks at every other rotation. Two reasons:

  1. positionOffset is applied in world coords, not rotated with pcbRotation. So at pcbRotation=270 (pads rotated to the east), the body stays 2.5 mm south instead of moving 2.5 mm east with the pads. The body drifts off the pads and the mouth points into the board.
  2. The wrapper's {...props, cadModel: HARDCODED} spread order silently discards any cadModel override passed by the caller. So you can't fix bug #1 by passing cadModel on <SmdUsbC>.

Filed upstream as TSCIRCUIT_FEATURE_REQUESTS.md §6.

The workaround: a local UsbCReceptacle.tsx

Ship a local wrapper in your project's lib/ that builds a raw <chip> with an explicit footprint (copied from ArnavK-09's JSX) and your own cadModel that rotates positionOffset with pcbRotation. This survives bun install, gives you full control, and — critically — you can unit-test it with the lint below without waiting on upstream fixes.

The template (copy this verbatim)

// lib/UsbCReceptacle.tsx
const USB_C_PN = "C165948"
const USB_C_UUID = "2a4bc2358b36497d9ab2a66ab6419ba3"

// Body-to-pad offset in the mouth direction for TYPE-C-31-M-12.
// Measured: the SMT pad row sits at the BACK of the receptacle, and
// the body centroid is 2.5 mm forward of that. Centering body on pads
// (positionOffset 0) makes the back half of the body extend BEHIND
// the pads into the board — wrong geometry, even if numerics look
// "clean".
const BODY_FORWARD_MAG = 2.5

const pinLabels = {
  pin1:  ["GND1", "A1"],  pin2:  ["GND2", "B12"],
  pin3:  ["VBUS1", "A4"], pin4:  ["VBUS2", "B9"],
  pin5:  ["SBU2", "B8"],  pin6:  ["CC1", "A5"],
  pin7:  ["DM2", "B7"],   pin8:  ["DP1", "A6"],
  pin9:  ["DM1", "A7"],   pin10: ["DP2", "B6"],
  pin11: ["SBU1", "A8"],  pin12: ["CC2", "B5"],
  pin13: ["VBUS1", "A9"], pin14: ["VBUS2", "B4"],
  pin15: ["GND1", "A12"], pin16: ["GND2", "B1"],
} as const

// 16 SMT pads, pitch 0.5 mm, centered at y=0 so bounds.center = pad
// centroid (no holes included — see §Why we drop the holes).
const PADS: Array<{ port: string; x: number }> = [
  { port: "A1",  x: -3.35 }, { port: "B12", x: -3.05 },
  { port: "A4",  x: -2.55 }, { port: "B9",  x: -2.25 },
  { port: "B8",  x: -1.75 }, { port: "A5",  x: -1.25 },
  { port: "B7",  x: -0.75 }, { port: "A6",  x: -0.25 },
  { port: "A7",  x:  0.25 }, { port: "B6",  x:  0.75 },
  { port: "A8",  x:  1.25 }, { port: "B5",  x:  1.75 },
  { port: "B4",  x:  2.25 }, { port: "A9",  x:  2.55 },
  { port: "B1",  x:  3.05 }, { port: "A12", x:  3.35 },
]

export const UsbCReceptacle = ({
  name, pcbX, pcbY, pcbRotation = 0, schX, schY, schRotation,
}: {
  name: string; pcbX: number; pcbY: number;
  pcbRotation?: 0 | 90 | 180 | 270
  // SCHEMATIC placement — ALWAYS forward to the inner <chip>. Without these
  // the receptacle silently defaults to the schematic origin (0,0) and piles
  // on top of other parts (caught by `adom-tsci lint` Check 5). A wrapper
  // component MUST pass schX/schY/schRotation through, or it can't be placed
  // on the schematic.
  schX?: number; schY?: number; schRotation?: number
}) => {
  // Native CAD has mouth at local +Y. cad.rotation.z = pcbRotation
  // + rotationOffset.z (tscircuit adds them, CCW). We set
  // rotationOffset.z=0 so cad_z = pcbRotation. Then the mouth direction
  // in world frame is rotate((0,+1), pcbRotation CCW) =
  // (-sin pcbRotation, cos pcbRotation).
  const rad = (pcbRotation * Math.PI) / 180
  const mouthX = -Math.sin(rad)
  const mouthY =  Math.cos(rad)
  const posX = BODY_FORWARD_MAG * mouthX
  const posY = BODY_FORWARD_MAG * mouthY

  return (
    <chip
      name={name}
      pcbX={pcbX} pcbY={pcbY} pcbRotation={pcbRotation}
      schX={schX} schY={schY} schRotation={schRotation}
      supplierPartNumbers={{ jlcpcb: [USB_C_PN] }}
      manufacturerPartNumber="TYPE-C-31-M-12"
      pinLabels={pinLabels as any}
      cadModel={{
        objUrl: `https://modelcdn.tscircuit.com/easyeda_models/download?uuid=${USB_C_UUID}&pn=${USB_C_PN}`,
        rotationOffset: { x: 0, y: 0, z: 0 },
        positionOffset: { x: posX, y: posY, z: 0 },
      }}
      footprint={
        <footprint>
          {PADS.map(p => (
            <smtpad key={p.port} portHints={[p.port]}
              shape="rect" width="0.3mm" height="1.3mm"
              pcbX={`${p.x}mm`} pcbY="0mm" />
          ))}
        </footprint>
      }
    />
  )
}

Per-edge placement recipe

For a board of width W centered at x=0 (so edges at ±W/2):

Edge you want mouth to face pcbRotation pcbX pcbY
South (bottom) 0 0 or offset along x −(H/2) + 4
East (right) 270 +(W/2) − 4 0 or offset along y
North (top) 180 0 or offset along x +(H/2) − 4
West (left) 90 −(W/2) + 4 0 or offset along y

pcbX / pcbY is the chip origin = pad row centroid position. For a 64×32 board with USB-C on east edge: pcbX=28, pcbY=0, pcbRotation=270. Pads at x=28 (inside the board by 4 mm), body at x=28+2.5=30.5, mouth cantilevers to about x=34.5 (2.5 mm past edge).

Corollary: no SMT pad may extend past the board edge.

The body / mouth / mechanical shell can and should cantilever past the edge. SMT pads must not. If any copper pad (VBUS1/2, GND1/2, DP1/2, DM1/2, CC1, CC2, SBU1/2) ends up at |pcbX| > W/2, tscircuit grows the GLB board mesh asymmetrically to include the overhang — and because the texture art is drawn with pcbX=0 at the canvas centre, the whole silkscreen / copper / annular-ring layer renders shifted by asymmetry/2 relative to the 3D mesh. Every via drill appears off-centre in its annular ring, every passive body appears shifted from its pad. The PCB itself is fine, but the 3D view looks broken.

Real failure: a 72×32 board with the USB-C receptacle at pcbX=35. That put the east-most SMT pads at pcbX≈37.2, growing the mesh to [-38.174, +37.2] (centre -0.487) and shifting every copper feature ~0.5 mm in the render. Fix: moved the receptacle to pcbX=33 (= W/2 − 4 + overhang_0). Mesh became [-37.2, +37.2], shift gone. Use pcbX = W/2 − 4 for the east edge; do not improvise.

Mandatory verification lint

Run this after every build with a USB-C receptacle. Not optional. The only way we stopped getting USB-C wrong was by gating on these two numeric checks:

// scripts/check-usbc.mjs
import j from "./dist/lib/index/circuit.json" with { type: "json" }

for (const src of j.filter(x => x.type === "source_component")) {
  // Heuristic: USB-C receptacle = any chip with jlcpcb matching a
  // known USB-C family. Extend as new families are supported.
  const pn = src.supplier_part_numbers?.jlcpcb?.[0]
  if (!pn || !/^C(165948|2760486|283540|840342)/.test(pn)) continue

  const pc = j.find(x => x.type === "pcb_component"
                      && x.source_component_id === src.source_component_id)
  const pads = j.filter(x => x.type === "pcb_smtpad"
                          && x.pcb_component_id === pc.pcb_component_id)
  const cad = j.find(x => x.type === "cad_component"
                        && x.pcb_component_id === pc.pcb_component_id)
  const board = j.find(x => x.type === "pcb_board")

  const padCx = pads.reduce((s, p) => s + p.x, 0) / pads.length
  const padCy = pads.reduce((s, p) => s + p.y, 0) / pads.length
  const rot   = cad.rotation.z * Math.PI / 180
  const mX    = -Math.sin(rot), mY = Math.cos(rot)

  // Check 1: body forward of pads by ~BODY_FORWARD_MAG in mouth dir
  const fwd = (cad.position.x - padCx) * mX + (cad.position.y - padCy) * mY
  if (fwd < 1.5 || fwd > 3.5) {
    throw new Error(`${src.name}: body_forward=${fwd.toFixed(2)} mm; expected ~2.5.
      Body is ${fwd < 0 ? "BEHIND" : "too close to"} pads — geometry wrong.`)
  }

  // Check 2: mouth ray exits the board outline
  const ray = { x: cad.position.x + 12 * mX, y: cad.position.y + 12 * mY }
  const bx = board.center?.x ?? 0, by = board.center?.y ?? 0
  const inside = ray.x >= bx - board.width/2 && ray.x <= bx + board.width/2
              && ray.y >= by - board.height/2 && ray.y <= by + board.height/2
  if (inside) {
    throw new Error(`${src.name}: mouth points INTO the board at ${ray.x.toFixed(1)},${ray.y.toFixed(1)}.
      Cable would collide with FR4. Rotate receptacle 180° (toggle rotationOffset.z 0↔180).`)
  }

  console.log(`✓ ${src.name}: body_fwd=${fwd.toFixed(2)}mm, mouth dir (${mX.toFixed(2)},${mY.toFixed(2)}) exits board at (${ray.x.toFixed(1)},${ray.y.toFixed(1)})`)
}

Wire it into your build loop:

bunx tsci build lib/index.tsx --glbs --svgs && adom-tsci lint

adom-tsci lint does exactly the checks above (for every JLC USB-C part in its known list) plus catches ghost chips and autorouter silent-failures. Same exit-code gating. Prefer it over the inline node -e above — it's shipped with the tool and maintained alongside the lint rules.

Or if you only have node and not adom-tsci lint:

Visual proof-of-placement (the toggle-and-shotlog ritual)

Numeric lint can be gamed. A body can satisfy forward_dot >= 1.5 yet still be visually misaligned with the pad geometry if a footprint dimension is wrong. After every USB-C placement change, do this three-step visual proof:

# 1. Top view with receptacle visible
adom-tsci view top --port 8853
adom-desktop browser_screenshot '{"sessionId":"<project>","maxWidth":1800}'
shotlog inject -c usbc-validate -d "USB-C placed on east edge, body visible cantilevered off the board" -s pup_screenshot <path>

# 2. Hide the receptacle to see the pads underneath
adom-tsci toggle-component J1 --port 8853 --hide
adom-desktop browser_screenshot '{"sessionId":"<project>","maxWidth":1800}'
shotlog inject -c usbc-validate -d "USB-C hidden; pads underneath should be flush with board east edge, all 16 pads on copper" -s pup_screenshot <path>

# 3. Un-hide
adom-tsci toggle-component J1 --port 8853 --show

The user reads the two screenshots side-by-side and confirms:

  • Body cantilevers off the correct edge (mouth away from board)
  • The pads sit inside the board (none hanging off)
  • Pad row aligns with where the body's back edge is

If you don't post both screenshots to shotlog, the user can't verify, and you can't claim the placement works. "The lint passes" is necessary but not sufficient.

Include ALL mounting holes — do NOT drop them to simplify the math

The rule: the TYPE-C-31-M-12 footprint must include all 4 plated corner anchor holes + 2 drill-only support holes. Without the anchors the connector is held by 16 tiny SMT joints only, which tear off the board under cable insert/extract stress in well under 100 cycles. A USB-C port that rips loose after a week is a bug, not a trade-off.

The compensation: tscircuit computes pcb_component.center as the bbox of pads + holes. For the TYPE-C-31-M-12 layout (pads at local y=0, holes at y ∈ {-1.04, -1.27, -5.22}), the bbox center sits 2.61 mm south of the pad centroid. To keep the cad body 2.5 mm forward of the pad row, bump BODY_FORWARD_MAG from 2.5 to 2.5 + 2.61 = 5.11 in the wrapper template.

Formula for any directional connector with asymmetric holes:

BODY_FORWARD_MAG = body_over_pads + |bbox_center_y − pad_row_y|

Measure the bbox shift by building once with holes included, reading pcb_component.center from circuit.json, and subtracting your known pad row y.

Filed upstream as TSCIRCUIT_FEATURE_REQUESTS §6.1: cadModel should accept boundsSource: "pads" | "all" so we don't have to compensate in userland. Until then, the compensation above is load-bearing.

Failure modes → diagnosis table

Symptom Cause Fix
Body visible but pads hanging off the board edge pcbX/Y too close to the edge; pads should be INSIDE copper Move pcbX inward by ~4 mm
Mouth points into the board (butt cantilevers off edge) rotationOffset.z is wrong by 180° Flip rotationOffset.z (0 ↔ 180)
Body drifts diagonally off the pads at non-zero rotation You used upstream SmdUsbC without the rotation-compensation Switch to the local UsbCReceptacle.tsx wrapper above
Body centered on pads, but half the body extends into the board positionOffset is (0,0); forgot BODY_FORWARD_MAG * mouth_dir Add the forward-offset math
USB-C missing from Components HUD in adom-tsci refdes doesn't match /^J[\d_]/i OR no cad_component emitted Rename to J1/J_1; check circuit.json for cad_component row
pcb_autorouting_error: Failed to solve 1 nodes after adding USB-C USB-C pads near high-density chip pin row Move USB-C further from adjacent chips, or grow board

USB-C on a MOLECULE edge (not a bare <board>)

When the board is an Adom <Molecule> (machine-pin corners for the probing workcell), edge placement gets one extra wrinkle: the molecule's wings grow the real board outline beyond the nominal size. A size="128x48" molecule renders as a ~130.4×50.4 mm board (≈1.2 mm wing per side); large pins grow it more. If you place the USB-C with pcbX = nominal/2 − 4, it ends up several mm inside the real edge and the plug can't reach.

Rules that make it work:

  1. Use absolute sizingsize="138x58 absolute" — so the real outer edge is exactly W/2. The size increments are coarse (…96, 128, 160…) and can't hit the width you need anyway; absolute gives you the precise edge to cantilever the mouth past.
  2. Place pcbX ≈ W/2 − 3 and iterate ONCE against circuit.json:
    • pcb_component_outside_board_error: J1 extends outside by N mm → pull J1 in by N (reduce pcbX).
    • confirm the rightmost USB-C pad's gap-to-edge > 0 (no pad past edge).
    • target: body/mouth ~2–3 mm past the edge, pads ~5 mm inside.
  3. Large corner pins + roundEdges={true} conflict. The rounded-corner arc carves out the corner where the machine pins sit, so large pins throw a constant MP1..MP4 extends outside by ~0.48mm (size-independent — the tell that it's the corners). Use roundEdges={false} with large pins.
  4. Put the connector mid-edge (between the two corner pins) — it won't collide with the corner machine pins there.

Worked example: the OLIVER LED nameplate molecule — size="178x58 absolute", type="4pin", pinType="MachinePinLargeStandard", roundEdges={false}, <UsbCReceptacle mount="east" pcbX={W/2 − 4} pcbY={0}> → SMT pads ~6.5 mm inside, plated anchor holes ~0.8 mm clear of the edge, body/mouth cantilevers ~2.5 mm proud so a cable seats. (W/2 − 4 is the audited, canonical value; earlier W/2 − 3.2 shoved the anchor holes ~0.03 mm from the edge — too close.)

Upstream feature requests

See adom-tsci/TSCIRCUIT_FEATURE_REQUESTS.md §6 for:

  • cadModel.positionOffset should co-rotate with pcbRotation
  • @tsci wrappers should use { cadModel: props.cadModel ?? DEFAULT, ...props } so caller overrides work
  • bounds.center should be opt-in pads-only for cadModel placement