app
Chip Fetcher (cf)
Public Made by Adomby adom
Crawls the web on your behalf to gather everything about the chips you care about: datasheets, symbols, footprints, 3D models, stock and pricing, straight from the manufacturer first, into your EDA tool. Say "cf" to your AI.
← Commit history
v1.1.0: wiki-as-part-source, pin-1 round-trip (chipsmith sign-off + footprint corner), adom-lbr BOM contract, symbol axis regen, refreshed README + hero v5
21 files changed
+1311−185
adom-chip-fetcher-hero-v5.pngchip-fetcher-stack.pngdashboard-pin1.pngpage.json+1−1playbooks/jst.md+96playbooks/symbol-axis-regen.md+44playbooks/wiki-bundle-reuse.md+82src/bom.rs+75src/cli.rs+121src/deliver.rs+4src/hints.rs+1src/import.rs+132src/inventory.rs+106−5src/main.rs+1src/provenance.rs+6−2src/serve.rs+29−1src/sources/mod.rs+1src/sources/wiki.rs+371src/sourcing.rs+7src/thumbnails.rs+20−107src/ui/index.html+214−69adom-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. 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>