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
name: adom-tsci
description: >
Trigger words: tscircuit preview, adom-tsci, tsci dev webview, preview
tscircuit, interactive tscircuit viewer, inspect board, hover chip info, what
is this chip, pcb inspect tool, measure pcb distance, measure between pins,
tscircuit hot reload, rerun autorouter, re-run autorouter, walkthrough demo,
board walkthrough, sharper silkscreen, bump texture resolution, tscircuit
8192, upgrade tscircuit, stale tscircuit deps, hide chip see traces,
AI-driven 3D pcb viewer, usb-c, type-c, UsbCReceptacle, place usb-c, rotate
usb-c. Interactive tscircuit preview inside a Hydrogen webview panel
("Tscircuit Board Viewer"). First-class 3D / PCB / Schematic tabs rendered
from the tscircuit build output, plus: an Inspect tool (hover any chip /
pad / hole / via / silkscreen / board and get a labelled info card with
refdes, footprint, net routing, stackup — all from circuit.json), a Measure
tool (Fusion 360-style smart picking, mm/inches/mils), a Walkthrough Demo
(guided 10-step 3D flyover tour), a one-click Re-run autorouter, a
background stale-deps check, and a --texture-resolution N flag that re-bakes
board-surface texture up to 8192x8192 for readable silkscreen at zoom. The
3D viewer is fully AI-drivable: every toolbar action has a CLI subcommand,
HTTP endpoint, JS eval channel, and console forwarder. Use when the user
asks to preview a tscircuit project, inspect board features, measure
pin-to-pin distances, start a walkthrough demo, re-run the autorouter, bump
silkscreen resolution, or upgrade tscircuit packages. Also routes the
adom-usbc child guide (adom-tsci/guides/adom-usbc.md) for USB-C
placement, rotation math, and the canonical UsbCReceptacle wrapper.
adom-tsci
An Adom app that gives you first-class 3D / PCB / Schematic tabs for a
tscircuit project inside a Hydrogen webview panel, plus a one-click
Re-run autorouter button, a background stale-deps check against the
npm registry, and a secondary tsci live tab (the raw tscircuit
RunFrame, served through a slingshot proxy) for features the primary UI
hasn't wrapped.
🛡 INVARIANTS — audit BEFORE every change to nearby code
These are user-confirmed behaviours that have already regressed at least
once. Before editing the listed files (or any function with the listed
keywords in its name) read this list and grep the relevant code path.
If your change would alter any of these, ask the user first —
don't silently re-trade them for "improvements".
| # | Invariant | What it protects | Where the rule lives in the code |
|---|---|---|---|
| 1 | Right-click hide ghosts at 20% alpha — never setEnabled(false) for any mesh kind, including InstancedMesh. The picker must still be able to hit the ghost so a second right-click can unhide. Hide auto-promotes the component (clone source mesh + clone material) so the alpha write hits ONLY this instance, not its siblings. |
The user's only "show me what's under this part" workflow. If we full-disable, they cannot ever bring the part back without a CLI command — UX dead-end. Without promotion, hiding R1 also ghosts R2/R3 because tscircuit shares one material across InstancedMesh siblings. | setComponentVisibility() in src/assets/shell.html calls _promoteComponent(name) BEFORE the alpha mutation. The "other" branch always goes through material.alpha = 0.20 on the (now-promoted) standalone mesh. Per-instance isolation also belongs in _makeFloatingMarker (HIGHLIGHT path), but via overlay-only architecture — not promotion. |
| 2 | Highlight = real Babylon HighlightLayer glow on the actual chip mesh. No bbox tube outlines, no synthetic cone+tail arrows, no emissive material tints, no fake floating squares. ONE consistent visual language for "this is selected": a teal halo around the chip itself. Auto-promote first so the addMesh hits only the target component (not its InstancedMesh siblings). |
Half-assed approaches (rectangles around bounding boxes, floating arrows that point at things, tinting the material) all read as different "selected" states and confused users. The user explicitly retired those: glow is the only highlight now. | _getHighlightLayer() returns the singleton (strong-blur outer-glow). highlightComponents() in shell.html adds the target's meshes via hl.addMesh(mesh, teal) after _promoteComponent(name). drawFeatureOutline() for feature.kind === 'chip' routes to the same glow path; only non-chip features (pads, holes, vias, board-edges) still get the tube outline because they have no mesh to glow. |
| 3 | Highlight material uses real PBR via stolen-constructor. new B.PBRMaterial() throws because the viewer ships a minified BABYLON. Find the class via scene.materials.find(m => 'albedoColor' in m && typeof m.metallic === 'number').constructor. |
Phong/StandardMaterial reads as a flat-shaded cartoon eyeball under the scene's 52,845-watt physical spotlight. Real PBR matches the board's shader family and looks like a glossy teal orb. | _makePbrTealMat() in src/assets/shell.html. Always exclude lights with intensity > 100 from the material so the physical spotlight doesn't clamp the channel to white. |
| 4 | Walkthrough JSON must match the live project. Mismatched _meta.project_name triggers a loud banner; walkthrough-gen is auto-run on start if the file is stale. |
Copy-paste-from-another-board class of bug. The user has been burned by this exact thing. | walkthrough-gen CLI + auto_regen_if_stale() called in cli/start.rs; banner check in src/assets/shell.html. |
| 5 | Every toolbar/HUD action has a CLI subcommand. No "open the preview and click X" instructions. | Ralph-loop testability + AI drivability + reproducibility. CLI-FIRST is the whole point of this app. | See the CLI table below in this skill. New UI affordance? Add a CLI subcommand at the same time. |
| 6 | Babylon classes added to Adom3DViewer.BABYLON belong in our vendored vendor/3d-viewer/standalone.ts, not in shell.html workarounds. Every "is not a constructor" we hit (Quaternion, TransformNode, PBRMaterial, HighlightLayer, GlowLayer, SceneLoader) traced to the upstream adom-inc/3d-viewer only exposing 5 Babylon classes. Workaround = silent breakage; right answer = export the class from our vendored bundle and rebuild. |
If you see a workaround that says "the bundle doesn't export X, so we manually …", flag it as tech debt and add X to the vendored standalone.ts instead. | vendor/3d-viewer/standalone.ts is our fork of Colby's upstream. Add the import + the export under window.Adom3DViewer.BABYLON.X, then bash vendor/build-viewer.sh to rebuild src/assets/js/adom-3d-viewer.min.js, then cargo build --release. Upstream stays untouched. |
| 7 | Two render modes: instanced (default, fast) and per-chip (on first per-component mutation). Default rendering keeps tscircuit's InstancedMesh packing — one source mesh + one material per package family — so the board is fast even with hundreds of passives. Any operation that needs per-component isolation (hide, future colour-override, future per-component wireframe) PROMOTES that single instance to a standalone Mesh + cloned material. Highlight uses overlay-only (no promotion). |
Lets the user manipulate one component without paying instancing-cost-restoration on the other 99. Promotion is sticky for the session — once R1 is standalone, it stays standalone. | _promoteComponent(name) in src/assets/shell.html. Auto-runs from setComponentVisibility() on hide. Manual: adom-tsci toggle-component R1 --promote. meta.promoted = true flag on componentMap entry. |
| 8 | Trace polygons use earcut + offset polyline + half-disc endcaps + miter joins. EXACT widths from circuit.json — no Math.max(0.15, …) floor, no × 1.05 oversize. Same rule for vias and pcb_plated_holes: via_diameter, outer_diameter, hole_diameter are taken straight from circuit.json. |
EDA traces are flat copper polygons, not 3D pipes — must look like KiCad/Altium/Eagle output. Floors and oversize multipliers visually lie about real PCB widths and break trace-to-pad-edge alignment. The user has explicitly called out "be exact … don't be lazy" multiple times. | _traceBuildOutline() + _traceBuildPolygonMesh() in src/assets/shell.html use window.Adom3DViewer.earcut. _traceRenderNet consumes seg.width and seg.via_diameter raw. _traceRenderPlatedHoles consumes outer_diameter and hole_diameter raw. Earcut is bundled in vendor/3d-viewer/standalone.ts (rebuild via bash vendor/build-viewer.sh). |
| 9 | Layer Z is derived from the actual board substrate mesh (Box0_primitive[012] bounding box), NOT window._boardBottomZ. Top-layer traces draw at top + 0.05, bottom-layer at bottom - 0.05, vias and plated-through-holes are full-thickness cylinders spanning top → bottom rotated onto the Z axis. EDA convention: top = red, bottom = blue. |
window._boardBottomZ is wrongly mirrored to +topZ elsewhere in the app — trusting it caused EVERY bottom-layer trace to render on top, EVERY via to have zero height (invisible discs). Discovered when the user pointed out "blue traces on top? you're missing the whole point." |
_traceComputeBoardZ() + _traceLayerZ() in src/assets/shell.html. Cached on enable, invalidated on disable (_traceBoardZ = null in _traceDisposeAll). |
| 10 | Trace mode is heavy-on-demand: meshes only exist while the Nets panel is open. Closing the panel calls _traceDisposeAll() which dispose every wire polygon, via cylinder, plated-hole barrel, AND every shared / per-net material. Board x-ray is also restored on close (_disableBoardXray re-applies the captured original alpha + transparencyMode + backFaceCulling per substrate mesh). |
Nets HUD on a 200-net board would otherwise leave thousands of mesh+material objects in memory after the user clicks ×. Same architectural rule as instanced-vs-promoted: pay the heavy cost only when the user is actively in that mode. | enableTraceMode() / disableTraceMode() + _traceDisposeAll() + _disableBoardXray() in src/assets/shell.html. |
If you're about to ship a change that touches highlight, hide,
visibility, walkthrough, or marker material — re-read the row above and
confirm it still holds. If you intentionally trade an invariant, call
it out in the user-facing message so the user can veto.
Guides (child content routed by this skill)
adom-tsci is a hub for its own workflow. Child guides live asadom-tsci/guides/<name>.md in the repo and deploy to~/.claude/skills/adom-tsci/guides/<name>.md via adom-tsci install
— mirroring the adom/guides/<name>.md pattern Ray uses for his
cross-tool skills. This skill's description carries the triggers;
when one matches, load the referenced guide.
| Guide | Load when the user says | Path |
|---|---|---|
adom-usbc — placement, rotation, lint for USB-C receptacles on an Adom tscircuit molecule; covers TYPE-C-31-M-12, per-edge rotation math, the UsbCReceptacle wrapper, and the "no SMT pad past the board edge" rule |
"usb-c", "type-c", "smd usb c", "UsbCReceptacle", "place usb-c", "rotate usb-c", "usb-c east edge", "usb-c west edge" | ~/.claude/skills/adom-tsci/guides/adom-usbc.md |
demo-recording — making a 30-second demo video of an adom-tsci board: 30/70 workspace split, -s small captions with pre-flight wrap test, mandatory x-ray shot (hide a subset of LEDs/chips), Andrew Neural TTS narration, ffmpeg audio-mix recipe |
"demo video", "record demo", "30-second demo", "make a demo of this board", "x-ray shot", "show off the 3D viewer", "demo for the wiki" | ~/.claude/skills/adom-tsci/guides/demo-recording.md |
| export-wiki — publishing a tscircuit project to the Adom Wiki as a molecule with the full interactive 3D viewer (auto-play walkthrough on load, Components / Nets HUDs, Inspect, Measure), the source bundle (lib/, package.json, walkthrough.json, plan.md), and a comprehensive AI-handoff body. Required ingredients: hero image, plan.md, body markdown that captures every design decision so a future agent can pick up | "publish to wiki", "export to wiki", "wiki page for this board", "share the molecule", "make this a molecule on the wiki", "publish molecule", "ship it to the wiki" | ~/.claude/skills/adom-tsci/guides/export-wiki.md |
No standalone adom-usbc skill exists — USB-C placement is meaningless
outside adom-tsci's workflow, so it lives under this parent, not as a
sibling. If older installs left ~/.claude/skills/adom-usbc/ behind,adom-tsci install 1.3.4+ removes it automatically.
⚡ CLI-FIRST — non-negotiable
If an adom-tsci preview is running (or you're about to start one),
every interaction goes through the adom-tsci CLI. Do not reach
for adom-cli hydrogen workspace add-tab, raw curl to the slingshot,fetch('/api/...') inside a shell, or any other workaround. The CLI
has a subcommand for every runtime concern — including multi-instance
tab management, JS eval inside the webview, console tailing, camera
control, per-component visibility, and autorouter rerun.
The reflex: when you think "I need to touch the running preview",
run adom-tsci --help first and find the subcommand. If the CLI
doesn't expose it, that's a bug in the tool, not a reason to
hand-roll bash — stop and extend the CLI instead. (If you need to
ship a temporary workaround, leave a TODO: CLI gap — add <flag>
breadcrumb in the skill so the gap gets closed.)
Quick-reference table — the subcommand you want is probably here:
| I want to… | Command |
|---|---|
| Spin up a preview (1st instance) | adom-tsci start <dir> |
| Spin up a 2nd / Nth preview (no tab collision) | adom-tsci start <dir> --port 8851 --tsci-port 3041 --tab-name "adom-tsci <ProjectName>" |
| Open / re-attach the webview tab on an existing slingshot | adom-tsci open --port <N> [--tab-name "..."] [--display-icon mdi:led-on] |
| Stop the preview (clean: kills tsci dev group + releases PID lock) | adom-tsci stop [--port <N>] |
| Re-run the autorouter on the running project | adom-tsci rerun [--clean] [--fast] |
| Kick the 3D / PCB / Schematic tabs to re-poll the build | adom-tsci reload [--port <N>] |
| Move the 3D camera | adom-tsci view <front|back|left|right|top|bottom|iso> or adom-tsci camera --alpha <A> --beta <B> --radius <R> |
| Cinematic orbit | adom-tsci tour start | stop |
| Run the guided walkthrough | adom-tsci walkthrough <start|next|prev|pause|resume|close|status> |
| Flip a toolbar flag | adom-tsci toggle <ground|wireframe|axes> |
| Hide a chip to see traces under it | adom-tsci toggle-component U1 --hide |
| List components in the loaded GLB | adom-tsci list-chips |
| Run JS inside the webview | adom-tsci eval "<js>" |
| Read the browser console | adom-tsci console [--follow] [--tail N] [--level log,warn,error] |
| Health / status | adom-tsci health [--port <N>], adom-tsci status [--port <N>] [--json] |
| Upgrade tscircuit packages in the target project | adom-tsci upgrade [<dir>] |
Every write-mutation targeting the preview has --port <N> if you
need to target a specific instance; defaults to 8850.
🚨 Iframe is opaque-origin — CORS is mandatory on every route
The Hydrogen webview mounts shell.html inside an iframe whose origin is
opaque / null even when the URL is on your own *.adom.cloud
subdomain. That means every fetch() from shell.html back to the
slingshot is cross-origin from the browser's perspective. WithoutAccess-Control-Allow-Origin: * on the slingshot response, the fetch
promise rejects with a CORS error — which shell.html's old catch {}
handlers silently ate. Symptom: 3D tab stuck on "Loading Adom 3D
viewer…" forever, /eval/pending never consumed, /console?since=0
empty. Cost an hour to diagnose once; no more.
Enforcement already shipped:
cors!()andno_cache!()macros insrc/server/mod.rsboth emitAccess-Control-Allow-Origin: *plus Methods + Headers.- Top-of-
route()OPTIONS preflight handler returns 204 with CORS. - Every existing
request.respond(...)call is wrapped in one of
those two macros. tests/cors_enforcement.rsgreps the source and fails the build
if you add a barerequest.respond(Response::...)without the
wrapper. Run withcargo test --test cors_enforcement.shell.htmlwraps fetch calls inbridgeFetch(); any failure paints
a red sticky banner at the top naming the URL + error + detected
origin. If CORS regresses, you see it in 1 second.
Rules for future edits:
- New HTTP route on the slingshot: use
cors!(resp)orno_cache!(resp). Never callrequest.respondon a bare Response. - New fetch in shell.html: use
bridgeFetch(url, opts), not plainfetch. Failures will surface in the banner automatically. - If the banner appears in the tab, read it first. It names the
offending URL. Don't guess or reset — look at the URL, check its
route forcors!/no_cache!wrapping, add whichever fits, rebuild.
🌩 Cloudflare caching — read cloudflare-networking.md BEFORE touching the server
All Adom container traffic routes through Cloudflare, which caches
assets at the edge for up to an hour regardless of what the browser
thinks it's doing. When you edit shell.html, the embedded viewer
bundle, or any static asset, the webview may still serve the
previous version unless the response carries the Cloudflare-specific
anti-cache triple:
Cache-Control: no-store, no-cache, must-revalidate, proxy-revalidate
Pragma: no-cache
Surrogate-Control: no-store ← the header Cloudflare actually obeys
These three are set by the no_cache! macro in src/server/mod.rs.
Every static-asset response goes through it. When you add a new
asset route, use the macro — do not hand-roll a Cache-Control
header.
Defense-in-depth: adom-tsci start / adom-tsci open both append a?_cb={ms-since-epoch} timestamp to the webview URL on every tab
creation. A URL Cloudflare has never seen before cannot hit a cached
entry — this is the belt to the headers' suspenders.
Full rule: ~/.claude/skills/adom/guides/cloudflare-networking.md.
Read it before writing any HTTP response on this binary. Skipping it
previously cost ~2 hours of "why is the bundle not updating" thrash.
👁 Always show your latest work — non-negotiable
If you're working on anything adom-tsci is designed to visualize — a
tscircuit molecule, a PCB change, a walkthrough edit, a feature of the
viewer itself — the user must be looking at it live in the webview
before you claim progress. The user is human; they verify with their
eyes, not by reading a diff.
The reflex after any of these changes:
- Ensure an
adom-tscipreview is running on the project you just
touched (adom-tsci status --json→ checkproject_dirmatches,
otherwiseadom-tsci stop && adom-tsci start <dir>). - Ensure the
adom-tsci previewtab (or the named instance) is
activated in a non-VS-Code pane:adom-cli hydrogen workspace active-tab --panel-id <P> --tab-id <T>. - Ensure the webview is pointed at the slingshot URL for this
instance's port — don't assume it didn't drift;adom-cli hydrogen webview navigate --tab-id <T> "$HOST/proxy/<port>/". - Confirm the 3D scene is actually rendered (not stuck on "Loading
Adom 3D viewer…"):adom-tsci list-chipsmust succeed, andadom-cli hydrogen screenshot panel --name "adom-tsci preview"
should show the board — then Read the screenshot to verify with
your own eyes before telling the user it's working. - Only then report the change is done.
Applies to skill-structure changes too. When you change howadom-tsci install deploys skills, when you bump the binary, when you
edit SKILL.md — don't stop at "the file is on disk." Run the thing
against a real project and show the user the webview. Filesystem checks
(ls ~/.claude/skills/...) are not proof the feature works; the
webview rendering the board is.
Pitfalls that mean you're not actually showing it:
- Webview drifted to
https://adom.incor a cached page — always
screenshot-verify the URL bar shows…/proxy/<port>/. - Tab still says "Loading Adom 3D viewer…" — the GLB hasn't been
produced yet. Runbunx tsci build lib/index.tsx --glbs --svgs --3d-png --pcb-pngin the project dir andadom-tsci reload. - The tab is inactive / buried behind another tab in the same pane —
active-tabbrings it forward. - A second
adom-tsciis running on another port and the webview is
pointed at the wrong one —adom-tsci status --port <N>each
instance you started.
Don't ask the user "want me to show it?" — just show it. The user
told you once; don't make them ask again.
🎯 Walkthrough highlight rules — DETERMINISTIC, don't "improve" casually
The highlightComponents(names) function in src/assets/shell.html paints
the "this is what the walkthrough step is pointing at" affordance. It has
four design rules baked in as comments. If you change any of them,
update THIS section in the same PR so the next Claude doesn't re-break
the same things.
The four rules
Colour is Adom TEAL —
rgb(0.35, 0.75, 0.70)— NEVER amber / yellow.
Amber is reserved for the Inspect tool ("what you're probing"). Teal is
the Walkthrough tool ("what we're pointing at"). Mixing the two destroys
the colour language of the app. There is no "more visible" colour —
keep it teal, keep it consistent with the teal app accent.Always clear everything before applying a new highlight. Prior
material clones, HighlightLayer meshes, AND synthesised testpoint discs
all get disposed at the top of everyhighlightComponentscall.
Without this, walkthrough steps visually bleed into each other — the
"capacitors" step ends up glowing the resistors that a previous step
highlighted. If you add a new highlight-drawing mechanism (anything
that creates geometry or mutates materials), also add its cleanup path
to the dispose loop at the top of the function.Testpoints have no 3D mesh of their own. Tscircuit renders every
testpoint as a flat copper pad baked into the board surface texture;
the "ghost cuboid" mesh thatenumerateComponentscreates above each
testpoint issetEnabled(false)on load (the cuboid is visually
noisy and hides the pad). So:HighlightLayer.addMesh(ghost)paints nothing — the mesh is hidden.material.emissiveColor = ...paints nothing — same reason.- The ONLY way to make a testpoint highlight visible is to synthesise
a small cyan disc (1.6 mm × 0.05 mm cylinder,TESTPOINT_CYAN) at
the testpoint's XY position on the board's top surface
(window._boardTopZ) and track it in_highlightMarkersso the next
call can dispose it.
If a user says "the testpoints aren't highlighting any more", it's
almost always because someone removed the disc-synthesis path or
switched it to a material/mesh approach without realising testpoints
don't have one. Restore it.Empty / missing
namesclears everything. Nothing pins between
steps. If you need a "pin this forever" primitive, build it as a
separate call (e.g. the Inspect tool's pinned card) — don't leak it
throughhighlightComponents.Classification is
source_component.ftype— NOT regex-on-refdes.componentMap.kindfor every component is derived from tscircuit's
emittedsource_component.ftype(which reflects the JSX tag the
board author wrote:<capacitor/>→simple_capacitor→ kindcapacitor). The old regex-basedclassify(name)(which guessedcapacitorfrom anything starting withC) is kept as a FALLBACK
for refdes prefixes tscircuit doesn't yet distinguish (testpoint /
contact / mounting), and only those. Any new kind must be added to_FTYPE_TO_KINDin shell.html — NOT expressed as a regex.Why it matters. A board author who writes
<capacitor name="CB_FILTER">
used to get misclassified as a resistor because of/^R[_\d]/.
Now theftypesayssimple_capacitorand the kind iscapacitor
regardless of what the refdes looks like. Classification agrees
with the JSX source by construction.If you see a misclassification: the fix lives in the board's
lib/index.tsx (wrong JSX tag), not in shell.html. Only touch_FTYPE_TO_KINDif tscircuit ships a genuinely new ftype.
Ralph loop = visual proof in shotlog (pup-driven). State JSON is NOT a ralph loop.
The user has been burned by this twice. A "JSON-level state test"
(eg querying mesh.material.alpha for every mesh after a hide) tells YOU
that the code works but it doesn't show the USER anything. The user
wants to see the actual rendered result, per-component, side-by-side
in shotlog so they can scroll the channel and confirm with their eyes.
Every ralph loop in this skill MUST end with shotlog-injected
side-by-side BEFORE/AFTER pairs, one per component, zoomed in tight on
that component. No exceptions, no "I'll check the JSON instead." If
you find yourself reaching for setComponentVisibility(name, false, true)
in eval and parsing the result as JSON, stop — that's a sanity check,
not a ralph loop.
Drive the page through pup, NOT the Hydrogen webview
Hydrogen workspace is shared with whatever else the user is doing
(aci, code editor, other panels). Touching it for a 16-component test
loop is intrusive. Run the test in a pup browser window instead:
- One pup session per app:
sessionId=adom-tsci,profile=adom-tsci,url=https://<slug>.adom.cloud/proxy/<port>/. Survives sleep/wake. - Shotlog goes in the SAME pup window. Do NOT call
shotlog open --channel X— that adds a Hydrogen tab the user has to manually
close every time. The shotlog channel page is hosted athttps://<slug>.adom.cloud/proxy/8820/log/<channel>/and is
reachable directly. Open it as a pup tab via:adom-desktop browser_eval '{"sessionId":"adom-tsci","expr":"window.open(\"<url>\",\"shotlog-<channel>\")"}' - Drive
setComponentVisibility(name, visible, true)directly viabrowser_eval— DO NOT go through/api/toggle-componentbecause
that's a 500ms-poll path and the screenshot races it. Direct eval
is synchronous in page context.
Mandatory recipe (pup + shotlog tab in pup)
SESSION=adom-tsci
PORT=8889
CHAN=my-test
DIR=/tmp/tsci-ralph; mkdir -p $DIR
# Open shotlog channel as a TAB in the pup window. NEVER `shotlog
# open` (that adds a Hydrogen tab; user has corrected this 10+ times).
SHOTLOG_URL="https://<slug>.adom.cloud/proxy/8820/log/$CHAN/"
adom-desktop browser_eval "{\"sessionId\":\"$SESSION\",\"expr\":\"window.open('$SHOTLOG_URL','shotlog-$CHAN')\"}"
# Lock the camera + suppress UI overlays for clean diffs
adom-desktop browser_eval "{\"sessionId\":\"$SESSION\",\"expr\":\"(()=>{ document.getElementById('comp-overlay').style.display='none'; const tb=document.getElementById('toolbar'); if(tb)tb.style.display='none'; const t=document.getElementById('toast'); if(t)t.style.display='none'; const c=window.viewer.getCamera(); c.alpha=0; c.beta=0.001; c.radius=35; window.viewer.getScene().render(); return 'ok' })()\"}"
cap_shot() {
local out=$1
local resp=$(adom-desktop browser_screenshot "{\"sessionId\":\"$SESSION\",\"maxWidth\":1600}")
local src=$(echo "$resp" | python3 -c "import sys,json; print(json.load(sys.stdin).get('savedTo',''))")
cp "$src" "$out"
}
mutate() { # name, visible (true/false)
adom-desktop browser_eval "{\"sessionId\":\"$SESSION\",\"expr\":\"(()=>{ setComponentVisibility('$1', $2, true); window.viewer.getScene().render(); return 'ok' })()\"}"
}
i=0
for COMP in $(adom-tsci list-chips --port $PORT | tail -n +2 | awk -F': ' '{print $2}' | tr ',' '\n' | tr -d ' '); do
i=$((i+1)); num=$(printf '%02d' $i)
cap_shot "$DIR/${num}-${COMP}-A.png"
mutate "$COMP" "false"
cap_shot "$DIR/${num}-${COMP}-B.png"
convert "$DIR/${num}-${COMP}-A.png" "$DIR/${num}-${COMP}-B.png" \
-compose difference -composite -colorspace gray -threshold 5% \
"$DIR/${num}-${COMP}-mask.png"
PIXELS=$(convert "$DIR/${num}-${COMP}-mask.png" -format "%[fx:w*h*mean]" info: | awk '{printf "%d", $1}')
convert "$DIR/${num}-${COMP}-A.png" -modulate 70 \
\( "$DIR/${num}-${COMP}-mask.png" -fill '#ff3030' -opaque white -transparent black \) \
-composite "$DIR/${num}-${COMP}-overlay.png"
montage -label "BEFORE" "$DIR/${num}-${COMP}-A.png" \
-label "HIDE $COMP ($PIXELS px)" "$DIR/${num}-${COMP}-B.png" \
-label "DIFF" "$DIR/${num}-${COMP}-overlay.png" \
-tile 3x1 -geometry '+4+4' -background '#111' -bordercolor '#111' \
-fill white -font DejaVu-Sans -pointsize 18 \
"$DIR/${num}-${COMP}-trio.png"
shotlog inject -c $CHAN -d "${num} ${COMP} ${PIXELS}px" -s pup "$DIR/${num}-${COMP}-trio.png"
mutate "$COMP" "true"
done
Then link the shotlog channel URL in your message back to the user.
The user expects to scroll the channel in the pup tab they already have
open. Don't paste a single composite when the loop covered 17
components.
fit-to-component exists as a CLI for per-component zoom; if a
sibling-bleed test needs zoomed shots instead of top-down full-board
diffs, use it before each cap_shot call.
Ralph-loop-test before claiming the highlight works
Eyeball-verification is a wasted turn. Use the adom-tsci highlight CLI
to deterministically test highlighting for each kind — flip on, screenshot,
flip off, screenshot, compare.
# 1. List the kinds the current board actually has (reads componentMap,
# reflects real ftype classification, not a guess).
adom-tsci highlight --list-kinds --port 8889
# 2. Baseline OFF screenshot — camera wherever it is now.
adom-tsci highlight --off --port 8889
adom-cli hydrogen screenshot panel --name '<board tab>' --reason 'ralph: off'
# 3. Per-kind ON screenshot — camera auto-fits to the kind.
for kind in capacitor resistor inductor chip testpoint contact mounting; do
adom-tsci highlight --kind $kind --port 8889 || continue
adom-cli hydrogen screenshot panel --name '<board tab>' --reason "ralph: $kind"
done
# 4. Read each pair back and compare. If the ON shot doesn't have teal
# strokes (or cyan discs for testpoints) on every named component,
# the highlight path is broken — fix it, rebuild, re-run.
--no-fit skips the camera zoom when you want to verify ON/OFF from the
same viewpoint. --list-kinds prints counts so you know what to iterate.
Fix workflow if the diff shows the highlight is wrong:
- Read the SKILL.md four rules (above) first — you probably broke one.
- Fix the code in
highlightComponents/_makeTestpointDisc/ the
classifier. cargo build --release && cp target/release/adom-tsci ~/.local/bin/.adom-tsci stop && adom-tsci start …so the new shell.html is served.- Re-run the ralph loop. If still wrong, goto 1.
Shape of the function today
const HIGHLIGHT_TEAL = { r: 0.35, g: 0.75, b: 0.70 }; // stroke + emissive
const TESTPOINT_CYAN = { r: 0.40, g: 0.90, b: 0.95 }; // disc colour
// highlightComponents(names):
// 1. Dispose _compHighlightBackup (material clones), clear
// _compHighlightLayer (Babylon HighlightLayer), dispose
// _highlightMarkers (synthesised cyan markers).
// 2. For each refdes:
// - kind === 'testpoint' → _makeTestpointDisc() at the ghost cuboid's
// centroid XY, clamped to window._boardTopZ. Push to _highlightMarkers.
// - otherwise → addMesh() to the HighlightLayer (teal stroke) + clone
// the material with an emissive teal tint (60% luminance of the
// stroke) + push to _compHighlightBackup.
🎯 Rotation-center sphere is a USER affordance — suppress it on programmatic camera moves
The little sphere that pops up at the camera target during rotation is
the adom-3d-viewer's center-of-rotation indicator. It exists to tell
a human user dragging with the mouse "here's what you're orbiting
around." It is not a general "camera is moving" badge.
Rule: the sphere must only appear when the user is directly driving
the camera with pointer input (drag to orbit, middle-click to pan, wheel
to zoom). CLI-driven moves — adom-tsci view <preset>, adom-tsci camera --alpha/--beta/--radius, adom-tsci tour, /api/camera-command
polling, walkthrough auto-fly, viewer.frameModel(), anything that runs
through the HTTP API — must not pop the sphere. It's distracting in
recorded demos and misleads the user into thinking they did something.
Implementation: shell.html wraps programmatic entry points in a_programmaticRotation flag while the move is in flight and patches
the viewer's detectAndShowRotationSphere to early-return whenever the
flag is set. User pointer-down on the canvas clears the flag. Any new
programmatic driver added to shell.html must set this flag around its
camera mutation — otherwise the sphere will leak into demo recordings.
Upstream intent: the correct long-term fix lives inadom-3d-viewer: only
call detectAndShowRotationSphere from the pointer handlers, not fromonBeforeRenderObservable. The shell.html patch is the workaround
until that ships.
💸 Do NOT rebuild example projects unless you actually need to
Every examples/<name>/ directory in this repo ships with a pre-builtdist/lib/index/ on disk — 3d.glb, 3d.png, pcb.svg, pcb.png,schematic.svg, circuit.json. That pre-build is the whole point of
example projects. A new user (or a new Claude reading the wiki) should
be able to look at the examples, screenshot them, stitch them into a
demo, paste them into docs — without ever running bunx tsci build.
A rebuild is:
- 60–180 seconds of wall time per example. Eight examples = 15+
minutes of a user's session you just ate. - ~$0.10–$0.50 of Anthropic token cost — log tailing, polling,
error re-reads, retries. Invisible to you, very visible to the user. - Non-deterministic — tscircuit router output can drift between
versions; a "fresh" rebuild may produce different traces than the
checked-indist/, so the example's wiki screenshots stop matching.
The rule: when the user asks you to show, screenshot, stitch, or
demo an example, use the pre-built dist/ on disk. ls examples/<name>/dist/lib/index/ first. Only rebuild when:
dist/is genuinely missing AND you need something it would produce.- The user just edited
lib/index.tsxand wants to see the change. - You're investigating a build-time bug and the failure is the goal.
Never rebuild "to make sure it's fresh" or "to match current CLI
version." The checked-in dist/ is authoritative unless proven stale
by a source-tree edit. If a specific artifact is missing (e.g.3d.png but pcb.svg exists), fall back to the sibling artifact
for that scene — a rasterized pcb.svg makes a perfectly fine demo
still image.
This rule applies to any other Claude helping a new Adom user too:
the first contact with adom-tsci is "point me at an example" and the
experience must be instant, not a 2-minute pause while eight builds
run. Update the wiki example page if you change this.
No Adom Viewer dependency (v0.3.0+)
As of v0.3.0, the 3D tab is a self-contained three.js canvas — no
external service, no port 8770 probe, no "AV not reachable" states.
The old Basic3dView iframe and the av_bridge module were removed. The
3D view is fully AI-drivable from the CLI: every toolbar button has
a subcommand, plus there's a JS eval channel for hot-patching and a
console forwarder so Claude can read UI-side errors. See the
"Remote-control from the CLI" section below.
What's new in v0.4.x
Everything below is in the v0.4.0 / v0.4.1 binary that's on the
wiki right now:
- Inspect tool (🔍 / keybind
I). Hover any feature on the board
for a labelled info card: chip refdes + footprint + pin count + JLCPCB
part #; pad → pin name + net + "Connects to MC1.pin1, TP1.pin1" via
N direct traces + solder-mask state; plated hole → drill/outer
diameters + role (mechanical-only vs wired); via → drill/pad/bridged
layers/net; silkscreen → text + layer + parent; board → dims +
thickness + layer count + baked texture resolution. Click to pin,
Esc unpins. Fields tscircuit doesn't emit today (MPN, datasheet,
surface finish) render as—with a tooltip explaining and linking
to upstream feature requests filed inTSCIRCUIT_FEATURE_REQUESTS.md. - Measure tool (📏 / keybind
M). Fusion 360-style smart picking
(pad / hole / chip / pin / edge), precision + secondary units
(mm / inches / mils), vertex snap. Hover highlights in cyan; Inspect
uses amber so you can tell them apart. - Walkthrough Demo (🎬). A narrated 10-step tour. Auto-advances
based on narration text length; orbit auto-pauses; 3D flyover
animations on the contact-ring + testpoint steps (6 waypoints per
step, ease-in-out, so the camera visibly scans instead of staring).
CLI drivable:adom-tsci walkthrough start | next | prev | pause | resume | close | status. - Board-surface texture resolution — tscircuit bakes silkscreen +
copper pads + annular rings + solder mask into a single PNG per
board face, hardcoded at 1024. On big boards that's unreadable at
zoom.adom-tsci start --texture-resolution 8192re-bakes the GLB
viacircuit-json-to-gltfand caches by circuit.json content hash
(first bake ~75s; cache hit ~190ms). Current resolution is shown on
the "Board Surface → Baked" row of the Components HUD. - Components HUD group toggles. Master eye per group (●/○/◐).
Test points are hidden by default — tscircuit's default cuboid
placeholders add noise without detail; toggle them on when needed. - Custom monochrome SVG toolbar icons per the Adom brand rule — no
emoji anywhere. Ruler with ticks for Measure, magnifier with a
data-point for Inspect, clapperboard with play arrow for
Walkthrough. - Robust shutdown + PID lock (v0.4.1).
adom-tsci startscans/procfor stalebun tsci devprocesses from prior runs and
SIGTERM+SIGKILL them before spawning. Atomic PID lock at/tmp/adom-tsci-<port>.pidrefuses to double-start on the same
port. Fixes the "15 orphan tsci devs from 15 crashed restarts"
situation that used to compound.
Keyboard shortcuts
| Key | Action |
|---|---|
M |
Toggle Measure tool |
I |
Toggle Inspect tool |
G |
Toggle ground plane |
W |
Toggle wireframe |
C |
Toggle Components panel |
Space |
Pause / resume walkthrough |
Esc |
Unpin Inspect card / close tool |
Shortcuts only fire when the 3D tab is active and no input field is
focused.
Credit to Ray
This app is an onboarding layer over Ray's authoritative tscircuit
workflow guide at~/.claude/skills/adom/guides/adom-tscircuit-skill.md (author: ray,
22 kB). Everything this tool does with tscircuit — the <Molecule>
component, MachineContactMedium placement rules, the sizing grid, the
DRC notes, the bunx tsci build --glbs --svgs flags, the project
skeleton, the SN65HVD230 reference — traces back to Ray's guide. If
you're building more than a single-chip molecule (connector families,
molecule generators, review servers, DRC tooling), go read Ray's
guide. This app doesn't replace any of it; it just makes the "show me
what I'm building" step trivial.
Quick start
Zero-to-preview — cold container, nothing installed
Run these top-to-bottom. The whole thing takes ~2 min on a warm cache.
# 1. bun (the tscircuit runtime — may not be pre-installed)
command -v bun >/dev/null || curl -fsSL https://bun.sh/install | bash
export PATH="$HOME/.bun/bin:$PATH"
# 2. adom-tsci binary + skill
sudo curl -fsSL https://wiki-ufypy5dpx93o.adom.cloud/static/apps/adom-tsci/adom-tsci \
-o /usr/local/bin/adom-tsci && sudo chmod +x /usr/local/bin/adom-tsci && \
adom-tsci install
# 3. Get a project. If you don't have one yet, clone the example repo:
# (skip if you already have a tscircuit project)
git clone https://github.com/adom-inc/adom-tsci.git
cd adom-tsci/examples/SN65HVD230-Molecule && bun install
# 4. CRITICAL: adom-tsci start refuses to create its webview tab unless an
# existing Web View pane is already present ("no existing Web View pane
# found" error). If the layout is a single VS Code pane, split one:
VSCODE_PANEL_ID=$(adom-cli hydrogen workspace tabs \
| python3 -c 'import json,sys; [print(t["panelId"]) for t in json.load(sys.stdin)["tabs"] if t["name"]=="Visual Studio Code"]' \
| head -1)
adom-cli hydrogen workspace split \
--panel-id "$VSCODE_PANEL_ID" \
--direction horizontal \
--panel-type adom/a1b2c3d4-0031-4000-a000-000000000031 \
--display-name "Web View" --display-icon "mdi:web" --ratio 0.5
# 5. Start the preview — auto-opens a tab in the Web View pane above.
adom-tsci start .
# 6. If the pane ever gets closed (e.g. after an approval-dialog cancel
# resets the workspace), re-split step 4 and run:
adom-tsci open
Day-to-day
# From any tscircuit project directory (webview pane already present):
adom-tsci start .
# …edit lib/index.tsx, click Re-run autorouter, stop with:
adom-tsci stop
Web View panel UUID (for reference): adom/a1b2c3d4-0031-4000-a000-000000000031.
Every split that lands the adom-tsci tab needs this --panel-type.
Moving or renaming a project directory — don't rebuild
When you relocate an existing tscircuit project (e.g. promoting a
scratch project in tscircuit-projects/ to adom-tsci/examples/),
just mv it. Don't delete node_modules / bun.lock / dist
and re-run bun install — that's minutes of wasted time and bandwidth
on every move.
node_moduleshas no path-dependent state. Bun / npm symlink
binaries inside the folder; nothing points out to the old location.
Moving the whole tree keeps every link valid.- Changing the
namefield inpackage.jsondoes not invalidate
installs — deps are keyed by the dep graph, not the root package
name. You can rename@tsci/scratch.Foo→@tsci/john.Foowithout
re-installing. bun.lockis relative and portable. Keep it.dist/is stale after an edit anyway; you'll rebuild the next time
you changelib/index.tsx. No reason to pre-emptively wipe it at
move time.
Safe sequence:
# stop any live preview watching the old path
adom-tsci stop --port <port>
mv old/path/MyChip-Molecule new/path/MyChip-Molecule
# edit package.json if renaming scope — that's it, no reinstall
adom-tsci start new/path/MyChip-Molecule --port <port> \
--tab-name "MyChip (new location)" --display-icon "mdi:chip"
Rule of thumb: bun install is for dependency changes
(bumping versions, adding/removing packages), not location changes.
The UI
┌──────────────────────────────────────────────────────────────────────────┐
│ [3D*] [PCB] [Schematic] GLB: 8:56:56 AM ⟳ Re-run autorouter │
│ ───── tsci live │
├────┬─────────────────────────────────────────────────┬───────────────────┤
│ ⌂ │ │ COMPONENTS │
│ T │ │ U1 CHIP │
│ F │ (three.js canvas — │ U2 CHIP │
│ R │ self-contained, no AV) │ C1 CAPACITOR │
│ I │ │ Y1 CRYSTAL │
│ ⎚ │ │ TP1 TESTPOINT │
│ ▭ │ │ ... │
└────┴─────────────────────────────────────────────────┴───────────────────┘
Primary tabs (first-class, big, prominent):
3D (default). A full-bleed canvas rendered by the real
Adom 3D Viewer (adom-3d-viewer.min.js, the same Babylon-based
engine the rest of Adom uses for 3D), bundled into the adom-tsci
binary so there is no AV service dependency — no port 8770
probe, no "AV not reachable" states. Shadows, IBL environment,
transparent ground plane all work out of the box.Everything else floats over the canvas so it doesn't steal real
estate:- Floating toolbar (top-centre, draggable via the
⋮⋮grip):
⌂ home (iso), ◱ frame, T top, F front, R right, I iso,
▭ ground, ⎚ wireframe, ⊡ ortho/perspective, 📏 measure. - Measure tool: click
📏then click two points on the
model to get the distance between them in millimetres, with an
on-screen label at the midpoint. Click a third time to clear. - Floating component panel (top-right, draggable via header,
collapsible with—, dismissible with×, restorable via the☰button): every chip / passive / testpoint / machine contact
fromcircuit.json, listed with kind. Click a row to hide
that component — the "hide U1 to see the traces routed under
the FPGA" use case. Click "all on" to restore.
Mesh → component-name mapping: tscircuit's GLB writer names meshes
Box0_primitive0 / OBJBox12_primitive1(no component names in the
scene graph). The UI fetches/circuit.json, auto-detects the GLB
unit scale (meters vs mm), probes orientation via mounting-post
corner positions, and groups meshes by nearest-neighbour assignment
within a per-kind tolerance (chip=10mm, testpoint=1.2mm,
contact=2mm, …). Meshes outside tolerance land in a catch-allBoardgroup so the board outline and any unmatched geometry is
still toggleable.- Floating toolbar (top-centre, draggable via the
PCB. Inline
dist/lib/index/pcb.svgin a panzoom container. Scroll
wheel zooms toward cursor, drag to pan. Lazy-loaded on first click.Schematic. Inline
dist/lib/index/schematic.svg, same panzoom.
Lazy-loaded on first click.
Secondary tab:
- tsci live. The raw tscircuit RunFrame served through the slingshot
proxy. Escape hatch for features tscircuit ships in the future that
we haven't wrapped yet. Dimmed via CSSfilter: brightness(0.78)
because RunFrame's light theme is hardcoded and would be blinding
inside a dark editor.
The Re-run autorouter button
Top-right of the header. Click it to:
- Touch
lib/index.tsxvia the slingshot →tsci devfile server.
A no-opPOST /api/files/upsertfires aFILE_UPDATEDevent,tsci devre-evaluates the circuit (which re-runs the autorouter),
and RunFrame (thetsci livetab) refreshes its in-memory view. - Spawn
bunx tsci build lib/index.tsx --glbs --svgsin the
background. This regeneratesdist/lib/index/{3d.glb,pcb.svg, schematic.svg}on disk, which the first-class 3D / PCB / Schematic
tabs poll for (/glb/metaevery 3 s). Within ~30 s the tabs
re-render with the new output. - Shift-click for a clean re-run. Holding Shift while clicking
deletesmanual-edits.jsonfirst, so any interactive trace edits
made in RunFrame's PCB editor are discarded and the autorouter runs
from scratch.
Same feature from the CLI:
adom-tsci rerun # re-run autorouter, preserve manual edits
adom-tsci rerun --clean # also delete manual-edits.json first
Stale-deps check
On adom-tsci start, a background thread querieshttps://registry.npmjs.org/@tscircuit/cli/latest and compares against
the version field in<project>/node_modules/@tscircuit/cli/package.json. If the installed
version is behind the latest, you see a HINT: line:
HINT: @tscircuit/cli is out of date (installed 0.1.1226, latest 0.1.1234).
Run `adom-tsci upgrade` to refresh.
The check is non-fatal (3-second timeout, no network → no warning)
and never blocks startup. tscircuit does daily releases — this just
nudges you when you're behind.
adom-tsci upgrade [project-dir] shells out tobun update --latest in the target directory, upgrading every dep to
its latest version (not just tscircuit). Restart adom-tsci start for
changes to take effect.
Credit to the slingshot: the 127.0.0.1 problem
tsci dev emits a hardcoded absolute URL in its HTML shell:
<script>
window.TSCIRCUIT_FILESERVER_API_BASE_URL = "http://127.0.0.1:3040/api";
</script>
<script type="module" src="/standalone.min.js"></script>
Inside a Hydrogen webview iframe, 127.0.0.1 resolves to the
user's machine, not the container, so every fetch fails and
RunFrame hangs at loading... forever.
adom-tsci's slingshot:
- Fetches the shell HTML from
127.0.0.1:3040/. - Rewrites the two hardcoded references to relative paths:
TSCIRCUIT_FILESERVER_API_BASE_URL = "api"and<script src="standalone.min.js">. - Proxies
/runframe/api/*and/runframe/standalone.min.jsto127.0.0.1:3040, preserving HTTP method, body, and query
string. A v0.1 bug dropped query strings on forward, which brokefiles/get?file_path=...; fixed in v0.2 with a full method-aware
proxy.
Tsci dev uses HTTP polling (/api/events/list?since=<id>) for hot
reload — no WebSockets — so the slingshot is a plain HTTP forwarder.
3D scene conventions (Z-up, XY = board)
The Babylon scene in shell.html is Z-up: the board's copper /
silkscreen surface lies in the XY plane, and +Z is the board
normal (above-board direction). Component positions fromcircuit.json (pcb_component.center.x/y) map directly to world X/Y,
and window._boardTopZ holds the Z of the top copper layer.
This matters whenever you add overlay geometry to the scene. Babylon'sMeshBuilder.CreateDisc and MeshBuilder.CreatePlane create meshes
in the XY plane with normal +Z by default, which — in this scene
— already lies flat on the board. Don't rotate them. Arotation.x = π/2 stands the overlay vertical, edge-on to the board,
which is wrong for anything that's meant to sit on the copper.
Highlight pattern: pad-surface overlay for baked-texture features
Components whose "body" is baked into the board-surface texture
(test points, silkscreen markers, contact pads) have no separate mesh
to tint. Their 3D representation is just texels on the PCB surface.
Hover-highlight via material clone fails — there's no mesh.
The pattern used for testpoints is:
- On enumeration, store each component's
(x, y)world position
and itspcb_smtpadgeometry (shape,radius/width/height) oncomponentMap.get(name).pad. - In
highlightComponents, count how many of a component's meshes
are actuallyisEnabled(). If zero, take the overlay path. - Overlay path: create a
CreateDisc(circle pads) orCreatePlane(rect pads) at(x, y, _boardTopZ + 0.3), sized
to the exact pad geometry. No rotation — it lies flat.
Z offset ≥ 0.3 mm is load-bearing: at 0.1 mm the overlay
z-fights the baked pad texture and disappears from top-down. sideOrientation: 2(DOUBLESIDE) onCreateDisc/CreatePlaneoptions. Default single-side culls the face whose
normal points away from the camera — a top-down view of an
XY-plane disc can show the back face, rendering nothing.- Material:
disableLighting = true,emissiveColorvivid +
opaque (e.g.Color3(0.45, 1.0, 0.95), no alpha). An
alpha-blended dim cyan over the green board is invisible — it
reads as a shadow, not a highlight. Keep it opaque, let the pad
geometry do the visual work.renderingGroupId = 2draws over
silkscreen + pads. - Track overlays in
_compHighlightOverlaysand dispose every
mesh + material on eachhighlightComponents()call so they
clear when the hover leaves.
Effect: the gold pad that's baked into the board-surface texture
appears to light up cyan when you hover its row in the Components
HUD, giving the user something to visually toggle for a feature
that otherwise has no 3D body to tint.
Highlight glow — manual halo (no GlowLayer in bundled Babylon)
The bundled adom-3d-viewer.min.js does not ship BABYLON.GlowLayer,HighlightLayer, or any post-processing pipeline. Cross-wiring
CDN-Babylon's GlowLayer onto the bundled scene fails because newer
Babylon's GlowLayer calls scene.addObjectRenderer, which the older
bundled runtime doesn't expose.
Fake it with a manual halo:
- Chips / resistors / capacitors (meshes with body): clone the
mesh viabaseMesh.clone(name, null, true), parent it to the base,
scale1.06x, apply an additive-blended emissive material
(alphaMode = 1= ADD,disableLighting = true,alpha = 0.5).
The additive blend makes overlapping pixels brighter than either
source — reads as bloom. - Testpoints (overlay discs): stack 2 concentric discs underneath
the solid cyan core at1.8x+2.6xradius, withalpha = 0.45/0.22and additive blend. Place at progressively
lower Z (0.005 mm decrement) so the solid core still wins depth
but the halo bleeds outward.
Both get disposed by _disposeHalos() on every highlightComponents()
call so the scene doesn't accumulate stale halos as the walkthrough or
hover state changes.
Walkthrough steps auto-highlight their focus
walkthroughRenderStep derives the highlight set from step.focus
whenever step.highlight isn't explicitly provided:
focus.kind |
auto-highlight |
|---|---|
component + name |
[name] |
components + names |
names |
kindAll + componentKind |
every componentMap entry whose kind matches (e.g. all testpoints) |
anything else (view, all, silkscreen) |
[] |
This is why a step whose focus is { kind: 'kindAll', componentKind: 'testpoint', flyover: true } lights up every testpoint pad with a
cyan overlay disc for the duration of that step — even without an
author-provided highlight list. The step is about those
components by definition, so they should be tinted.
Explicit step.highlight still wins when set — useful for
cross-cutting steps that want to highlight components outside the
focus target (e.g. "this chip talks to THAT chip").
Don't confuse "toggle visibility" with highlight
Toggle (setComponentVisibility) and highlight (highlightComponents)
touch the same meshes but differently:
- Toggle uses
mesh.visibility = 0.05 | 1.0(per-mesh, works with
shared materials). AlwayshighlightComponents([])first to
restore any clone materials — otherwise the visibility change lands
on a short-lived clone that gets disposed when the row re-renders. - Highlight clones the mesh's material and tints the clone's
emissiveColor. For baked-texture components (no enabled mesh),
falls back to the overlay-disc path above.
The two mechanisms share state through componentMap.get(name).meshes
but one-way: toggle never reads highlight state, highlight restores
any clones it created before doing its work.
CLI commands
Every command follows the Adom CLI conventions (OK: / ERROR: /Hint: prefixes, both human and --json output).
| Command | Purpose |
|---|---|
adom-tsci start <dir> [--port 8850] [--tsci-port 3040] [--no-open] [--texture-resolution N] |
Spawn tsci dev, start the slingshot + shell server, open the webview tab. --texture-resolution N re-bakes the board-surface texture at N×N (default 1024; recommend 2048 for 60-100 mm boards, 4096 for 100-200 mm, 8192 for >200 mm) |
adom-tsci stop |
Clean shutdown of the preview server + the tsci dev process group (and the descendant sweep + PID lock release — no more orphan bun processes) |
adom-tsci walkthrough <start|next|prev|pause|resume|close|status> |
Drive the Walkthrough Demo from the CLI / AI |
adom-tsci status [--json] |
GET /state from the running instance |
adom-tsci open |
Re-add or navigate to the webview tab |
adom-tsci reload |
Force the 3D / PCB / Schematic tabs to re-poll the build outputs |
adom-tsci rerun [--clean] |
Re-run the tscircuit autorouter (optionally deleting manual-edits.json) |
adom-tsci upgrade [dir] |
bun update --latest in the target tscircuit project |
adom-tsci health |
Probe tsci_reachable |
adom-tsci install |
Drop SKILL.md into ~/.claude/skills/adom-tsci/ |
adom-tsci --version |
Print version |
Remote-control the 3D viewer from the CLI (no clicking):
| Command | Purpose |
|---|---|
adom-tsci view <preset> |
Camera preset: front / back / left / right / top / bottom / iso / isometric |
adom-tsci camera [--alpha A] [--beta B] [--radius R] |
Raw alpha/beta (radians) + radius multiplier |
adom-tsci tour start | stop |
Cinematic slow-orbit camera |
adom-tsci toggle <flag> |
Flip a toolbar flag: ground, wireframe, axes |
adom-tsci toggle-component <NAME> [--hide | --show] |
Show/hide a single component — e.g. adom-tsci toggle-component U1 --hide to see traces under the FPGA |
adom-tsci list-chips |
List the components the 3D viewer discovered in the current GLB |
adom-tsci eval "<js>" |
Run a JS snippet inside the running webview, print the result |
adom-tsci console [--follow] [--tail N] [--level log,warn,error] |
Print the JS console log forwarded from the webview (all console.* + uncaught errors) |
HTTP API
Every CLI verb is a thin wrapper over an HTTP endpoint. Curl them
directly if you want to script without the CLI. All on127.0.0.1:8850 by default.
| Route | Method | Purpose |
|---|---|---|
/ |
GET | The tabbed shell HTML |
/runframe/ |
GET | Slingshot root: rewrites tsci dev's HTML shell to relative paths |
/runframe/api/* |
any | Full HTTP proxy to 127.0.0.1:<tsci-port>/api/* (method, body, query string preserved) |
/runframe/standalone.min.js |
GET | Streamed proxy of the 8.6 MB RunFrame bundle |
/adom.css, /favicon.svg |
GET | Static brand assets |
/glb |
GET | Streams <project>/dist/lib/index/3d.glb |
/glb/meta |
GET | {mtime, size} for polling |
/pcb.svg |
GET | Streams <project>/dist/lib/index/pcb.svg |
/schematic.svg |
GET | Streams <project>/dist/lib/index/schematic.svg |
/circuit.json |
GET | Streams <project>/dist/lib/index/circuit.json (UI uses it to map GLB meshes to component names) |
/rerun[?clean=1] |
POST | Touch lib/index.tsx via upsert + spawn bunx tsci build --glbs --svgs |
/reload |
POST | Bump GLB mtime to force 3D tab reload |
/health |
GET | {ok, tsci_reachable, project_dir} |
/state |
GET | Server state + toolbar flags + component visibility + component list |
/shutdown |
POST | Kill tsci dev process group, return 204, exit |
/api/set-view |
POST | Body {"view":"top"} — camera preset |
/api/set-camera |
POST | Body {"alpha":…,"beta":…,"radius":…} — raw camera |
/api/tour |
POST | Body {"action":"start"|"stop"} — slow orbit |
/api/toggle |
POST | Body {"name":"wireframe"} — toggle a toolbar flag |
/api/toggle-component |
POST | Body {"name":"U1"[,"visible":false]} — show/hide a component |
/api/components |
GET/POST | GET returns the UI-discovered component list; POST is how the UI publishes it after GLB load |
/api/camera-command |
GET | UI polls this every 500 ms; returns the latest queued command and atomically clears it |
/console |
GET/POST | POST from UI appends a console message; GET returns {messages, next_since} for the CLI adom-tsci console |
/eval |
POST | Body {"code":"…"} — queue a JS snippet; returns {"id":…} |
/eval/pending |
GET | UI polls this; returns the next pending snippet or 204 |
/eval/:id/result |
POST | UI posts the snippet's result |
/eval/:id |
GET | AI polls for the result |
curl examples:
# Health check
curl -sS http://127.0.0.1:8850/health
# Re-run autorouter, clean mode
curl -sS -X POST "http://127.0.0.1:8850/rerun?clean=1"
# Fetch the PCB SVG
curl -sS http://127.0.0.1:8850/pcb.svg > pcb.svg
# Stop the server
curl -sS -X POST http://127.0.0.1:8850/shutdown
Architecture
+---------------------------+ +---------------------------+
| Hydrogen webview tab | | adom-tsci (Rust) |
| /proxy/8850/ |<------>| tiny_http on 127.0.0.1:8850
| | | |
| +----------+ | | / shell.html
| | 3D |<------------+------->| /glb file stream
| +----------+ | | /pcb.svg file stream
| | PCB | | | /schematic.svg file stream
| +----------+ | | |
| | Schematic| | | /runframe/ slingshot
| +----------+ | | /runframe/api/* (proxy)
| | | /runframe/standalone.min.js
| +----------+ | | |
| | tsci live|<------------+------->| /compare compare.html
| +----------+ | | /av/basic3d AV bridge
| | 3d cmp | | | /av/upload-glb AV bridge
| +----------+ | | |
| | | /rerun [-> upsert + spawn build]
| ⟳ Re-run autorouter -----+------->| /reload /shutdown /health
+---------------------------+ +---------------------------+
| |
v v
+--------------------+ +----------------+
| bun tsci dev | | AV (8770/8771) |
| -p 3040 | | Basic3dView + |
| (process group) | | serve_glb |
+--------------------+ +----------------+
Example session
User: preview my SN65HVD230 molecule
Claude: (runs
adom-tsci start ~/project/adom-tsci-projects/SN65HVD230-Molecule)Output:
OK: spawning tsci dev -p 3040 HINT: @tscircuit/cli is out of date (installed 0.1.1226, latest 0.1.1234). Run `adom-tsci upgrade` to refresh. OK: tsci dev is ready OK: webview tab created OK: adom-tsci running — project=... port=8850 tsci_port=3040The
adom-tsciwebview tab opens in the editor (with a brand-compliant#e6edf3monochrome chip icon in the tab bar). The 3D tab is active
by default, showing the molecule in a clean dark-themed three.js
viewer. User rotates with the mouse, clicks through to PCB, then
Schematic, sees the tscircuit SVGs in panzoom containers.User: move R_TERM to pcbX=8
Claude: (edits
lib/index.tsx) Done. Click ⟳ Re-run
autorouter in the header to regenerate the build outputs, then the
3D / PCB / Schematic tabs will auto-refresh.User: (clicks the button)
Shell flashes
⟳ Running…→⟳ Re-ran. ~30 s later the 3D tab shows
R_TERM at its new position; PCB and Schematic re-fetch on next click.User: ship it
Claude: (runs
bunx tsci push)
Troubleshooting
Error: no existing Web View pane foundadom-tsci start will refuse to create its tab over a non-webview
pane. See step 4 of the Zero-to-preview section above for the exactworkspace split command — it needs --panel-type adom/a1b2c3d4-0031-4000-a000-000000000031. Once a Web View pane
exists, re-run adom-tsci start, or adom-tsci open if the server
is already running.
Webview tab vanished mid-session (workspace reset)
Dismissing certain approval dialogs (screen-share, AI events access)
can reset the Hydrogen layout back to a single VS Code pane and drop
the adom-tsci tab. The server keeps running; just re-split the pane
(step 4 above) and run adom-tsci open to re-attach.
HINT: @tscircuit/cli is out of date
Not an error. tscircuit does daily releases; adom-tsci upgrade in
your project directory pulls the latest. Or ignore it — the hint is
fire-and-forget.
Re-run autorouter button does nothing visible
The rebuild is async and takes ~30 s. Watch the GLB: <time> pill in
the header; when it updates, the 3D tab has re-rendered. PCB and
Schematic lazy-reload on next click.
tsci dev crashed with MethodNotAllowedError: only POST accepted
That's tsci dev's own internal fileserver spitting non-fatal noise
during its node_modules upload on startup. Doesn't break anything —
the server recovers. Check /health to confirm tsci_reachable: true.
Webview stuck at Loading files...
Known tscircuit v0.1.1226+ issue. RunFrame's isLoadingFiles flag
only clears when files/list + files/get?file_path=... both succeed.
In v0.1 of this app, the slingshot dropped query strings on forward,
which broke files/get; v0.2 forwards the query string. If you're
hitting this on v0.2, curl http://127.0.0.1:8850/runframe/api/files/get?file_path=lib/index.tsx
and see if the response has the real file content.
3D tab — flat-lit, no shadows, no transparent ground
The same-origin bridge didn't run. Symptoms:
- ground plane is opaque (not 50% transparent)
- no board shadow on the ground
iframe.contentWindow.viewerisundefined
Causes to check:
- Different origin. If the shell is on
/proxy/8850/but
Basic3dView is on a different host (not/proxy/8770/on the
same subdomain), the contentWindow.viewer access will throwSecurityError: Blocked a frame with origin. Check thatVSCODE_PROXY_URIis set to the same subdomain as AV's 8770. - Viewer not yet constructed. The bridge polls for up to
10 s waiting forscene.meshesto have at least one
non-ground mesh. On a cold cache where Basic3dView has to
download its Babylon bundle + the GLB, this may not be enough. - basic-3d.html changed module structure. The bridge relies
onvar viewerbeing top-level scope in a non-module<script>.
If upstream Adom wraps basic-3d.html intype="module", the bridge
breaks. Confirm withcurl .../proxy/8770/basic-3d.html | grep 'var viewer'.
3D compare pane is still flat-lit
Known v0.2 limitation. The 3d compare secondary tab lives in a
separate compare.html where both Basic3dView and three.js use
default lighting. The compare view is for material-fixup regression,
not for aesthetic rendering. Tracked for v0.3.
"Some sites block embedding" banner at the top of the webview
That banner is a persistent affordance shown on every webview tab
regardless of whether the page loaded. It is not an error signal.
If the panel below it is actually blank, check /health and thetsci-preview.log file for subprocess errors.
tsci dev subprocess doesn't die after stop
Shouldn't happen in v0.1.0+. tsci dev is spawned in a new process
group (setsid) and the whole group is killed on shutdown. If you
see an orphan bun ... tsci dev after adom-tsci stop, file an
issue with ps auxf | grep tsci output.
Exporting fab files (gerbers / BOM / CPL)
adom-tsci is a preview tool, but the underlying tsci CLI can emit
manufacture-ready files. Four gotchas to know before the obvious
command actually ships a manufacturable package.
Gotcha 0 — tsci export does NOT write dist/. Only tsci build
does. If you're inspecting dist/lib/index/circuit.json to check route
counts, autorouter errors, or component sizes, make sure the last thing
you ran was tsci build (not tsci export). If you only ran export
and then grep dist/, you're reading a stale circuit.json from the
previous build — which can make it look like routing failed when the
gerber zip it just wrote actually has full routing. Always:
bunx tsci build lib/index.tsx --glbs --svgs # writes dist/, regenerates circuit.json
bunx tsci export lib/index.tsx --format gerbers --output ../gerbers.zip # reads current dist/, writes zip
If you need the latest numbers after an export, run build again or
extract + parse a .gbr file directly (e.g. grep -c '^X.*D0[12]' onF_Cu.gbr counts draw operations).
Gotcha 0.5 — BOM + CPL inside gerbers.zip are EMPTY.tsci export --format gerbers writes bom.csv and pick_and_place.csv
into the zip, next to the gerber files. JLCPCB reads those (not any
sibling file on disk) when you upload the zip. But tsci's versions are
stub CSVs with only designators + values — no LCSC, no MFR part
number, no package — so the JLCPCB assembly tool will show "no parts
matched" on every row regardless of what you put in fab/bom.csv
alongside.
Fix: after tsci export, splice your populated BOM/CPL into the zip
over tsci's empty ones. Quick Python:
import zipfile, shutil
src = 'fab/gerbers.zip'; tmp = 'fab/_tmp.zip'
with zipfile.ZipFile(src,'r') as zin, zipfile.ZipFile(tmp,'w',zipfile.ZIP_DEFLATED) as zout:
for n in zin.namelist():
if n in ('bom.csv','pick_and_place.csv'): continue
zout.writestr(n, zin.read(n))
zout.writestr('bom.csv', open('fab/bom.csv').read())
zout.writestr('pick_and_place.csv',open('fab/cpl.csv').read())
shutil.move(tmp, src)
Verify with unzip -p fab/gerbers.zip bom.csv | head -3 — first row
after the header should show your real part number, not a blank.
Gotcha 1 — --output path resolution.tsci export <file> --output <path> treats the output path as relative
to the entrypoint's directory (lib/), including absolute paths, which
fails with
Error writing file: ENOENT ... lib/<your path>/gerbers.zip
Workaround: pass a ../-prefixed relative path and move the file after.
cd ~/project/your-molecule
bunx tsci export lib/index.tsx --format gerbers --output ../gerbers.zip
mv gerbers.zip fab/
Gotcha 2 — inner-layer support.
Out of the box, @tscircuit/cli only emits F_Cu and B_Cu gerbers,
regardless of whether the board declares layers={4}. Inner-layer traces
are routed on inner1 / inner2 in circuit.json but never written out,
so a 4-layer board ships as a 2-layer gerber package (silent
data loss — fab will build wrong).
Fix (patch node_modules/@tscircuit/cli/dist/cli/main.js until it's
upstreamed):
- Extend the layer-prefix map to include inner layers:
var layerRefToGerberPrefix = { top: "F_", bottom: "B_", inner1: "In1_", inner2: "In2_" // ← add }; - In
convertSoupToGerberCommands, add the two new copper layer
headers to theglayersobject betweenF_PasteandB_Cu:
and includeIn1_Cu: getCommandHeaders({ layer: "inner1", layer_type: "copper" }), In2_Cu: getCommandHeaders({ layer: "inner2", layer_type: "copper" }),"In1_Cu", "In2_Cu"in the aperture-loop array. - Change the two hardcoded trace/via loops from
["top", "bottom"]
/["top", "bottom", "edgecut"]to include inner layers:for (const layer of ["top", "inner1", "inner2", "bottom", "edgecut"]) { - Update
getAllTraceWidthsto returninner1+inner2width lists. - File extension fix — most fabs (JLCPCB, PCBWay, Eurocircuits)
reject.gbron inner layers and expect.g1/.g2(Protel
convention). Change the zip-write loop:for (const [fileName, fileContents] of Object.entries(gerberFileContents)) { const ext = fileName === "In1_Cu" ? "g1" : fileName === "In2_Cu" ? "g2" : "gbr"; zip.file(`${fileName}.${ext}`, fileContents); }
With all five patches applied, the resulting zip contains
F_Cu.gbr In1_Cu.g1 In2_Cu.g2 B_Cu.gbr
F_Mask.gbr B_Mask.gbr F_Paste.gbr B_Paste.gbr
F_SilkScreen.gbr B_SilkScreen.gbr Edge_Cuts.gbr
drill.drl drill_npth.drl bom.csv pick_and_place.csv
and JLCPCB's upload will accept it as a proper 4-layer board.
Upstream this: the patches above are stopgaps in a local
node_modules. When the project is rebuilt from a fresh install they
get wiped — the real fix is a PR totscircuit/tscircuit-cli
extendingconvertSoupToGerberCommandswith configurable layer lists
- the
.g1/.g2extension map.
Other useful formats (all honour the same --output ../ trick):
| Flag | Output |
|---|---|
--format gerbers |
Zip of gerbers + drill + BOM + CPL |
--format kicad_zip |
KiCad 7/8 project zip (importable in pcbnew) |
--format kicad_pcb |
Single .kicad_pcb file |
--format step |
3D STEP model of the assembled board |
--format readable-netlist |
Human-readable net list |
Publishing your molecule
adom-tsci is a preview tool — it doesn't publish anything. Use
tscircuit's own flow:
cd ~/project/adom-tsci-projects/MyChip-Molecule
bunx tsci push
This publishes to @tsci/<your-user>.MyChip-Molecule on the
tscircuit registry. Requires bunx tsci login once (browser OAuth,
must be an adom-inc org member).
For the full production workflow (connector families, molecule
generators, DRC, review servers, snapshot approval), go to Ray's
guide at ~/.claude/skills/adom/guides/adom-tscircuit-skill.md.
See also
- Ray's guide:
~/.claude/skills/adom/guides/adom-tscircuit-skill.md
— the authoritative tscircuit-on-Adom workflow (deployed bygallia/install.mjs). Single source of truth for writing
tscircuit molecules: connector families, molecule generators,
sizing grid,<MachineContactMedium>placement rules, DRC, review
servers, snapshot approval, all of it. If the user asks how to
build a molecule, route them here — do not summarise Ray's
content in this skill. - hydrogen-tab-icons skill: documents how the
#e6edf3monochrome
chip favicon insrc/assets/icon.svgends up on the tab bar (spoiler:
the page's<link rel="icon">overrides any--display-iconplaceholder). - Basic3dView:
~/.claude/skills/adom/guides/basic-3d-viewer.md
— the Adom Babylon viewer used in the 3D compare tab's left pane. - Web View panel API:
~/.claude/skills/adom/webview/SKILL.md. - Adom brand guide:
~/.claude/skills/adom/guides/brand/SKILL.md
— all icons must be monochrome#e6edf3, the chip favicon follows
this rule. - tscircuit docs · iconify MDI
catalog