pixalynx/usbc-led-switch
A single-sided USB-C–powered LED light board with a latching pushbutton, 100 Ω current-limiting resistor, white 5730 SMD LED, and dual 5.1 kΩ USB-C CC pull-down resistors.
- Version
- 0.0.1
- License
- unset
- Stars
- 0
scripts/gen-cad-models.py
#!/usr/bin/env python3
"""3D bodies for every part -> models/*.obj + lib/cad-models.ts.
Every land pattern here is hand-built (lib/footprints.tsx), and tscircuit draws parts on custom footprints as
nothing in the 3D view unless they carry a cadModel. tscircuit places a model so that the model-frame point
`modelOriginPosition` lands on the board surface at the cad_component position, which is the centre of the
part's land-pattern bounds (the "anchor"), so each origin below is computed against the anchor read from the
last build (dist/index/circuit.json).
* J1 uses JLC/EasyEDA's own model of C2765186 (TYPE-C 16PIN 2MD(073)), the same 16-pin geometry as the socket
on Amazon. Its EasyEDA footprint matches lib/footprints.tsx UsbC16pMill up to a pure y shift, measured from the
locating-peg holes; the model is then placed the way tscircuit's EasyEDA importer does it:
origin xy = mesh XY-bounds centre - (3D outline centre - anchor) (c_rotation is 0 for this part)
origin z = mesh min z - SVGNODE z (10 mil units) -> shell underside on the board
* The resistors, the 5730 LED and the LG-15F switch have no usable EasyEDA model, so their bodies are built from
the datasheet dimensions, directly in the footprint frame (mm, z up from the board).
Usage: python3 scripts/gen-cad-models.py (after `bun run build`; pure Python)
Stop `tsci dev` first and restart it afterwards: it misses model files written while it runs.
"""
import json, math, os, re, subprocess, sys, time
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0 Safari/537.36'
MIL10 = 0.254
D = json.load(open(os.path.join(ROOT, 'dist', 'index', 'circuit.json')))
SRC = open(os.path.join(ROOT, 'index.circuit.tsx')).read()
src = {c['source_component_id']: c['name'] for c in D if c['type'] == 'source_component'}
cad = {src[c['source_component_id']]: c for c in D if c['type'] == 'cad_component' and c.get('source_component_id') in src}
def placement(ref):
m = re.search(rf'const {ref} = {{ x: (-?[\d.]+), y: (-?[\d.]+) }}', SRC)
if not m:
sys.exit(f'no `const {ref} = {{ x, y }}` placement in index.circuit.tsx')
return float(m.group(1)), float(m.group(2))
def anchor(ref):
"""cad_component position in the footprint frame (every part here is placed at rotation 0)."""
x, y = placement(ref)
p = cad[ref]['position']
return p['x'] - x, p['y'] - y
# ---------------------------------------------------------------------------------------------------------------
# OBJ writing: boxes and extruded convex polygons, materials inline in EasyEDA's style (the viewer reads them).
# ---------------------------------------------------------------------------------------------------------------
METAL, BLACK, WHITE = (0.78, 0.78, 0.80), (0.08, 0.08, 0.08), (0.95, 0.95, 0.93)
def box(rgb, x0, y0, z0, x1, y1, z1):
return rgb, [(x0, y0), (x1, y0), (x1, y1), (x0, y1)], z0, z1
def disc(rgb, r, z0, z1, n=32, ymax=None):
pts = [(r * math.cos(2 * math.pi * i / n), r * math.sin(2 * math.pi * i / n)) for i in range(n)]
if ymax is not None: # flats at |y| = ymax (the LG-15F body is a disc cut by two flats)
a = math.asin(ymax / r)
arc = lambda a0, a1: [(r * math.cos(a0 + (a1 - a0) * i / (n // 2)), r * math.sin(a0 + (a1 - a0) * i / (n // 2))) for i in range(n // 2 + 1)]
pts = arc(-a, a) + arc(math.pi - a, math.pi + a)
return rgb, pts, z0, z1
def write_obj(path, solids):
out, faces, n = [], [], 0
for i, (rgb, *_ ) in enumerate(solids):
out += [f'newmtl m{i}', 'Ka 1.0 1.0 1.0', 'Kd %.3f %.3f %.3f' % rgb, 'Ks 0.3 0.3 0.3', 'd 0.0', 'endmtl']
for i, (_, pts, z0, z1) in enumerate(solids):
k = len(pts)
out += [f'v {x:.4f} {y:.4f} {z0:.4f}' for x, y in pts] + [f'v {x:.4f} {y:.4f} {z1:.4f}' for x, y in pts]
faces.append(f'usemtl m{i}')
b, t = n + 1, n + 1 + k
faces += [f'f {b}// {b + j + 1}// {b + j}//' for j in range(1, k - 1)] # bottom (facing -z)
faces += [f'f {t}// {t + j}// {t + j + 1}//' for j in range(1, k - 1)] # top
for j in range(k):
a, c = j, (j + 1) % k
faces += [f'f {b + a}// {b + c}// {t + c}//', f'f {b + a}// {t + c}// {t + a}//']
n += 2 * k
open(path, 'w').write('\n'.join(out + faces) + '\n')
# 1206 resistor upright (lib/footprints.tsx R1206V: pins along y): 3.2 x 1.6 x 0.55, end caps 0.5 long.
R1206V = [
box(BLACK, -0.78, -1.1, 0.0, 0.78, 1.1, 0.55),
box(METAL, -0.8, -1.6, 0.0, 0.8, -1.1, 0.6),
box(METAL, -0.8, 1.1, 0.0, 0.8, 1.6, 0.6),
]
# 5730 LED (Yuji YJ-BC-5730L-G02 outline 5.7 x 3.0 x 0.8): white housing, yellow phosphor window, cathode mark on
# the -x corner (lib/footprints.tsx LED5730 puts the anode at +x), electrode tabs at both ends.
LED5730 = [
box(WHITE, -2.85, -1.5, 0.0, 2.85, 1.5, 0.75),
box((1.0, 0.86, 0.35), -2.45, -1.15, 0.75, 2.45, 1.15, 0.8),
box((0.15, 0.55, 0.25), -2.85, 1.15, 0.75, -2.45, 1.5, 0.8),
box(METAL, -3.0, -1.0, 0.0, -2.85, 1.0, 0.25),
box(METAL, 2.85, -1.0, 0.0, 3.0, 1.0, 0.25),
]
# LG-15F (Adafruit 3870) from the Legong drawing: flat tabs 4.3 wide to 23.0 overall, 0.2 below the body base;
# body = disc 12.1 cut to 8.1 deep, 4.3 tall; housing ring 6.9 to 6.3; actuator 4.4 to 7.5 (Adafruit's height).
LG15F = [
box(METAL, -11.5, -2.15, 0.0, -6.6, 2.15, 0.3),
box(METAL, 6.6, -2.15, 0.0, 11.5, 2.15, 0.3),
box(BLACK, -7.0, -2.4, 0.2, 7.0, 2.4, 2.0),
disc(BLACK, 6.05, 0.2, 4.3, n=48, ymax=4.05),
disc((0.12, 0.12, 0.12), 3.45, 4.3, 6.3, n=40),
disc((0.22, 0.22, 0.24), 2.2, 6.3, 7.5, n=32),
]
GENERATED = {
'r1206v': (['R1', 'R2', 'R3'], '1206 chip resistor, 3.2 x 1.6 x 0.55', R1206V),
'led5730': (['LED1'], '5730 LED, Yuji YJ-BC-5730L-G02 outline', LED5730),
'lg15f': (['SW1'], 'Legong LG-15F / Adafruit 3870, from the Legong drawing', LG15F),
}
def easyeda(lcsc):
f = os.path.join(ROOT, '.tmp', 'jlc', f'{lcsc}.json')
if not (os.path.exists(f) and 'success' in open(f).read(40)):
os.makedirs(os.path.dirname(f), exist_ok=True)
subprocess.run(['curl', '-s', '-m', '30', '-A', UA, '-H', 'Accept: application/json, text/plain, */*',
'-H', 'Referer: https://easyeda.com/', f'https://easyeda.com/api/products/{lcsc}/components?version=6.4.19.5',
'-o', f], check=True)
time.sleep(3)
return json.load(open(f))['result']['packageDetail']['dataStr']
os.makedirs(os.path.join(ROOT, 'models'), exist_ok=True)
rows, report = [], []
# --- J1: EasyEDA model of C2765186 -------------------------------------------------------------------------------
ds = easyeda('C2765186')
svg = next(json.loads(s.split('~', 1)[1]) for s in ds['shape'] if s.startswith('SVGNODE~'))
a = svg['attrs']
hx, hy = float(ds['head']['x']), float(ds['head']['y'])
to_mm = lambda x, y: ((float(x) - hx) * MIL10, -(float(y) - hy) * MIL10)
# footprint shift EasyEDA -> ours, from the two locating-peg holes (ours: +/-2.89, 1.055)
pegs = sorted(to_mm(*s.split('~')[1:3]) for s in ds['shape'] if s.startswith('HOLE~'))
shift = (0.0, round(1.055 - sum(p[1] for p in pegs) / len(pegs), 4))
if len(pegs) != 2 or any(abs(abs(p[0]) - 2.89) > 0.01 for p in pegs):
sys.exit(f'C2765186 peg holes {pegs} do not match the footprint')
obj = os.path.join(ROOT, 'models', 'usbc16p.obj')
if not os.path.exists(obj) or os.path.getsize(obj) < 100:
subprocess.run(['curl', '-s', '-m', '120', '-A', UA, f"https://modules.easyeda.com/3dmodel/{a['uuid']}", '-o', obj], check=True)
vs = [tuple(map(float, l.split()[1:4])) for l in open(obj, errors='replace') if l.startswith('v ')]
if not vs:
os.remove(obj); sys.exit('USB-C model download is not an OBJ (blocked?), run again')
mn = [min(v[i] for v in vs) for i in range(3)]; mx = [max(v[i] for v in vs) for i in range(3)]
pts = []
for ch in svg.get('childNodes') or []:
if isinstance(ch, dict) and (ch.get('attrs') or {}).get('points'):
vals = list(map(float, str(ch['attrs']['points']).split()))
pts += [(vals[i], vals[i + 1]) for i in range(0, len(vals) - 1, 2)]
cx, cy = to_mm((max(q[0] for q in pts) + min(q[0] for q in pts)) / 2, (max(q[1] for q in pts) + min(q[1] for q in pts)) / 2)
if a.get('c_rotation', '0,0,0').split(',')[2].strip() not in ('0', '0.0'):
sys.exit('C2765186 model is rotated; extend the script before trusting the origin')
ax, ay = anchor('J1')
tx, ty = cx + shift[0] - ax, cy + shift[1] - ay # 3D outline centre relative to the anchor, footprint frame
origin = dict(x=round((mn[0] + mx[0]) / 2 - tx, 4), y=round((mn[1] + mx[1]) / 2 - ty, 4),
z=round(mn[2] - float(a.get('z', 0) or 0) * MIL10, 4))
rows.append(('usbc16p', origin, f"C2765186 {a.get('title', '')} (EasyEDA)"))
report.append(f"usbc16p J1 origin ({origin['x']:+.3f}, {origin['y']:+.3f}, {origin['z']:+.3f}) footprint shift {shift} "
f"body {mx[0] - mn[0]:.2f} x {mx[1] - mn[1]:.2f} x {mx[2] - mn[2]:.2f}; mouth at footprint y "
f"{mn[1] - origin['y'] + ay:+.3f} (drawing -5.225)")
# --- generated bodies ----------------------------------------------------------------------------------------------
for key, (refs, note, solids) in GENERATED.items():
write_obj(os.path.join(ROOT, 'models', f'{key}.obj'), solids)
anchors = {anchor(r) for r in refs}
if len({(round(x, 4), round(y, 4)) for x, y in anchors}) != 1:
sys.exit(f'{key}: parts {refs} have different anchors {anchors}; give them separate models')
ax, ay = anchors.pop()
rows.append((key, dict(x=round(ax, 4), y=round(ay, 4), z=0.0), note))
report.append(f"{key:8s} {','.join(refs):8s} origin ({ax:+.3f}, {ay:+.3f}, +0.000) {note}")
ts = ['// Generated by scripts/gen-cad-models.py. Do not edit.',
'// modelOriginPosition = the model-frame point placed on the board surface at the land-pattern centre.']
ts += [f'import m_{k} from "../models/{k}.obj"' for k, *_ in rows]
ts += ['', 'export const CAD = {']
ts += [f' {k}: {{ objUrl: m_{k}, pcbRotationOffset: 0, modelOriginPosition: {{ x: {o["x"]}, y: {o["y"]}, z: {o["z"]} }} }}, // {note}'
for k, o, note in rows]
ts += ['} as const']
open(os.path.join(ROOT, 'lib', 'cad-models.ts'), 'w').write('\n'.join(ts) + '\n')
print('\n'.join(report))