# 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:

```js
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)

```tsx
// 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,
}: {
  name: string; pcbX: number; pcbY: number;
  pcbRotation?: 0 | 90 | 180 | 270
}) => {
  // 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}
      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:

```js
// 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:**

```bash
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:

```bash
# 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:

```text
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 |

## 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
