app
Adom TSCI - Tscircuit Board Viewer
Public Made by Adomby adom
Interactive tscircuit preview in a Hydrogen webview — first-class 3D / PCB / Schematic tabs, Components & Nets HUDs, an auto-glow x-ray view, Inspect, Measure, a Walkthrough Demo, and a one-click `exp
← Commit history
Publish 1.5.0
7 files changed
+560−688
README.md+23@@ -4,6 +4,14 @@ Interactive tscircuit preview inside a Hydrogen webview panel. First-class **3D / PCB / Schematic** tabs rendered from the tscircuit build output, plus: +- **`start --view top`** — start with a top-down camera preset and reapply it+ after page refreshes and model rebuilds. Other camera controls remain available.+- **Live PCB/schematic refresh** — SVG changes refresh independently of the GLB;+ `reload` also refreshes unchanged files. Existing 2D pan and zoom are preserved.+- **Direct artifact review** — `status --json` reports generated SVG, PNG, and+ GLB paths, availability, and file versions in `artifacts`. Read the generated+ PCB image directly when reviewing layout or silkscreen.+ - **🔍 Inspect tool** (key `I`) — hover any feature for a labelled info card: chip refdes + footprint + pin count, pad → net routing ("Connects to MC1.pin1, TP1.pin1 via 2 direct traces"), machine-@@ -26,6 +34,21 @@ build output, plus: - **Stale-deps check** against `@tscircuit/cli` on startup. - **Secondary tabs:** `tsci live` (raw RunFrame) and `3d compare`. +For a top-down work session:++```bash+adom-tsci start . --view top+adom-tsci status --json+```++The `artifacts.pcb_svg.path` and `artifacts.schematic_svg.path` fields locate+the generated 2D images. Check `available` before opening an artifact; PNG+exports may not exist. If your image reader needs a raster image, rasterize+the SVG locally. Use viewer screenshots for testing the viewer or inspecting+3D behavior. Keep any disabled walkthrough preference disabled.++Developer checks: `cargo test` and `node --test tests/build_review.test.cjs`.+ **Recent (1.4.x):** - **Canonical Adom app-header** — one-row `brand · subject · actions` with a hover app-info popover (version / engine / wiki / GitHub), brand fonts
SKILL.md+55−48@@ -8,8 +8,7 @@ description: > 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, usb-c looks wrong, usb-c doesn't look correct, connector is a white- box, cuboid placeholder. Interactive tscircuit preview inside a Hydrogen webview panel+ 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@@ -24,9 +23,7 @@ description: > 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, the canonical `UsbCReceptacle` wrapper, the mandatory- lint + numeric + visual verification, and the "USB-C looks wrong" triage- (render placeholder vs real placement bug).+ placement, rotation math, and the canonical `UsbCReceptacle` wrapper. --- # adom-tsci@@ -49,15 +46,16 @@ 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. |+| 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. Instanced parts glow through a temporary overlay (`_componentGlowMeshes`: a geometry-sharing clone of the source at the instance, named `hl-ovl-*`, disposed when the highlight clears), so only the target glows and nothing is promoted (Ray, 2026-10-01). | 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()` and `drawFeatureOutline()` in `shell.html` add `_componentGlowMeshes(name).meshes` via `hl.addMesh(mesh, teal)`; never `_promoteComponent` on a highlight or hover path. `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. |+| 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 and hover use overlay-only (no promotion): hover used to promote every part it touched, so one Inspect sweep de-instanced the F411 (81 -> 0 instanced meshes, 21 -> 177 materials). | 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`. |+| 11 | **The camera moves only when the user moves it or explicitly asks.** Opening a board shows it still (no walkthrough autoplay, no tour). Hover, Components-panel hover, click-select and right-click glow the part but never retarget or zoom. Orbit and pan keep a fixed target; a press becomes a click (select / context menu) only if the pointer comes back up within 4 px. Click-to-orbit-center is OFF by default and, when on, needs a STILL press-and-hold: any drag cancels it. A rebuild (save, `rerun`, `reload`) keeps the user's pose; only the page's first load frames the board. Opening Nets or clicking a net glows it without moving the camera. Framing is opt-in: walkthrough steps, `adom-tsci net`, `adom-tsci highlight` (without `--no-fit`), `fit-to-component`, `view`, `camera`. Click-vs-drag uses pointer events: Babylon preventDefaults pointerdown, so `mousedown` never fires on the canvas. | Ray, 2026-10-01: "I just want to view the board without it moving. panning and orbiting is horrible cuz it keeps thinking I want to zoom in on this part I'm randomly hovering over." Every orbit press selected the part under it and flew the camera there; panel hover did the same; the 500 ms click-to-set timer fired mid-drag. | `highlightComponents()` has no camera code. Click-select is on `pointerup` gated by `_wasClick()` in `_setupCompContextMenu`. `_initClickNav()` defaults off; the pref key is `tsciClickNavPref`, written only by a toolbar click. Drag-cancel lives in `vendor/3d-viewer/.../AdomCamera.ts` (`clickHoldSlopPx`). Probe: camera target and radius must not change across hover / orbit / pan / panel hover. | If you're about to ship a change that touches highlight, hide, visibility, walkthrough, or marker material — re-read the row above and@@ -75,7 +73,7 @@ when one matches, load the referenced guide. | Guide | Load when the user says | Path | |---|---|---|-| **adom-usbc** — USB-C receptacles on an Adom tscircuit molecule: the copy-verbatim `UsbCReceptacle` wrapper (`TYPE-C-31-M-12`, merged same-net pads, all 6 holes), placement by `mount` + `pcbX = W/2 − 4`, the lint → numeric edge check → two-screenshot visual proof gate, and the **"it looks wrong" triage tree** (white-box placeholder = poisoned rebake cache, NOT a footprint bug). **Load it at three moments: before placing, after every build, and the instant anyone says the USB-C looks wrong** | "usb-c", "type-c", "smd usb c", "UsbCReceptacle", "place usb-c", "rotate usb-c", "usb-c east edge", "usb-c west edge", "usb-c looks wrong", "connector doesn't look right", "iphone charger", "cuboid placeholder" | `~/.claude/skills/adom-tsci/guides/adom-usbc.md` |+| **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` | @@ -100,28 +98,14 @@ LED nameplates are a signature Adom build and the catalog's LEDs are excellent — if the project smells like a nameplate (a name in an LED grid), start from Adom Basic Parts, not from random JLCPCB searches. -**USB-C gets its own guide, and it is not optional.** Placement is subtle-(mouth must slightly overhang the board edge or the cable can't seat) and-popular enough that it's a bundled sub-skill of this app:+**USB-C gets its own guide.** Placement is subtle (mouth must slightly+overhang the board edge or the cable can't seat) and popular enough+that it's a bundled sub-skill of this app: `~/.claude/skills/adom-tsci/guides/adom-usbc.md` — the CLI also nudges you (`Hint:` lines on `start` and `lint`) whenever it detects a USB-C part in the project. Trust those hints; they exist because USB-C was mis-placed ~20 times before the rules were written down. -The three hard rules, so you don't skip the guide and wing it:--1. **Copy `lib/UsbCReceptacle.tsx` verbatim** from the guide (or `diff`- against a shipped nameplate). Never author, simplify, or re-derive a- wrapper; never touch its offsets to "fix" a direction — pass `mount`.-2. **Every build with a USB-C ends with the gate**: `adom-tsci lint` →- the numeric edge check → body-ON and body-OFF screenshots that you- READ. Lint alone is not proof (it can't see a poisoned render).-3. **"The USB-C doesn't look right" means run the guide's triage tree- first.** A flat white/black box where the receptacle should be is the- viewer's cuboid placeholder — a re-bake cache problem, not a footprint- problem. Check `adom-tsci console` for `cuboid placeholder` before- editing a single line of the board.- ## 🧹 Process hygiene — don't be the thread that leaks tsci devs (v1.4.23) Every preview holds a `bun tsci dev` that idles at ~200 MB and grows.@@ -258,26 +242,58 @@ entry — this is the belt to the headers' suspenders. 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+## Review generated images directly++For PCB layout, silkscreen, contact labels, and schematic review, read the+generated 2D artifact directly. Do not take a screenshot of the PCB picture+inside the viewer as the default review loop.++1. Build using the project's existing build/review command.+2. Use `adom-tsci status --port <port> --json` to confirm `project_dir` and+ inspect `artifacts`. Each entry has `path`, `available`, `size`, `mtime_ms`,+ and `version`. The version is a file-change token, not a DRC result.+3. Read `artifacts.pcb_svg.path` or `artifacts.schematic_svg.path` directly.+ If a generated PNG is available, it can be read directly too. Otherwise,+ rasterize the SVG locally when the image reader requires PNG; use the+ project's renderer if it has one. Never substitute a stale PNG for a newer+ SVG: verify build timestamps or regenerate it.+4. Keep the live preview current for the user. SVG changes are polled every+ three seconds independently of the GLB. `adom-tsci reload --port <port>`+ forces artifact refresh even without a file timestamp change. Hidden SVG+ panels refresh when selected; the active panel preserves pan and zoom.++For a new top-down 3D work session, use `adom-tsci start <project> --view top`.+The selected preset applies on initial load, page refresh, and each model+rebuild. Supported values: `top`, `bottom`, `front`, `back`, `left`, `right`,+`isometric` (default), `iso`, and `home`. Existing camera controls still work+between rebuilds. Do not restart an existing preview just to set its camera;+use `adom-tsci view top --port <port>`. Preserve disabled walkthrough/demo+preferences. Do not start a walkthrough as a side effect of reviewing a board.++Screenshots remain appropriate for actual 3D appearance and viewer UI+verification. Generated-image inspection does not prove the viewer refreshed;+viewer screenshots do not replace electrical checks.++## 👁 Keep the live preview current 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.+viewer itself — keep the relevant preview available to the user. Review PCB+layout through the generated artifact as described above. Verify actual viewer+changes in the viewer. Preserve the user's foreground and panel preferences. **The reflex** after any of these changes: 1. Ensure an `adom-tsci` preview is running on the project you just touched (`adom-tsci status --json` → check `project_dir` matches, otherwise `adom-tsci stop && adom-tsci start <dir>`).-2. Ensure the `adom-tsci preview` tab (or the named instance) is- activated in a non-VS-Code pane: `adom-cli hydrogen workspace- active-tab --panel-id <P> --tab-id <T>`.+2. When the user asks to see the preview, activate the named instance in+ the requested pane with `adom-cli hydrogen workspace active-tab+ --panel-id <P> --tab-id <T>`. Otherwise preserve the foreground. 3. 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>/"`.-4. Confirm the 3D scene is actually rendered (not stuck on "Loading+4. When testing the 3D viewer, confirm the scene is rendered (not stuck on "Loading Adom 3D viewer…"): `adom-tsci list-chips` must succeed, and `adom-cli hydrogen screenshot panel --name "adom-tsci preview"` should show the board — then **Read the screenshot** to verify with@@ -350,6 +366,12 @@ the same things. switched it to a material/mesh approach without realising testpoints don't have one. Restore it. + **Same for machine contacts / pins with no 3D model** (molecules): tscircuit+ emits a translucent 24-vertex placeholder box over each pad. They load with+ `visibility = 0` (still pickable) and are shown only while their part is+ highlighted, so a contact step still glows. Real contact models (alpha 1)+ are never touched. `_isPlaceholderBox()` / `_hidePlaceholderBoxes()`.+ 4. **Empty / missing `names` clears 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@@ -1250,21 +1272,6 @@ curl -sS -X POST http://127.0.0.1:8850/shutdown ## Troubleshooting -**A part renders as a flat white or black box (USB-C, any chip with a cadModel)**-`adom-tsci console --tail 50 | grep -i placeholder` → `cuboid placeholder-for "J1"` means the GLB the server is SERVING has no body mesh for that-part. `start` re-bakes the GLB for texture resolution (`circuit-json-to-gltf`)-and caches the result by circuit.json hash in-`~/.cache/adom-tsci/rebakes/<hash>-<res>.glb`; if a modelcdn OBJ fetch-failed during that bake, the cached mesh has 0 vertices and stays poisoned-until circuit.json changes. Confirm with `curl -s :8850/glb -o /tmp/served.glb`-and compare its size to `dist/lib/index/3d.glb` (the tsci-built one is-fine). Fix: delete that cache entry, `adom-tsci stop && adom-tsci start …`,-or re-bake by hand and `adom-tsci open` (a bare `reload` does not re-read-the GLB). The footprint is not the problem — do not edit the board.-(TODO: CLI gap — `start` should validate a rebake against the tsci-built-GLB's per-mesh vertex counts before caching it.)- **`Error: no existing Web View pane found`** `adom-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 exact
VERSION+1−1@@ -1 +1 @@-1.4.24+1.5.0
guides/adom-usbc.md+276−422@@ -1,391 +1,27 @@ # adom-usbc — making USB-C receptacles actually work -**Read this at THREE moments, not one:**--1. **Before** you write a board that has a USB-C receptacle.-2. **After every build** that contains one (`adom-tsci lint` + the numeric- check + the visual proof below — all three, every time).-3. **The moment anyone says "the USB-C doesn't look right."** Go straight to- §5 (triage). Do NOT start by editing the footprint or the rotation — the- last three "USB-C looks wrong" reports were a *render* problem, not a- placement problem, and rewriting the wrapper is how it gets broken.--Written because Claude got USB-C wrong ~20 times before we wrote the rules-down, and then got it wrong again by re-deriving instead of copying. If you-skip a step you will repeat one of those failures.--## 0. The rules in one screen--| Rule | Value |-|---|---|-| Part | TYPE-C-31-M-12 · JLCPCB **C165948** (16-pin, power + CC + D±) |-| Wrapper | **Copy `UsbCReceptacle.tsx` from §1 verbatim.** Never write, "simplify", or re-derive one. |-| Placement API | `<UsbCReceptacle name="J1" mount="east" pcbX={W/2 - 4} pcbY={0} />` — pass `mount`, never a rotation number |-| Mouth | Overhangs the board edge by ~2–3 mm. Flush-or-proud, **never recessed** |-| SMT pads | **Inside** the copper, ~5–7 mm from the edge. No pad past the edge, ever |-| CC pulldowns | Rd = 5.1 k ±10 % on CC1 **and** CC2 (Adom stocks 4.7 k 0402 — in window). Without them a charger never enables VBUS |-| Gate | `adom-tsci lint` passes **and** §3 numeric check passes **and** §4 screenshots read back |--`W` is the board's nominal width (the `size="80x24"` number, not the wing-grown-82.4). For a Molecule the wings add ~1.2 mm per side; `W/2 − 4` already-accounts for that on medium pins (see §"USB-C on a MOLECULE edge" for large-pins / absolute sizes).--## 1. The wrapper — copy this file, do not author one--This is the wrapper that ships on every LED nameplate (`bill-nameplate`,-`vijay-nameplate`, …). It differs from the old guide template in two ways-that matter, and both were learned the hard way:--- **All 6 mounting holes are included** (4 plated pill anchors + 2 drill- supports). Without the anchors the port is held by 16 tiny SMT joints and- tears off in < 100 cable cycles.-- **The four same-net pad pairs are merged** into single 0.6 mm tabs.- USB-C doubles GND (A1/B12, B1/A12) and VBUS (A4/B9, B4/A9) for flip- symmetry; as separate 0.3 mm pads on 0.3 mm pitch they have **0 mm- clearance** (fails `pcb_pad_pad_clearance_error`) and make `J1.GND1` an- ambiguous multi-pad reference. Merging keeps the pad centroid at x=0 so- the body/rotation invariant is untouched. Pin aliases: `GND1`/`GND2`,- `VBUS1`/`VBUS2` = **left/right tab**, `CC1`, `CC2`, `DP1`/`DP2`, `DM1`/`DM2`,- `SBU1`/`SBU2`.--```tsx-// lib/UsbCReceptacle.tsx-/**- * USB-C receptacle (TYPE-C-31-M-12 / JLCPCB C165948).- *- * ─────────────── THE CORE INVARIANT ───────────────- * Footprint (pads + holes) and cadModel (3D body) are TIED TOGETHER.- * Whatever rotation is applied to one MUST be applied identically to- * the other. Earlier iterations violated this by (a) shifting pad- * positions in the footprint (Y_SHIFT) while leaving the cadModel- * offset in a different frame, and (b) letting tscircuit apply- * positionOffset in WORLD coords while pads rotated in LOCAL. Result:- * at pcbRotation≠0 the 3D body drifted away from its own holes.- *- * Fix: keep footprint at native ArnavK-09 positions AND compute- * positionOffset by rotating the native (0, -2.5) vector by pcbRotation- * in userland. Both footprint and body then follow the SAME rotation.- *- * ─────────────── NATIVE LAYOUT ───────────────- * pads at local (x, +2.449) for 16 pads — this is the BACK edge of- * the receptacle (solder-tails come out- * the back of the plastic shell)- * drill holes at (±2.9, +1.18)- * plated anchor holes at (±4.325, +1.41) and (±4.325, -2.774)- * — 4 corners of the receptacle body- * body centroid at local (0, -2.5) in native (from SmdUsbC hardcode)- * mouth at local -Y (south) in native- *- * This layout has body_centroid south of pads by 4.95 mm — that's the- * distance from the pad row (back of receptacle) through the body to- * the body's midpoint. Rotated together, this 4.95 mm distance is- * preserved at every pcbRotation so holes ALWAYS stay inside the body.- */--const USB_C_PN = "C165948"-const USB_C_UUID = "2a4bc2358b36497d9ab2a66ab6419ba3"--// Native positionOffset from ArnavK-09 / seveibar.smd-usb-c (pcbRotation=0):-// body centroid is 2.5 mm south of chip origin. Rotated by pcbRotation-// in userland below so the body rotates with the footprint.-const NATIVE_POS_OFFSET = { x: 0, y: -2.5 }--// 12 physical pads (see PADS below): the four same-net pairs that USB-C-// doubles for flip symmetry are single wider tabs, exactly as they are on-// the real TYPE-C-31-M-12 land pattern. Each merged tab is ONE pin carrying-// both of its native contact designators.-//-// The GND1/GND2/VBUS1/VBUS2 aliases are kept, but now mean LEFT/RIGHT tab-// rather than the A-row/B-row contact. That is what makes them unambiguous:-// one alias -> exactly one pad. Naming them per-row instead (the native-// scheme) would put VBUS1 on both tabs and VBUS2 on both tabs, and the-// router would emit a degenerate pad-to-itself trace.-const pinLabels = {- pin1: ["GND1", "A1", "B12"], // left GND tab (merged)- pin2: ["VBUS1", "A4", "B9"], // left VBUS tab (merged)- pin3: ["SBU2", "B8"],- pin4: ["CC1", "A5"],- pin5: ["DM2", "B7"],- pin6: ["DP1", "A6"],- pin7: ["DM1", "A7"],- pin8: ["DP2", "B6"],- pin9: ["SBU1", "A8"],- pin10: ["CC2", "B5"],- pin11: ["VBUS2", "B4", "A9"], // right VBUS tab (merged)- pin12: ["GND2", "B1", "A12"], // right GND tab (merged)-} as const--// ArnavK-09 native positions — DO NOT SHIFT in the mouth (Y) direction.-// Shifting Y decouples the footprint from the cadModel, which was the bug.-//-// X-direction merge (safe, and required to pass DRC): the four native-// pad PAIRS that sit on 0.3 mm pitch — (A1,B12) and (B1,A12) are both-// GND, (A4,B9) and (B4,A9) are both VBUS — are 0.3 mm wide at 0.3 mm-// spacing, i.e. exactly 0 mm clearance. `adom-tsci lint` fails that as-// pcb_pad_pad_clearance_error (min 0.1 mm), and the doubled labels also-// make `J1.GND1` an ambiguous multi-pad reference. Because each pair is-// the SAME NET (USB-C doubles those pins for flip symmetry), the correct-// fix is to merge each pair into one 0.6 mm pad carrying both port hints.-// This keeps the pad centroid at x=0 (so the body/rotation invariant is-// untouched) and restores 0.2 mm clearance everywhere.-const PAD_Y_LOCAL = 2.449 // all pads at this y-const PADS: Array<{ ports: string[]; x: number; w: number }> = [- { ports: ["A1", "B12"], x: -3.2, w: 0.6 }, // GND (merged pair)- { ports: ["A4", "B9"], x: -2.4, w: 0.6 }, // VBUS (merged pair)- { ports: ["B8"], x: -1.75, w: 0.3 },- { ports: ["A5"], x: -1.25, w: 0.3 },- { ports: ["B7"], x: -0.75, w: 0.3 },- { ports: ["A6"], x: -0.25, w: 0.3 },- { ports: ["A7"], x: 0.25, w: 0.3 },- { ports: ["B6"], x: 0.75, w: 0.3 },- { ports: ["A8"], x: 1.25, w: 0.3 },- { ports: ["B5"], x: 1.75, w: 0.3 },- { ports: ["B4", "A9"], x: 2.4, w: 0.6 }, // VBUS (merged pair)- { ports: ["B1", "A12"], x: 3.2, w: 0.6 }, // GND (merged pair)-]--/** Rotate a 2D vector by θ radians CCW (tscircuit's pcbRotation convention). */-const rotCCW = (x: number, y: number, θ: number) => ({- x: x * Math.cos(θ) - y * Math.sin(θ),- y: x * Math.sin(θ) + y * Math.cos(θ),-})--/**- * Mount convention — pass `mount` instead of raw `pcbRotation` so the- * edge-facing semantics are explicit. Native mouth = -Y (south) after- * the built-in rotationOffset.z = 180 flip, so:- * south → pcbRotation = 0- * east → pcbRotation = 90 (native -Y rotates CCW 90 to +X east... wait,- * CCW 90 of (0,-1) = (1, 0) = east? Let me re-derive:- * CCW θ: (x,y) → (x cos θ - y sin θ, x sin θ + y cos θ).- * For θ=90: (0,-1) → (0*0 - (-1)*1, 0*1 + (-1)*0) = (1, 0) = east ✓)- * north → pcbRotation = 180- * west → pcbRotation = 270- */-type Mount = "south" | "east" | "north" | "west"-const MOUNT_TO_PCBROT: Record<Mount, 0 | 90 | 180 | 270> = {- south: 0, east: 90, north: 180, west: 270,-}--type Props = {- name: string- pcbX: number- pcbY: number- /** Where the mouth points. Equivalent: pass pcbRotation directly via `pcbRotationDeg`. */- mount?: Mount- pcbRotationDeg?: 0 | 90 | 180 | 270-}--export const UsbCReceptacle = ({- name, pcbX, pcbY, mount, pcbRotationDeg,-}: Props) => {- const pcbRot = pcbRotationDeg ?? (mount ? MOUNT_TO_PCBROT[mount] : 0)- const θ = (pcbRot * Math.PI) / 180-- // Rotate the native positionOffset by pcbRotation so the body- // rotates with the footprint. This preserves the body-to-pad and- // body-to-hole distances at every rotation — the invariant the- // user (correctly) demanded.- const rotated = rotCCW(NATIVE_POS_OFFSET.x, NATIVE_POS_OFFSET.y, θ)-- return (- <chip- name={name}- pcbX={pcbX}- pcbY={pcbY}- pcbRotation={pcbRot}- 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}`,- // Native SmdUsbC rotationOffset — CAD body's native +Y mouth- // gets flipped to -Y so it matches the native footprint's- // mouth-south orientation.- rotationOffset: { x: 0, y: 0, z: 180 },- // Rotated version of the native (0, -2.5). This is what ties- // the body to the footprint.- positionOffset: { x: rotated.x, y: rotated.y, z: 0 },- }}- footprint={- <footprint>- {PADS.map(p => (- <smtpad key={p.ports.join("_")} portHints={p.ports}- shape="rect" width={`${p.w}mm`} height="1.3mm"- pcbX={`${p.x}mm`} pcbY={`${PAD_Y_LOCAL}mm`} />- ))}- {/* 2 drill-only mechanical support holes, 1.18 mm south of chip origin */}- <hole pcbX="-2.9mm" pcbY="1.18mm" diameter="0.75mm" />- <hole pcbX="2.9mm" pcbY="1.18mm" diameter="0.75mm" />- {/* 4 plated anchor holes at the 4 body corners — 2 near pad row- (at y=+1.41) + 2 toward mouth (at y=-2.774). Rotate together- with the body, so they stay inside the body footprint. */}- <platedhole portHints={["alt_0"]} pcbX="-4.325mm" pcbY="1.41mm"- shape="pill" outerWidth="1.2mm" outerHeight="2.0mm"- innerWidth="0.8mm" innerHeight="1.6mm" height="1.6mm" />- <platedhole portHints={["alt_1"]} pcbX="4.325mm" pcbY="1.41mm"- shape="pill" outerWidth="1.2mm" outerHeight="2.0mm"- innerWidth="0.8mm" innerHeight="1.6mm" height="1.6mm" />- <platedhole portHints={["alt_2"]} pcbX="4.325mm" pcbY="-2.774mm"- shape="pill" outerWidth="1.2mm" outerHeight="1.8mm"- innerWidth="0.8mm" innerHeight="1.4mm" height="1.4mm" />- <platedhole portHints={["alt_3"]} pcbX="-4.325mm" pcbY="-2.774mm"- shape="pill" outerWidth="1.2mm" outerHeight="1.8mm"- innerWidth="0.8mm" innerHeight="1.4mm" height="1.4mm" />- </footprint>- }- />- )-}-```--Wire it like this (power-only board; add D± traces if you use data):--```tsx-<UsbCReceptacle name="J1" pcbX={36} pcbY={0} mount="east" /> // 80-wide board-<resistor name="R_CC1" resistance="4.7k" footprint="0402" pcbX={29} pcbY={2} />-<resistor name="R_CC2" resistance="4.7k" footprint="0402" pcbX={29} pcbY={-2} />-<capacitor name="C_BYP" capacitance="100nF" footprint="0402" pcbX={32} pcbY={5} />--<trace from="J1.VBUS1" to="net.VBUS" /> <trace from="J1.VBUS2" to="net.VBUS" />-<trace from="J1.GND1" to="net.GND" /> <trace from="J1.GND2" to="net.GND" />-<trace from="R_CC1.pin1" to="J1.CC1" /> <trace from="R_CC1.pin2" to="net.GND" />-<trace from="R_CC2.pin1" to="J1.CC2" /> <trace from="R_CC2.pin2" to="net.GND" />-<trace from="C_BYP.pin1" to="net.VBUS" /><trace from="C_BYP.pin2" to="net.GND" />-```--**Schematic:** if you add `schX`/`schY` to other parts, forward them through-the wrapper too (add the props to `Props` and pass them to `<chip>`), or the-receptacle piles at the schematic origin — `adom-tsci lint` check 5 catches it.--## 2. Placement — by `mount`, never by rotation number--`pcbX`/`pcbY` is the chip origin = the SMT pad-row centroid. The body-extends ~2.5 mm *forward* of the pads and the mouth ~4.7 mm.--| Mouth faces | `mount` | `pcbX` | `pcbY` |-|---|---|---|---|-| East (right) — the iPhone-charger default | `"east"` | `+W/2 − 4` | 0 (or along the edge) |-| West (left) | `"west"` | `−W/2 + 4` | 0 |-| South (bottom) | `"south"` | 0 | `−H/2 + 4` |-| North (top) | `"north"` | 0 | `+H/2 − 4` |--Keep the receptacle **mid-edge, between the corner machine pins**. Leave a-~13 mm zone inboard of the connector for the CC resistors and bypass cap —-on an 80×24 nameplate that means the LED text must end at `x ≤ +20`.--Do not touch `rotationOffset`, `positionOffset`, `NATIVE_POS_OFFSET` or-`PAD_Y_LOCAL` in the wrapper to "fix" a direction. If the mouth points the-wrong way you passed the wrong `mount`; if the body is off its pads you-edited the wrapper — restore it from §1.--## 3. Build → lint → numeric edge check (every build)--```bash-bunx tsci build lib/index.tsx --glbs --svgs # writes dist/ (tsci export does NOT)-adom-tsci lint # body_fwd + mouth-exits-edge + clearance + ghost chips-```--`lint` reports `J1: body_fwd=5.26mm, mouth … exits board edge at (41.2, 0)`-for a correct east-edge placement (5.26 = 2.5 mm body-over-pads + the 2.6 mm-bbox shift the anchor holes add; see §"Include ALL mounting holes").--Then the numbers lint does not print — pad and hole gaps to the actual edge:--```bash-python3 - <<'EOF'-import json-c=json.load(open('dist/lib/index/circuit.json'))-sc=[s for s in c if s['type']=='source_component' and s['name']=='J1'][0]-pc=[p for p in c if p['type']=='pcb_component' and p['source_component_id']==sc['source_component_id']][0]-pads=[p for p in c if p['type']=='pcb_smtpad' and p['pcb_component_id']==pc['pcb_component_id']]-holes=[p for p in c if p['type'] in('pcb_plated_hole','pcb_hole') and p['pcb_component_id']==pc['pcb_component_id']]-cad=[p for p in c if p['type']=='cad_component' and p['pcb_component_id']==pc['pcb_component_id']][0]-b=[e for e in c if e['type']=='pcb_board'][0]; edge=b['center']['x']+b['width']/2 # east edge; adapt for other edges-print('edge x', edge)-print('pad gap to edge %.2f mm (want 5..8)' % (edge-max(p['x']+max(p['width'],p['height'])/2 for p in pads)))-print('hole gap to edge %.2f mm (want > 0.8)' % (edge-max(h['x']+h.get('outer_width',h.get('hole_diameter',0))/2 for h in holes)))-print('mouth overhang %.2f mm (want 2..3)' % (cad['position']['x']+4.7-edge))-print('pads', len(pads), '(want 12)', 'holes', len(holes), '(want 6)')-EOF-```--Reference values from `vijay-nameplate` (80×24 molecule, `pcbX=36`): pad gap-7.35, hole gap 1.83, overhang 2.31, 12 pads, 6 holes. If yours differ by more-than ~1 mm, fix placement before going further.--## 4. Visual proof — two screenshots, both READ back--Numbers can be gamed; the user verifies with their eyes. Take these in the-live `adom-tsci` tab (Hydrogen) — pup + shotlog is the alternative when the-workspace must not be touched.--```bash-adom-tsci fit-to-component J1-adom-tsci camera --alpha 0.4 --beta 1.0 --radius 1.6-adom-cli hydrogen screenshot panel --name "<tab name>" --reason "USB-C body" # 1. body ON-adom-tsci toggle-component J1 --hide-adom-cli hydrogen screenshot panel --name "<tab name>" --reason "USB-C pads" # 2. body OFF → pads/holes-adom-tsci toggle-component J1 --show-adom-tsci highlight --off && adom-tsci view home-```--Read shot 1: a **grey/silver receptacle shell with a visible mouth opening**-hanging ~2 mm off the edge, mouth outward. Read shot 2: 12 gold pads in a-row + 4 pill anchors + 2 drill holes, all on green copper inside the edge.--**If shot 1 shows a flat white or black box instead of a receptacle, stop —-that is not a placement bug. Go to §5.**--## 5. "It doesn't look right" — triage IN THIS ORDER--```-Is the 3D body a featureless box (white/black slab)?-│-├─ yes → RENDER problem, footprint is probably fine.-│ adom-tsci console --tail 50 | grep -i placeholder-│ └─ "cuboid placeholder for J1" → the served GLB has no J1 body.-│ adom-tsci serves a RE-BAKED GLB (texture upscale) cached by-│ circuit.json hash in ~/.cache/adom-tsci/rebakes/<hash>-<res>.glb.-│ If the modelcdn OBJ fetch for C165948 failed during that bake,-│ the cache holds a 0-vertex J1 mesh — and it STAYS poisoned-│ until circuit.json changes.-│ Confirm: curl -s :8850/glb -o /tmp/served.glb-│ ls -l /tmp/served.glb dist/lib/index/3d.glb # served much smaller?-│ Fix: rm ~/.cache/adom-tsci/rebakes/<newest>.glb-│ adom-tsci stop && adom-tsci start . --tab-name "<same>"-│ (or re-bake by hand with circuit-json-to-gltf and-│ overwrite the cache file, then `adom-tsci open`;-│ a bare `adom-tsci reload` is NOT enough)-│ Then redo §4.-│-└─ no, it's a real receptacle but wrong →- ├─ mouth points INTO the board / butt hangs off → wrong `mount`. Fix the prop.- ├─ body sits beside / diagonal to its pads → wrapper was edited. Restore §1.- ├─ body is inboard, pads near the edge → `pcbX` sign or W confusion. Recompute W/2 − 4.- ├─ pads hang past the edge → pcbX too big; the GLB mesh grows asymmetrically- │ and EVERY feature renders shifted. Pull in to W/2 − 4.- └─ lint fails `pcb_pad_pad_clearance_error` on J1 → you have the old- 16-pad wrapper. Replace with §1 (merged pairs).-```--Never "fix" a render-side symptom by editing `UsbCReceptacle.tsx`. If lint-and §3 pass, the copper is right regardless of what the 3D tab shows.--## 6. Definition of done--- [ ] wrapper is byte-identical to §1 (`diff` against a nameplate that shipped)-- [ ] placed by `mount`, `pcbX = ±(W/2 − 4)`, mid-edge-- [ ] CC1 **and** CC2 pulled down; VBUS bypassed-- [ ] `adom-tsci lint` passes (0 build errors, body_fwd ≈ 5.3, mouth exits edge)-- [ ] §3 numbers within ~1 mm of reference; 12 pads, 6 holes-- [ ] §4 body shot shows a real receptacle mouth-out over the edge; pads shot shows 12 + 4 + 2 inside-- [ ] `plan.md` records the placement numbers---# Background — why the rules are what they are+**Read this before touching a USB-C receptacle on an Adom tscircuit+board.** Written because Claude got USB-C wrong ~20 times before we+wrote the rules down. If you skip this skill and try to wing it, you+will repeat every one of those failures.++## THE OVERHANG RULE — why the mouth must clear the board edge++A USB-C receptacle is useless if the plug can't seat. The plug's+collar is wider than the receptacle mouth, so the mouth must **slightly+overhang the board edge** (for TYPE-C-31-M-12 the mouth cantilevers+~2.5 mm past the edge at the recommended placement) — flush-or-proud+of the edge, NEVER recessed inboard. Recessed = the cable bottoms out+on the board edge and never makes contact. This is also the general+wiki rule: see the **pcb-design** skill on the wiki+([wiki.adom.inc/adom/pcb-design](https://wiki.adom.inc/adom/pcb-design)),+rule "USB connector placement": edge-mounted, flush or slightly+overhanging. `adom-tsci lint` enforces the geometry (body forward of+pads, mouth exits the board edge within a connector depth).++**Sourcing:** use the Adom Basic Parts catalog for the receptacle and+its passives — verified symbols, footprints, and 3D bodies:+[wiki.adom.inc/adom/adom-basic-parts](https://wiki.adom.inc/adom/adom-basic-parts). ## THE CORE INVARIANT — DO NOT VIOLATE @@ -399,9 +35,9 @@ Ways to satisfy this invariant: 1. **Bundle them in one component** with a single `pcbRotation` prop that applies to BOTH the `<chip>`'s footprint children AND the- cadModel's `positionOffset`. The §1 wrapper does this — it computes- `positionOffset` as `rotCCW(NATIVE_POS_OFFSET, pcbRotation)` in userland- so the body rotates with the footprint.+ cadModel's `positionOffset`. The wrapper in §"The workaround"+ below does this — it computes `positionOffset` as `rotate(native_offset,+ pcbRotation)` in userland so the body rotates with the footprint. 2. **Compute body placement in the SAME local frame as the footprint.** Never express body offset in world coords while the footprint is in local coords. If you set `positionOffset` directly without@@ -453,6 +89,235 @@ where the receptacle's body sits relative to its back-row of SMT pads. Filed upstream as `TSCIRCUIT_FEATURE_REQUESTS.md §6`. +## The workaround: a local `UsbCReceptacle.tsx`++Ship a local wrapper in your project's `lib/` that builds a raw+`<chip>` with an explicit footprint (copied from ArnavK-09's JSX) and+your own `cadModel` that rotates `positionOffset` with `pcbRotation`.+This survives `bun install`, gives you full control, and — critically+— you can unit-test it with the lint below without waiting on+upstream fixes.++### The template (copy this verbatim)++```tsx+// lib/UsbCReceptacle.tsx+const USB_C_PN = "C165948"+const USB_C_UUID = "2a4bc2358b36497d9ab2a66ab6419ba3"++// Body-to-pad offset in the mouth direction for TYPE-C-31-M-12.+// Measured: the SMT pad row sits at the BACK of the receptacle, and+// the body centroid is 2.5 mm forward of that. Centering body on pads+// (positionOffset 0) makes the back half of the body extend BEHIND+// the pads into the board — wrong geometry, even if numerics look+// "clean".+const BODY_FORWARD_MAG = 2.5++const pinLabels = {+ pin1: ["GND1", "A1"], pin2: ["GND2", "B12"],+ pin3: ["VBUS1", "A4"], pin4: ["VBUS2", "B9"],+ pin5: ["SBU2", "B8"], pin6: ["CC1", "A5"],+ pin7: ["DM2", "B7"], pin8: ["DP1", "A6"],+ pin9: ["DM1", "A7"], pin10: ["DP2", "B6"],+ pin11: ["SBU1", "A8"], pin12: ["CC2", "B5"],+ pin13: ["VBUS1", "A9"], pin14: ["VBUS2", "B4"],+ pin15: ["GND1", "A12"], pin16: ["GND2", "B1"],+} as const++// 16 SMT pads, pitch 0.5 mm, centered at y=0 so bounds.center = pad+// centroid (no holes included — see §Why we drop the holes).+const PADS: Array<{ port: string; x: number }> = [+ { port: "A1", x: -3.35 }, { port: "B12", x: -3.05 },+ { port: "A4", x: -2.55 }, { port: "B9", x: -2.25 },+ { port: "B8", x: -1.75 }, { port: "A5", x: -1.25 },+ { port: "B7", x: -0.75 }, { port: "A6", x: -0.25 },+ { port: "A7", x: 0.25 }, { port: "B6", x: 0.75 },+ { port: "A8", x: 1.25 }, { port: "B5", x: 1.75 },+ { port: "B4", x: 2.25 }, { port: "A9", x: 2.55 },+ { port: "B1", x: 3.05 }, { port: "A12", x: 3.35 },+]++export const UsbCReceptacle = ({+ name, pcbX, pcbY, pcbRotation = 0, schX, schY, schRotation,+}: {+ name: string; pcbX: number; pcbY: number;+ pcbRotation?: 0 | 90 | 180 | 270+ // SCHEMATIC placement — ALWAYS forward to the inner <chip>. Without these+ // the receptacle silently defaults to the schematic origin (0,0) and piles+ // on top of other parts (caught by `adom-tsci lint` Check 5). A wrapper+ // component MUST pass schX/schY/schRotation through, or it can't be placed+ // on the schematic.+ schX?: number; schY?: number; schRotation?: number+}) => {+ // Native CAD has mouth at local +Y. cad.rotation.z = pcbRotation+ // + rotationOffset.z (tscircuit adds them, CCW). We set+ // rotationOffset.z=0 so cad_z = pcbRotation. Then the mouth direction+ // in world frame is rotate((0,+1), pcbRotation CCW) =+ // (-sin pcbRotation, cos pcbRotation).+ const rad = (pcbRotation * Math.PI) / 180+ const mouthX = -Math.sin(rad)+ const mouthY = Math.cos(rad)+ const posX = BODY_FORWARD_MAG * mouthX+ const posY = BODY_FORWARD_MAG * mouthY++ return (+ <chip+ name={name}+ pcbX={pcbX} pcbY={pcbY} pcbRotation={pcbRotation}+ schX={schX} schY={schY} schRotation={schRotation}+ supplierPartNumbers={{ jlcpcb: [USB_C_PN] }}+ manufacturerPartNumber="TYPE-C-31-M-12"+ pinLabels={pinLabels as any}+ cadModel={{+ objUrl: `https://modelcdn.tscircuit.com/easyeda_models/download?uuid=${USB_C_UUID}&pn=${USB_C_PN}`,+ rotationOffset: { x: 0, y: 0, z: 0 },+ positionOffset: { x: posX, y: posY, z: 0 },+ }}+ footprint={+ <footprint>+ {PADS.map(p => (+ <smtpad key={p.port} portHints={[p.port]}+ shape="rect" width="0.3mm" height="1.3mm"+ pcbX={`${p.x}mm`} pcbY="0mm" />+ ))}+ </footprint>+ }+ />+ )+}+```++## Per-edge placement recipe++For a board of width `W` centered at x=0 (so edges at ±W/2):++| Edge you want mouth to face | `pcbRotation` | `pcbX` | `pcbY` |+|---|---|---|---|+| South (bottom) | 0 | 0 or offset along x | −(H/2) + 4 |+| East (right) | 270 | +(W/2) − 4 | 0 or offset along y |+| North (top) | 180 | 0 or offset along x | +(H/2) − 4 |+| West (left) | 90 | −(W/2) + 4 | 0 or offset along y |++`pcbX / pcbY` is the **chip origin** = **pad row centroid** position.+For a 64×32 board with USB-C on east edge: `pcbX=28, pcbY=0,+pcbRotation=270`. Pads at x=28 (inside the board by 4 mm), body at+x=28+2.5=30.5, mouth cantilevers to about x=34.5 (2.5 mm past edge).++### **Corollary: no SMT pad may extend past the board edge.**++The **body / mouth / mechanical shell** can and should cantilever+past the edge. SMT **pads** must not. If any copper pad (VBUS1/2,+GND1/2, DP1/2, DM1/2, CC1, CC2, SBU1/2) ends up at `|pcbX| > W/2`,+tscircuit grows the GLB board mesh asymmetrically to include the+overhang — and because the texture art is drawn with `pcbX=0` at+the canvas centre, the whole silkscreen / copper / annular-ring+layer renders shifted by `asymmetry/2` relative to the 3D mesh.+Every via drill appears off-centre in its annular ring, every+passive body appears shifted from its pad. The PCB itself is fine,+but the 3D view looks broken.++Real failure: a 72×32 board with the USB-C receptacle at `pcbX=35`.+That put the east-most SMT pads at `pcbX≈37.2`, growing the mesh+to `[-38.174, +37.2]` (centre `-0.487`) and shifting every copper+feature ~0.5 mm in the render. Fix: moved the receptacle to+`pcbX=33` (= `W/2 − 4 + overhang_0`). Mesh became `[-37.2, +37.2]`,+shift gone. **Use `pcbX = W/2 − 4` for the east edge; do not improvise.**++## Mandatory verification lint++**Run this after every build with a USB-C receptacle.** Not+optional. The only way we stopped getting USB-C wrong was by+gating on these two numeric checks:++```js+// scripts/check-usbc.mjs+import j from "./dist/lib/index/circuit.json" with { type: "json" }++for (const src of j.filter(x => x.type === "source_component")) {+ // Heuristic: USB-C receptacle = any chip with jlcpcb matching a+ // known USB-C family. Extend as new families are supported.+ const pn = src.supplier_part_numbers?.jlcpcb?.[0]+ if (!pn || !/^C(165948|2760486|283540|840342)/.test(pn)) continue++ const pc = j.find(x => x.type === "pcb_component"+ && x.source_component_id === src.source_component_id)+ const pads = j.filter(x => x.type === "pcb_smtpad"+ && x.pcb_component_id === pc.pcb_component_id)+ const cad = j.find(x => x.type === "cad_component"+ && x.pcb_component_id === pc.pcb_component_id)+ const board = j.find(x => x.type === "pcb_board")++ const padCx = pads.reduce((s, p) => s + p.x, 0) / pads.length+ const padCy = pads.reduce((s, p) => s + p.y, 0) / pads.length+ const rot = cad.rotation.z * Math.PI / 180+ const mX = -Math.sin(rot), mY = Math.cos(rot)++ // Check 1: body forward of pads by ~BODY_FORWARD_MAG in mouth dir+ const fwd = (cad.position.x - padCx) * mX + (cad.position.y - padCy) * mY+ if (fwd < 1.5 || fwd > 3.5) {+ throw new Error(`${src.name}: body_forward=${fwd.toFixed(2)} mm; expected ~2.5.+ Body is ${fwd < 0 ? "BEHIND" : "too close to"} pads — geometry wrong.`)+ }++ // Check 2: mouth ray exits the board outline+ const ray = { x: cad.position.x + 12 * mX, y: cad.position.y + 12 * mY }+ const bx = board.center?.x ?? 0, by = board.center?.y ?? 0+ const inside = ray.x >= bx - board.width/2 && ray.x <= bx + board.width/2+ && ray.y >= by - board.height/2 && ray.y <= by + board.height/2+ if (inside) {+ throw new Error(`${src.name}: mouth points INTO the board at ${ray.x.toFixed(1)},${ray.y.toFixed(1)}.+ Cable would collide with FR4. Rotate receptacle 180° (toggle rotationOffset.z 0↔180).`)+ }++ console.log(`✓ ${src.name}: body_fwd=${fwd.toFixed(2)}mm, mouth dir (${mX.toFixed(2)},${mY.toFixed(2)}) exits board at (${ray.x.toFixed(1)},${ray.y.toFixed(1)})`)+}+```++**Wire it into your build loop:**++```bash+bunx tsci build lib/index.tsx --glbs --svgs && adom-tsci lint+```++`adom-tsci lint` does exactly the checks above (for every JLC USB-C+part in its known list) plus catches ghost chips and autorouter+silent-failures. Same exit-code gating. Prefer it over the inline+`node -e` above — it's shipped with the tool and maintained+alongside the lint rules.++Or if you only have `node` and not `adom-tsci lint`:++## Visual proof-of-placement (the toggle-and-shotlog ritual)++**Numeric lint can be gamed.** A body can satisfy `forward_dot >= 1.5`+yet still be visually misaligned with the pad geometry if a footprint+dimension is wrong. After every USB-C placement change, do this+three-step visual proof:++```bash+# 1. Top view with receptacle visible+adom-tsci view top --port 8853+adom-desktop browser_screenshot '{"sessionId":"<project>","maxWidth":1800}'+shotlog inject -c usbc-validate -d "USB-C placed on east edge, body visible cantilevered off the board" -s pup_screenshot <path>++# 2. Hide the receptacle to see the pads underneath+adom-tsci toggle-component J1 --port 8853 --hide+adom-desktop browser_screenshot '{"sessionId":"<project>","maxWidth":1800}'+shotlog inject -c usbc-validate -d "USB-C hidden; pads underneath should be flush with board east edge, all 16 pads on copper" -s pup_screenshot <path>++# 3. Un-hide+adom-tsci toggle-component J1 --port 8853 --show+```++The user reads the two screenshots side-by-side and confirms:+- Body cantilevers off the correct edge (mouth away from board)+- The pads sit inside the board (none hanging off)+- Pad row aligns with where the body's back edge is++**If you don't post both screenshots to shotlog, the user can't+verify, and you can't claim the placement works.** "The lint passes"+is necessary but not sufficient.+ ## Include ALL mounting holes — do NOT drop them to simplify the math **The rule:** the TYPE-C-31-M-12 footprint must include all 4 plated@@ -461,18 +326,14 @@ 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 §1 wrapper already includes all six holes at the native ArnavK-09-positions** (pads at local y=+2.449, drill supports at +1.18, anchors at-+1.41 and −2.774) with the native body offset `(0, −2.5)`. That is why-`adom-tsci lint` reports `body_fwd ≈ 5.3 mm` rather than 2.5: lint measures-from the pad-row centroid, and the pad row sits ~2.8 mm behind the chip-origin that the body offset is relative to. **5.3 is the correct number for-this wrapper — do not "fix" it toward 2.5.** (`adom-tsci lint` only requires-`body_fwd > 0.5` plus a mouth that exits the edge within 12 mm; the 2.5-figure in older text was for a pads-only footprint.)+**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. -If you ever build a different directional connector with asymmetric holes,-the compensation you need is:+Formula for any directional connector with asymmetric holes: ```text BODY_FORWARD_MAG = body_over_pads + |bbox_center_y − pad_row_y|@@ -480,25 +341,33 @@ BODY_FORWARD_MAG = body_over_pads + |bbox_center_y − pad_row_y| Measure the bbox shift by building once with holes included, reading `pcb_component.center` from `circuit.json`, and subtracting your-known pad row y. For TYPE-C-31-M-12 this is already baked into §1.+known pad row y. Filed upstream as TSCIRCUIT_FEATURE_REQUESTS §6.1: `cadModel` should accept `boundsSource: "pads" | "all"` so we don't have to compensate in userland. Until then, the compensation above is load-bearing. +## Failure modes → diagnosis table++| Symptom | Cause | Fix |+|---|---|---|+| Body visible but pads hanging off the board edge | `pcbX/Y` too close to the edge; pads should be INSIDE copper | Move `pcbX` inward by ~4 mm |+| Mouth points into the board (butt cantilevers off edge) | `rotationOffset.z` is wrong by 180° | Flip `rotationOffset.z` (0 ↔ 180) |+| Body drifts diagonally off the pads at non-zero rotation | You used upstream `SmdUsbC` without the rotation-compensation | Switch to the local `UsbCReceptacle.tsx` wrapper above |+| Body centered on pads, but half the body extends into the board | `positionOffset` is (0,0); forgot `BODY_FORWARD_MAG * mouth_dir` | Add the forward-offset math |+| USB-C missing from Components HUD in adom-tsci | refdes doesn't match `/^J[\d_]/i` OR no cad_component emitted | Rename to `J1`/`J_1`; check `circuit.json` for `cad_component` row |+| `pcb_autorouting_error: Failed to solve 1 nodes` after adding USB-C | USB-C pads near high-density chip pin row | Move USB-C further from adjacent chips, or grow board |+ ## USB-C on a MOLECULE edge (not a bare `<board>`) When the board is an Adom `<Molecule>` (machine-pin corners for the probing workcell), edge placement gets one extra wrinkle: **the molecule's wings grow the real board outline beyond the nominal `size`.** A `size="128x48"` molecule renders as a ~130.4×50.4 mm board (≈1.2 mm wing per side); large-pins grow it more.+pins grow it more. If you place the USB-C with `pcbX = nominal/2 − 4`, it+ends up several mm *inside* the real edge and the plug can't reach. -For the common case — **medium pins, nominal `size`, `wing="nominal"`** (every-LED nameplate: 64x24, 80x24) — `pcbX = nominal/2 − 4` is verified to give a-2.3 mm mouth overhang and 1.8 mm hole clearance (§3 reference numbers). The-rules below are for **large pins or absolute sizes**, where the wing growth-is bigger and the nominal formula lands the mouth inside the edge:+Rules that make it work: 1. **Use absolute sizing** — `size="138x58 absolute"` — so the real outer edge is exactly `W/2`. The size increments are coarse (…96, 128, 160…)@@ -523,21 +392,6 @@ inside, plated anchor holes ~0.8 mm clear of the edge, body/mouth cantilevers ~2.5 mm proud so a cable seats. (W/2 − 4 is the audited, canonical value; earlier W/2 − 3.2 shoved the anchor holes ~0.03 mm from the edge — too close.) -## Failure modes → diagnosis table--| Symptom | Cause | Fix |-|---|---|---|-| **Body renders as a flat white/black box** | Viewer cuboid placeholder: served (re-baked) GLB has a 0-vertex J1 mesh because the modelcdn OBJ fetch failed during the bake; cache is keyed by circuit.json hash so it sticks | §5: check `adom-tsci console` for `cuboid placeholder`, delete the rebake cache entry, restart, `adom-tsci open`. Do NOT touch the footprint |-| Body visible but pads hanging off the board edge | `pcbX/Y` too close to the edge; pads should be INSIDE copper | Move `pcbX` inward to `W/2 − 4` |-| Mouth points into the board (butt cantilevers off edge) | wrong `mount` | Pass the edge the mouth should face |-| Body drifts diagonally off the pads at non-zero rotation | Upstream `SmdUsbC`, or an edited wrapper | Restore the §1 wrapper verbatim |-| Body centered on pads, half of it inside the board | `positionOffset` zeroed; forgot `NATIVE_POS_OFFSET` rotation | Restore the §1 wrapper verbatim |-| `pcb_pad_pad_clearance_error` on J1 | Old 16-pad wrapper (0 mm between same-net pairs) | Restore the §1 wrapper (merged pairs) |-| `trace[.J1 > port.A4, .J1 > port.A4]` self-trace / "disconnected endpoint" | Pin labels duplicated across pads (`VBUS1` on two pins) | §1 wrapper: one alias → one pad |-| USB-C missing from Components HUD in adom-tsci | refdes doesn't match `/^J[\d_]/i` OR no cad_component emitted | Rename to `J1`; check `circuit.json` for the `cad_component` row |-| `pcb_autorouting_error` after adding USB-C | USB-C pads near a dense chip pin row | Move USB-C further from adjacent chips, or grow the board |-| Charger plugged in, nothing lights | No CC pulldowns (or only one) | 4.7 k–5.1 k from CC1 and CC2 to GND |- ## Upstream feature requests See `adom-tsci/TSCIRCUIT_FEATURE_REQUESTS.md §6` for:
guides/export-wiki.md+202−211@@ -1,250 +1,241 @@-# adom-tsci/export-wiki — publishing a tscircuit board to the Adom Wiki+# adom-tsci/export-wiki — publishing a tscircuit molecule to the Adom Wiki **Read this before running `adom-tsci export-wiki` on any project.**+The CLI is deliberately opinionated: it ships the interactive 3D+viewer, the source bundle, the plan.md, AND a comprehensive body+markdown to the wiki page in one shot. Skipping any of those pieces+makes the page useless to a future viewer (human or AI) — the goal+is "another agent can pick up exactly where this one left off" with+nothing missing. -> **Read `wiki-readme-html` from the [Adom Wiki Skill Pack](https://wiki.adom.inc/adom/adom-wiki-skillpack)-> first.** It owns the generic readme.html contract: the sandbox shims, the-> size budget, the `readme-height` protocol, the shadowing rule, theming. This-> guide covers only what is specific to shipping a **tscircuit board** through-> that contract, and defers to it everywhere else.--## Current state of the tooling (2026-08-20)--**`adom-tsci export-wiki` cannot upload.** It shells out to-`adom-wiki asset upload --asset-type tsci-viewer`, and `adom-wiki` has no-`asset` pillar any more (file-bearing verbs are now `repo push`,-`release upload`, `page attach`). Use `--no-upload` for the staging half —-which is still correct — and push the files yourself.--The older text of this guide described the wiki auto-rendering an iframe-section from a `tsci-viewer` asset. That is not what happens. Current pages-push `viewer/` as ordinary repo files and embed it from a hand-written-`README.html`, which the wiki renders in preference to `README.md`.--## What a board page ships+## What the CLI does, in order ```-README.html the page body; embeds the viewer (see wiki-readme-html)-README.md plain-text source of record-board.glb Z-UP, for the wiki's own native 3D viewer-pcb.svg schematic.svg picked up by the native Symbol & Footprint section-viewer/ the self-contained adom-tsci viewer bundle-plan.md the brief and every decision made against it-<slug>-source.tar.gz full project source-docs/hero.png 16:10, 1920x1200+adom-tsci export-wiki [<project>] --slug <wiki-slug> \+ --page-type molecules \+ --label "Live 3D viewer" \+ [--rebuild] [--texture-resolution N] [--no-upload] ``` -## The three tscircuit-specific traps--Everything else in the sandbox is `wiki-readme-html`'s territory.--### 1. `board.glb` must be Z-up; `viewer/3d.glb` must not--glTF is Y-up by spec and that is what tscircuit emits — `Box0` spans about-±0.7 in **Y**, which is the board thickness. Two consumers, two conventions:--| file | consumer | needs |-|---|---|---|-| `viewer/3d.glb` | the adom-tsci viewer | **Y-up** — it rotates at runtime (`zUp: true` + `applyGlbZUpTransform`) |-| `board.glb` | the wiki's native 3D Model viewer | **Z-up** — it consumes the raw file |--Publish an unrotated `board.glb` and the wiki stands your board on edge like a-wall, which reads as a modelling error rather than a convention mismatch.-Rotate both and the tsci viewer double-rotates. The wiki viewer also looks-from −Y, so a board whose connector edge is at +X faces away; composing 180°-about Z is usually wanted.+1. **Confirms `dist/lib/index/circuit.json` exists.** Skip-rebuild is+ the default — `bunx tsci build` can take 10 minutes on dense+ boards. Pass `--rebuild` to force.++ **Fallback-dist search** (1.3.91+): if the in-place dist is missing,+ the CLI looks for an existing dist at known parallel locations+ before erroring out. Order: `~/project/adom-tsci-projects/<basename>/dist`,+ `~/project/adom-tsci/examples/<basename>/dist`,+ `~/project/tscircuit-projects/<basename>/dist`. Picks the freshest+ by `circuit.json` mtime. Many canonical molecules live in two+ places (the example-library copy and the local scratch-build+ copy); only one has a fresh dist at any given moment, and there's+ no reason to spend 10 minutes rebuilding when the other copy is+ already there.++ **The reflex when a build seems missing:** `ls+ ~/project/adom-tsci-projects/<name>/dist/lib/index/circuit.json`+ FIRST. Only rebuild if neither copy has a fresh dist. The CLI does+ this automatically now, but the same rule applies to any AI helper+ debugging "why doesn't this project have a dist?" — search before+ rebuilding.+2. **Resolves the GLB.** Default = `dist/lib/index/3d.glb` (1024-px+ texture). Pass `--texture-resolution N` to rebake at higher res+ (4096 typical for 100–200 mm boards; first bake ~75 s, cache hit+ ~190 ms).+3. **Stages the viewer bundle** at `/tmp/adom-tsci-export-<slug>/`:+ - `index.html` — `shell.html` rewritten for static hosting (fetch+ monkeypatch, walkthrough autoplay, all `glb`-style URLs+ remapped to relative paths).+ - `js/adom-3d-viewer.min.js`, `js/tscircuit-alphabet.js`,+ `js/environmentSpecular.env`, `adom.css`, `favicon.svg` —+ embedded into the binary, copied out at staging time.+ - `3d.glb`, `circuit.json`, `walkthrough.json`, `pcb.svg`,+ `schematic.svg` — the project's build artifacts.+4. **Tarballs the viewer bundle** as+ `<slug>-tsci-viewer.tar.gz`. Uploads via `adom-wiki asset upload+ --asset-type tsci-viewer`. The wiki extracts to+ `/static/<page-type-plural>/<slug>/tsci-viewer/<label-slug>/` and+ auto-renders an iframe section on the page.+5. **Builds a separate source bundle** at+ `/tmp/<slug>-source.tar.gz` containing every file an agent would+ need to recreate the molecule:+ - `lib/` (recursive)+ - `package.json`+ - `tscircuit.config.json`+ - `tsconfig.json`+ - `walkthrough.json`+ - `.npmrc`+ - **`plan.md`** (or `PLAN.md`) — the design brief. Required by+ convention; see "plan.md" below.+ - `README.md` (if present)+ Excluded: `dist/` (build output) and `node_modules/` (deps).+ Uploaded as a `file` asset.+6. **Uploads `plan.md` standalone** as a separate `file` asset so the+ wiki can render a one-click link to it without untar.++Failures on steps 5 / 6 are non-fatal — the viewer publish in step 4+is the load-bearing one and lands first. If a transient wiki failure+blocks the source-bundle upload, the CLI emits a hint with the retry+command.++## Walkthrough autoplay++The static export of `shell.html` registers a small bootstrapper that+**fires `walkthroughStart()` ~2 s after the page loads**. This is on+by default for the wiki context — the walkthrough is the canonical+"tour the board" experience for an external viewer who isn't going+to know to click anything. It can be opted out with+`?autoplay=off` on the iframe URL if a future wiki template wants to+defer firing it.++The walkthrough is loaded from `walkthrough.json` in the bundle. If+the project has no `walkthrough.json`, the autoplay no-ops gracefully+(no walkthroughStart call until the array is populated). Generate+one with: ```bash-node scripts/glb-make-zup.mjs <pristine.glb> board.glb 180+adom-tsci walkthrough-gen # auto-generate from circuit.json ``` -Verify by rotating probe vectors, not by squinting: `+Y → +Z`, and the-bounding box should come out with its thin axis on Z.+…or hand-edit `walkthrough.json` directly. The `_meta` block has a+`do_not_edit_by_hand` field — set it to a plausible-looking string+(e.g. "Hand-edited intentionally") to keep `walkthrough-gen` from+overwriting your edits. -### 2. The viewer bundle is 13 MB, and `render/` 413s over ~4 MB+## plan.md — the design brief -`wiki-readme-html` gives you the document-level size budget. This is the-per-file one: any single file served through-`https://wiki.adom.inc/<owner>/<slug>/render/<path>` over roughly 4 MB returns-`{"error":"File too large to render"}`. A `<script src>` cannot surface that;-it just fails.+**Every molecule publish should include `plan.md` at the project+root.** plan.md is the original prompt the project was authored from,+plus the refinements that came out during build. It carries forward+to the wiki so a future agent skimming the page can answer: -Ship `adom-3d-viewer.min.js` as 3.5 MB parts and reassemble client-side:+- What was the user actually asking for?+- What design decisions were made and why?+- What's intentionally out of scope? -```bash-split -b 3500000 -d js/adom-3d-viewer.min.js js/adom-3d-viewer.min.js.part-cat js/adom-3d-viewer.min.js.part* | cmp - js/adom-3d-viewer.min.js # always verify-```+If the project doesn't have a plan.md when you run `export-wiki`,+**create one before publishing**. Reconstruct it from the original+prompt + the design conversation; check it into the project root.+Skeleton: -Two rules in the loader:+```markdown+# Plan — <Project Name> -- **Reassemble as BYTES, never text.** A multi-byte character can straddle a- split boundary; decoding per part corrupts it. Concatenate `Uint8Array`s,- then `new TextDecoder('utf-8').decode(all)` once.-- **Inject with `script.textContent`, not a blob URL.** `script-src` allows- `'unsafe-inline'` but not `blob:`.+## Original prompt -### 3. GLB textures must be `data:` URIs, and externalising them does not work+> <copy the user's first ask verbatim> -`wiki-readme-html` already says `blob:` images are blocked. The tscircuit--specific sting: Babylon's glTF loader blobs every bufferView-backed image, and-this failure **throws** rather than degrading, killing the entire model load:+## Refinements during the build -```-Unable to load from ./3d.glb: /textures/0: … blob:null/… - Fallback texture was used-```+- <each meaningful refinement, one bullet per> -The obvious fix — write the images out as sibling `.png` files and point the-GLB at them — **does not work either**, because Babylon still blobs a relative-URI in a null-origin document. `data:` URIs are the only accepted form.+## Hardware decisions -```bash-node scripts/glb-datauri-textures.mjs <in.glb> viewer/3d.glb-```--Costs about 8% file size, and the script prunes the vacated bufferViews while-remapping every accessor.--## One more, if your page has both viewers--A hardware page renders the wiki's own native 3D viewer from `board.glb` above-your README. If your README's viewer iframe starts pulling 15 MB at the same-time, the native viewer's ~2.8 MB fetch is starved and it gives up with-*"Failed to load model — taking too long"*. The file is fine. Wait for `load`-plus ~2.5 s before arming the `IntersectionObserver` that loads your iframe.+- <each component choice with rationale> -Prefer auto-load-on-scroll over a click-to-load poster: a poster cannot be-driven by a synthetic click from outside the sandboxed frame, so it is-untestable in a ralph loop, and a still with a button on it reads as broken to-anyone who does not realise it is a button.+## Out of scope (deliberate) -## Diagnosing a blank 3D tab+- <what was explicitly NOT built and why> -If the viewer is blank, the storage shim from `wiki-readme-html` is missing or-ran late. Confirm that before chasing anything else:+## Build → publish workflow -```js-const s = viewer.getScene(), e = viewer.getEngine()-;({ lights: s.lights.map(l => l.name), env: !!s.environmentTexture,- loops: e.activeRenderLoops.length, cam: String(viewer.getCamera()) })-// healthy: ["light","spotLight"], true, 1, "Name: adomCamera, type: ArcRotateCamera"-// broken: [], false, 0, "null" ``` -Two things about the broken state that waste hours if you do not know them:--- **`viewer.getCamera()` returning `null` silently kills the walkthrough.**- `walkthroughFlyTo` opens with `if (!scene || !cam) return;`, so every camera- fly bails on line 4 while the step counter and narration keep advancing. It- presents as "the walkthrough animates nothing", which is a different bug- than the one you actually have.-- **A `typeof window !== "undefined"` guard is not enough.** `window` exists in- a sandboxed iframe; the *origin* is null, so the property read itself throws.- The shipped bundle still has one unguarded read- (`localStorage.getItem("adom_camera_mode")`) behind exactly that wrong guard.- Tracked in [`adom/wiki#60`](https://wiki.adom.inc/adom/wiki/issues/60).--## Measurement traps--Both of these produce confident wrong conclusions about a viewer.--- **`engine.getFps()` lies.** It reports 60 while `scene.render()` has never- been called once. Count real frames instead:- ```js- let n = 0; scene.onAfterRenderObservable.add(() => n++)- await new Promise(r => setTimeout(r, 1200)) // n === 0 -> nothing is drawing- ```-- **An occluded window has rAF at zero.** `setTimeout` keeps running, so a- walkthrough advances its steps and narration while the camera sits perfectly- still — indistinguishable from a broken tween. Check `document.hidden`- before believing any animation measurement, and either foreground the window- (`pup_raise_os_window`) or force frames with- `for (let i = 0; i < 90; i++) scene.render()`.--## Publish sequence+(See `adom-tsci-projects/Brianna-LED-Nameplate/plan.md` for a full+example.) -```bash-bunx tsci build lib/index.tsx --glbs --svgs --3d-png --pcb-png-adom-tsci lint--adom-tsci export-wiki . --slug <slug> --page-type molecules --no-upload-mkdir -p wiki-page/viewer && cp -r /tmp/adom-tsci-export-<slug>/* wiki-page/viewer/--node scripts/glb-datauri-textures.mjs <rebake.glb> wiki-page/viewer/3d.glb-node scripts/glb-make-zup.mjs <rebake.glb> wiki-page/board.glb 180-split -b 3500000 -d wiki-page/viewer/js/adom-3d-viewer.min.js \- wiki-page/viewer/js/adom-3d-viewer.min.js.part-# + the storage shim and the part-loader patch in viewer/index.html-# + README.html per wiki-readme-html--adom-wiki page create <slug> --type hardware --kind molecule --org adom \- --title "..." --brief "..." --because "..." \- --headline "..." --subhead "..." --screenshot docs/hero.png --no-chrome \- --source tscircuit --version 1.0.0--cd wiki-page-adom-wiki repo push <owner>/<slug> --message "..." \- $(find . -type f | sed 's|^\./||' | sed 's/^/--files /' | tr '\n' ' ')-```+The CLI uploads `plan.md` standalone (one-click link on the page)+AND inside the source bundle (preserved alongside the rest of the+project source). -`repo push` requires explicit, repeatable `--files`; there is no-"push everything changed" mode.+## The body markdown — comprehensive, AI-readable -## Verify on a cold profile, from the wiki+The wiki page's `content` field — the markdown body — must give a+downstream agent everything they'd need to continue work without+reading external docs. **The CLI does not write the body for you;+you produce it via `adom-wiki page edit --field content` (or as part+of `page publish`).** Required sections: -Not your dev server, and not a browser that may have cached a working bundle.+| Section | What it captures |+|---|---|+| **Brief** (above the fold) | One-line elevator pitch. Same as the page brief. |+| **Live 3D viewer** | Pointer to the iframe, mention `?autoplay=off` opt-out. |+| **How it works** | The circuit story: what each block does, key constants, font/layout source. |+| **Hardware summary** table | Block / Part / JLCPCB / Notes for every refdes family. |+| **Mechanical** | Board dims, USB-C edge, workcell-pin info, silkscreen choices. |+| **Source layout** | The project's directory tree with one-line per file. |+| **Build commands** | The exact bash to clone → install → build → preview → publish. |+| **Color options / variants** (if applicable) | What's parameterised and how to retune. |+| **Known issues / lessons learned** | Things that bit during the build, in case they come up again. |+| **Build provenance** | Author, tooling versions, walkthrough authorship. |+| **Files attached** | Two sub-lists: **Source — recreate the molecule** (plan.md, source bundle) and **Build outputs** (GLB, SVGs, demo video). |++**Rule:** if a future agent has to ask the human user any factual+question about the project that they could have answered by reading+this page, the body is incomplete. Treat it as a writeup, not a+README stub.++## Hero image++`adom-wiki page publish` requires `--hero-image <path>` (the wiki+enforces it for non-app pages). The hero image is **the thumbnail**+that shows up on the molecule index page — heavy interactive viewers+can't be embedded in index lists, so a static iso render is the+right artifact for that role.++For an adom-tsci-published molecule:++- The **index/listing page** uses the `--hero-image` PNG as the+ thumbnail.+- The **full molecule page** has the interactive `tsci-viewer`+ iframe as its hero (rendered automatically by the wiki when a+ `tsci_viewer` asset is present).++Take the hero image with `adom-cli hydrogen screenshot panel+--tab-id <brianna-tab>` after setting the camera to `iso` view and+hiding the components HUD. ~1500 px wide is plenty.++## Verifying the publish++After `export-wiki` returns `OK: published …`, open the molecule URL+in a browser: -```bash-adom-bridge --ai-thread "<thread>" pup_open_window \- '{"sessionId":"verify","profile":"verify","freshProfile":true,- "url":"https://wiki.adom.inc/<owner>/<slug>/render/viewer/index.html"}' ```--- [ ] `lights` is `["light","spotLight"]`, not `[]`, and not a fallback you added-- [ ] `env: true` — the IBL loaded, so it looks like the local webview-- [ ] `activeRenderLoops.length === 1` (2 means something raced the bundle)-- [ ] walkthrough camera radius CHANGES between steps-- [ ] PCB / Schematic / Parts tabs render-- [ ] the wiki's native 3D viewer shows the board flat and readable-- [ ] every `render/` asset returns 200 to an unauthenticated `curl`-- [ ] no `localhost` / `/proxy/` / `.adom.cloud` in README.html — a published- page must outlive the container that built it--## Known limitations, do not chase these--- **`MP1`–`MP4` stack at the schematic origin and `lint` fails on it.**- `<Molecule>` renders `jsx(MachinePin, {type, name, pcbX, pcbY})`, and- `MachinePinOrContact` in `@tsci/adom-inc.library` destructures only- `{portHints, pcbX, pcbY, name}`, dropping schematic coordinates. No caller- can fix it, including a hand-rolled `<board>`.-- **`schX`/`schY` is all-or-nothing.** Placing *some* parts disables- auto-layout for the rest and stacks them at the origin.-- **`GET viewer/runframe/` 404s** on every load — the "tsci live" tab only- exists against a dev server.-- **`--texture-resolution N` silently falls back** to the 1024 GLB with a hint- about `rebake-glb.mjs`. The 4096 rebakes are already in- `~/.cache/adom-tsci/rebakes/`; the keys are content hashes, so match one to- your board with `scripts/glb-inspect.mjs`.--## Helper scripts--These live in the adom-tsci page repo under `scripts/`. `adom-tsci install`-deploys `SKILL.md` and the guides but **not** these yet, so fetch them:--```bash-for f in glb-datauri-textures glb-make-zup glb-inspect; do- curl -fsSL "https://wiki.adom.inc/adom/adom-tsci/render/scripts/$f.mjs" -o "scripts/$f.mjs"-done+https://wiki-ufypy5dpx93o.adom.cloud/molecules/<slug> ``` +Confirm visually: -| script | what it does |-|---|---|-| `glb-datauri-textures.mjs` | embedded images → `data:` URIs, prunes vacated bufferViews, remaps accessors |-| `glb-make-zup.mjs` | wraps scene roots in a `ZUpRoot` (+90° about X), optionally composed with a Z spin |-| `glb-inspect.mjs` | node/LED counts, board extents, J1 placement — for matching a cache rebake to your board |+1. **Walkthrough auto-plays** within ~2 s of page load. Step text+ appears at the bottom of the iframe with Back/Next controls.+2. **3D scene is rendered** — the board (not the loading screen) is+ visible.+3. **Source bundle download works** — click the `*-source.tar.gz`+ link in the body, untar, confirm `lib/index.tsx` and `plan.md`+ are present.+4. **plan.md link works** — opens directly without untar.++If the walkthrough doesn't fire, check the iframe URL has no+`?autoplay=off`, and that `walkthrough.json` was non-empty in the+bundle (`tar -tzf <bundle>.tar.gz | grep walkthrough`). -## Worked example+## Troubleshooting -[`adom/evan-led-nameplate`](https://wiki.adom.inc/adom/evan-led-nameplate)-exercises every item on this page.+| Symptom | Cause | Fix |+|---|---|---|+| `dist/lib/index/circuit.json not found` | No build artifacts | Pass `--rebuild` or run `bunx tsci build` first |+| `--texture-resolution N` warns "no rebake-glb.mjs in cache" | First-time use without prior `adom-tsci start` | Run `adom-tsci start .` once to populate the cache, then re-export |+| `bundle is over 95 MB` warning | Big GLB (very high texture res) or many extra files | Drop `--texture-resolution` to 4096 or 2048 |+| `adom-wiki upload failed: page-type mismatch` | Slug exists at a different page-type | Either pass the matching `--page-type`, OR delete the existing page first (`adom-wiki page delete <type>/<slug>`) |+| Source-bundle upload fails but viewer publish succeeds | Transient wiki error | Re-run `adom-wiki asset upload <page>/<slug> --asset-type file --file /tmp/<slug>-source.tar.gz` manually — the CLI prints this hint |+| Walkthrough doesn't auto-play on the wiki page | walkthrough.json missing or empty | Run `adom-tsci walkthrough-gen` in the project, hand-edit if needed, re-export |++## See also++- `adom-tsci/guides/demo-recording.md` — making a 30-second narrated+ demo video to attach to the page (separate workflow; the live+ viewer is now the primary artifact, demo video is supplementary).+- `adom-tsci/guides/adom-usbc.md` — USB-C placement rules for the+ molecule before you publish.+- `adom-wiki` skill — the underlying CLI for asset uploads and page+ edits.
package.json+2−5@@ -1,6 +1,6 @@ { "slug": "adom-tsci",- "version": "1.4.25",+ "version": "1.5.0", "type": "app", "description": "Interactive tscircuit board viewer in a Hydrogen webview \u2014 first-class 3D / PCB / Schematic tabs, an Inspect tool, a Fusion-style Measure tool, net x-ray, guided Walkthrough demos, one-click autorouter re-run, and auto high-res board-surface texture. Fully AI-drivable (CLI + HTTP + JS eval). Also publishes molecules/boards to the Adom Wiki.", "dependencies": {},@@ -28,9 +28,6 @@ "VERSION", "guides/adom-usbc.md", "guides/demo-recording.md",- "guides/export-wiki.md",- "scripts/glb-datauri-textures.mjs",- "scripts/glb-make-zup.mjs",- "scripts/glb-inspect.mjs"+ "guides/export-wiki.md" ] }
page.json+1−1@@ -4,7 +4,7 @@ "slug": "adom-tsci", "title": "Adom TSCI - Tscircuit Board Viewer", "brief": "Interactive tscircuit preview in a Hydrogen webview — first-class 3D / PCB / Schematic tabs, Components & Nets HUDs, an auto-glow x-ray view, Inspect, Measure, a Walkthrough Demo, and a one-click `exp",- "version": "1.4.24",+ "version": "1.5.0", "tags": [ "tscircuit", "pcb",