Adom TSCI - Tscircuit Board Viewer
Public Made by Adomby adom
Interactive tscircuit preview in a Hydrogen webview — first-class 3D / PCB / Schematic tabs, Components & Nets HUDs, an auto-glow x-ray view, Inspect, Measure, a Walkthrough Demo, and a one-click `exp
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 OVERHANG RULE — why the mouth must clear the board edge
A USB-C receptacle is useless if the plug can't seat. The plug's
collar is wider than the receptacle mouth, so the mouth must slightly
overhang the board edge (for TYPE-C-31-M-12 the mouth cantilevers
~2.5 mm past the edge at the recommended placement) — flush-or-proud
of the edge, NEVER recessed inboard. Recessed = the cable bottoms out
on the board edge and never makes contact. This is also the general
wiki rule: see the pcb-design skill on the wiki
(wiki.adom.inc/adom/pcb-design),
rule "USB connector placement": edge-mounted, flush or slightly
overhanging. adom-tsci lint enforces the geometry (body forward of
pads, mouth exits the board edge within a connector depth).
Sourcing: use the Adom Basic Parts catalog for the receptacle and its passives — verified symbols, footprints, and 3D bodies: wiki.adom.inc/adom/adom-basic-parts.
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:
- Bundle them in one component with a single
pcbRotationprop that applies to BOTH the<chip>'s footprint children AND the cadModel'spositionOffset. The wrapper in §"The workaround" below does this — it computespositionOffsetasrotate(native_offset, pcbRotation)in userland so the body rotates with the footprint. - 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
positionOffsetdirectly without rotating it bypcbRotation, 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:
positionOffsetis applied in world coords, not rotated withpcbRotation. So atpcbRotation=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.- The wrapper's
{...props, cadModel: HARDCODED}spread order silently discards anycadModeloverride passed by the caller. So you can't fix bug #1 by passingcadModelon<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:
- Use absolute sizing —
size="138x58 absolute"— so the real outer edge is exactlyW/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. - Place
pcbX ≈ W/2 − 3and iterate ONCE againstcircuit.json:pcb_component_outside_board_error: J1 extends outside by N mm→ pull J1 in by N (reducepcbX).- 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.
- Large corner pins +
roundEdges={true}conflict. The rounded-corner arc carves out the corner where the machine pins sit, so large pins throw a constantMP1..MP4 extends outside by ~0.48mm(size-independent — the tell that it's the corners). UseroundEdges={false}with large pins. - 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
# 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 OVERHANG RULE — why the mouth must clear the board edge
A USB-C receptacle is useless if the plug can't seat. The plug's
collar is wider than the receptacle mouth, so the mouth must **slightly
overhang the board edge** (for TYPE-C-31-M-12 the mouth cantilevers
~2.5 mm past the edge at the recommended placement) — flush-or-proud
of the edge, NEVER recessed inboard. Recessed = the cable bottoms out
on the board edge and never makes contact. This is also the general
wiki rule: see the **pcb-design** skill on the wiki
([wiki.adom.inc/adom/pcb-design](https://wiki.adom.inc/adom/pcb-design)),
rule "USB connector placement": edge-mounted, flush or slightly
overhanging. `adom-tsci lint` enforces the geometry (body forward of
pads, mouth exits the board edge within a connector depth).
**Sourcing:** use the Adom Basic Parts catalog for the receptacle and
its passives — verified symbols, footprints, and 3D bodies:
[wiki.adom.inc/adom/adom-basic-parts](https://wiki.adom.inc/adom/adom-basic-parts).
## 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, 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:
```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 |
## 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 sizing** — `size="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