← Commit history
adom-chip-fetcher-hero-v5.pngadded
⋯ 1 unchanged line ⋯
chip-fetcher-stack.png
⋯ 1 unchanged line ⋯
dashboard-pin1.pngadded
⋯ 1 unchanged line ⋯
page.json+1−1
@@ -2,7 +2,7 @@   "slug": "adom-chip-fetcher",   "version": "1.1.0",   "type": "app",-  "description": "Your whole parts library — manufacturer-grade chip CAD (symbol, footprint, 3D) one tap from your EDA tool.",+  "description": "Your whole parts library \u2014 manufacturer-grade chip CAD (symbol, footprint, 3D) one tap from your EDA tool.",   "dependencies": {     "adom/adom-sfconvert": "*",     "adom/adom-symbol": "*",
playbooks/jst.md+96
@@ -253,3 +253,99 @@ demo re-submit, and a double-submit).  `src/sources/jst.rs` — `series_index_url`, `series_url`, `search_url`, `download_url`, `catalog_pdf_url`, `DocKind`, `FORM_FIELDS`, `base_part`.+++## Building animated 3D mating component pages (wiki components)++This is the high-value deliverable: a wiki **component page** whose 3D viewer shows+the connector **assembling itself in a looping animation** — board sliding into the+PCB (THT pins passing through to the underside), then the wire housing mating into+the board. KiCad sits beside it as a comparison foil. Learned end-to-end 2026-06-28.++### Scene recipe (per system)+- **Columns:** left = KiCad library model (foil), right = JST manufacturer model.+  If KiCad has **no 3D model** for the part (e.g. ZH `B2B-ZR` → `kicad-packages3d`+  404s), make it a **single JST column** and say so on the page; KiCad sym/fp still+  compare below.+- **Colour = source:** JST renders blue, KiCad renders tan/grey. Plus an **opaque+  nameplate** per column ("JST manufacturer" / "KiCad library"). Users WANT to know+  which model is which — never drop the labels.+- **PCB:** a thin **floating** slab (≈1.6 mm), footprint texture on top, **no riser+  block underneath** — a riser hides the pin tips poking through, which is the whole+  point. The footprint pads sit a few mm above the viewer's ground plane.+- **THT vs SMT seating:** THT parts (`B2B-*` PH/XH/ZH) seat with **pins through the+  PCB, protruding ≈2 mm out the bottom** (the money shot — user checks pins line up+  with holes). SMT parts (`SM02B/BM02B` SH/GH) rest tabs on the pad surface (no+  through-pins — say so).++### Mate orientation (which way the wire slides in)+Encoded in the JST part number AND drawn in the datasheet:+- `B…` prefix (B2B, BM) = **top entry** → wire mates **straight down (+Z)**.+- `S…` prefix (SM, S2B) = **side entry / right-angle** → wire mates **horizontally (+Y)**.+Confirm per part. Animate the wire along that axis.++### Getting each model's orientation right+STEP frames are inconsistent. Don't guess from bbox (connectors are ~cubic). Use:+- `step2glb features <step>` → floor_z / longest-axis hints (treat as hints, verify).+- `step2glb thumbnail <step|glb> --pose front|top|side` → read the pin axis directly.+- KiCad models are reliably **Z-up, pins -Z** (identity). JST THT boards here needed+  **R_x(+90)** (pins along native -Y); each JST wire housing differs — check its orthos.+- Seat board so `bbox.min_z` (pin tip) = `PCB_bottom - protrude` (THT) or `PCB_top` (SMT).++### Authoring the animation (GLB node channels)+- Build the scene in **trimesh**, giving the board/wire their own **named nodes**+  (`add_geometry(..., node_name="board1")`), geometry baked at the **seated** pose so+  node base-translation = 0. (If a viewer ignores animation, it still looks correct.)+- Add animation with **@gltf-transform** (`createAnimationSampler` / `createAnimationChannel`,+  target path `translation`, LINEAR). Each node lerps from a small **retract offset**+  back to 0 (seated). Keep lifts **small** (~board: clear the PCB top + ~2.5 mm; wire:+  ~its height + 4 mm) — big lifts waste the loop and bury the money shot.+- Timeline: board drops, then wire mates, then **dwell long on the fully-mated state**,+  quick reset, loop. The wiki 3D viewer (Babylon) **autoplays + loops** embedded GLB+  animations — no play button needed.++### CRITICAL gotchas+- **Viewer grounds to the model's lowest geometry.** So a footprint quad that is the+  lowest thing sits coplanar with the ground plane → **z-fighting**. Fix by giving the+  PCB real geometry and letting **pins** be the lowest element (also what we want).+- **The 3D viewer is an `<iframe>`** (`/viewer/3d/component/<slug>`). Reloading the+  parent page does **not** refresh the iframe's cached GLB. To see an update: bump the+  GLB **filename** (`-v5.glb`) and/or `iframe.src = src.split('?')[0]+'?cb=…'`. Burned+  ~4 republishes chasing "my fix isn't showing" — it was always iframe cache.+- **Transparent label PNGs bleed through** a back-to-back double-sided sign. Composite+  the label onto an **opaque** background before texturing; put the normal image on the+  front quad and a `ImageOps.mirror` copy on the back quad, separated ~0.4 mm.+- **STEP→GLB emits Draco-compressed** GLBs the merge tools can't read — decompress with+  gltf-transform (`unpartition` + dispose `KHR_draco_mesh_compression`) first.++### Pipeline & publish+`step2glb convert` (JST STEP + KiCad STEP) → gltf-transform decompress → trimesh merge+(`merge_mate.py`: pedestal + seat + nameplate, emits `<out>.anim.json`) → `add_anim.mjs`+→ component repo (`parts.model_3d`) → `adompkg publish --org adom` (components are+org-owned; publishing as your user 409s). ZH note: `service-kicad fp fetch Connector_JST+JST_ZH_B2B-ZR_1x02_P1.50mm_Vertical` gives the footprint even though no 3D model exists.+++### Refinements learned the hard way (2026-06-28, v0.8.9)+- **bbox-check every object; never eyeball the math.** The merge prints each part's+  bbox and asserts: connector **body_bottom == PCB_top** (housing sits ON the PCB, not+  sunk into it), THT **pin_tip < PCB_bottom** (pins truly protrude), wire stays above+  the PCB. Detect the **body bottom** (not bbox-min, which is the pin tips) by walking z+  slices and finding where the cross-section widens to the body — seat THAT on the PCB.+  Seating by bbox-min sinks the housing into the board; this was a real bug.+- **Insert the wire DEEP — ~80% of the board-body depth**, not just touching. The user+  needs to see it truly seat and see JST's contact-lock slots engage. (`INSERT=0.80`.)+- **Float the PCB high (top ≈8 mm) with a tiny ground anchor.** The viewer grounds to+  the lowest geometry, so drop a ~0.3 mm speck at z=0 under each column — the viewer's+  ground (and shadow) sit there, the PCB floats ~6 mm up, and you can angle the camera+  **under** the board to inspect the protruding pins. No riser block (it hides the pins).+- **Shadows are viewer-side, already on:** the Babylon viewer has a SpotLight + 1+  shadowGenerator (that's the ground shadow). You can't toggle it from the GLB; raising+  the PCB is what makes the descending part's shadow read clearly. Richer per-part+  inter-shadowing would be a wiki-viewer change, not a GLB change.+- **If KiCad has no 3D model** for a JST part (e.g. ZH `B2B-ZR`), don't silently drop+  the column — render a standing **red placard** ("KiCad: no 3D model — ships sym+fp only")+  in the KiCad slot so the user knows *why* it's absent. (Add a `{"placeholder":true}`+  column to the merge config.)+- **Animation dwell:** keep lifts small and dwell long on the fully-mated state — that+  (pins through + wire seated) is the money shot, not the parts hovering far above.
playbooks/symbol-axis-regen.mdadded+44
@@ -0,0 +1,44 @@+---+name: symbol-axis-regen+description: chip-fetcher's side of the default-symbol 3D outline — re-render the symbol axis-aware when the user switches the up-axis, and the non-blocking SYM-tile spinner while it regenerates. The symbol-DEFAULT behavior (compositing the white outline + lasered name, the outline contract) lives in adom-symbol's docs/default-symbol-3d-outline.md, not here. Trigger words — axis switch regenerate symbol, chosen_up_axis symbol, sym tile spinner, orientation regen, set-as-up regenerate.+---++# Symbol axis-regen + SYM-tile spinner (chip-fetcher side)++> The DEFAULT symbol layout — compositing the **white 3D-chip outline + lasered+> name**, why it must be natively white, and the outline contract — is+> **adom-symbol's** behavior. See `adom-symbol/docs/default-symbol-3d-outline.md`.+> This playbook is only chip-fetcher's orchestration: keeping that outline in the+> right ORIENTATION and showing progress.++## Axis-aware regeneration++chip-thumbnailer honors `info.json.chosen_up_axis` when it renders the outline, so+when the user switches the up-axis on a card the symbol's embedded chip must+re-render to match. `POST /api/orientation {mpn, axis}`+(`src/serve.rs::set_orientation`):++1. Persist `chosen_up_axis`.+2. **Off-thread** (so the click returns instantly): `adom-chip-thumbnailer+   rerender <mpn> --kind outline` — the LIGHT path that regenerates just the+   vector outline at the new axis (one OCCT pass, not the whole shaded-iso family).+3. Then `adom-symbol render` recomposites the canonical symbol with the new+   outline. Response includes `"regen":"outline+symbol"`.++## Non-blocking SYM-tile spinner++While that regen runs, overlay a **lightweight** spinner on the SYM tile so the+user knows it's refreshing — without blocking the rest of the dashboard:++- Pure-CSS ring, `pointer-events:none`, only that one tile dims. Everything else+  stays interactive.+- **Data-driven, not a transient DOM node** — the dashboard re-renders on a poll+  and would wipe a hand-added spinner. Drive it from state keyed on `sym_rev`+  (the `<mpn>-symbol.svg` mtime): record `sym_rev` at click-time; the spinner+  clears the instant a newer `sym_rev` arrives (regen done), with a ~95s safety+  cap so a failed regen can't spin forever.+- Show it instantly on click (before the round-trip), then poll just until it+  clears.++Reference: `src/serve.rs::set_orientation`; `src/ui/index.html` — `SYM_REFRESHING`+map, `isSymRefreshing`, `renderThumb` spinner inject, `setOrientation`.
playbooks/wiki-bundle-reuse.mdadded+82
@@ -0,0 +1,82 @@+---+name: wiki-bundle-reuse+description: Reuse an already-published Adom Wiki component bundle as a part-source BEFORE re-fetching from external vendors. The wiki (wiki.adom.inc) hosts `component` pages whose git repo carries a complete, Adom-vetted CAD bundle (<mpn>.kicad_sym/.kicad_mod, STEP/GLB, datasheet PDF, 3D-outline SVGs). If the part is already there, pull that bundle — it's free, fast, and guarantees Adom-consistent geometry + provenance. Trigger words — wiki part source, reuse published bundle, adom-wiki source, component page reuse, query the wiki first, wiki.adom.inc bundle, pull from wiki, wiki cad cache.+---++# Reusing published wiki component bundles as a part-source++The Adom Wiki is the **first** step on chip-fetcher's sourcing ladder — ahead of+manufacturer / SnapMagic / Mouser / DigiKey / Arrow / CSE-UL / LCSC. If a part is+already published as a `component` page with a CAD bundle, reuse it instead of+scraping vendors again. Reference implementation: `chip-fetcher/src/sources/wiki.rs`+(+ `Source::AdomWiki` first in `src/sourcing.rs`). Reads only — the public read+API needs no token.++## The API (read-only, public)++```+GET /api/v1/pages/<slug>/files/page.json     # is it type:"component"? component.mpn, org+GET /api/v1/discover?triggers=<MPN>          # discovery hits (LOOSE — see gotchas)+GET /api/v1/search?q=<MPN>                    # full-text (throws on hyphens — strip them)+GET /api/v1/pages/<slug>/files               # { "files":[ {"path","type":"blob|tree"} ] }+GET /api/v1/pages/<slug>/files/<path>        # raw file bytes+```++No HTTP crate in chip-fetcher — shell `curl -fsSL --max-time N` (matches the+dependency-free, shell-a-sibling-binary style of the rest of the CLI).++## The gotchas (every one learned the hard way)++1. **`search` (FTS) throws on hyphens.** Never send a raw hyphenated MPN — strip+   hyphens for the query. Prefer the exact-slug and `discover` paths; `search` is+   the fallback.+2. **`discover`/`search` return LOOSE matches.** A nonsense MPN and `ADS1115`+   both "matched" `b2b-zr` before this was fixed — the API happily returns a+   popular/related page. **You MUST verify** the candidate's+   `component.mpn` (normalized) equals the requested MPN. Without that check the+   feature reuses the wrong part's bundle.+3. **`component.mpn` can be malformed.** Some pages glue a description onto it+   (`"BQ7692003PWR — BQ76920 3–5 Cell …"`), so its normalized form won't match.+   **Also accept a slug match** (`norm(slug) == norm(mpn)`) — the slug is the+   page's clean identity.+   - Normalize for all comparisons: lowercase + drop non-alphanumerics+     (`B2B-ZR` → `b2bzr`).+4. **Pull ONLY the canonical file of each type.** Pages carry variant/compare+   models — `B2B-ZR-v2.glb`, `B2B-ZR-compare.glb`, `B2B-ZR-mate.glb`. Match the+   file stem to the part (`stem == mpn`) so you grab `<mpn>.glb`, not 14 variants+   that collapse onto one canonical name. For outline SVGs, require the+   `<mpn>-3d-outline-…` prefix.+5. **Key file selection off the REQUESTED MPN, not `component.mpn`.** On-disk+   files are named after the real part number; the requested MPN is what the user+   typed and what the library dir is keyed by. (Using a malformed `component.mpn`+   as the prefix would reject the page's own files.)+6. **Files live in git (`/files`), not releases.** `/releases` is empty for these+   pages; there's no package tarball to `adompkg install`.+7. **Most component pages are metadata-only stubs.** They have `page.json` ++   `README.md` and *no* CAD files. Treat "page exists but has no core CAD"+   (`.kicad_sym`/`.kicad_mod`/`.step`) as **not a usable bundle → fall through**+   to the vendor ladder. Don't claim a wiki hit when there's nothing to pull.++## Pull + provenance++- Route core CAD + datasheet through the importer so they get magic-byte+  validation + canonical `<mpn>.<ext>` naming + a per-source variant+  (`<mpn>.adomwiki.kicad_sym`). Copy GLB/WRL/outline SVGs in directly.+- Stamp provenance `discovery_source = content_origin = "adom-wiki"` + the page+  URL on every file, plus an `adom-wiki` `fetched_via_chain` entry, so the source+  carat shows **"Adom Wiki"** (see `chip-provenance-labeling`). `source_label`+  maps `adom-wiki → "Adom Wiki"`.++## Fall-through contract++`try_fetch(mpn) -> Ok(None)` when there's no match OR only a metadata-only page,+so the caller drops straight into the existing ladder unchanged. The wiki source+ADDS a step; it never removes one.++## The reuse cache is sparse today++As of this writing, most component pages are metadata-only; only some (e.g. the+JST connectors, a few passives + ICs) carry real bundles. The value grows as full+bundles get published — so the natural companion is a **write-back**: publish a+completed bundle to the wiki after a successful vendor fetch, so the cache fills+itself. (Not built yet — flagged as the obvious next step.)
src/bom.rsadded+75
@@ -0,0 +1,75 @@+//! Supply-chain (BOM) identifiers for the adom-lbr v1.6.0 contract.+//!+//! Whenever chip-fetcher shells `adom-lbr generate`/`export` it MUST pass the+//! supply-chain IDs it already looked up — `--mfr`, `--mpn`, `--mouser`,+//! `--digikey`, `--lcsc` — so adom-lbr writes MF/MPN/MOUSER/DIGIKEY/LCSC into+//! the library device <technology>, which Fusion 360 / Altium / OrCAD surface in+//! the BOM. Omitting one is only OK when that distributor genuinely has no match+//! (adom-lbr just notes it). We read everything we know from `stock.json` (the+//! Mouser snapshot), falling back to `info.json` and `<mpn>-symbol.extracted.json`.++use serde_json::Value;+use std::path::Path;++fn read_json(dir: &Path, name: &str) -> Value {+    std::fs::read_to_string(dir.join(name))+        .ok()+        .and_then(|s| serde_json::from_str(&s).ok())+        .unwrap_or(Value::Null)+}++/// Follow a path of keys to a non-empty string.+fn s(v: &Value, path: &[&str]) -> Option<String> {+    let mut cur = v;+    for k in path {+        cur = cur.get(k)?;+    }+    cur.as_str().filter(|x| !x.is_empty()).map(|x| x.to_string())+}++/// The adom-lbr BOM flags for a part, in order. Always includes `--mpn` (the+/// orderable MPN); `--mfr` and `--mouser` when known; `--digikey`/`--lcsc` only+/// when a real distributor part number is on record (omitted otherwise, which+/// adom-lbr accepts as "not stocked there").+pub fn flags(dir: &Path, mpn: &str) -> Vec<String> {+    let stock = read_json(dir, "stock.json");+    let info = read_json(dir, "info.json");+    let sym = read_json(dir, &format!("{mpn}-symbol.extracted.json"));++    // Manufacturer: Mouser snapshot → info.json → extracted symbol.+    let mfr = s(&stock, &["mouser", "manufacturer"])+        .or_else(|| s(&info, &["manufacturer"]))+        .or_else(|| s(&sym, &["manufacturer"]));++    // Orderable MPN: Mouser's exact MPN → info.json → the part name itself.+    let part_mpn = s(&stock, &["mouser", "mpn"])+        .or_else(|| s(&info, &["mpn"]))+        .unwrap_or_else(|| mpn.to_string());++    let mouser = s(&stock, &["mouser", "mouser_pn"]);+    // DigiKey / LCSC only when an explicit order code is recorded (stock.json is+    // Mouser-centric; these are usually absent → correctly omitted).+    let digikey = s(&stock, &["digikey", "digikey_pn"]).or_else(|| s(&stock, &["digikey_pn"]));+    let lcsc = s(&stock, &["lcsc", "lcsc_pn"]).or_else(|| s(&stock, &["lcsc_pn"]));++    let mut f = Vec::new();+    if let Some(m) = mfr {+        f.push("--mfr".into());+        f.push(m);+    }+    f.push("--mpn".into());+    f.push(part_mpn);+    if let Some(m) = mouser {+        f.push("--mouser".into());+        f.push(m);+    }+    if let Some(d) = digikey {+        f.push("--digikey".into());+        f.push(d);+    }+    if let Some(l) = lcsc {+        f.push("--lcsc".into());+        f.push(l);+    }+    f+}
src/cli.rs+121
@@ -85,6 +85,10 @@ enum Cmd {         #[arg(long)]         project: Option<String>,     },+    /// Query the Adom Wiki ONLY (ladder step 0) and reuse a published bundle if+    /// one exists. Same code path `fetch` runs first — exposed standalone so the+    /// wiki reuse can be tested without driving the whole vendor ladder.+    WikiFetch { mpn: String },     /// Open a source's login page so the user can authenticate once     Login { source: String },     /// Show what's already in the library@@ -183,6 +187,15 @@ enum Cmd {         #[arg(long)]         mpn: Option<String>,     },+    /// Backfill provenance for files that landed without going through import+    /// (so their source isn't a bare "—"). Derives each present-but-+    /// un-provenanced file's origin from info.json (step_source / the+    /// sourcing-chain entry that succeeded) and records it; flags what it can't.+    BackfillProvenance {+        /// Backfill only this MPN (default: every chip in library/).+        #[arg(long)]+        mpn: Option<String>,+    },     /// Detect manufacturer from MPN and open their canonical product /     /// search page in pup. AI: run this BEFORE any third-party source.     /// The mfr-first rule is enforced in code — `adom-chip-fetcher mfr-probe@@ -452,6 +465,7 @@ pub fn run() -> Result<()> {             require_planned(&mpn)?;             fetch(&mpn)         }+        Cmd::WikiFetch { mpn } => wiki_fetch_cmd(&mpn),         Cmd::Login { source } => login(&source),         Cmd::List => list(),         Cmd::Status { mpn } => status(&mpn),@@ -467,6 +481,7 @@ pub fn run() -> Result<()> {         Cmd::Hint => hint_cmd(),         Cmd::Sources => sources_cmd(),         Cmd::Gate { strict, mpn } => gate_cmd(strict, mpn.as_deref()),+        Cmd::BackfillProvenance { mpn } => backfill_provenance_cmd(mpn.as_deref()),         Cmd::MfrProbe { mpn, no_open } => { require_planned(&mpn)?; mfr_probe_cmd(&mpn, no_open) }         Cmd::NextSource { mpn } => next_source_cmd(&mpn),         Cmd::Validate { mpn } => validate_cmd(&mpn),@@ -593,6 +608,17 @@ fn cmd_ds2sf(mpn: &str) -> Result<()> {         let why = result.as_ref().and_then(|r| r.get("summary").and_then(|s| s.as_str())).unwrap_or("see extraction.result.json");         anyhow::bail!("ds2sf extraction failed for {mpn}: {why}");     }+    // Inject the canonical RefDes/Value textPlacement into the footprint+    // extraction so exporters (adom-lbr → Fusion/.lbr, Altium, OrCAD) validate+    // against intent instead of re-deriving from pads. adom-footprint is the+    // single source of truth for placement; best-effort (skips if no pads).+    let fp_json = dir.join(format!("{mpn}-footprint.extracted.json"));+    if fp_json.is_file() {+        let _ = std::process::Command::new("adom-footprint")+            .args(["placement", "--write", "--file"])+            .arg(&fp_json)+            .output();+    }     // Render the ds2sf variant thumbnails so the carat + validation surface them.     auto_render_thumbnails(mpn);     let _ = hints::heartbeat(mpn, "ds2sf: extracted");@@ -1337,6 +1363,9 @@ fn refetch_cmd(mpn: &str, format: &str, source: &str, url_only: bool, timeout: u fn canonical_source_url(mpn: &str, source: sourcing::Source) -> Result<String> {     let enc = mpn.replace(' ', "%20");     Ok(match source {+        // The wiki is pulled via its API (sources::wiki), not by opening a vendor+        // page in pup — this is just the human search URL for completeness.+        sourcing::Source::AdomWiki => format!("https://wiki.adom.inc/search?q={enc}"),         sourcing::Source::Manufacturer => {             sourcing::guess_mfr(mpn)                 .map(|g| g.search_url)@@ -1448,6 +1477,34 @@ fn next_source_cmd(mpn: &str) -> Result<()> { /// the sourcing-ladder rule, prints HINT lines per missing artifact, and /// returns non-zero exit when any card is incomplete. The AI cannot honestly /// claim "done" without this returning zero — that's the point.+fn backfill_provenance_cmd(mpn_filter: Option<&str>) -> Result<()> {+    let lib = inventory::scan()?;+    let mut fixed = 0usize;+    let mut still_unknown: Vec<(String, Vec<String>)> = Vec::new();+    for c in &lib.chips {+        if mpn_filter.is_some_and(|m| c.mpn != m) { continue; }+        let dir = library::root().join(&c.mpn);+        let (recovered, unknown) = crate::import::backfill_dir(&dir, &c.mpn)?;+        if !recovered.is_empty() {+            fixed += 1;+            println!("  {} → recovered source for: {}", c.mpn, recovered.join(", "));+        }+        if !unknown.is_empty() {+            still_unknown.push((c.mpn.clone(), unknown));+        }+    }+    println!();+    println!("backfill: recovered provenance on {fixed} chip(s).");+    if !still_unknown.is_empty() {+        println!();+        println!("Source genuinely UNRECORDED (no signal to derive from — refetch to capture):");+        for (mpn, files) in &still_unknown {+            println!("  {mpn}: {}", files.join(", "));+        }+    }+    Ok(())+}+ fn gate_cmd(strict: bool, mpn_filter: Option<&str>) -> Result<()> {     let lib = inventory::scan()?;     let chips: Vec<_> = lib.chips.into_iter()@@ -1489,6 +1546,28 @@ fn gate_cmd(strict: bool, mpn_filter: Option<&str>) -> Result<()> {         if strict {             if !c.has_altium     { missing.push("Altium (.SchLib+.PcbLib OR .IntLib)".into()); }             if !c.has_fusion_lbr { missing.push("Fusion .lbr".into()); }+            // Pin-1 alignment: the 3D chip's pin-1 must be lined up to the+            // footprint's pin-1 and signed off in chipsmith (writes+            // <mpn>.chipsmith.json). Only required once both a STEP + footprint+            // exist (you can't align without both).+            if c.has_step && c.has_mod && !c.pin1_signed {+                missing.push("pin-1 alignment (open chipsmith → Align pin-1 → sign off)".into());+            }+        }+        // A pin-1 corner MISMATCH (signed chip corner != the footprint's pin-1+        // corner) means the 3D model is misaligned to the footprint — a real+        // defect, so flag it in BOTH modes, not just strict.+        if let (true, Some(cc), Some(fp)) = (c.pin1_signed, c.pin1_corner.as_deref(), c.fp_pin1_corner.as_deref()) {+            if cc != fp {+                missing.push(format!("pin-1 MISMATCH: chip {cc} vs footprint {fp} (re-align in chipsmith)"));+            }+        }+        // PERMANENT GUARANTEE 3 — every file we ship must know where it came+        // from. A file present on disk with no recorded source is a lazy fetch.+        // `backfill-provenance` recovers what it can; whatever's left is real.+        let unprov = crate::import::unprovenanced_files(&dir, &c.mpn);+        if !unprov.is_empty() {+            missing.push(format!("provenance for {} (run `backfill-provenance`, then refetch any still unknown)", unprov.join(", ")));         }         if missing.is_empty() {             complete += 1;@@ -1700,9 +1779,51 @@ fn auto_render_thumbnails(mpn: &str) {     } } +/// Ladder step 0 in isolation: query the Adom Wiki and reuse a published bundle+/// if one exists. Falls through (prints a clear message) when there's no match,+/// so it doubles as the fall-through test without touching the vendor ladder.+fn wiki_fetch_cmd(mpn: &str) -> Result<()> {+    library::ensure_dir(mpn)?;+    match crate::sources::wiki::try_fetch(mpn)? {+        Some(pull) => {+            println!("OK: reused Adom Wiki bundle ({} files) from {}", pull.files_added.len(), pull.url);+            for f in &pull.files_added {+                println!("  + {f}");+            }+            let _ = thumbnails::generate_for(mpn, true);+            println!("\nReused from the Adom Wiki — no external fetch needed.");+        }+        None => {+            println!("Adom Wiki: no published bundle for {mpn}.");+            hints::print_hint(&format!("not on the wiki — run `adom-chip-fetcher fetch {mpn}` to walk the vendor ladder (manufacturer → SnapMagic → …)."));+        }+    }+    Ok(())+}+ fn fetch(mpn: &str) -> Result<()> {     library::ensure_dir(mpn)?; +    // Ladder step 0 — the Adom Wiki. If this part is already published with a+    // complete bundle, reuse it (free, fast, Adom-consistent) and skip the+    // external vendors entirely. Reads only; no token. On no match (or only a+    // metadata-only page with no CAD files) we fall straight through to the+    // vendor ladder below — the existing ladder is untouched.+    match crate::sources::wiki::try_fetch(mpn) {+        Ok(Some(pull)) => {+            println!("OK: reused Adom Wiki bundle ({} files) from {}", pull.files_added.len(), pull.url);+            for f in &pull.files_added {+                println!("  + {f}");+            }+            let _ = hints::heartbeat(mpn, "imported (Adom Wiki)");+            let _ = thumbnails::generate_for(mpn, true);+            println!("\nReused from the Adom Wiki — no external fetch needed. Run `adom-chip-fetcher gate --mpn {mpn}` to confirm completeness.");+            return Ok(());+        }+        Ok(None) => println!("Adom Wiki: no published bundle for {mpn} — falling through to the vendor ladder."),+        Err(e) => println!("WARN: Adom Wiki lookup failed ({e}) — falling through to the vendor ladder."),+    }+     // Rail: dashboard MUST be visible before a fetch starts. Auto-open if not.     // Best-effort — a failure here doesn't block the fetch (user might be     // running headless), but they'll see a WARN.
src/deliver.rs+4
@@ -114,6 +114,10 @@ pub fn deliver(mpn: &str, dir: &Path, target: &str) -> Value {     if sym.is_file() { g.args(["--sym"]).arg(&sym); }     if md.is_file() { g.args(["--fp"]).arg(&md); }     g.args(["--name", mpn]).arg("--output").arg(&lbr_path);+    // adom-lbr v1.6.0 BOM contract: pass the supply-chain IDs we already looked+    // up (MF / MPN / Mouser / DigiKey / LCSC) so the library carries them for+    // the Fusion/Altium/OrCAD BOM. Omitted distributors are noted, not fatal.+    g.args(crate::bom::flags(&dir, mpn));     match g.output() {         Ok(o) if o.status.success() || lbr_path.is_file() => {}         Ok(o) => return json!({ "ok": false, "error": "adom-lbr generate failed", "detail": String::from_utf8_lossy(&o.stderr) }),
src/hints.rs+1
@@ -19,6 +19,7 @@ const DASHBOARD_SURFACE_PATH: &str = ".config/adom-chip-fetcher/dashboard.json"; /// you should do digikey, and then only after that try LCSC. I really want to /// avoid LCSC at all costs, but it is in the list if we're desperate."* pub const SOURCE_LADDER: &[(&str, &str)] = &[+    ("adom-wiki",    "wiki.adom.inc — reuse an already-published, Adom-vetted CAD bundle. TRY FIRST: if the part exists here we skip the external vendors entirely (free, fast, Adom-consistent geometry + provenance)."),     ("manufacturer", "vendor.com — STEP, datasheet, lifecycle. ALWAYS try first, even when confident."),     ("snapmagic",    "snapeda.com — free CAD bundles, login-gated. Right after manufacturer."),     ("mouser",       "mouser.com — stock + pricing always; sometimes ECAD Models / CSE deep-links."),
src/import.rs+132
@@ -181,6 +181,13 @@ fn infer_mpn(path: &Path) -> Option<String> {     None } +/// Public wrapper so other part-sources (e.g. the Adom Wiki puller) can record+/// provenance for files they copy in directly — the GLB and 3D-outline SVGs that+/// don't pass through `import_path` (which only ingests core CAD + datasheet).+pub fn record_file_provenance(dest: &Path, filename: &str, prov: &FileProvenance) -> Result<()> {+    write_file_provenance(dest, filename, prov)+}+ /// Append a per-file provenance entry to `<MPN>-file-provenance.json`. /// Chipsmith reads this to show "source: SnapMagic" in the footprint /// tooltip. Format:@@ -209,6 +216,131 @@ fn write_file_provenance(dest: &Path, filename: &str, prov: &FileProvenance) ->     Ok(()) } +/// The core artifacts whose origin we insist on knowing. (Present-on-disk files+/// without a recorded source are the "lazy fetch" failure.)+fn core_files(mpn: &str) -> Vec<String> {+    vec![+        format!("{mpn}.step"), format!("{mpn}.kicad_sym"), format!("{mpn}.kicad_mod"),+        format!("{mpn}.lbr"), format!("{mpn}.SchLib"), format!("{mpn}.PcbLib"),+        format!("{mpn}.IntLib"), format!("{mpn}.pdf"),+    ]+}++/// Files present in `dir` that have NO recorded provenance entry. Empty = good.+pub fn unprovenanced_files(dir: &Path, mpn: &str) -> Vec<String> {+    let prov: serde_json::Map<String, serde_json::Value> =+        std::fs::read_to_string(dir.join(format!("{mpn}-file-provenance.json")))+            .ok().and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default();+    core_files(mpn).into_iter()+        .filter(|f| dir.join(f).is_file())+        .filter(|f| {+            // present, but no entry OR an entry with no real source recorded+            match prov.get(f) {+                None => true,+                Some(e) => {+                    let d = e.get("discovery_source").and_then(|v| v.as_str()).unwrap_or("");+                    let o = e.get("content_origin").and_then(|v| v.as_str()).unwrap_or("");+                    (d.is_empty() || d == "unknown") && (o.is_empty() || o == "unknown")+                }+            }+        })+        .collect()+}++/// Backfill provenance for files that landed without going through import (the+/// B2B-XH-A "heartbeat stub" case). Derives each present-but-un-provenanced+/// file's source from the strongest signal on info.json — `step_source` for the+/// STEP, else the `fetched_via_chain` entry that succeeded — and records it+/// (tagged `derived: true`). When nothing is recoverable it writes+/// `discovery_source: "unknown"` so the gate keeps flagging it for a refetch+/// instead of silently passing. Returns (recovered, still_unknown) filenames.+pub fn backfill_dir(dir: &Path, mpn: &str) -> Result<(Vec<String>, Vec<String>)> {+    use std::time::{SystemTime, UNIX_EPOCH};+    let info: serde_json::Value = std::fs::read_to_string(dir.join("info.json"))+        .ok().and_then(|s| serde_json::from_str(&s).ok()).unwrap_or(serde_json::json!({}));+    // The chain entry that actually succeeded = where files were found.+    let chain_src = info.get("fetched_via_chain").and_then(|c| c.as_array()).and_then(|arr| {+        arr.iter().rev().find_map(|e| {+            let o = e.get("outcome").and_then(|v| v.as_str()).unwrap_or("").to_lowercase();+            (o.contains("ok") || o.contains("success") || o.contains("got"))+                .then(|| e.get("source").and_then(|v| v.as_str()).unwrap_or("").to_string())+                .filter(|s| !s.is_empty())+        })+    });+    let step_src = info.get("step_source").and_then(|v| v.as_str())+        .filter(|s| !s.is_empty() && *s != "—" && s.to_lowercase() != "none").map(String::from);++    let path = dir.join(format!("{mpn}-file-provenance.json"));+    let mut map: serde_json::Map<String, serde_json::Value> = if path.exists() {+        serde_json::from_str(&std::fs::read_to_string(&path).unwrap_or_default()).unwrap_or_default()+    } else { serde_json::Map::new() };+    let now = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);+    let (mut recovered, mut unknown) = (Vec::new(), Vec::new());++    // SELF-HEAL the datasheet source for the ONE provably-impossible case: the+    // KiCad standard library ships symbols / footprints / 3D models and ZERO+    // datasheets, so a `.pdf` tagged `kicad*` is a guaranteed wholesale mis-record+    // (the whole JST/connector bundle got stamped with the KiCad-library source,+    // datasheet included). The datasheet actually came from the manufacturer in+    // that fetch, so re-attribute it to a manufacturer-tagged sibling (the STEP).+    // John: "how could a pdf source come from std kicad library? i ONLY want the+    // pdf from the manufacturer website." We do NOT touch aggregator-tagged PDFs+    // (CSE / SnapEDA / Mouser) — those genuinely host datasheets, so relabeling+    // them "manufacturer" would be a lie; the UI honestly flags them instead.+    let impossible_for_datasheet = |s: &str|+        matches!(s.to_lowercase().as_str(), "kicad" | "kicad-library" | "kicad_community");+    let mfr_sibling = map.values().find(|e| {+        let o = e.get("content_origin").and_then(|v| v.as_str()).unwrap_or("").to_lowercase();+        let d = e.get("discovery_source").and_then(|v| v.as_str()).unwrap_or("").to_lowercase();+        // a sibling downloaded from the maker's OWN site (not an aggregator/KiCad)+        matches!(o.as_str(), "manufacturer" | "mfr" | "vendor")+            && !impossible_for_datasheet(&d)+            && !matches!(d.as_str(), "cse" | "componentsearch" | "cse_ul" | "samacsys"+                | "snapeda" | "snapmagic" | "ultralibrarian" | "ul" | "mouser"+                | "digikey" | "arrow" | "lcsc")+    }).cloned();+    let pdf_name = format!("{mpn}.pdf");+    if let Some(entry) = map.get(&pdf_name).cloned() {+        let disc = entry.get("discovery_source").and_then(|v| v.as_str()).unwrap_or("");+        let orig = entry.get("content_origin").and_then(|v| v.as_str()).unwrap_or("");+        // Only act on the impossible case, and only when we have a genuine+        // manufacturer-direct sibling to copy — otherwise leave it truthful.+        if (impossible_for_datasheet(disc) || impossible_for_datasheet(orig)) && mfr_sibling.is_some() {+            let m = mfr_sibling.as_ref().unwrap();+            map.insert(pdf_name.clone(), serde_json::json!({+                "discovery_source": m.get("discovery_source").and_then(|v| v.as_str()).unwrap_or("manufacturer"),+                "content_origin": m.get("content_origin").and_then(|v| v.as_str()).unwrap_or("manufacturer"),+                "url": m.get("url").and_then(|v| v.as_str()).unwrap_or(""),+                "fetched_at": now, "derived": true, "recovered": true,+                "corrected": "datasheet re-sourced to the manufacturer (was mis-tagged to the KiCad library, which ships no PDFs)",+            }));+            recovered.push(pdf_name);+        }+    }++    // Only fill files that have NO entry at all — so this is idempotent and safe+    // to run on every scan. (A file already recorded "unknown" keeps failing the+    // gate but is not re-written each time.)+    let to_fill: Vec<String> = core_files(mpn).into_iter()+        .filter(|f| dir.join(f).is_file() && !map.contains_key(f.as_str()))+        .collect();+    for f in to_fill {+        // STEP prefers step_source; everything else uses the chain source.+        let src = if f.ends_with(".step") { step_src.clone().or_else(|| chain_src.clone()) }+                  else { chain_src.clone().or_else(|| step_src.clone()) };+        let disc = src.clone().unwrap_or_else(|| "unknown".into());+        if src.is_some() { recovered.push(f.clone()); } else { unknown.push(f.clone()); }+        map.insert(f, serde_json::json!({+            "discovery_source": disc, "content_origin": "", "url": "",+            "fetched_at": now, "derived": true, "recovered": src.is_some(),+        }));+    }+    if !recovered.is_empty() || !unknown.is_empty() {+        std::fs::write(&path, serde_json::to_string_pretty(&serde_json::Value::Object(map))?)?;+    }+    Ok((recovered, unknown))+}+ fn looks_like_mpn(s: &str) -> bool {     let has_alpha = s.chars().any(|c| c.is_ascii_alphabetic());     let has_digit = s.chars().any(|c| c.is_ascii_digit());
src/inventory.rs+106−5
@@ -22,6 +22,7 @@ pub struct Chip {     pub pdf_count: usize,     pub has_info: bool,     pub has_thumb_symbol: bool,+    pub sym_rev: u64,                   // mtime(ms) of <MPN>-symbol.svg — bumps when adom-symbol re-renders, so the dashboard reloads the thumbnail instantly     pub has_thumb_footprint: bool,     pub has_thumb_chip3d: bool,         // Z-up iso (canonical, <MPN>-3d-iso.png)     pub has_thumb_chip3d_yup: bool,     // Y-up iso (<MPN>-3d-iso-yup.png) — ME / Y-up source convention@@ -54,6 +55,20 @@ pub struct Chip {     /// the dashboard so the user sees the gold-dataset state per card.     pub validation_status: Option<String>,     pub validation_variants: Vec<String>,+    /// Pin-1 sign-off from chipsmith (`<MPN>.chipsmith.json`). `pin1_signed` = the+    /// user lined up the 3D chip's pin-1 to the footprint's pin-1 in chipsmith and+    /// signed off; `pin1_corner` is which corner pin-1 sits in (NW/NE/SW/SE). The+    /// baked `<MPN>.chipsmith.step` is the orientation-corrected canonical model+    /// downstream tools consume. None/false until signed.+    pub pin1_signed: bool,+    pub pin1_corner: Option<String>,+    pub pin1_signed_at: Option<String>,+    pub pin1_measure_ok: Option<bool>,+    /// Which corner the FOOTPRINT's pin-1 sits in (NW/NE/SW/SE), derived from the+    /// `.kicad_mod` (pad "1" vs the pad centroid). This is the TARGET the 3D chip's+    /// pin-1 must line up with — shown as a hint on unsigned cards, and compared+    /// against `pin1_corner` to flag a chip-vs-footprint mismatch once signed.+    pub fp_pin1_corner: Option<String>,     /// Per-file provenance: which ladder source + URL each library file     /// came from. Chipsmith reads this for tooltips. Written by     /// import::import_path_with_provenance at import time.@@ -78,6 +93,58 @@ pub struct Library {     pub stub_count: usize, } +/// Which corner the footprint's pin-1 sits in (NW/NE/SW/SE), from the `.kicad_mod`.+/// We take pad "1"'s position vs the centroid of all pads. KiCad footprint space+/// is **Y-down** (negative Y = top/north). Returns None when there's no pad "1"+/// or pin-1 sits dead-centre (ambiguous). Pure string parse — no s-expr dep.+fn footprint_pin1_corner(dir: &Path, mpn: &str) -> Option<String> {+    let content = std::fs::read_to_string(dir.join(format!("{mpn}.kicad_mod"))).ok()?;+    let mut p1: Option<(f64, f64)> = None;+    let (mut sx, mut sy, mut n) = (0.0f64, 0.0f64, 0usize);+    for line in content.lines() {+        let l = line.trim_start();+        if !l.starts_with("(pad ") {+            continue;+        }+        // pad name = text between the first pair of quotes: (pad "1" smd ...+        let name = match l.split('"').nth(1) {+            Some(s) => s,+            None => continue,+        };+        // coords follow "(at ": (at X Y[ rot])+        let at = match l.find("(at ") {+            Some(i) => &l[i + 4..],+            None => continue,+        };+        let mut it = at.split_whitespace();+        let x = match it.next().and_then(|t| t.trim_end_matches(')').parse::<f64>().ok()) {+            Some(v) => v,+            None => continue,+        };+        let y = match it.next().and_then(|t| t.trim_end_matches(')').parse::<f64>().ok()) {+            Some(v) => v,+            None => continue,+        };+        sx += x;+        sy += y;+        n += 1;+        if name == "1" {+            p1 = Some((x, y));+        }+    }+    if n == 0 {+        return None;+    }+    let (x, y) = p1?;+    let (cx, cy) = (sx / n as f64, sy / n as f64);+    let ew = if x < cx - 0.01 { "W" } else if x > cx + 0.01 { "E" } else { "" };+    let ns = if y < cy - 0.01 { "N" } else if y > cy + 0.01 { "S" } else { "" }; // Y-down+    if ew.is_empty() || ns.is_empty() {+        return None; // pin-1 centred on one axis → ambiguous, no hint+    }+    Some(format!("{ns}{ew}"))+}+ fn vendor_from_mpn(mpn: &str) -> &'static str {     let m = mpn.to_uppercase();     let prefixes = [@@ -112,6 +179,11 @@ pub fn scan() -> anyhow::Result<Library> {         let path = entry.path();         if !path.is_dir() { continue; }         let mpn = entry.file_name().to_string_lossy().to_string();+        // ENFORCE provenance at the source: any file that landed without going+        // through import gets its origin recorded now (derived from info.json, or+        // flagged "unknown"). Idempotent — only writes files with no entry yet —+        // so a stray un-sourced file can never silently persist past one scan.+        let _ = crate::import::backfill_dir(&path, &mpn);         let chip = read_chip(&path, &mpn)?;         total_size += chip.size_bytes;         chips.push(chip);@@ -185,6 +257,10 @@ fn read_chip(dir: &Path, mpn: &str) -> anyhow::Result<Chip> {     // Thumbnails live alongside the source files using chip-thumbnailer's     // canonical naming: <MPN>-symbol.svg / <MPN>-footprint.svg / <MPN>-3d-iso.png     let has_thumb_symbol     = dir.join(format!("{mpn}-symbol.svg")).is_file();+    let sym_rev = std::fs::metadata(dir.join(format!("{mpn}-symbol.svg"))).ok()+        .and_then(|m| m.modified().ok())+        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())+        .map(|d| d.as_millis() as u64).unwrap_or(0);     let has_thumb_footprint  = dir.join(format!("{mpn}-footprint.svg")).is_file();     let has_thumb_chip3d     = dir.join(format!("{mpn}-3d-iso.png")).is_file();     let has_thumb_chip3d_yup = dir.join(format!("{mpn}-3d-iso-yup.png")).is_file();@@ -266,6 +342,23 @@ fn read_chip(dir: &Path, mpn: &str) -> anyhow::Result<Chip> {             .collect())         .unwrap_or_default(); +    // Pin-1 sign-off from chipsmith (<MPN>.chipsmith.json). The user lines up the+    // 3D chip's pin-1 with the footprint's pin-1 in chipsmith + signs off; we read+    // it back so pin-1 is visible on the card and the baked .chipsmith.step is the+    // canonical placement model for downstream tools. (See the chipsmith launch.)+    let chipsmith = std::fs::read_to_string(dir.join(format!("{mpn}.chipsmith.json")))+        .ok()+        .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok());+    let pin1_signed = chipsmith.as_ref().map(|v| v.get("signed_at").is_some()).unwrap_or(false);+    let pin1_corner = chipsmith.as_ref()+        .and_then(|v| v.get("user_placed_pin1")).and_then(|p| p.get("corner"))+        .and_then(|c| c.as_str()).map(String::from);+    let pin1_signed_at = chipsmith.as_ref()+        .and_then(|v| v.get("signed_at")).and_then(|s| s.as_str()).map(String::from);+    let pin1_measure_ok = chipsmith.as_ref()+        .and_then(|v| v.get("measurements")).and_then(|m| m.get("passes")).and_then(|b| b.as_bool());+    let fp_pin1_corner = footprint_pin1_corner(&dir, mpn);+     // Per-file provenance map — chipsmith reads this for tooltips.     let file_provenance = std::fs::read_to_string(dir.join(format!("{mpn}-file-provenance.json")))         .ok()@@ -311,7 +404,7 @@ fn read_chip(dir: &Path, mpn: &str) -> anyhow::Result<Chip> {         has_fusion_lbr, fusion_not_available,         has_pdf, pdf_count,         has_info,-        has_thumb_symbol, has_thumb_footprint, has_thumb_chip3d, has_thumb_chip3d_yup,+        has_thumb_symbol, sym_rev, has_thumb_footprint, has_thumb_chip3d, has_thumb_chip3d_yup,         inferred_up_axis, chosen_up_axis,         step_source,         size_bytes: size,@@ -322,6 +415,8 @@ fn read_chip(dir: &Path, mpn: &str) -> anyhow::Result<Chip> {         authoritative_footprint_svg_source,         validation_status,         validation_variants,+        pin1_signed, pin1_corner, pin1_signed_at, pin1_measure_ok,+        fp_pin1_corner,         file_provenance,         thumb_errors,         symbol_sources,@@ -380,7 +475,10 @@ fn source_tags(         .and_then(|e| e.get("discovery_source").or_else(|| e.get("content_origin")))         .and_then(|s| s.as_str())         .or_else(|| info.get("source_of_record").and_then(|s| s.get(sor_key)).and_then(|s| s.as_str()))-        .unwrap_or("vendor")+        // Don't FALSELY claim "Manufacturer (direct site)" when we never recorded+        // a source — that misleading default is what made the carat look wrong+        // ("Manufacturer direct site" on a chip we couldn't actually attribute).+        .unwrap_or("unknown")         .to_string();      // chip-fetcher's goal: show EVERY library + format we pulled down, named, so@@ -393,8 +491,11 @@ fn source_tags(     let has = |f: String| dir.join(f).is_file();     let mut tags = Vec::new();     // KiCad library — the canonical render shown on the tile (current source).+    // When the source IS the KiCad standard library, don't say "KiCad library,+    // from KiCad standard library" — the source already names the format.     if has_canonical {-        tags.push(SourceTag { method: "kicad".into(), label: format!("{src} - KiCad Library"), current: true, thumb: canon_thumb });+        let kicad_label = if src.starts_with("KiCad") { src.clone() } else { format!("KiCad library, from {src}") };+        tags.push(SourceTag { method: "kicad".into(), label: kicad_label, current: true, thumb: canon_thumb });     }     // Altium library (.SchLib has the symbol/device, .PcbLib the footprint).     // adom-sfconvert turns it into KiCad sym+mod via KiCad 10 (service-kicad),@@ -403,14 +504,14 @@ fn source_tags(     let altium = if sor_key == "kicad_sym" { format!("{mpn_prefix}.SchLib") } else { format!("{mpn_prefix}.PcbLib") };     if has(altium) {         let altium_thumb = dir.join(format!("{mpn_prefix}-{thumb_kind}.altium.svg")).is_file();-        tags.push(SourceTag { method: "altium".into(), label: format!("{src} - Altium Library"), current: false, thumb: altium_thumb });+        tags.push(SourceTag { method: "altium".into(), label: format!("Altium library, from {src}"), current: false, thumb: altium_thumb });     }     // Fusion 360 / EAGLE library (.lbr holds both the device + the package).     // adom-sfconvert turns it into KiCad sym+mod, rendered to its own variant     // thumbnail — so the carat previews the Fusion library's OWN geometry.     if has(format!("{mpn_prefix}.lbr")) {         let fusion_thumb = dir.join(format!("{mpn_prefix}-{thumb_kind}.fusion.svg")).is_file();-        tags.push(SourceTag { method: "fusion".into(), label: format!("{src} - Fusion 360 Library"), current: false, thumb: fusion_thumb });+        tags.push(SourceTag { method: "fusion".into(), label: format!("Fusion 360 library, from {src}"), current: false, thumb: fusion_thumb });     }     // The manufacturer DATASHEET, extracted by ds2sf — the independent check.     let ds2sf_thumb = dir.join(format!("{mpn_prefix}-{thumb_kind}.ds2sf.svg")).is_file();
src/main.rs+1
@@ -1,3 +1,4 @@+mod bom; mod cli; mod creds; mod provenance;
src/provenance.rs+6−2
@@ -45,7 +45,9 @@ pub fn source_label(discovery: &str, manufacturer: &str) -> String {             } else if !manufacturer.is_empty() {                 format!("{manufacturer} (direct)")             } else {-                "Manufacturer (direct site)".to_string()+                // Don't claim "direct site" when we can't name the site — that's+                // exactly the vague label John called out. Be honest instead.+                "manufacturer (site unrecorded)".to_string()             }         }         // 3rd-party aggregators — NEVER "the manufacturer".@@ -56,7 +58,9 @@ pub fn source_label(discovery: &str, manufacturer: &str) -> String {         "digikey" => "DigiKey".into(),         "arrow" => "Arrow".into(),         "lcsc" => "LCSC".into(),-        "kicad_community" => "KiCad community".into(),+        "kicad_community" | "kicad-library" | "kicad" => "KiCad standard library".into(),+        // The Adom Wiki — a published, Adom-vetted bundle reused as-is.+        "adom-wiki" | "adom_wiki" | "adomwiki" => "Adom Wiki".into(),         other => other.to_string(),     } }
src/serve.rs+29−1
@@ -535,7 +535,33 @@ fn set_orientation(body: &str) -> Response<std::io::Cursor<Vec<u8>>> {     if let Err(e) = std::fs::write(&info_path, pretty) {         return error_response(500, format!("{{\"error\":\"write info.json: {e}\"}}"));     }-    json_response(format!("{{\"ok\":true,\"mpn\":{mpn:?},\"axis\":{axis:?}}}"))++    // Re-orient the symbol's composited 3D-chip outline to the newly-chosen+    // up-axis. chip-thumbnailer honors chosen_up_axis; its LIGHT `--kind outline`+    // path re-renders just the vector outline (one OCCT pass, no shaded-iso+    // family), then we recomposite the canonical symbol so its embedded 3D chip+    // matches the orientation the user just picked. Off-thread so the click+    // returns instantly — the dashboard's poll picks up the new SVG.+    {+        let mpn = mpn.to_string();+        std::thread::spawn(move || {+            let dir = crate::library::root().join(&mpn);+            let _ = std::process::Command::new("adom-chip-thumbnailer")+                .args(["rerender", &mpn, "--kind", "outline"])+                .output();+            let sym = dir.join(format!("{mpn}.kicad_sym"));+            if sym.is_file() {+                let _ = std::process::Command::new("adom-symbol")+                    .args(["render", "--file"])+                    .arg(&sym)+                    .arg("--out")+                    .arg(dir.join(format!("{mpn}-symbol.svg")))+                    .output();+            }+        });+    }++    json_response(format!("{{\"ok\":true,\"mpn\":{mpn:?},\"axis\":{axis:?},\"regen\":\"outline+symbol\"}}")) }  /// Millisecond timestamp for cache-busting an already-open app tab so a fresh@@ -585,6 +611,8 @@ fn load_lbr_into_app(dir: &std::path::Path, mpn: &str, port: u16) -> bool {     args.push(mpn.to_string());     args.push("--output".into());     args.push(out_lbr.to_string_lossy().to_string());+    // adom-lbr v1.6.0 BOM contract: attach MF / MPN / Mouser / DigiKey / LCSC.+    args.extend(crate::bom::flags(dir, mpn));     let gen = std::process::Command::new("adom-lbr").args(&args).output();     // generate exits 2 on lint failure but still writes the file; accept any     // run that produced the .lbr.
src/sources/mod.rs+1
@@ -1,2 +1,3 @@ pub mod jst; pub mod ti;+pub mod wiki;
src/sources/wiki.rsadded+371
@@ -0,0 +1,371 @@+//! Adom Wiki as the FIRST part-source on the sourcing ladder.+//!+//! The wiki (https://wiki.adom.inc) hosts published `component` pages whose git+//! repo carries a complete, Adom-vetted CAD bundle — `<mpn>.kicad_sym`,+//! `<mpn>.kicad_mod`, STEP/GLB, datasheet PDF, and the 3D-outline SVGs. If a+//! part already exists there we REUSE that bundle instead of re-fetching and+//! rebuilding from external vendors: faster, free, and guaranteed+//! Adom-consistent geometry + provenance.+//!+//! Reads only — the public read API needs no token. There is no HTTP crate in+//! this tree, so we shell `curl` (the same dependency-free style the rest of the+//! CLI uses to drive sibling binaries).++use crate::import::{self, FileProvenance};+use crate::sourcing::{self, Source};+use anyhow::{anyhow, Context, Result};+use serde_json::Value;+use std::path::{Path, PathBuf};++const WIKI: &str = "https://wiki.adom.inc";++/// A wiki `component` page that matched an MPN and carries a usable bundle.+pub struct WikiHit {+    pub slug: String,+    pub title: String,+    pub url: String,          // human page URL — recorded as provenance+}++/// Normalize an MPN for matching: lowercase, drop all non-alphanumerics. So+/// "B2B-ZR" == "b2bzr" == "B2B_ZR". Used to compare the requested MPN against a+/// page's component.mpn and to match canonical file prefixes.+fn norm(s: &str) -> String {+    s.chars().filter(|c| c.is_ascii_alphanumeric()).map(|c| c.to_ascii_lowercase()).collect()+}++/// Result of a successful reuse-from-wiki.+pub struct WikiPull {+    pub url: String,+    pub files_added: Vec<String>,+}++// ---------- tiny curl-backed HTTP (no HTTP crate in-tree) ----------++fn curl_json(url: &str) -> Option<Value> {+    let out = std::process::Command::new("curl")+        .args(["-fsSL", "--max-time", "20", url])+        .output()+        .ok()?;+    if !out.status.success() {+        return None;+    }+    serde_json::from_slice(&out.stdout).ok()+}++fn curl_download(url: &str, dest: &Path) -> Result<()> {+    let out = std::process::Command::new("curl")+        .args(["-fsSL", "--max-time", "60", "-o"])+        .arg(dest)+        .arg(url)+        .output()+        .with_context(|| format!("curl {url}"))?;+    if !out.status.success() {+        return Err(anyhow!(+            "curl {url} failed: {}",+            String::from_utf8_lossy(&out.stderr)+        ));+    }+    Ok(())+}++// ---------- matching ----------++/// Candidate slugs to try as an exact page lookup, most-specific first.+fn slug_candidates(mpn: &str) -> Vec<String> {+    let lower = mpn.to_ascii_lowercase();+    let alnum: String = lower.chars().filter(|c| c.is_ascii_alphanumeric()).collect();+    let dashed: String = lower+        .chars()+        .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })+        .collect();+    let mut v = vec![lower.clone(), dashed, alnum];+    v.dedup();+    v+}++/// The MPN with hyphens stripped — the wiki's full-text search throws on+/// hyphens (known bug), so we never send a raw hyphenated query.+fn search_safe(mpn: &str) -> String {+    mpn.chars().filter(|c| *c != '-').collect()+}++fn url_encode(s: &str) -> String {+    let mut out = String::with_capacity(s.len());+    for b in s.bytes() {+        match b {+            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => out.push(b as char),+            _ => out.push_str(&format!("%{:02X}", b)),+        }+    }+    out+}++/// Is this page a `component` page? Returns (title, human URL, component.mpn).+fn component_meta(slug: &str) -> Option<(String, String, String)> {+    let pj = curl_json(&format!("{WIKI}/api/v1/pages/{slug}/files/page.json"))?;+    if pj.get("type").and_then(|t| t.as_str()) != Some("component") {+        return None;+    }+    let title = pj.get("title").and_then(|t| t.as_str()).unwrap_or(slug).to_string();+    // The canonical MPN: component.mpn, falling back to the slug.+    let page_mpn = pj+        .get("component")+        .and_then(|c| c.get("mpn"))+        .and_then(|m| m.as_str())+        .filter(|s| !s.is_empty())+        .unwrap_or(slug)+        .to_string();+    Some((title, page_url(slug, &pj), page_mpn))+}++/// Human page URL — `wiki.adom.inc/<org>/<slug>` when we know the org, else+/// `wiki.adom.inc/<slug>`.+fn page_url(slug: &str, meta: &Value) -> String {+    let org = meta+        .get("org_name")+        .or_else(|| meta.get("org").and_then(|o| o.get("name")))+        .and_then(|o| o.as_str())+        .unwrap_or("");+    if org.is_empty() {+        format!("{WIKI}/{slug}")+    } else {+        format!("{WIKI}/{org}/{slug}")+    }+}++/// Collect candidate component slugs for an MPN via the three documented paths,+/// in priority order: exact slug → discover(triggers) → full-text search.+fn candidate_slugs(mpn: &str) -> Vec<String> {+    fn push(s: String, slugs: &mut Vec<String>) {+        if !s.is_empty() && !slugs.contains(&s) {+            slugs.push(s);+        }+    }+    let mut slugs: Vec<String> = Vec::new();++    // 1. exact / normalized slug+    for cand in slug_candidates(mpn) {+        push(cand, &mut slugs);+    }++    // 2. discovery by trigger+    if let Some(v) = curl_json(&format!("{WIKI}/api/v1/discover?triggers={}", url_encode(mpn))) {+        if let Some(arr) = v.as_array() {+            for r in arr {+                if r.get("type").and_then(|t| t.as_str()) == Some("component") {+                    if let Some(s) = r.get("slug").and_then(|s| s.as_str()) {+                        push(s.to_string(), &mut slugs);+                    }+                }+            }+        }+    }++    // 3. full-text search (hyphens stripped — FTS throws on them)+    if let Some(v) = curl_json(&format!("{WIKI}/api/v1/search?q={}", url_encode(&search_safe(mpn)))) {+        if let Some(arr) = v.get("results").and_then(|r| r.as_array()) {+            for r in arr {+                if r.get("type").and_then(|t| t.as_str()) == Some("component") {+                    if let Some(s) = r.get("slug").and_then(|s| s.as_str()) {+                        push(s.to_string(), &mut slugs);+                    }+                }+            }+        }+    }++    slugs+}++/// List a page's file paths (flat). Handles `{ "files": [ { "path": .. } ] }`.+fn list_files(slug: &str) -> Vec<String> {+    let v = match curl_json(&format!("{WIKI}/api/v1/pages/{slug}/files")) {+        Some(v) => v,+        None => return vec![],+    };+    let arr = v+        .get("files")+        .and_then(|f| f.as_array())+        .or_else(|| v.as_array())+        .cloned()+        .unwrap_or_default();+    arr.iter()+        .filter(|e| e.get("type").and_then(|t| t.as_str()) != Some("tree"))+        .filter_map(|e| {+            e.get("path")+                .or_else(|| e.get("name"))+                .and_then(|p| p.as_str())+                .map(|s| s.to_string())+        })+        .collect()+}++/// How a wiki file maps into the bundle. `Core` goes through the importer+/// (magic-byte validation + canonical `<mpn>.<ext>` naming + per-source+/// variant); `Aux(name)` is copied in directly under `name`.+enum Pull {+    Core,+    Aux(String),+}++/// Classify a wiki file for pulling. `mpn` is the requested MPN — used both to+/// match the canonical file prefix and to name targets. (We key off the+/// requested MPN, not the page's component.mpn, because the on-disk wiki files+/// are named after the real part number and some pages glue a description onto+/// component.mpn.) Only the CANONICAL file of each type is pulled — variant /+/// compare GLBs like `B2B-ZR-v2.glb` or `B2B-ZR-compare.glb` are skipped.+fn classify(mpn: &str, path: &str) -> Option<Pull> {+    let base = path.rsplit('/').next().unwrap_or(path);+    let bl = base.to_ascii_lowercase();+    let pm = norm(mpn);++    // 3D-outline SVGs: <prefix>-3d-outline-<style>.svg, prefix == the part.+    if bl.ends_with(".svg") {+        if let Some(idx) = bl.find("-3d-outline-") {+            if norm(&base[..idx]) == pm {+                return Some(Pull::Aux(format!("{mpn}{}", &base[idx..])));+            }+        }+        return None;+    }++    // Everything else keys on (stem, ext); stem must BE the part (not a variant).+    let (stem, ext) = base.rsplit_once('.')?;+    if norm(stem) != pm {+        return None;+    }+    match ext.to_ascii_lowercase().as_str() {+        "kicad_sym" | "kicad_mod" | "step" | "stp" | "pdf" => Some(Pull::Core),+        "glb" => Some(Pull::Aux(format!("{mpn}.glb"))),+        "wrl" => Some(Pull::Aux(format!("{mpn}.wrl"))),+        _ => None,+    }+}++/// Is `path` a core CAD file for `mpn`? Its presence is what makes a page a+/// USABLE bundle (a metadata-only component page has none → fall through).+fn has_core(mpn: &str, path: &str) -> bool {+    matches!(classify(mpn, path), Some(Pull::Core))+        && !path.to_ascii_lowercase().ends_with(".pdf")+}++/// Find the component page that actually carries a CAD bundle AND whose MPN+/// matches the request (normalized). Without the MPN check, a loose+/// discover/search hit (or the wiki returning a popular page) would wrongly+/// "match" any query — verified the hard way: a nonsense MPN matched B2B-ZR.+pub fn find_component(mpn: &str) -> Option<WikiHit> {+    let want = norm(mpn);+    for slug in candidate_slugs(mpn) {+        let (title, url, page_mpn) = match component_meta(&slug) {+            Some(m) => m,+            None => continue, // not a component page (or 404)+        };+        // Match on the page's component.mpn OR its slug — some pages glue a+        // description onto component.mpn (e.g. "BQ7692003PWR — BQ76920 …"), so the+        // clean slug is the reliable identity. Either exact (normalized) match is+        // accepted; a loose discover/search hit for a different part is rejected.+        if norm(&page_mpn) != want && norm(&slug) != want {+            continue;+        }+        let files = list_files(&slug);+        if files.iter().any(|f| has_core(mpn, f)) {+            return Some(WikiHit { slug, title, url });+        }+        // matches the MPN but metadata-only (no CAD) → not usable, keep looking+    }+    None+}++// ---------- pulling the bundle ----------++/// Query the wiki and, if a usable component bundle exists, pull it into+/// `library/<mpn>/` with canonical names + `adom-wiki` provenance. Returns+/// `Ok(None)` when nothing usable matched (→ caller falls through to the+/// external ladder). Reads only.+pub fn try_fetch(mpn: &str) -> Result<Option<WikiPull>> {+    let hit = match find_component(mpn) {+        Some(h) => h,+        None => return Ok(None),+    };+    println!("Adom Wiki: reusing published bundle \"{}\" from {} ({})", hit.title, hit.url, hit.slug);++    let dir = crate::library::ensure_dir(mpn)?;+    let stage = std::env::temp_dir().join(format!("cf-wiki-{}", mpn));+    let _ = std::fs::remove_dir_all(&stage);+    std::fs::create_dir_all(&stage)?;++    let prov = FileProvenance {+        discovery_source: "adom-wiki".into(),+        content_origin: "adom-wiki".into(),+        url: hit.url.clone(),+    };++    let mut added: Vec<String> = Vec::new();+    for path in list_files(&hit.slug) {+        let pull = match classify(mpn, &path) {+            Some(p) => p,+            None => continue, // README / page.json / variant GLB / unrelated file+        };+        let base = path.rsplit('/').next().unwrap_or(&path).to_string();+        let staged: PathBuf = stage.join(&base);+        let file_url = format!(+            "{WIKI}/api/v1/pages/{}/files/{}",+            hit.slug,+            path.split('/').map(url_encode).collect::<Vec<_>>().join("/")+        );+        if let Err(e) = curl_download(&file_url, &staged) {+            eprintln!("WARN: skipped {base}: {e}");+            continue;+        }+        match pull {+            Pull::Core => {+                // Core CAD + datasheet → through the importer: magic-byte+                // validation, canonical <mpn>.<ext> naming, per-source variant,+                // provenance.+                match import::import_path_with_provenance(&staged, Some(mpn), Some(&prov)) {+                    Ok(rep) => added.extend(rep.files_added),+                    Err(e) => eprintln!("WARN: import {base} failed: {e}"),+                }+            }+            Pull::Aux(name) => {+                // GLB / WRL / 3D-outline SVGs → copy in directly + record provenance.+                let target = dir.join(&name);+                if let Err(e) = std::fs::copy(&staged, &target) {+                    eprintln!("WARN: copy {base} failed: {e}");+                    continue;+                }+                let _ = import::record_file_provenance(&dir, &name, &prov);+                added.push(name);+            }+        }+    }+    let _ = std::fs::remove_dir_all(&stage);++    // Record the wiki as the (winning) ladder step + stamp info.json provenance.+    let _ = sourcing::append_chain(mpn, Source::AdomWiki, "OK", Some(&hit.url));+    let _ = stamp_info_provenance(&dir, mpn, &hit.url);++    if added.is_empty() {+        // Found a page but pulled nothing usable after all → fall through.+        return Ok(None);+    }+    Ok(Some(WikiPull {+        url: hit.url,+        files_added: added,+    }))+}++/// Record the top-level discovery provenance on info.json so the source carat+/// shows "Adom Wiki" as a selectable method like the other ladder sources.+fn stamp_info_provenance(dir: &Path, mpn: &str, url: &str) -> Result<()> {+    let path = dir.join("info.json");+    let mut info: Value = std::fs::read_to_string(&path)+        .ok()+        .and_then(|s| serde_json::from_str(&s).ok())+        .unwrap_or_else(|| serde_json::json!({ "mpn": mpn }));+    info["discovery_source"] = Value::String("adom-wiki".into());+    info["content_origin"] = Value::String("adom-wiki".into());+    info["adom_wiki_url"] = Value::String(url.into());+    std::fs::write(&path, serde_json::to_string_pretty(&info)?)?;+    Ok(())+}
src/sourcing.rs+7
@@ -22,6 +22,10 @@ use std::path::PathBuf; /// One step in the sourcing ladder. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Source {+    /// Adom Wiki — reuse an already-published, Adom-vetted CAD bundle. FIRST on+    /// the ladder: if the part exists on wiki.adom.inc we don't re-fetch from+    /// external vendors at all.+    AdomWiki,     Manufacturer,     SnapMagic,     Mouser,@@ -34,6 +38,7 @@ pub enum Source { impl Source {     pub fn as_slug(&self) -> &'static str {         match self {+            Source::AdomWiki     => "adom-wiki",             Source::Manufacturer => "manufacturer",             Source::SnapMagic    => "snapmagic",             Source::Mouser       => "mouser",@@ -46,6 +51,7 @@ impl Source {      pub fn ladder() -> &'static [Source] {         &[+            Source::AdomWiki,             Source::Manufacturer,             Source::SnapMagic,             Source::Mouser,@@ -200,6 +206,7 @@ fn normalize_slug(s: &str) -> String {         "cse" | "ul" | "cseul" | "ultralibrarian" => "cse_ul".into(),         "snapeda" => "snapmagic".into(),         "mfr" => "manufacturer".into(),+        "adom_wiki" | "adomwiki" | "wiki" | "adom" => "adom-wiki".into(),         other => other.to_string(),     } }
src/thumbnails.rs+20−107
@@ -128,62 +128,25 @@ fn generate_ds2sf_variants(dir: &std::path::Path, mpn: &str) {             .arg(&fp_json).arg("--out").arg(&out)             .output();     }-    // Black-&-white vector OUTLINE icons (OCCT HLR) at the chosen orientation.-    let step = dir.join(format!("{mpn}.step"));-    if step.is_file() {-        let axis = std::fs::read_to_string(dir.join("info.json"))-            .ok()-            .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())-            .and_then(|v| v.get("chosen_up_axis").and_then(|a| a.as_str()).map(String::from))-            .unwrap_or_else(|| "z".to_string());-        let _ = Command::new("python3")-            .arg("/home/adom/project/chip-fetcher/scripts/occt-outline.py")-            .arg(&step).arg(dir).arg(mpn).arg(&axis)-            .output();-        // Transparent-background iso icon (chroma-key the shaded iso for the-        // chosen axis) so the symbol-centre overlay has no ugly box.-        let iso_in = match axis.as_str() {-            "y" | "yup" => format!("{mpn}-3d-iso-yup.png"),-            "x" | "xup" => format!("{mpn}-3d-iso-xup.png"),-            "-x" | "xdown" => format!("{mpn}-3d-iso-xdown.png"),-            "-y" | "ydown" => format!("{mpn}-3d-iso-ydown.png"),-            "-z" | "zdown" => format!("{mpn}-3d-iso-zdown.png"),-            _ => format!("{mpn}-3d-iso.png"),-        };-        let iso_path = dir.join(&iso_in);-        if iso_path.is_file() {-            let _ = Command::new("python3")-                .arg("/home/adom/project/chip-fetcher/scripts/occt-iso.py")-                .arg(&iso_path)-                .arg(dir.join(format!("{mpn}-3d-iso-icon.png")))-                .output();-        }-        // Name-marked 3D: emboss the part number on the chip's top face (real-        // STEP geometry) and render the iso + flat-top outline variants, so the-        // "laser the name on the chip" mode has perspective-correct assets that-        // match what ships to KiCad. Best-effort; skipped if OCCT isn't present.-        generate_named_assets(dir, mpn, &axis);-    }+    // The full chip-icon family (transparent iso icon, vector OUTLINE styles via+    // OCCT HLR, and the name-lasered emboss + named variants) is produced by+    // `chip-thumbnailer once` above — chip-thumbnailer is the chip-icon factory+    // and calls service-step2glb's /outline + /name-emboss for the OCCT work.+    // chip-fetcher no longer shells the local occt-*.py scripts for these. -    // Canonical SYMBOL render — done LAST, AFTER the transparent iso icon exists.-    // John's rule: show adom-symbol's DEFAULT symbol with the iso 3D chip-    // composited into the body. The order matters: rendering the symbol BEFORE-    // the `-3d-iso-icon.png` (transparent) is generated bakes in the shaded-    // `-3d-iso.png` instead — a gray box behind the chip. Generating the icon-    // first (above) guarantees the symbol gets the clean transparent overlay.+    // Canonical SYMBOL render — done LAST, AFTER chip-thumbnailer produced the+    // 3D-chip OUTLINE assets (`<mpn>-3d-outline-<style>-named.svg`) in the `once`+    // call. John's rule: the DEFAULT symbol shows the 3D chip as a WHITE vector+    // OUTLINE with the chip name lasered on, composited into the body. We let+    // `adom-symbol render` auto-discover that white outline+name asset (its+    // default layout) — we no longer pass `--iso`, because the shaded raster read+    // as a gray box and the BLACK "bold" outline was invisible on the dark canvas.     let canon_sym = dir.join(format!("{mpn}.kicad_sym"));     if canon_sym.is_file() {-        let mut cmd = Command::new("adom-symbol");-        cmd.args(["render", "--file"]).arg(&canon_sym)-            .arg("--out").arg(dir.join(format!("{mpn}-symbol.svg")));-        let iso_icon = dir.join(format!("{mpn}-3d-iso-icon.png"));-        let iso_plain = dir.join(format!("{mpn}-3d-iso.png"));-        if iso_icon.is_file() {-            cmd.arg("--iso").arg(&iso_icon);-        } else if iso_plain.is_file() {-            cmd.arg("--iso").arg(&iso_plain);-        }-        let _ = cmd.output();+        let _ = Command::new("adom-symbol")+            .args(["render", "--file"]).arg(&canon_sym)+            .arg("--out").arg(dir.join(format!("{mpn}-symbol.svg")))+            .output();     }      // Foreign-format libraries (EAGLE/Fusion .lbr) → convert to KiCad sym+mod via@@ -275,60 +238,10 @@ fn convert_foreign_libraries(dir: &std::path::Path, mpn: &str) {     } } -const SCRIPTS: &str = "/home/adom/project/chip-fetcher/scripts";--/// Emboss the part number onto the chip's 3D STEP top face and render the-/// name-marked iso + flat-top outline variants. The mark is real geometry so it-/// renders perspective-correctly AND travels with the STEP to KiCad. The shaded-/// iso uses a shallow raised relief; the line-art outline uses flat top-face-/// geometry (top edge only — no extrusion clutter). Best-effort, idempotent.-fn generate_named_assets(dir: &std::path::Path, mpn: &str, axis_raw: &str) {-    let step = dir.join(format!("{mpn}.step"));-    if !step.is_file() {-        return;-    }-    // The emboss only knows the y-up / z-up top face; normalize, skip exotic axes.-    let up = match axis_raw.to_lowercase().as_str() {-        "y" | "yup" | "+y" => "y",-        "z" | "zup" | "+z" | "" => "z",-        _ => return,-    };-    let icon = dir.join(format!("{mpn}-3d-iso-named-icon.png"));-    if icon.is_file() {-        return; // already generated-    }-    // Optional variant second line (e.g. "QFAA-R7") from info.json, if recorded.-    let variant = std::fs::read_to_string(dir.join("info.json"))-        .ok()-        .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())-        .and_then(|v| v.get("variant_marking").and_then(|a| a.as_str()).map(String::from))-        .unwrap_or_default();--    // 1. Embossed (shallow relief) STEP → for the shaded iso + KiCad delivery.-    let named_step = dir.join(format!("{mpn}-named.step"));-    let mut emboss = Command::new("python3");-    emboss.arg(format!("{SCRIPTS}/occt-name-on-chip.py")).arg(&step).arg(up).arg(mpn).arg(&named_step);-    if !variant.is_empty() { emboss.arg(&variant); }-    if !matches!(emboss.output(), Ok(o) if o.status.success()) {-        return;-    }-    // 2. Render the named STEP's iso (xvfb-backed V3d) → transparent icon.-    let tmp_iso = dir.join(format!("{mpn}-3d-iso-named.png"));-    let _ = Command::new("xvfb-run").args(["-a", "python3"])-        .arg(format!("{SCRIPTS}/render-iso.py")).arg(&named_step).arg(&tmp_iso).arg(axis_raw)-        .output();-    if tmp_iso.is_file() {-        let _ = Command::new("python3").arg(format!("{SCRIPTS}/occt-iso.py")).arg(&tmp_iso).arg(&icon).output();-    }-    // 3. Outline "-named" variants — single-stroke (centerline) part-number drawn-    //    on the top face (clean even for long names; no outlined-glyph clutter).-    let mut ol = Command::new("python3");-    ol.arg(format!("{SCRIPTS}/occt-outline.py"))-        .arg(&step).arg(dir).arg(mpn).arg(axis_raw)-        .args(["--suffix", "-named", "--name", mpn]);-    if !variant.is_empty() { ol.args(["--line2"]).arg(&variant); }-    let _ = ol.output();-}+// The name-laser emboss + vector-outline generation moved to chip-thumbnailer+// (the chip-icon factory), which calls service-step2glb's /name-emboss ++// /outline. chip-fetcher no longer shells the local occt-*.py scripts; the full+// chip-icon family lands via the `chip-thumbnailer once` call in generate_for.  pub fn generate_all(force: bool) -> Vec<GenReport> {     let root = library::root();
src/ui/index.html+214−69
@@ -271,7 +271,11 @@   .card .thumbs { display: flex; flex-direction: column; gap: 6px; margin: 10px 0; }   .card .thumb-row { display: grid; gap: 6px; }   .card .thumb-row.primary { grid-template-columns: 1fr 1fr; }-  .card .thumb-row.secondary { grid-template-columns: 2fr 1fr; align-items: stretch; }+  /* 3D models are the row that tells near-identical parts (JST connectors) apart,+     so give the 3D group the full card width (big Z↑/Y↑), and shrink the .lbr+     build action to a thin bar beneath it. */+  .card .thumb-row.secondary { grid-template-columns: 1fr; align-items: stretch; }+  .card .thumb-row.secondary .thumb-lbr { aspect-ratio: auto; height: 38px; flex-direction: row; }   .card .thumb-3d-group { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; position: relative; border: 1px solid var(--border-muted); border-radius: 6px; padding: 2px; background: rgba(64,75,85,.15); }   .card .thumb-3d-group .orient-more-btn { position: absolute; bottom: 2px; left: 50%; transform: translateX(-50%); z-index: 5; background: var(--panel); border: 1px solid var(--border); color: var(--muted); border-radius: 3px; padding: 0 6px; font-size: 9px; line-height: 1.5; cursor: pointer; opacity: 0; transition: opacity .12s; }   .card:hover .thumb-3d-group .orient-more-btn, .card.selected .thumb-3d-group .orient-more-btn { opacity: 1; }@@ -336,6 +340,16 @@   .card .thumb[data-orient-inactive="true"]:hover { opacity: 1; }   .card .thumb .orient-check { position: absolute; top: 4px; right: 4px; width: 16px; height: 16px; background: var(--green); border-radius: 50%; display: flex; align-items: center; justify-content: center; color: #0d1117; pointer-events: none; }   .card .thumb .orient-check svg { width: 11px; height: 11px; }+  /* Lightweight "symbol re-rendering" overlay: a CSS-only spinner over the SYM+     tile while the background outline+symbol regen runs after an up-axis switch.+     pointer-events:none + a single dimmed tile = the rest of the dashboard stays+     fully interactive while it spins. */+  .card .thumb.refreshing img { opacity: .3; transition: opacity .2s; }+  .card .thumb-spinner { position: absolute; top: 50%; left: 50%; width: 22px; height: 22px;+    margin: -11px 0 0 -11px; border: 2px solid rgba(255,255,255,.22);+    border-top-color: var(--accent); border-radius: 50%;+    animation: thumbspin .7s linear infinite; pointer-events: none; z-index: 4; }+  @keyframes thumbspin { to { transform: rotate(360deg); } }   .card .thumb:hover { border-color: var(--accent); transform: scale(1.06); z-index: 2; box-shadow: 0 6px 18px rgba(0,0,0,0.4), 0 0 0 1px var(--accent); }   /* Symbol/footprint tiles are non-interactive — no hover lift, no pointer. */   .card .thumb.thumb-static { cursor: default; }@@ -359,6 +373,9 @@   .b.altium { background: rgba(34,197,94,.15); color: var(--green); }   .b.fusion { background: rgba(34,197,94,.15); color: var(--green); }   .b.partial-eda { background: rgba(245,158,11,.18); color: var(--amber); }+  .b.pin1ok { background: rgba(0,184,177,.18); color: var(--accent); }   /* pin-1 signed off in chipsmith */+  .b.pin1   { background: rgba(245,158,11,.16); color: var(--amber); }   /* pin-1 not yet aligned */+  .b.pin1bad{ background: rgba(248,81,73,.18); color: #ff7b72; }          /* chip pin-1 corner != footprint pin-1 corner */   .b.pdf    { background: rgba(0,184,176,.15); color: var(--accent); }   .b.clickable { cursor: pointer; }   .b.clickable:hover { filter: brightness(1.25); }@@ -431,7 +448,7 @@   .auto-zoom-toggle { display: inline-flex; align-items: center; gap: 4px; font-size: 12px; color: var(--muted); cursor: pointer; margin-left: 8px; user-select: none; }   .auto-zoom-toggle input { accent-color: var(--accent); }   /* 6-orientation popover (button lives inside .thumb-3d-group) */-  .card .orient-popover { position: absolute; top: 100%; left: 50%; transform: translateX(-50%); z-index: 50; background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 8px; display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; box-shadow: 0 8px 24px rgba(0,0,0,0.5); }+  .card .orient-popover { position: absolute; top: 100%; left: 50%; transform: translateX(-50%); z-index: 50; background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 10px; display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; box-shadow: 0 8px 24px rgba(0,0,0,0.5); }   /* symbol/footprint provenance carat — always-visible tappable source chip */   .card .thumb-src-group { position: relative; display: flex; }   .card .thumb-src-group .thumb { flex: 1; }@@ -442,15 +459,19 @@   .card .src-chip:hover, .card .src-chip:active { border-color: var(--accent); background: rgba(0,184,177,.16); }   .card .src-chip-lab { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }   .card .src-chip-car { color: var(--muted); font-size: 8px; }+  /* Variant picker is a side-by-side GALLERY with large thumbnails, so similar+     variants (e.g. JST connector symbols/footprints) are actually tellable apart. */   .card .src-popover { position: absolute; top: 24px; left: 4px; z-index: 30;-    background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 6px; min-width: 168px;-    display: flex; flex-direction: column; gap: 4px; box-shadow: 0 10px 28px rgba(0,0,0,.55); }-  .card .src-opt { cursor: pointer; display: flex; align-items: center; gap: 8px; border: 1px solid var(--border-muted); border-radius: 6px;-    padding: 5px 7px; font-size: 11px; color: var(--text); transition: border-color .12s, background .12s; white-space: nowrap; }+    background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 10px;+    display: flex; flex-direction: row; flex-wrap: wrap; gap: 10px; max-width: 520px;+    box-shadow: 0 14px 36px rgba(0,0,0,.6); }+  .card .src-opt { cursor: pointer; display: flex; flex-direction: column; align-items: center; gap: 6px;+    border: 1px solid var(--border-muted); border-radius: 8px; padding: 8px; width: 152px;+    font-size: 11px; color: var(--text); transition: border-color .12s, background .12s; }   .card .src-opt:hover, .card .src-opt:active { border-color: var(--accent); background: rgba(0,184,177,.08); }   .card .src-opt.chosen { border-color: var(--green); box-shadow: 0 0 0 1px var(--green) inset; }-  .card .src-opt-thumb { width: 30px; height: 30px; object-fit: contain; background: #0d1117; border-radius: 4px; flex: 0 0 30px; }-  .card .src-opt-lab { flex: 1; }+  .card .src-opt-thumb { width: 136px; height: 136px; object-fit: contain; background: #0d1117; border-radius: 6px; flex: none; }+  .card .src-opt-lab { flex: none; text-align: center; line-height: 1.3; white-space: normal; }   .card .src-cur { color: var(--green); font-size: 9px; }   .card .src-dot { width: 9px; height: 9px; border-radius: 50%; flex: 0 0 9px; }   .card .src-dot.vendor { background: #00b8b1; }@@ -458,8 +479,8 @@   .card .orient-opt { cursor: pointer; border: 1px solid var(--border-muted); border-radius: 6px; padding: 4px; background: #404B55; text-align: center; transition: border-color .12s; }   .card .orient-opt:hover { border-color: var(--accent); }   .card .orient-opt.chosen { border-color: var(--green); box-shadow: 0 0 0 1px var(--green) inset; }-  .card .orient-opt img { width: 48px; height: 48px; object-fit: contain; display: block; margin: 0 auto 2px; }-  .card .orient-opt span { font-size: 9px; color: var(--muted); font-family: 'JetBrains Mono', monospace; }+  .card .orient-opt img { width: 130px; height: 130px; object-fit: contain; display: block; margin: 0 auto 2px; }+  .card .orient-opt span { font-size: 11px; color: var(--muted); font-family: 'JetBrains Mono', monospace; }   aside { background: var(--panel); border-left: 1px solid var(--border); padding: 20px; overflow-y: auto; }   aside.empty { display: flex; align-items: center; justify-content: center; color: var(--muted); }   /* Tabbed detail panel — keeps the 66-file list from dominating. */@@ -545,6 +566,7 @@     <span class="subject-name idle" id="subjectName">—</span>     <span class="state-pill" id="subjectPill"></span>     <span class="subject-meta" id="subjectMeta">+      <span class="sep">·</span><span id="metaCards" title="Each tile below is a “card” — one part's full CAD bundle. Tell the AI e.g. “open the B2B-PH-K-S card.”">0 cards</span>       <span class="sep">·</span><span id="metaFull">0 full</span>       <span class="sep">·</span><span id="metaStubs">0 stubs</span>       <span class="sep">·</span><span id="metaSize">0 MB</span>@@ -583,7 +605,7 @@   <button id="btn-creds" data-tooltip="Manage saved credentials for vendor portals (Component Search Engine, etc.).\nUsed to authenticate downloads from SnapEDA / Ultra Librarian / DigiKey when a chip needs a login.">Credentials</button>   <button id="btn-reveal-lib" data-tooltip="Reveal the chip-fetcher library folder in the VS Code Explorer sidebar.\nOpens ~/project/chip-fetcher/library/ so you can see every chip directory."><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="vertical-align:-2px;margin-right:4px"><path d="M3 7.5a2 2 0 0 1 2-2h4l2 2.5h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7.5z"/></svg>Library folder</button>   <button id="btn-arch" data-tooltip="Architecture map — every tool chip-fetcher depends on: local CLIs (run in your container), native Rust libraries, remote service containers (called over HTTPS), and the sub-apps you may also need to modify. Open this before editing chip-fetcher to understand the whole structure.">🗺 Architecture</button>-  <label class="auto-zoom-toggle" data-tooltip="When ON, the active chip's card auto-zooms to the foreground whenever the AI moves to a new chip.\nLets you watch live progress at readable size without clicking each card."><input type="checkbox" id="auto-zoom-cb" checked> auto-zoom</label>+  <label class="auto-zoom-toggle" data-tooltip="When ON, the active chip's card auto-zooms to the foreground whenever the AI moves to a new chip.\nLets you watch live progress at readable size without clicking each card."><input type="checkbox" id="auto-zoom-cb"> auto-zoom</label> </div>  <div class="project-tabs" id="project-tabs" style="display:none;padding:0 16px 6px;gap:6px;align-items:center">@@ -628,7 +650,7 @@   </div>   <div id="validation-view" style="display:none;grid-column:1/-1;padding:18px 20px;overflow:auto"></div>   <div id="sources-view" style="display:none;grid-column:1/-1;padding:18px 24px;overflow:auto"></div>-  <aside class="empty" id="detail">select a chip to see details</aside>+  <aside class="empty" id="detail">select a card to see details</aside> </main> <script> let LIB = null;@@ -726,6 +748,7 @@ function updateSubject() {     pillEl.removeAttribute('title');   }   if (LIB) {+    document.getElementById('metaCards').textContent = `${LIB.fully_count + LIB.stub_count} cards`;     document.getElementById('metaFull').textContent  = `${LIB.fully_count} full`;     document.getElementById('metaStubs').textContent = `${LIB.stub_count} stubs`;     document.getElementById('metaSize').textContent  = `${(LIB.total_size/1024/1024).toFixed(0)} MB`;@@ -760,7 +783,7 @@ async function refreshLibraryIfChanged() {   try {     const libR = await fetch(API_BASE + '/api/library');     const newLib = await libR.json();-    const hash = JSON.stringify(newLib.chips.map(c => [c.mpn, c.size_bytes, c.has_step, c.has_sym, c.has_mod, c.pdf_count, c.has_thumb_symbol, c.has_thumb_footprint, c.has_thumb_chip3d, c.validation_status]));+    const hash = JSON.stringify(newLib.chips.map(c => [c.mpn, c.size_bytes, c.has_step, c.has_sym, c.has_mod, c.pdf_count, c.has_thumb_symbol, c.sym_rev, c.has_thumb_footprint, c.has_thumb_chip3d, c.validation_status, c.pin1_signed, c.pin1_corner, c.fp_pin1_corner]));     if (hash !== LAST_LIB_HASH) {       LIB = newLib;       LAST_LIB_HASH = hash;@@ -1031,6 +1054,30 @@ function render() {     const ked = badgeForEda('KICAD',  c.has_sym, c.has_mod, c.info, c.file_provenance, c.mpn);     const alt = badgeForEda('ALTIUM', c.has_altium_sch, c.has_altium_pcb, c.info, c.file_provenance, c.mpn);     const fus = badgeForEda('FUSION', c.has_fusion_lbr, c.has_fusion_lbr, c.info, c.file_provenance, c.mpn); // Fusion ships sym+mod in one .lbr+    // Pin-1 alignment status from chipsmith: did the user line up the 3D chip's+    // pin-1 with the footprint's pin-1 and sign off? Only meaningful once both the+    // 3D model and the footprint exist.+    let pin1Badge = '';+    if (c.has_step && c.has_mod) {+      const fp = c.fp_pin1_corner || null;   // footprint's pin-1 corner = the target+      if (c.pin1_signed) {+        const corner = c.pin1_corner || '?';+        const when = c.pin1_signed_at ? ' (signed ' + String(c.pin1_signed_at).slice(0,10) + ')' : '';+        const meas = c.pin1_measure_ok === false ? '\nNote: chipsmith flagged a measurement mismatch.' : '';+        if (fp && corner !== '?' && corner !== fp) {+          pin1Badge = `<span class="b pin1bad" data-tooltip="Pin-1 MISMATCH${when}.\nThe 3D chip's pin-1 was signed off in the ${escapeHtml(corner)} corner, but the footprint's pin-1 is in the ${escapeHtml(fp)} corner. Re-align in chipsmith so the chip and footprint agree.${meas}">Pin-1 ⚠ chip ${escapeHtml(corner)} / fp ${escapeHtml(fp)}</span>`;+        } else {+          const agree = fp ? ' (matches the footprint)' : '';+          pin1Badge = `<span class="b pin1ok" data-tooltip="Pin-1 aligned + signed off in chipsmith${when}.\nPin-1 sits in the ${escapeHtml(corner)} corner${agree}; the 3D chip is lined up to the footprint. ${escapeHtml(c.mpn)}.chipsmith.step is the canonical oriented model downstream tools use.${meas}">Pin-1 ✓ ${escapeHtml(corner)}</span>`;+        }+      } else {+        const target = fp ? ' · fp ' + escapeHtml(fp) : '';+        const tip = fp+          ? `Pin-1 not yet aligned.\nThe footprint pin-1 is in the ${fp} corner (that's the target). Use the ‘Align pin-1’ button (opens chipsmith) to line up the 3D chip's pin-1 to it and sign off; chip-fetcher then shows the chip corner here + bakes ${escapeHtml(c.mpn)}.chipsmith.step.`+          : `Pin-1 not yet aligned.\nThe 3D chip and footprint are both here. Use the ‘Align pin-1’ button (opens chipsmith) to line up pin-1 and sign off.`;+        pin1Badge = `<span class="b pin1" data-tooltip="${tip}">Pin-1 ?${target}</span>`;+      }+    }     // Validation badge (concur status). Read straight from inventory.rs's     // validation_status field which we read from <MPN>-validation-status.json.     let valBadge = '';@@ -1082,25 +1129,8 @@ function render() {         </div>         <!-- Secondary row: 3D up-axis QA + the .lbr packaging action — smaller. -->         <div class="thumb-row secondary">-          <div class="thumb-3d-group">-            ${srcChipHtml(c)}-            ${renderThumb(c.mpn, 'chip3d',     c.has_thumb_chip3d,     'Z↑', { upAxis: 'z', chosen: c.chosen_up_axis, inferred: c.inferred_up_axis }, thumbErr(c, '3d'))}-            ${renderThumb(c.mpn, 'chip3d-yup', c.has_thumb_chip3d_yup, 'Y↑', { upAxis: 'y', chosen: c.chosen_up_axis, inferred: c.inferred_up_axis }, thumbErr(c, '3d'))}-            <button class="orient-more-btn" data-tooltip="Pick from all 6 face-up orientations.\nZ↑ and Y↑ cover 98% of chips. For unusual STEP files (WAGO connectors, through-hole parts), try X↑, X↓, Y↓, or Z↓. The chosen orientation is saved to info.json for downstream tools." data-mpn="${escapeHtml(c.mpn)}">▾</button>-            <div class="orient-popover" data-mpn="${escapeHtml(c.mpn)}" style="display:none">-              ${['xup','xdown','ydown','zdown'].map(o => {-                const kind = 'chip3d-' + o;-                const label = o.replace('up','↑').replace('down','↓').toUpperCase().replace('X','X ').replace('Y','Y ').replace('Z','Z ');-                const url = API_BASE + '/thumb/' + encodeURIComponent(c.mpn) + '/' + kind + '?t=' + Date.now();-                const isChosen = c.chosen_up_axis === o;-                return `<div class="orient-opt${isChosen?' chosen':''}" data-mpn="${escapeHtml(c.mpn)}" data-axis="${o}" data-tooltip="Mark the ${label} 3D rendering as the correct up-axis for this chip.">-                  <img src="${url}" onerror="this.parentElement.style.display='none'" />-                  <span>${label}</span>-                </div>`;-              }).join('')}-            </div>-          </div>-          ${(c.has_sym || c.has_mod) ? `<div class="thumb thumb-lbr" data-tooltip="Build a true EAGLE .lbr (symbol + footprint) for ${escapeHtml(c.mpn)} and open it in the live adom-lbr app — lints it for Fusion 360 / Altium / OrCAD, then one-click Send to your desktop EDA tool." data-thumb-mpn="${escapeHtml(c.mpn)}" data-thumb-kind="lbr"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 5.5A1.5 1.5 0 0 1 5.5 4H9a1.5 1.5 0 0 1 1.5 1.5V20a1.5 1.5 0 0 0-1.5-1.5H5.5A1.5 1.5 0 0 1 4 17z"/><path d="M20 5.5A1.5 1.5 0 0 0 18.5 4H15a1.5 1.5 0 0 0-1.5 1.5V20a1.5 1.5 0 0 1 1.5-1.5h3.5a1.5 1.5 0 0 0 1.5-1.5z"/></svg><span class="thumb-label">.lbr</span></div>` : ''}+          <div class="thumb-3d-group">${render3dGroup(c)}</div>+          ${(c.has_sym || c.has_mod) ? `<div class="thumb thumb-lbr" data-tooltip="Build a true EAGLE .lbr (symbol + footprint) for ${escapeHtml(c.mpn)} and open it in the live adom-lbr app — the ONE EAGLE library that Fusion 360 / Altium / OrCAD all import directly. Lints it, then one-click Send to your desktop EDA tool.&#10;&#10;KiCad isn't in that list on purpose: KiCad can't read .lbr — it uses its own native .kicad_sym + .kicad_mod, which you already have (the KICAD capsule / Send → KiCad)." data-thumb-mpn="${escapeHtml(c.mpn)}" data-thumb-kind="lbr"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 5.5A1.5 1.5 0 0 1 5.5 4H9a1.5 1.5 0 0 1 1.5 1.5V20a1.5 1.5 0 0 0-1.5-1.5H5.5A1.5 1.5 0 0 1 4 17z"/><path d="M20 5.5A1.5 1.5 0 0 0 18.5 4H15a1.5 1.5 0 0 0-1.5 1.5V20a1.5 1.5 0 0 1 1.5-1.5h3.5a1.5 1.5 0 0 0 1.5-1.5z"/></svg><span class="thumb-label">.lbr</span></div>` : ''}         </div>       </div>       <div class="badges">@@ -1113,8 +1143,9 @@ function render() {         ${ked}         ${alt}         ${fus}-        <span class="b ${c.pdf_count>0?'pdf':'miss'}${c.has_pdf ? ' clickable' : ''}" data-tooltip="${c.pdf_count>0?c.pdf_count+' datasheet PDF'+(c.pdf_count>1?'s':'')+' in the chip directory.\nClick to open in the PDF viewer.':'No datasheet PDF yet.\nUse ⤓ → Manufacturer → Datasheet PDF to fetch it.'}" ${c.has_pdf ? `data-thumb-mpn="${escapeHtml(c.mpn)}" data-thumb-kind="datasheet"` : ''}>${c.pdf_count>0 ? c.pdf_count : '?'} PDF${c.pdf_count===1?'':'s'}</span>-        <span class="b src ${tier}" data-tooltip="${(function(){var r=resolveStepSource(c);return r.found?'STEP source: '+r.label+'.\nThis is where the 3D model came from. Ladder priority: Mfr → SnapMagic → Mouser → DigiKey → Arrow → CSE/UL → LCSC.':'STEP source '+r.label+'.\nchip-fetcher downloaded this STEP but did not record where from — refetch to capture it.';})()}">${escapeHtml((function(){var r=resolveStepSource(c);return r.found?srcShort(r.label):'src ?';})())}</span>+        <span class="b ${c.pdf_count>0?'pdf':'miss'}${c.has_pdf ? ' clickable' : ''}" data-tooltip="${c.pdf_count>0?'Source: ' + fileSource(c, /\.pdf$/i).label + '\n\n' + c.pdf_count+' datasheet PDF'+(c.pdf_count>1?'s':'')+' in the chip directory.\nClick to open in the PDF viewer.':'No datasheet PDF yet.\nUse ⤓ → Manufacturer → Datasheet PDF to fetch it.'}" ${c.has_pdf ? `data-thumb-mpn="${escapeHtml(c.mpn)}" data-thumb-kind="datasheet"` : ''}>${c.pdf_count>0 ? c.pdf_count : '?'} PDF${c.pdf_count===1?'':'s'}</span>+        <span class="b src ${tier}" data-tooltip="${(function(){var r=resolveStepSource(c);return r.found?'STEP source: '+r.label+'.\nThis is where the 3D model came from. Ladder priority: Mfr → SnapMagic → Mouser → DigiKey → Arrow → CSE/UL → LCSC.':'STEP source '+r.label+'.\nchip-fetcher downloaded this STEP but did not record where from. Refetch to capture it.';})()}">${escapeHtml((function(){var r=resolveStepSource(c);return r.found?srcShort(r.label):'src ?';})())}</span>+        ${pin1Badge}       </div>     `;     card.onclick = (ev) => {@@ -1465,6 +1496,63 @@ function thumbErr(c, key) {   return (e && typeof e === 'object' && e[key]) ? String(e[key]) : null; } +// The six face-up orientations: thumbnail kind + label per axis token.+function axis3dInfo(axis) {+  return ({+    z:     { kind: 'chip3d',        label: 'Z↑' },+    y:     { kind: 'chip3d-yup',    label: 'Y↑' },+    xup:   { kind: 'chip3d-xup',    label: 'X↑' },+    xdown: { kind: 'chip3d-xdown',  label: 'X↓' },+    ydown: { kind: 'chip3d-ydown',  label: 'Y↓' },+    zdown: { kind: 'chip3d-zdown',  label: 'Z↓' },+  })[axis] || { kind: 'chip3d-' + axis, label: axis };+}++// The 3D thumbnail group. Slot 1 is always Z↑; slot 2 shows the CHOSEN+// orientation whenever it isn't z/y — so picking X↑/X↓/Y↓/Z↓ from the dropdown+// SWAPS that render into the visible main view (marked ✓), instead of the choice+// hiding inside the closed dropdown. The dropdown lists the other four.+function render3dGroup(c) {+  const chosen = c.chosen_up_axis;+  const slot2 = (chosen && chosen !== 'z' && chosen !== 'y') ? chosen : 'y';+  const mainAxes = ['z', slot2];+  const dropAxes = ['y', 'xup', 'xdown', 'ydown', 'zdown'].filter(a => a !== slot2);+  const present = ax => ax === 'z' ? c.has_thumb_chip3d : ax === 'y' ? c.has_thumb_chip3d_yup : true;+  const main = mainAxes.map(ax => {+    const ai = axis3dInfo(ax);+    return renderThumb(c.mpn, ai.kind, present(ax), ai.label,+      { upAxis: ax, chosen: chosen, inferred: c.inferred_up_axis }, thumbErr(c, '3d'));+  }).join('');+  const drop = dropAxes.map(o => {+    const ai = axis3dInfo(o);+    const url = API_BASE + '/thumb/' + encodeURIComponent(c.mpn) + '/' + ai.kind + '?t=' + Date.now();+    const isChosen = chosen === o;+    return `<div class="orient-opt${isChosen ? ' chosen' : ''}" data-mpn="${escapeHtml(c.mpn)}" data-axis="${o}" data-tooltip="Mark the ${ai.label} 3D rendering as the correct up-axis — it swaps into the main view.">+      <img src="${url}" onerror="this.parentElement.style.display='none'" /><span>${ai.label}</span></div>`;+  }).join('');+  return `${srcChipHtml(c)}+    ${main}+    <button class="orient-more-btn" data-tooltip="Pick from all 6 face-up orientations. The one you pick swaps into the main view and is marked ✓. Saved to info.json for downstream tools." data-mpn="${escapeHtml(c.mpn)}">▾</button>+    <div class="orient-popover" data-mpn="${escapeHtml(c.mpn)}" style="display:none">${drop}</div>`;+}++// Tracks which chips' SYM tiles are mid-regen (after an up-axis switch). Keyed+// by the symbol revision (mtime of <mpn>-symbol.svg) captured at click-time: the+// spinner clears itself the moment a newer sym_rev arrives on a poll — or after a+// 95s safety cap so a failed regen can't spin forever.+const SYM_REFRESHING = new Map(); // mpn -> { at, rev }+function chipByMpn(mpn) { return ((LIB && LIB.chips) || []).find(x => x.mpn === mpn); }+function markSymRefreshing(mpn) {+  SYM_REFRESHING.set(mpn, { at: Date.now(), rev: (chipByMpn(mpn) || {}).sym_rev });+}+function isSymRefreshing(mpn) {+  const e = SYM_REFRESHING.get(mpn);+  if (!e) return false;+  const c = chipByMpn(mpn);+  if (Date.now() - e.at > 95000 || (c && c.sym_rev !== e.rev)) { SYM_REFRESHING.delete(mpn); return false; }+  return true;+}+ function renderThumb(mpn, kind, present, label, orient, errMsg, sources) {   if (!present) {     // Distinguish a FAILED render (chip-thumbnailer errored) from one that was@@ -1493,7 +1581,7 @@ function renderThumb(mpn, kind, present, label, orient, errMsg, sources) {   let orientAttrs = '';   let orientCheck = '';   let titleSuffix = '';-  if (orient && (kind === 'chip3d' || kind === 'chip3d-yup')) {+  if (orient && kind.indexOf('chip3d') === 0) {     const isActive = orient.chosen === orient.upAxis;     if (isActive) {       orientAttrs = ' data-orient-active="true"';@@ -1525,10 +1613,14 @@ function renderThumb(mpn, kind, present, label, orient, errMsg, sources) {   // tooltip) so they don't fight the source-carat menu sitting on them.   const noHover = (kind === 'symbol' || kind === 'footprint');   const tipAttr = noHover ? '' : ` data-tooltip="${escapeHtml(openTip + titleSuffix)}"`;-  const thumbHtml = `<div class="thumb${noHover ? ' thumb-static' : ''}"${tipAttr} data-thumb-mpn="${escapeHtml(mpn)}" data-thumb-kind="${kind}"${orientAttrs}>+  // SYM tile shows a spinner while its outline+symbol regenerate (up-axis switch).+  const refreshing = (kind === 'symbol' && isSymRefreshing(mpn));+  const spinner = refreshing ? '<span class="thumb-spinner" aria-label="re-rendering symbol"></span>' : '';+  const thumbHtml = `<div class="thumb${noHover ? ' thumb-static' : ''}${refreshing ? ' refreshing' : ''}"${tipAttr} data-thumb-mpn="${escapeHtml(mpn)}" data-thumb-kind="${kind}"${orientAttrs}>     <img src="${src}" alt="${escapeHtml(mpn)} ${escapeHtml(label)}" loading="lazy" ${onerr}>     <span class="thumb-label">${escapeHtml(label)}</span>     ${orientCheck}+    ${spinner}   </div>`;   // Symbol/footprint provenance carat: an always-visible, tappable source chip   // (works on touch) + a popover listing every method that produced a variant.@@ -1562,6 +1654,11 @@ function srcCarat(mpn, kind, sources) {  async function setOrientation(mpn, axis) {   try {+    // Show the spinner INSTANTLY (before the round-trip) — the server regenerates+    // the outline+symbol in the background, so the click returns right away and+    // the user keeps working while the SYM tile spins.+    markSymRefreshing(mpn);+    render();     const r = await fetch(API_BASE + '/api/orientation', {       method: 'POST',       headers: {'Content-Type': 'application/json'},@@ -1569,14 +1666,29 @@ async function setOrientation(mpn, axis) {     });     const j = await r.json().catch(() => ({}));     if (!r.ok || !j.ok) {+      SYM_REFRESHING.delete(mpn);       alert('Could not set orientation: ' + (j.error || ('HTTP ' + r.status)));+      render();       return;     }     const libR = await fetch(API_BASE + '/api/library');     LIB = await libR.json();     LAST_LIB_HASH = '';     render();+    // Poll just until the re-rendered symbol lands (sym_rev bumps) — then the+    // spinner self-clears. Capped so a failed regen can't poll forever.+    const poll = setInterval(async () => {+      if (!isSymRefreshing(mpn)) { clearInterval(poll); render(); return; }+      try {+        const lr = await fetch(API_BASE + '/api/library');+        LIB = await lr.json();+        LAST_LIB_HASH = '';+        render();+      } catch (e) { /* transient; keep polling */ }+    }, 2500);   } catch (e) {+    SYM_REFRESHING.delete(mpn);+    render();     alert('Could not reach /api/orientation: ' + e.message);   } }@@ -1636,6 +1748,10 @@ document.addEventListener('click', (ev) => {     return;   }   document.querySelectorAll('.src-popover').forEach(p => { if (!p.contains(ev.target)) p.style.display = 'none'; });+  // Click anywhere outside an open orientation dropdown closes it.+  document.querySelectorAll('.orient-popover').forEach(p => {+    if (!p.contains(ev.target) && !(ev.target.closest && ev.target.closest('.orient-more-btn'))) p.style.display = 'none';+  }); });  // Lightweight toast (bottom-center). Optional href makes it a tappable link.@@ -1747,58 +1863,87 @@ function srcChipHtml(c) {          '<span class="src3d-dot"></span>' + escapeHtml(short) + '</div>'; } -function resolveStepSource(c) {-  var mpn = c.mpn, fp = c.file_provenance, info = c.info || {};-  // 1) per-file provenance for <mpn>.step+// Resolve the BEST recorded source for the file(s) matching `extRe`, the SAME+// way for every capsule: per-file provenance (who made it / where found) →+// optional `fallbackField` on the chip → the sourcing-chain entry that+// succeeded. Returns { label, found }; found=false = never recorded.+function fileSource(c, extRe, fallbackField, opts) {+  opts = opts || {};+  var fp = c.file_provenance, info = c.info || {};+  function siteOf(url) { try { return (new URL(url)).hostname.replace(/^www\./, ''); } catch (e) { return ''; } }+  var MFR = function (s) { return s === 'manufacturer' || s === 'mfr' || s === 'vendor'; };+  var KIC = function (s) { return /^kicad(-library|_community)?$/.test(s); };+  // Aggregators / CAD libraries — NEVER "the manufacturer". A datasheet found on+  // one of these did NOT come from the maker's own site.+  var AGG = function (s) { return KIC(s) || ['cse', 'componentsearch', 'cse_ul', 'samacsys',+    'snapeda', 'snapmagic', 'ultralibrarian', 'ul', 'mouser', 'digikey', 'arrow', 'lcsc'].indexOf(s) > -1; };+  function labelFor(e) {+    var made = (e.content_origin || '').toLowerCase(), found = (e.discovery_source || '').toLowerCase();+    var site = siteOf(e.url || '');+    if (made === 'unknown') made = '';+    if (found === 'unknown') found = '';+    // A datasheet counts as "from the manufacturer" only when it was DISCOVERED on+    // the maker's own site — never via a CAD library / aggregator (CSE may host+    // the maker's PDF, but that's not downloading it from the manufacturer).+    if (opts.requireManufacturer && (AGG(found) || AGG(made))) return null;+    // The Adom Wiki — a reused, Adom-vetted published bundle.+    if (made === 'adom-wiki' || found === 'adom-wiki') return 'Adom Wiki' + (site ? ' · ' + site : '');+    // NOTE: we intentionally do NOT surface the internal `derived` flag here.+    // "derived" just means this provenance record was reconstructed after a bad+    // wholesale tag overwrote it — it does NOT mean the source is a guess. The+    // attribution itself (e.g. the maker's own site) is genuine, so showing+    // "(derived)" only made users doubt a real manufacturer source.+    if (KIC(made) || KIC(found)) return 'KiCad standard library';+    // Manufacturer-direct: NAME the site from the URL — never a vague "direct site".+    if (MFR(made) || MFR(found)) return site ? 'manufacturer · ' + site : 'manufacturer (site unrecorded)';+    if (site && (made || found)) return (made || found) + ' · ' + site;+    if (made || found) {+      var lbl = made || found;+      if (made && found && found !== made) lbl = made + ' (via ' + found + ')';+      return lbl;+    }+    return site || '';+  }   if (fp && typeof fp === 'object') {     for (var fn in fp) {-      if (!/\.step$/i.test(fn)) continue;-      var e = fp[fn] || {};-      var made = e.content_origin || '', found = e.discovery_source || '';-      if (made === 'unknown') made = '';-      if (found === 'unknown') found = '';-      // The KiCad standard library is one source, not "kicad (via kicad-library)".-      var kic = function(s){ return /^kicad(-library|_community)?$/.test(s); };-      if (kic(made) || kic(found)) {-        return { label: 'KiCad standard library' + (e.derived ? ' (derived)' : ''), found: true };-      }-      if (made || found) {-        var lbl = made || found;-        if (made && found && found !== made) lbl = made + ' (via ' + found + ')';-        if (e.derived) lbl += ' (derived)';   // backfilled, not recorded at fetch-        return { label: lbl, found: true };-      }+      if (!extRe.test(fn)) continue;+      var l = labelFor(fp[fn] || {});+      if (l) return { label: l, found: true };   // null = mis-tagged source, keep looking     }   }-  // 2) explicit step_source field-  var ss = (c.step_source || '').trim();-  if (ss && ss !== '—' && ss.toLowerCase() !== 'none') return { label: ss, found: true };-  // 3) the chain entry that actually succeeded-  var chain = info.fetched_via_chain || [];-  for (var i = 0; i < chain.length; i++) {-    var o = (chain[i].outcome || '').toLowerCase();-    if (o.indexOf('ok') > -1 || o.indexOf('success') > -1 || o === 'got it') {-      return { label: chain[i].source || '?', found: true };+  if (fallbackField && !opts.requireManufacturer) {+    var v = (c[fallbackField] || '').trim();+    if (v && v !== '—' && v.toLowerCase() !== 'none') return { label: v, found: true };+  }+  if (!opts.requireManufacturer) {+    var chain = info.fetched_via_chain || [];+    for (var i = 0; i < chain.length; i++) {+      var o = (chain[i].outcome || '').toLowerCase();+      if (o.indexOf('ok') > -1 || o.indexOf('success') > -1 || o === 'got it') return { label: chain[i].source || '?', found: true };     }   }+  if (opts.requireManufacturer) return { label: 'NOT from the manufacturer yet — refetch the datasheet from the maker’s site', found: false };   return { label: 'not recorded — refetch to capture it', found: false }; }+function resolveStepSource(c) { return fileSource(c, /\.step$/i, 'step_source'); }  // One badge per EDA tool: green if both halves present, amber if partial, // grey "?" if missing. Fusion uses one .lbr containing both sym+mod, so // callers pass the same flag twice for that tool. function badgeForEda(label, hasSym, hasMod, info, fileProv, mpn) {   const fullName = {KICAD:'KiCad',ALTIUM:'Altium',FUSION:'Fusion 360'}[label]||label;-  var prov = provenanceSummary(info, fileProv, mpn);+  const extRe = {KICAD:/\.kicad_(sym|mod)$/i, ALTIUM:/\.(SchLib|PcbLib|IntLib)$/i, FUSION:/\.lbr$/i}[label] || /$.^/;+  // Lead with WHERE these library files came from (same resolver as STEP/PDF).+  const src = (hasSym || hasMod) ? 'Source: ' + fileSource({file_provenance: fileProv, info: info}, extRe).label + '\n\n' : '';   if (hasSym && hasMod) {-    return `<span class="b ${label.toLowerCase()}" data-tooltip="${fullName} schematic symbol + PCB footprint both present.\nThis chip has a complete ${fullName} library — ready for import into your EDA tool.${prov}">${label}</span>`;+    return `<span class="b ${label.toLowerCase()}" data-tooltip="${src}${fullName} schematic symbol + PCB footprint both present — a complete ${fullName} library, ready for import into your EDA tool.">${label}</span>`;   }   if (hasSym || hasMod) {     const has = hasSym ? 'schematic symbol' : 'PCB footprint';     const miss = hasSym ? 'PCB footprint' : 'schematic symbol';-    return `<span class="b partial-eda" data-tooltip="${fullName}: has ${has} but missing ${miss}.\nPartial library — use the refetch button to get the missing piece.${prov}">${label} ${hasSym?'sym':'fp'}</span>`;+    return `<span class="b partial-eda" data-tooltip="${src}${fullName}: has ${has} but missing ${miss}.\nPartial library — use the refetch button to get the missing piece.">${label} ${hasSym?'sym':'fp'}</span>`;   }-  return `<span class="b miss" data-tooltip="${fullName} schematic symbol + PCB footprint both missing.\nNo ${fullName} library files fetched yet.${prov}">${label} ?</span>`;+  return `<span class="b miss" data-tooltip="${fullName} schematic symbol + PCB footprint both missing.\nNo ${fullName} library files fetched yet.">${label} ?</span>`; }  async function select(mpn) {@@ -1873,7 +2018,7 @@ async function select(mpn) {           ${c.has_mod ? `<button class="openbtn" data-kind="footprint" data-tooltip="Open ${escapeHtml(c.mpn)}'s PCB footprint in the live adom-footprint app (layers + InstaPCB solder-paste dots).">adom-footprint</button>` : ''}           ${(c.has_sym || c.has_mod) ? `<button class="openbtn" data-kind="lbr" data-tooltip="Build + open a true EAGLE .lbr (symbol + footprint) in adom-lbr.">adom-lbr</button>` : ''}           ${c.has_step ? `<button class="openbtn" data-kind="chip3d" data-tooltip="Orbit the real 3D STEP model in the adom-step viewer.">adom-3D</button>` : ''}-          ${c.has_step ? `<button class="openbtn" data-kind="chipsmith" data-tooltip="Open in chipsmith — STEP-native pin/footprint detection + sign-off.">chipsmith</button>` : ''}+          ${(c.has_step && c.has_mod) ? `<button class="openbtn" data-kind="chipsmith" data-tooltip="Align pin-1 in chipsmith.\nOpens the 3D STEP + footprint together so you can line up the chip's pin-1 with the footprint's pin-1, then sign off. chip-fetcher reads the result back: the Pin-1 ${c.pin1_signed ? '✓' : '?'} badge + a baked ${escapeHtml(c.mpn)}.chipsmith.step.">${c.pin1_signed ? 'Pin-1 ✓' : 'Align pin-1'}</button>` : (c.has_step ? `<button class="openbtn" data-kind="chipsmith" data-tooltip="Open in chipsmith (STEP-native detection + sign-off). A footprint (.kicad_mod) is needed to align pin-1.">chipsmith</button>` : '')}           ${(c.symbol_sources||[]).some(s=>s.method==='ds2sf') ? `<button class="openbtn" data-kind="ds2sf" data-tooltip="View ds2sf's datasheet-extracted symbol (opens adom-symbol on the ds2sf source).">ds2sf extraction</button>` : ''}         </div>       </div>