adom-tsci — Tscircuit Board Viewer
UnreviewedInteractive 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 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 setpositionOffsetdirectly 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 atpcbRotation=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,
}: {
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 topcbX=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 inlinenode -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, readingpcb_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