app
Adom DS2SF - Datasheet to Symbol and Footprint
Public Made by Adomby adom
Datasheet to symbol + footprint + provenance JSON, with a live dashboard: pins, groups, land pattern, and the datasheet page every fact came from.
← Commit history
Upload full source (MIT) — open source under adom org
18 files changed
+4748−586
.gitignoreadded+3@@ -0,0 +1,3 @@+target/+*.swp+.DS_Store
Cargo.lockadded+893@@ -0,0 +1,893 @@+# This file is automatically @generated by Cargo.+# It is not intended for manual editing.+version = 4++[[package]]+name = "adler2"+version = "2.0.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"++[[package]]+name = "adom-ds2sf"+version = "0.8.1"+dependencies = [+ "anyhow",+ "clap",+ "serde",+ "serde_json",+ "sha2",+ "ureq",+]++[[package]]+name = "anstream"+version = "1.0.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"+dependencies = [+ "anstyle",+ "anstyle-parse",+ "anstyle-query",+ "anstyle-wincon",+ "colorchoice",+ "is_terminal_polyfill",+ "utf8parse",+]++[[package]]+name = "anstyle"+version = "1.0.14"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"++[[package]]+name = "anstyle-parse"+version = "1.0.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"+dependencies = [+ "utf8parse",+]++[[package]]+name = "anstyle-query"+version = "1.1.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"+dependencies = [+ "windows-sys 0.61.2",+]++[[package]]+name = "anstyle-wincon"+version = "3.0.11"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"+dependencies = [+ "anstyle",+ "once_cell_polyfill",+ "windows-sys 0.61.2",+]++[[package]]+name = "anyhow"+version = "1.0.102"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"++[[package]]+name = "base64"+version = "0.22.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"++[[package]]+name = "block-buffer"+version = "0.10.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"+dependencies = [+ "generic-array",+]++[[package]]+name = "cc"+version = "1.2.61"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d"+dependencies = [+ "find-msvc-tools",+ "shlex",+]++[[package]]+name = "cfg-if"+version = "1.0.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"++[[package]]+name = "clap"+version = "4.6.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"+dependencies = [+ "clap_builder",+ "clap_derive",+]++[[package]]+name = "clap_builder"+version = "4.6.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"+dependencies = [+ "anstream",+ "anstyle",+ "clap_lex",+ "strsim",+]++[[package]]+name = "clap_derive"+version = "4.6.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"+dependencies = [+ "heck",+ "proc-macro2",+ "quote",+ "syn",+]++[[package]]+name = "clap_lex"+version = "1.1.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"++[[package]]+name = "colorchoice"+version = "1.0.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"++[[package]]+name = "cpufeatures"+version = "0.2.17"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"+dependencies = [+ "libc",+]++[[package]]+name = "crc32fast"+version = "1.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"+dependencies = [+ "cfg-if",+]++[[package]]+name = "crypto-common"+version = "0.1.7"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"+dependencies = [+ "generic-array",+ "typenum",+]++[[package]]+name = "digest"+version = "0.10.7"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"+dependencies = [+ "block-buffer",+ "crypto-common",+]++[[package]]+name = "displaydoc"+version = "0.2.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"+dependencies = [+ "proc-macro2",+ "quote",+ "syn",+]++[[package]]+name = "find-msvc-tools"+version = "0.1.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"++[[package]]+name = "flate2"+version = "1.1.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"+dependencies = [+ "crc32fast",+ "miniz_oxide",+]++[[package]]+name = "form_urlencoded"+version = "1.2.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"+dependencies = [+ "percent-encoding",+]++[[package]]+name = "generic-array"+version = "0.14.7"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"+dependencies = [+ "typenum",+ "version_check",+]++[[package]]+name = "getrandom"+version = "0.2.17"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"+dependencies = [+ "cfg-if",+ "libc",+ "wasi",+]++[[package]]+name = "heck"+version = "0.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"++[[package]]+name = "icu_collections"+version = "2.2.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"+dependencies = [+ "displaydoc",+ "potential_utf",+ "utf8_iter",+ "yoke",+ "zerofrom",+ "zerovec",+]++[[package]]+name = "icu_locale_core"+version = "2.2.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"+dependencies = [+ "displaydoc",+ "litemap",+ "tinystr",+ "writeable",+ "zerovec",+]++[[package]]+name = "icu_normalizer"+version = "2.2.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"+dependencies = [+ "icu_collections",+ "icu_normalizer_data",+ "icu_properties",+ "icu_provider",+ "smallvec",+ "zerovec",+]++[[package]]+name = "icu_normalizer_data"+version = "2.2.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"++[[package]]+name = "icu_properties"+version = "2.2.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"+dependencies = [+ "icu_collections",+ "icu_locale_core",+ "icu_properties_data",+ "icu_provider",+ "zerotrie",+ "zerovec",+]++[[package]]+name = "icu_properties_data"+version = "2.2.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"++[[package]]+name = "icu_provider"+version = "2.2.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"+dependencies = [+ "displaydoc",+ "icu_locale_core",+ "writeable",+ "yoke",+ "zerofrom",+ "zerotrie",+ "zerovec",+]++[[package]]+name = "idna"+version = "1.1.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"+dependencies = [+ "idna_adapter",+ "smallvec",+ "utf8_iter",+]++[[package]]+name = "idna_adapter"+version = "1.2.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"+dependencies = [+ "icu_normalizer",+ "icu_properties",+]++[[package]]+name = "is_terminal_polyfill"+version = "1.70.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"++[[package]]+name = "itoa"+version = "1.0.18"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"++[[package]]+name = "libc"+version = "0.2.186"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"++[[package]]+name = "litemap"+version = "0.8.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"++[[package]]+name = "log"+version = "0.4.29"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"++[[package]]+name = "memchr"+version = "2.8.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"++[[package]]+name = "miniz_oxide"+version = "0.8.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"+dependencies = [+ "adler2",+ "simd-adler32",+]++[[package]]+name = "once_cell"+version = "1.21.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"++[[package]]+name = "once_cell_polyfill"+version = "1.70.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"++[[package]]+name = "percent-encoding"+version = "2.3.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"++[[package]]+name = "potential_utf"+version = "0.1.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"+dependencies = [+ "zerovec",+]++[[package]]+name = "proc-macro2"+version = "1.0.106"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"+dependencies = [+ "unicode-ident",+]++[[package]]+name = "quote"+version = "1.0.45"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"+dependencies = [+ "proc-macro2",+]++[[package]]+name = "ring"+version = "0.17.14"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"+dependencies = [+ "cc",+ "cfg-if",+ "getrandom",+ "libc",+ "untrusted",+ "windows-sys 0.52.0",+]++[[package]]+name = "rustls"+version = "0.23.40"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"+dependencies = [+ "log",+ "once_cell",+ "ring",+ "rustls-pki-types",+ "rustls-webpki",+ "subtle",+ "zeroize",+]++[[package]]+name = "rustls-pki-types"+version = "1.14.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"+dependencies = [+ "zeroize",+]++[[package]]+name = "rustls-webpki"+version = "0.103.13"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"+dependencies = [+ "ring",+ "rustls-pki-types",+ "untrusted",+]++[[package]]+name = "serde"+version = "1.0.228"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"+dependencies = [+ "serde_core",+ "serde_derive",+]++[[package]]+name = "serde_core"+version = "1.0.228"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"+dependencies = [+ "serde_derive",+]++[[package]]+name = "serde_derive"+version = "1.0.228"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"+dependencies = [+ "proc-macro2",+ "quote",+ "syn",+]++[[package]]+name = "serde_json"+version = "1.0.149"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"+dependencies = [+ "itoa",+ "memchr",+ "serde",+ "serde_core",+ "zmij",+]++[[package]]+name = "sha2"+version = "0.10.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"+dependencies = [+ "cfg-if",+ "cpufeatures",+ "digest",+]++[[package]]+name = "shlex"+version = "1.3.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"++[[package]]+name = "simd-adler32"+version = "0.3.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"++[[package]]+name = "smallvec"+version = "1.15.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"++[[package]]+name = "stable_deref_trait"+version = "1.2.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"++[[package]]+name = "strsim"+version = "0.11.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"++[[package]]+name = "subtle"+version = "2.6.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"++[[package]]+name = "syn"+version = "2.0.117"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"+dependencies = [+ "proc-macro2",+ "quote",+ "unicode-ident",+]++[[package]]+name = "synstructure"+version = "0.13.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"+dependencies = [+ "proc-macro2",+ "quote",+ "syn",+]++[[package]]+name = "tinystr"+version = "0.8.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"+dependencies = [+ "displaydoc",+ "zerovec",+]++[[package]]+name = "typenum"+version = "1.20.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"++[[package]]+name = "unicode-ident"+version = "1.0.24"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"++[[package]]+name = "untrusted"+version = "0.9.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"++[[package]]+name = "ureq"+version = "2.12.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"+dependencies = [+ "base64",+ "flate2",+ "log",+ "once_cell",+ "rustls",+ "rustls-pki-types",+ "serde",+ "serde_json",+ "url",+ "webpki-roots 0.26.11",+]++[[package]]+name = "url"+version = "2.5.8"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"+dependencies = [+ "form_urlencoded",+ "idna",+ "percent-encoding",+ "serde",+]++[[package]]+name = "utf8_iter"+version = "1.0.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"++[[package]]+name = "utf8parse"+version = "0.2.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"++[[package]]+name = "version_check"+version = "0.9.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"++[[package]]+name = "wasi"+version = "0.11.1+wasi-snapshot-preview1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"++[[package]]+name = "webpki-roots"+version = "0.26.11"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"+dependencies = [+ "webpki-roots 1.0.7",+]++[[package]]+name = "webpki-roots"+version = "1.0.7"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d"+dependencies = [+ "rustls-pki-types",+]++[[package]]+name = "windows-link"+version = "0.2.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"++[[package]]+name = "windows-sys"+version = "0.52.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"+dependencies = [+ "windows-targets",+]++[[package]]+name = "windows-sys"+version = "0.61.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"+dependencies = [+ "windows-link",+]++[[package]]+name = "windows-targets"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"+dependencies = [+ "windows_aarch64_gnullvm",+ "windows_aarch64_msvc",+ "windows_i686_gnu",+ "windows_i686_gnullvm",+ "windows_i686_msvc",+ "windows_x86_64_gnu",+ "windows_x86_64_gnullvm",+ "windows_x86_64_msvc",+]++[[package]]+name = "windows_aarch64_gnullvm"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"++[[package]]+name = "windows_aarch64_msvc"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"++[[package]]+name = "windows_i686_gnu"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"++[[package]]+name = "windows_i686_gnullvm"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"++[[package]]+name = "windows_i686_msvc"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"++[[package]]+name = "windows_x86_64_gnu"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"++[[package]]+name = "windows_x86_64_gnullvm"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"++[[package]]+name = "windows_x86_64_msvc"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"++[[package]]+name = "writeable"+version = "0.6.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"++[[package]]+name = "yoke"+version = "0.8.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca"+dependencies = [+ "stable_deref_trait",+ "yoke-derive",+ "zerofrom",+]++[[package]]+name = "yoke-derive"+version = "0.8.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"+dependencies = [+ "proc-macro2",+ "quote",+ "syn",+ "synstructure",+]++[[package]]+name = "zerofrom"+version = "0.1.7"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df"+dependencies = [+ "zerofrom-derive",+]++[[package]]+name = "zerofrom-derive"+version = "0.1.7"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"+dependencies = [+ "proc-macro2",+ "quote",+ "syn",+ "synstructure",+]++[[package]]+name = "zeroize"+version = "1.8.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"++[[package]]+name = "zerotrie"+version = "0.2.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"+dependencies = [+ "displaydoc",+ "yoke",+ "zerofrom",+]++[[package]]+name = "zerovec"+version = "0.11.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"+dependencies = [+ "yoke",+ "zerofrom",+ "zerovec-derive",+]++[[package]]+name = "zerovec-derive"+version = "0.11.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"+dependencies = [+ "proc-macro2",+ "quote",+ "syn",+]++[[package]]+name = "zmij"+version = "1.0.21"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
Cargo.tomladded+22@@ -0,0 +1,22 @@+[package]+name = "adom-ds2sf"+version = "0.8.1"+edition = "2021"+description = "Datasheet → symbol + footprint + provenance JSON. Reads a chip's datasheet PDF and produces sym_create-shaped symbol JSON, footprint metadata, and a field-level provenance audit trail."++[[bin]]+name = "adom-ds2sf"+path = "src/main.rs"++[dependencies]+anyhow = "1"+clap = { version = "4", features = ["derive"] }+serde = { version = "1", features = ["derive"] }+serde_json = "1"+sha2 = "0.10"+ureq = { version = "2", features = ["json"] }++[profile.release]+opt-level = "z"+lto = "thin"+strip = true
LICENSE+15−3@@ -2,8 +2,20 @@ MIT License Copyright (c) 2026 Adom Industries Inc. -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:+Permission is hereby granted, free of charge, to any person obtaining a copy+of this software and associated documentation files (the "Software"), to deal+in the Software without restriction, including without limitation the rights+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell+copies of the Software, and to permit persons to whom the Software is+furnished to do so, subject to the following conditions: -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.+The above copyright notice and this permission notice shall be included in all+copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE+SOFTWARE.
README.md+115−545@@ -1,605 +1,175 @@-# adom-ds2sf+# ds2sf -**Datasheet â symbol + footprint + provenance. Read a chip's PDF, extract its pins and pads, and emit structured JSON the rest of the Adom EDA tools build on.**+**Datasheet → symbol + footprint + provenance JSON.** -adom-ds2sf ("datasheet to symbol & footprint") reads a manufacturer datasheet PDF and extracts the part's electrical interface â pin names, numbers, types, and logical groups (POWER, GROUND, GPIO split by port, DEBUGâ¦) â plus the land pattern when the datasheet provides one, into `<MPN>-symbol.extracted.json` and `<MPN>-footprint.extracted.json` with **per-field provenance**: every pin and every dimension records the datasheet page it came from. It's the source that lets **adom-symbol** auto-generate a clean, balanced, grouped symbol straight from the datasheet â independent of any vendor's CAD draw.+A small Rust CLI that runs downstream of `chip-fetcher`: it reads a chip's datasheet PDF and produces the JSON inputs needed by Adom's `sym_create` MCP plus a footprint metadata file plus a field-level provenance audit trail. Built for both human use and **agent-to-agent calling** by an upstream automation (`chip-fetcher`, batch pipelines). -## What it does+Mirrors the `step2glb` naming convention. -- **`extract`** â datasheet PDF â symbol + footprint + provenance JSON.-- **`inspect`** â summarize an existing extraction in a chip directory.-- **`svg`** â regenerate the authoritative footprint SVG from cached JSON (no re-extraction).+--- -Note: a datasheet without a land-pattern table yields a symbol but no footprint pads â adom-ds2sf reports exactly what it could and couldn't extract, with provenance on every datapoint.+## What it produces -# Seven worked examples+For a chip directory like `~/project/chip-fetcher/library/<MPN>/`, ds2sf writes four JSONs: -Each example below was produced by `adom-ds2sf extract` on the manufacturer's own datasheet. For every part you can see the **generated symbol + footprint**, the **full extracted pin list**, the **provenance JSON** (with the datasheet page each datapoint came from), and **the actual datasheet pages** adom-ds2sf read â plus a link to the original PDF. Seven different part types, seven different packages.--## 1. DMG2305UX-7 â P-channel MOSFET--*Diodes Incorporated · SOT-23 · [original datasheet PDF](https://www.diodes.com/assets/Datasheets/DMG2305UX.pdf)*--P-channel enhancement-mode MOSFET in SOT-23. -20V Vds, -4.2A continuous Id at Ta=25°C (Vgs=-4.5V), Rds(on) typ 40 mΩ at Vgs=-4.5V / 52 mΩ at Vgs=-2.5V. Low-Vgs(th) load-switch / power-management device.--**Symbol + footprint, generated by adom-ds2sf:**----**Extracted pin list (3 pins):**--| # | Name | Type | Group |-|---|------|------|-------|-| 1 | G | `input` | CONTROL |-| 2 | S | `passive` | POWER |-| 3 | D | `passive` | POWER |--**Provenance** â every field traces back to a datasheet page:--```json-{- "mpn": "DMG2305UX-7",- "datasheet": {- "url": "https://www.diodes.com/assets/Datasheets/DMG2305UX.pdf",- "page_count": 6,- "sha256": "7421a75157a2e700â¦"- },- "symbol.pin_provenance": [- {- "confidence": "high",- "page": 1,- "pin_name": "G",- "pin_number": "1",- "table_caption": "Top View / Internal Schematic (page 1)"- },- {- "confidence": "high",- "page": 1,- "pin_name": "S",- "pin_number": "2",- "table_caption": "Top View / Internal Schematic (page 1)"- }- ],- "footprint.dimension_provenance": [- {- "field": "bodyDimensions.x",- "figure_label": "Package Outline Dimensions â SOT23 (Standard)",- "page": 5,- "value": 2.9- },- {- "field": "bodyDimensions.y",- "figure_label": "Package Outline Dimensions â SOT23 (Standard)",- "page": 5,- "value": 1.3- }- ]-} ```--**The datasheet pages adom-ds2sf read** â the pin table (p.1) and the package drawing (p.5):------[â Open the original DMG2305UX-7 datasheet](https://www.diodes.com/assets/Datasheets/DMG2305UX.pdf)--## 2. LM358D â Dual op-amp--*Texas Instruments · SOIC-8 · [original datasheet PDF](https://www.ti.com/lit/ds/symlink/lm358.pdf)*--Industry-standard dual general-purpose operational amplifier. Operates from a single 3V to 32V supply (or split ±1.5V to ±16V), with an input common-mode range that includes ground and a typical 1.2 MHz gain-bandwidth product. SOIC-8 (D)â¦--**Symbol + footprint, generated by adom-ds2sf:**----**Extracted pin list (8 pins):**--| # | Name | Type | Group |-|---|------|------|-------|-| 1 | OUT1 | `output` | OTHER |-| 2 | IN1- | `input` | OTHER |-| 3 | IN1+ | `input` | OTHER |-| 4 | V- | `power_in` | POWER |-| 5 | IN2+ | `input` | OTHER |-| 6 | IN2- | `input` | OTHER |-| 7 | OUT2 | `output` | OTHER |-| 8 | V+ | `power_in` | POWER |--**Provenance** â every field traces back to a datasheet page:--```json-{- "mpn": "LM358D",- "datasheet": {- "url": "https://www.ti.com/lit/ds/symlink/lm358.pdf",- "page_count": 68,- "sha256": "9c879c3a3e9291c5â¦"- },- "symbol.pin_provenance": [- {- "confidence": "high",- "page": 3,- "pin_name": "OUT1",- "pin_number": "1",- "table_caption": "Table 4-1. Pin Functions"- },- {- "confidence": "high",- "page": 3,- "pin_name": "IN1-",- "pin_number": "2",- "table_caption": "Table 4-1. Pin Functions"- }- ],- "footprint.dimension_provenance": [- {- "field": "bodyDimensions.x",- "figure_label": "D0008A Package Outline â SOIC - 1.75 mm max height",- "page": 59,- "value": 4.91- },- {- "field": "bodyDimensions.y",- "figure_label": "D0008A Package Outline â SOIC - 1.75 mm max height",- "page": 59,- "value": 3.9- }- ]-}+<MPN>-symbol.extracted.json # sym_create-shaped (drop-in MCP input)+<MPN>-footprint.extracted.json # padCount, bodyDimensions, leadDimensions,+ # padDescriptions, symbolPinMap, kicad_baseline+<MPN>-extraction.provenance.json # per-pin {page, table_caption}+ # per-dimension {field, value, page, figure_label}+<MPN>-extraction.result.json # agent-to-agent contract: status + exit_code + hints ``` -**The datasheet pages adom-ds2sf read** â the pin table (p.3) and the package drawing (p.60):+The provenance JSON is the audit trail. Pick any pin or any dimension, follow the `page` field, open the PDF on that page, and confirm the value is visibly there. Same loop for footprint dimensions via `figure_label`. -+--- -+## How it works -[â Open the original LM358D datasheet](https://www.ti.com/lit/ds/symlink/lm358.pdf)+Two focused `claude -p` calls per datasheet: -## 3. TPS62840YBGR â Buck converter+1. **Symbol pass** — pinout tables, pin types, Adom logical groups (POWER / I2C / GPIO / etc.), per-pin page provenance.+2. **Footprint pass** — package outline, recommended land pattern, body + lead dimensions, per-dimension page provenance. -*Texas Instruments · WCSP-6 (YBG, DSBGA) · [original datasheet PDF](https://www.ti.com/lit/ds/symlink/tps62840.pdf)*+Each pass has its own JSON schema and its own validator. Failures retry once with the validation error fed back to the model. Symbol-pass receives the canonical pin-name → pin-number map for the footprint pass so the two outputs cannot disagree with themselves. -1.8-V to 6.5-V input, 750-mA step-down (buck) converter with ultra-low 60-nA operating quiescent current and 100% duty-cycle mode (120-nA Iq). DCS-Control architecture with 1.8-MHz nominal switching, 16 selectable output voltages via VSETâ¦+After Claude finishes, ds2sf normalizes the package name against the **service-kicad standard library** (`Package_SO`, `Package_QFP`, `Package_DFN_QFN`, `Package_LQFP`, `Package_TO_SOT_SMD`, `Package_DIP`, …). When the package matches, downstream tools just pull the `.kicad_mod` from there. When it doesn't (custom OLGA packages, modules, optical sensors), `bodyDimensions` + `leadDimensions` carry the datasheet-extracted values and `kicad_baseline` is `null`. -**Symbol + footprint, generated by adom-ds2sf:**+--- -+## Sample output -**Extracted pin list (6 pins):**--| # | Name | Type | Group |-|---|------|------|-------|-| A1 | GND | `power_in` | GROUND |-| A2 | VOS | `input` | POWER |-| B1 | VIN | `power_in` | POWER |-| B2 | SW | `power_out` | POWER |-| C1 | EN | `input` | CONTROL |-| C2 | VSET | `input` | CONTROL |--**Provenance** â every field traces back to a datasheet page:+### Top of `<MPN>-symbol.extracted.json` (ADS1115IDGSR) ```json {- "mpn": "TPS62840YBGR",- "datasheet": {- "url": "https://www.ti.com/lit/ds/symlink/tps62840.pdf",- "page_count": 45,- "sha256": "b715ae80beb8f08bâ¦"- },- "symbol.pin_provenance": [- {- "confidence": "high",- "page": 5,- "pin_name": "GND",- "pin_number": "A1",- "table_caption": "Pin Functions"- },- {- "confidence": "high",- "page": 5,- "pin_name": "VOS",- "pin_number": "A2",- "table_caption": "Pin Functions"- }+ "symbolName": "ADS1115",+ "manufacturer": "Texas Instruments",+ "package": "VSSOP-10",+ "description": "Ultra-small 16-bit sigma-delta ADC with PGA, internal reference, …",+ "category": "ic",+ "referencePrefix": "U",+ "datasheetUrl": "https://www.ti.com/lit/ds/symlink/ads1115.pdf",+ "pins": [+ { "number": "8", "name": "VDD", "type": "power_in", "group": "POWER", "description": "…" },+ { "number": "3", "name": "GND", "type": "passive", "group": "GROUND", "description": "…" },+ { "number": "9", "name": "SDA", "type": "bidirectional","group": "I2C", "description": "…" },+ { "number": "4", "name": "AIN0","type": "input", "group": "ADC", "description": "…" } ],- "footprint.dimension_provenance": [- {- "field": "bodyDimensions.x",- "figure_label": "YBG0006 Package Outline â DSBGA - 0.5 mm max height (D: 1.498/1.438 mm, E: 0.998/0.938 mm)",- "page": 39,- "value": 0.968- },- {- "field": "bodyDimensions.y",- "figure_label": "YBG0006 Package Outline â DSBGA - 0.5 mm max height (D: 1.498/1.438 mm, E: 0.998/0.938 mm)",- "page": 39,- "value": 1.468- }- ]+ "groups": [ { "name": "POWER", "description": "…" }, … ] } ``` -**The datasheet pages adom-ds2sf read** â the pin table (p.5) and the package drawing (p.40):------[â Open the original TPS62840YBGR datasheet](https://www.ti.com/lit/ds/symlink/tps62840.pdf)--## 4. INA226AIDGSR â Current / power monitor--*Texas Instruments · VSSOP-10 · [original datasheet PDF](https://www.ti.com/lit/ds/symlink/ina226.pdf)*--36V, 16-bit ultra-precise I²C/SMBus current, voltage, and power monitor with alert. Senses bus voltages from 0V to 36V (high- or low-side), with ±81.92 mV shunt range, 0.1% gain error, 10 µV offset, and configurable averaging. Operatesâ¦--**Symbol + footprint, generated by adom-ds2sf:**----**Extracted pin list (10 pins):**--| # | Name | Type | Group |-|---|------|------|-------|-| 1 | A1 | `input` | CONTROL |-| 2 | A0 | `input` | CONTROL |-| 3 | Alert | `output` | ALERT |-| 4 | SDA | `bidirectional` | I2C |-| 5 | SCL | `input` | I2C |-| 6 | VS | `power_in` | POWER |-| 7 | GND | `power_in` | GROUND |-| 8 | VBUS | `input` | ADC |-| 9 | IN- | `input` | ADC |-| 10 | IN+ | `input` | ADC |--**Provenance** â every field traces back to a datasheet page:+### Top of `<MPN>-extraction.provenance.json` ```json {- "mpn": "INA226AIDGSR", "datasheet": {- "url": "https://www.ti.com/lit/ds/symlink/ina226.pdf",- "page_count": 40,- "sha256": "8652754621863692â¦"+ "path": "/…/ADS1115IDGSR.pdf",+ "url": "https://www.ti.com/lit/ds/symlink/ads1115.pdf",+ "sha256": "65231e81…",+ "source_tier": "mfr",+ "source_host": "ti.com/lit",+ "page_count": 57 },- "symbol.pin_provenance": [- {- "confidence": "high",- "page": 3,- "pin_name": "A1",- "pin_number": "1",- "table_caption": "Table 4-1. Pin Functions"- },- {- "confidence": "high",- "page": 3,- "pin_name": "A0",- "pin_number": "2",- "table_caption": "Table 4-1. Pin Functions"- }- ],- "footprint.dimension_provenance": [- {- "field": "bodyDimensions.x",- "figure_label": "DGS0010A Package Outline â VSSOP - 1.1 mm max height (JEDEC MO-187 BA)",- "page": 37,- "value": 3.0- },- {- "field": "bodyDimensions.y",- "figure_label": "DGS0010A Package Outline â VSSOP - 1.1 mm max height (JEDEC MO-187 BA)",- "page": 37,- "value": 3.0- }- ]+ "symbol": {+ "pin_provenance": [+ { "pin_number": "1", "pin_name": "ADDR",+ "page": 3, "table_caption": "Table 4-1. Pin Functions: RUG, DYN, and DGS Packages",+ "confidence": "high" }+ ]+ },+ "footprint": {+ "dimension_provenance": [+ { "field": "bodyDimensions.x", "value": 3.1,+ "page": 52, "figure_label": "DGS0010A VSSOP - 1.1 mm max height Package Outline",+ "confidence": "high" }+ ]+ } } ``` -**The datasheet pages adom-ds2sf read** â the pin table (p.3) and the package drawing (p.38):------[â Open the original INA226AIDGSR datasheet](https://www.ti.com/lit/ds/symlink/ina226.pdf)--## 5. MSP430G2553IPW20 â 16-bit MCU--*Texas Instruments · TSSOP-20 · [original datasheet PDF](https://www.ti.com/lit/ds/symlink/msp430g2553.pdf)*+--- -Ultra-low-power 16-bit RISC microcontroller with 16 KB flash, 512 B SRAM, 10-bit ADC, and integrated UART/SPI/I²C interfaces. Operates at 1.8â3.6 V with active mode consumption of 230 µA/MHz and RTC mode of 0.1 µA.+## Agent-to-agent contract -**Symbol + footprint, generated by adom-ds2sf:**+Every invocation writes `<MPN>-extraction.result.json` and exits with one of three codes: -+| Exit | `status` | Caller action |+|---|---|---|+| 0 | `ok` | Accept the outputs. `review_required[]` may have informational entries. |+| 2 | `needs_better_pdf` | Try one of `hints[].candidates` and re-call with `--pdf <new>`. |+| 3 | `unrecoverable` | Surface to a human; inspect `.ds2sf-cache/` for the raw Claude responses. | -**Extracted pin list (20 pins):**+`hints[]` is a tagged-union list: -| # | Name | Type | Group |-|---|------|------|-------|-| 1 | DVCC | `power_in` | POWER |-| 2 | P1.0/TA0CLK/ACLK/A0/CA0 | `bidirectional` | GPIO |-| 3 | P1.1/TA0.0/UCA0RXD/UCA0SOMI/A1/CA1 | `bidirectional` | GPIO |-| 4 | P1.2/TA0.1/UCA0TXD/UCA0SIMO/A2/CA2 | `bidirectional` | GPIO |-| 5 | P1.3/ADC10CLK/A3/VREF-/VREF+/CA3/CAOUT | `bidirectional` | GPIO |-| 6 | P1.4/SMCLK/UCB0STE/UCA0CLK/A4/VREF+/CA4/TCK | `bidirectional` | GPIO |-| 7 | P1.5/TA0.0/UCB0CLK/UCAOSSTE/A5/CA5/TMS | `bidirectional` | GPIO |-| 8 | P2.0/TA1.0 | `bidirectional` | GPIO |-| 9 | P2.1/TA1.1 | `bidirectional` | GPIO |-| 10 | P2.2/TA1.1 | `bidirectional` | GPIO |-| 11 | P2.3/TA1.0 | `bidirectional` | GPIO |-| 12 | P2.4/TA1.2 | `bidirectional` | GPIO |-| 13 | P2.5/TA1.2 | `bidirectional` | GPIO |-| 14 | NC | `unconnected` | NC |-| 15 | P1.6/TA0.1/A6/CA6/UCB0SOMI/UCB0SCL/TDI/TCLK | `bidirectional` | GPIO |-| 16 | P1.7/A7/CA7/CAOUT/UCB0SIMO/UCB0SDA/TDO/TDI | `bidirectional` | GPIO |-| 17 | TEST/SBWTCK | `input` | DEBUG |-| 18 | XOUT/P2.7 | `bidirectional` | TIMING |-| 19 | XIN/P2.6/TA0.1 | `bidirectional` | TIMING |-| 20 | DVSS | `power_in` | GROUND |+- `try_alternate_pdf` — `{reason, candidates: PathBuf[]}`. ds2sf already ranked the chip-dir's other PDFs; pick the first one not yet tried.+- `update_upstream_metadata` — `{file, key, current, extracted}`. e.g. `info.json`'s `package` disagrees with the datasheet; patch it and re-run.+- `use_datasheet_extracted_footprint` — `{package}`. Custom package; downstream footprint authoring should use the captured dimensions.+- `human_review` — `{reason, notes}`. Engineering nuance the schematic designer should see. -**Provenance** â every field traces back to a datasheet page:+This means an upstream agent like `chip-fetcher` can call ds2sf, walk `hints[].candidates` until it gets `status: ok`, and accept the extraction without human input. -```json-{- "mpn": "MSP430G2553IPW20",- "datasheet": {- "url": "https://www.ti.com/lit/ds/symlink/msp430g2553.pdf",- "page_count": 85,- "sha256": "c94b53fef9ba6065â¦"- },- "symbol.pin_provenance": [- {- "confidence": "high",- "page": 3,- "pin_name": "DVCC",- "pin_number": "1",- "table_caption": "Device Pinout, MSP430G2x13 and MSP430G2x53, 20-Pin Devices, TSSOP and PDIP"- },- {- "confidence": "high",- "notes": "Muxed pin with multiple peripheral functions; configure via software.",- "page": 6,- "pin_name": "P1.0/TA0CLK/ACLK/A0/CA0",- "pin_number": "2",- "table_caption": "Table 2. Terminal Functions"- }- ],- "footprint.dimension_provenance": [- {- "field": "bodyDimensions.x",- "figure_label": "PW0020A Package Outline",- "page": 76,- "value": 6.6- },- {- "field": "bodyDimensions.y",- "figure_label": "PW0020A Package Outline",- "page": 76,- "value": 4.5- }- ]+```rust+let mut tried = HashSet::new();+let mut r = run_ds2sf(chip_dir, None);+tried.insert(r.datasheet_used.clone());+while r.status == "needs_better_pdf" {+ let next = r.hints.iter()+ .filter_map(|h| if let Hint::TryAlternatePdf{candidates, ..} = h { Some(candidates) } else { None })+ .flatten().find(|c| !tried.contains(*c));+ let Some(next) = next else { break };+ tried.insert(next.clone());+ r = run_ds2sf(chip_dir, Some(next)); } ``` -**The datasheet pages adom-ds2sf read** â the pin table (p.6) and the package drawing (p.77):------[â Open the original MSP430G2553IPW20 datasheet](https://www.ti.com/lit/ds/symlink/msp430g2553.pdf)--## 6. BMI270 â 6-axis IMU--*Bosch · LGA-14 · [original datasheet PDF](https://www.bosch-sensortec.com/media/boschsensortec/downloads/datasheets/bst-bmi270-ds000.pdf)*--Ultra-low-power 16-bit 6-axis IMU (accelerometer + gyroscope) with integrated motion-triggered interrupts, gesture recognition, and advanced power management. Dual-protocol interface supporting I²C and SPI with optional auxiliary sensorâ¦+Full reason / action enums and the calling recipe are in [SKILL.md](SKILL.md). -**Symbol + footprint, generated by adom-ds2sf:**+--- -+## CLI -**Extracted pin list (14 pins):**--| # | Name | Type | Group |-|---|------|------|-------|-| 1 | SDO | `output` | I2C |-| 2 | ASDx | `bidirectional` | OTHER |-| 3 | ASCx | `input` | OTHER |-| 4 | INT1 | `output` | ALERT |-| 5 | VDDIO | `power_in` | POWER |-| 6 | GNDIO | `passive` | GROUND |-| 7 | GND | `passive` | GROUND |-| 8 | VDD | `power_in` | POWER |-| 9 | INT2 | `output` | ALERT |-| 10 | OCSB | `input` | OTHER |-| 11 | OSDO | `output` | OTHER |-| 12 | CSB | `input` | SPI |-| 13 | SCx | `input` | I2C |-| 14 | SDx | `bidirectional` | I2C |--**Provenance** â every field traces back to a datasheet page:--```json-{- "mpn": "BMI270",- "datasheet": {- "url": "https://www.bosch-sensortec.com/media/boschsensortec/downloads/datasheets/bst-bmi270-ds000.pdf",- "page_count": 150,- "sha256": "51818aed4619bcccâ¦"- },- "symbol.pin_provenance": [- {- "confidence": "high",- "notes": "SPI MISO (4-wire), I2C address bit-0 select; open-drain output",- "page": 135,- "pin_name": "SDO",- "pin_number": "1",- "table_caption": "Table 22: Pin-out and pin connections"- },- {- "confidence": "high",- "notes": "Secondary interface; muxed for Aux I2C SDA, OIS SDI, or DNC",- "page": 135,- "pin_name": "ASDx",- "pin_number": "2",- "table_caption": "Table 22: Pin-out and pin connections"- }- ],- "footprint.dimension_provenance": [- {- "field": "bodyDimensions.x",- "figure_label": "Section 8.1, Package outline dimensions",- "page": 143,- "value": 3.0- },- {- "field": "bodyDimensions.y",- "figure_label": "Section 8.1, Package outline dimensions",- "page": 143,- "value": 2.5- }- ]-} ```--**The datasheet pages adom-ds2sf read** â the pin table (p.135) and the package drawing (p.145):------[â Open the original BMI270 datasheet](https://www.bosch-sensortec.com/media/boschsensortec/downloads/datasheets/bst-bmi270-ds000.pdf)--## 7. ESP32-S3-WROOM-1-N4 â Wi-Fi + BLE module--*Espressif Systems · SMD-41 (18 x 25.5 x 3.1 mm castellated module, PCB antenna) · [original datasheet PDF](https://www.espressif.com/sites/default/files/documentation/esp32-s3-wroom-1_wroom-1u_datasheet_en.pdf)*--ESP32-S3-WROOM-1-N4 is a Wi-Fi (802.11 b/g/n) and Bluetooth LE 5 module built around the ESP32-S3 SoC (dual-core Xtensa LX7 @ 240 MHz, vector instructions for ML). The -N4 variant integrates 4 MB SPI flash and no PSRAM. Module exposes upâ¦--**Symbol + footprint, generated by adom-ds2sf:**----**Extracted pin list (41 pins):**--| # | Name | Type | Group |-|---|------|------|-------|-| 1 | GND | `power_in` | GROUND |-| 2 | 3V3 | `power_in` | POWER |-| 3 | EN | `input` | CONTROL |-| 4 | IO4 | `bidirectional` | GPIO |-| 5 | IO5 | `bidirectional` | GPIO |-| 6 | IO6 | `bidirectional` | GPIO |-| 7 | IO7 | `bidirectional` | GPIO |-| 8 | IO15 | `bidirectional` | GPIO |-| 9 | IO16 | `bidirectional` | GPIO |-| 10 | IO17 | `bidirectional` | GPIO |-| 11 | IO18 | `bidirectional` | GPIO |-| 12 | IO8 | `bidirectional` | GPIO |-| 13 | IO19 | `bidirectional` | USB |-| 14 | IO20 | `bidirectional` | USB |-| 15 | IO3 | `bidirectional` | GPIO |-| 16 | IO46 | `bidirectional` | GPIO |-| 17 | IO9 | `bidirectional` | GPIO |-| 18 | IO10 | `bidirectional` | GPIO |-| 19 | IO11 | `bidirectional` | GPIO |-| 20 | IO12 | `bidirectional` | GPIO |-| 21 | IO13 | `bidirectional` | GPIO |-| 22 | IO14 | `bidirectional` | GPIO |-| 23 | IO21 | `bidirectional` | GPIO |-| 24 | IO47 | `bidirectional` | GPIO |-| 25 | IO48 | `bidirectional` | GPIO |-| 26 | IO45 | `bidirectional` | GPIO |-| 27 | IO0 | `bidirectional` | GPIO |-| 28 | IO35 | `bidirectional` | GPIO |-| 29 | IO36 | `bidirectional` | GPIO |-| 30 | IO37 | `bidirectional` | GPIO |-| 31 | IO38 | `bidirectional` | GPIO |-| 32 | IO39 | `bidirectional` | DEBUG |-| 33 | IO40 | `bidirectional` | DEBUG |-| 34 | IO41 | `bidirectional` | DEBUG |-| 35 | IO42 | `bidirectional` | DEBUG |-| 36 | RXD0 | `bidirectional` | UART |-| 37 | TXD0 | `bidirectional` | UART |-| 38 | IO2 | `bidirectional` | GPIO |-| 39 | IO1 | `bidirectional` | GPIO |-| 40 | GND | `power_in` | GROUND |-| 41 | EPAD | `power_in` | GROUND |--**Provenance** â every field traces back to a datasheet page:--```json-{- "mpn": "ESP32-S3-WROOM-1-N4",- "datasheet": {- "url": "https://www.espressif.com/sites/default/files/documentation/esp32-s3-wroom-1_wroom-1u_datasheet_en.pdf",- "page_count": 87,- "sha256": "2d5a7cb7fd559d8dâ¦"- },- "symbol.pin_provenance": [- {- "confidence": "high",- "page": 11,- "pin_name": "GND",- "pin_number": "1",- "table_caption": "Table 3-1. Pin Definitions"- },- {- "confidence": "high",- "page": 11,- "pin_name": "3V3",- "pin_number": "2",- "table_caption": "Table 3-1. Pin Definitions"- }- ],- "footprint.dimension_provenance": [- {- "field": "bodyDimensions.x",- "figure_label": "Figure 10-1. ESP32-S3-WROOM-1 Physical Dimensions",- "page": 42,- "value": 18.0- },- {- "field": "bodyDimensions.y",- "figure_label": "Figure 10-1. ESP32-S3-WROOM-1 Physical Dimensions",- "page": 42,- "value": 25.5- }- ]-}+ds2sf extract <CHIP-DIR> # read PDF, emit symbol + footprint + provenance + result JSONs+ds2sf normalize <PACKAGE> # resolve a package string against service-kicad stdlib (debug)+ds2sf inspect <CHIP-DIR> # print summary of an existing extraction+ds2sf health # probe `claude` CLI + service-kicad reachability+ds2sf install # drop SKILL.md to ~/.claude/skills/ds2sf/ ``` -**The datasheet pages adom-ds2sf read** â the pin table (p.11) and the package drawing (p.45):+`extract` flags: `--pdf`, `--mpn`, `--manufacturer`, `--datasheet-url`, `--source-tier`, `--out-dir`, `--model` (default `claude-opus-4-7`), `--force`, `--symbol-only`, `--footprint-only`, `--json-only`. -+--- -+## Validated chips (initial release) -[â Open the original ESP32-S3-WROOM-1-N4 datasheet](https://www.espressif.com/sites/default/files/documentation/esp32-s3-wroom-1_wroom-1u_datasheet_en.pdf)+| MPN | Package | Stdlib | Pins | Note |+|---|---|---|---|---|+| ADS1115IDGSR | VSSOP-10 | ✅ `Package_SO:MSOP-10_3x3mm_P0.5mm` | 10 | clean |+| DRV8316RRGFR | VQFN-40 (7×5) | ❌ custom | 40 | caught chip-fetcher's info.json lying about package size |+| VL53L8CX | OLGA-16 | ❌ custom | 17 | all 5 muxed pins flagged, thermal pad called out |+| ESP32-S3-WROOM-1-N4 | SMD module | ❌ custom | 41 | castellated pads, boot-strapping pins, PSRAM variant conflicts | -# Open source â check it out, vibe-code it, send it back+Failure-path also verified: feeding the SoC datasheet to a module product correctly returned `status: needs_better_pdf` with five ranked candidate PDFs in `hints[]`. -adom-ds2sf is MIT and **fully open**. The complete Rust source is in this page's **Files** tab.+--- -1. **Build it.** `cargo build --release` drops a standalone binary.-2. **Point your install at your checkout.** `adompkg link john/adom-ds2sf ./adom-ds2sf`.-3. **Vibe-code a change.** Open it in Claude Code â a new provenance field, a different grouping heuristic, another datasheet quirk to handle.-4. **Send it back.** Open a **pull request** on this page, or start a discussion thread.+## Build -## Part of the Adom EDA family+```bash+cargo build --release+sudo cp target/release/ds2sf /usr/local/bin/+ds2sf install+``` -adom-ds2sf is the extractor; its results are *viewed and used* through **adom-symbol** / **adom-footprint** (it has no viewer of its own). It feeds **adom-chip-fetcher**'s source carat as the "adom-ds2sf · datasheet" source.+Or just `./build.sh`. -## Run it+--- -```bash-adom-ds2sf extract <chip-dir> # datasheet PDF â symbol + footprint + provenance JSON-adom-ds2sf inspect <chip-dir> # summarize an extraction-```-MIT, contributions welcome.+## Cost & latency (Haiku 4.5 baseline)++- ~$0.20–0.30 per chip end-to-end+- ~2–3 min wall time per chip+- Anthropic prompt cache keeps the PDF warm across the symbol→footprint calls
SKILL.md+36−36@@ -1,37 +1,37 @@ ----name: adom-ds2sf-description: Datasheet â symbol + footprint + provenance JSON. Reads a chip's datasheet PDF and emits sym_create-shaped symbol JSON, footprint metadata, a field-level provenance audit trail, and an agent-to-agent result.json with stable exit codes for programmatic re-search-and-retry. Trigger words â adom-ds2sf, datasheet to symbol, datasheet to footprint, extract symbol from datasheet, extract footprint from datasheet, datasheet pinout extract, sym extracted json, fp extracted json, datasheet sym fp, chip pin grouping, pin provenance, footprint provenance, sym_create input from datasheet, adom-ds2sf result.json, datasheet extractor agent contract.+name: ds2sf+description: Datasheet → symbol + footprint + provenance JSON. Reads a chip's datasheet PDF and emits sym_create-shaped symbol JSON, footprint metadata, a field-level provenance audit trail, and an agent-to-agent result.json with stable exit codes for programmatic re-search-and-retry. Trigger words — ds2sf, datasheet to symbol, datasheet to footprint, extract symbol from datasheet, extract footprint from datasheet, datasheet pinout extract, sym extracted json, fp extracted json, datasheet sym fp, chip pin grouping, pin provenance, footprint provenance, sym_create input from datasheet, ds2sf result.json, datasheet extractor agent contract. --- -# adom-ds2sf â datasheet â symbol + footprint + provenance+# ds2sf — datasheet → symbol + footprint + provenance -Pipeline: read a adom-chip-fetcher chip directory, send the datasheet PDF to Claude in two focused passes (symbol â footprint), normalize the package against the service-kicad standard library, and emit four JSON files alongside the existing adom-chip-fetcher artifacts.+Pipeline: read a chip-fetcher chip directory, send the datasheet PDF to Claude in two focused passes (symbol → footprint), normalize the package against the service-kicad standard library, and emit four JSON files alongside the existing chip-fetcher artifacts. -## â Always Opus, never Haiku+## ⚠ Always Opus, never Haiku Production runs MUST use the default `--model claude-opus-4-7`. EEs rely on this output to wire schematics; Haiku's chip-data misreads are not acceptable in that pipeline. Concur (the cross-source consensus check) does catch most Haiku errors via 4-source consensus, but the right answer is to not produce them in the first place. Override the model only for development experiments where a follow-up Opus pass is acceptable. ## When to use -- After `adom-chip-fetcher` lands a chip into `~/project/adom-chip-fetcher/library/<MPN>/`.-- When you want an Adom-grouped symbol (POWER / I2C / GPIO / etc.) â the UL bundle's `.kicad_sym` is flat and ungrouped.+- After `chip-fetcher` lands a chip into `~/project/chip-fetcher/library/<MPN>/`.+- When you want an Adom-grouped symbol (POWER / I2C / GPIO / etc.) — the UL bundle's `.kicad_sym` is flat and ungrouped. - When you need a downstream-ready `sym_create` input JSON. - When you need page-level provenance to deeplink the Adom user back to the datasheet PDF page each fact came from. ## Module products: pass `--pdf` explicitly -For **module products** (ESP32-WROOM, NORA, ATWINC, SARA-R5, etc.), adom-chip-fetcher's auto-named `<MPN>.pdf` may be the internal SoC datasheet â which has the SoC's pinout, not the module's external castellated-pad pinout. The extractor will refuse to hallucinate (writes a result.json with `status: "needs_better_pdf"` and a list of candidate PDFs already on disk).+For **module products** (ESP32-WROOM, NORA, ATWINC, SARA-R5, etc.), chip-fetcher's auto-named `<MPN>.pdf` may be the internal SoC datasheet — which has the SoC's pinout, not the module's external castellated-pad pinout. The extractor will refuse to hallucinate (writes a result.json with `status: "needs_better_pdf"` and a list of candidate PDFs already on disk). Look for the module-specific PDF in the chip-dir (`<MPN>-original.pdf`, `<MPN>-datasheethardware-guideline.pdf`, etc.) and pass it via `--pdf`: ```bash-adom-ds2sf extract /home/adom/project/adom-chip-fetcher/library/ESP32-S3-WROOM-1-N4 \- --pdf /home/adom/project/adom-chip-fetcher/library/ESP32-S3-WROOM-1-N4/ESP32-S3-WROOM-1-N4-original.pdf+ds2sf extract /home/adom/project/chip-fetcher/library/ESP32-S3-WROOM-1-N4 \+ --pdf /home/adom/project/chip-fetcher/library/ESP32-S3-WROOM-1-N4/ESP32-S3-WROOM-1-N4-original.pdf ``` ## Commands -### `adom-ds2sf extract <CHIP-DIR>`+### `ds2sf extract <CHIP-DIR>` Run the full pipeline against a chip directory. @@ -43,7 +43,7 @@ Run the full pipeline against a chip directory. | `--datasheet-url <url>` | Override the datasheet URL. | | `--source-tier <mfr\|snapmagic\|mouser\|digikey\|arrow\|cse\|lcsc\|unknown>` | Override the upstream provenance tier. | | `--out-dir <path>` | Output directory (default: chip-dir). |-| `--model <id>` | Claude model (default `claude-opus-4-7`). **Always use Opus for production EE work.** Haiku produces frequent misreads on chip data â pin-order swaps (DMG2305 GDSâGSD), pad undercounts (ICE40UP5K saw 24 of 48), package-variant confusion (WSL2010 â WSL2512), and JTAG-letter typos (MTDO â MTD0). Concur catches these via cross-source consensus, but the right answer is to never let them happen: use Opus. Only override to a cheaper model for development testing where a follow-up Opus run is acceptable. |+| `--model <id>` | Claude model (default `claude-opus-4-7`). **Always use Opus for production EE work.** Haiku produces frequent misreads on chip data — pin-order swaps (DMG2305 GDS↔GSD), pad undercounts (ICE40UP5K saw 24 of 48), package-variant confusion (WSL2010 → WSL2512), and JTAG-letter typos (MTDO → MTD0). Concur catches these via cross-source consensus, but the right answer is to never let them happen: use Opus. Only override to a cheaper model for development testing where a follow-up Opus run is acceptable. | | `--force` | Bypass cache, re-run Claude. | | `--symbol-only` | Skip footprint pass. | | `--footprint-only` | Re-run only footprint pass against cached symbol. |@@ -51,29 +51,29 @@ Run the full pipeline against a chip directory. **Example:** ```bash-adom-ds2sf extract /home/adom/project/adom-chip-fetcher/library/ADS1115IDGSR+ds2sf extract /home/adom/project/chip-fetcher/library/ADS1115IDGSR ``` -### `adom-ds2sf normalize <PACKAGE>`+### `ds2sf normalize <PACKAGE>` Resolve a package string against service-kicad's standard library (debug aid). ```bash-adom-ds2sf normalize "VSSOP-10"-# OK: VSSOP-10 â Package_SO:MSOP-10_3x3mm_P0.5mm (service-kicad stdlib)+ds2sf normalize "VSSOP-10"+# OK: VSSOP-10 → Package_SO:MSOP-10_3x3mm_P0.5mm (service-kicad stdlib) ``` -### `adom-ds2sf inspect <CHIP-DIR>`+### `ds2sf inspect <CHIP-DIR>` Print a summary of an existing extraction without re-running anything. -### `adom-ds2sf health`+### `ds2sf health` Probe `claude` CLI + service-kicad reachability. -### `adom-ds2sf install`+### `ds2sf install` -Drop this `SKILL.md` into `~/.claude/skills/adom-ds2sf/`.+Drop this `SKILL.md` into `~/.claude/skills/ds2sf/`. ## Outputs @@ -86,19 +86,19 @@ Drop this `SKILL.md` into `~/.claude/skills/adom-ds2sf/`. .ds2sf-cache/ # raw Claude responses for re-emit / debug ``` -The provenance JSON is the audit trail. Open the file, pick a pin, follow `pin_provenance[i].page` â open that page in the datasheet PDF â confirm the pin name + description are visibly there. Same for footprint dimensions via `dimension_provenance[i].page` + `figure_label`.+The provenance JSON is the audit trail. Open the file, pick a pin, follow `pin_provenance[i].page` → open that page in the datasheet PDF → confirm the pin name + description are visibly there. Same for footprint dimensions via `dimension_provenance[i].page` + `figure_label`. --- ## Agent-to-agent contract -adom-ds2sf is built so an upstream agent (adom-chip-fetcher, automation) can call it programmatically, read the result, and decide what to do next without human input.+ds2sf is built so an upstream agent (chip-fetcher, automation) can call it programmatically, read the result, and decide what to do next without human input. ### Exit codes | Exit | `status` | Meaning | Caller action | |---|---|---|---|-| 0 | `ok` | Extraction completed. `review_required[]` may still have entries â they're informational, not blocking. | Accept the outputs. |+| 0 | `ok` | Extraction completed. `review_required[]` may still have entries — they're informational, not blocking. | Accept the outputs. | | 2 | `needs_better_pdf` | Extractor refused because the supplied PDF doesn't have the right data (typically: SoC datasheet was passed for a module, or wrong package variant). | Try one of `hints[].candidates` and re-call with `--pdf <new>`. | | 3 | `unrecoverable` | Hard failure (Claude returned malformed output twice; pre-flight failure; emit error). | Surface to a human; inspect `.ds2sf-cache/`. | @@ -114,9 +114,9 @@ Always written. Stable schema (`schema_version: "1"`): "mpn": "ADS1115IDGSR", "manufacturer": "TI", "datasheet_used": "/home/.../ADS1115IDGSR.pdf",- "datasheet_sha256": "65231e81â¦",- "summary": "ADS1115IDGSR â 10 pins, package VSSOP-10 â¦",- "review_required": [ ⦠merged from symbol + footprint passes ⦠],+ "datasheet_sha256": "65231e81…",+ "summary": "ADS1115IDGSR — 10 pins, package VSSOP-10 …",+ "review_required": [ … merged from symbol + footprint passes … ], "hints": [ { "action": "try_alternate_pdf", "reason": "module_external_pinout_missing",@@ -124,13 +124,13 @@ Always written. Stable schema (`schema_version: "1"`): { "action": "update_upstream_metadata", "file": "/path/to/info.json", "key": "package",- "current": "VQFN-32 (5Ã5Ã0.9 mm)",- "extracted": "VQFN-40 (7Ã5Ã0.9 mm)" },+ "current": "VQFN-32 (5×5×0.9 mm)",+ "extracted": "VQFN-40 (7×5×0.9 mm)" }, { "action": "use_datasheet_extracted_footprint", "package": "Optical LGA-16" }, { "action": "human_review", "reason": "muxed_pin_protocol_select",- "notes": "â¦" }+ "notes": "…" } ], "outputs": { "symbol_json": "/path/to/X-symbol.extracted.json | null",@@ -146,9 +146,9 @@ Always written. Stable schema (`schema_version: "1"`): | Reason | When | Caller action | |---|---|---|-| `module_external_pinout_missing` | Empty pins[] returned because the supplied PDF was a SoC datasheet for a module product. | `hints[].try_alternate_pdf` â re-call with one of the candidate PDFs. |-| `package_mismatch_in_hint` | info.json's `package` disagrees with the datasheet's actual package. | `hints[].update_upstream_metadata` â patch info.json's `package` to match `extracted`, then accept. |-| `non_standard_package_no_kicad_baseline` | The package isn't in service-kicad's stdlib (custom OLGA, modules, optical). | `hints[].use_datasheet_extracted_footprint` â use `<MPN>-footprint.extracted.json`'s `bodyDimensions` + `leadDimensions` directly. |+| `module_external_pinout_missing` | Empty pins[] returned because the supplied PDF was a SoC datasheet for a module product. | `hints[].try_alternate_pdf` → re-call with one of the candidate PDFs. |+| `package_mismatch_in_hint` | info.json's `package` disagrees with the datasheet's actual package. | `hints[].update_upstream_metadata` → patch info.json's `package` to match `extracted`, then accept. |+| `non_standard_package_no_kicad_baseline` | The package isn't in service-kicad's stdlib (custom OLGA, modules, optical). | `hints[].use_datasheet_extracted_footprint` → use `<MPN>-footprint.extracted.json`'s `bodyDimensions` + `leadDimensions` directly. | | `muxed_pin_protocol_select` | Some pins (e.g. SDA/MOSI) are protocol-multiplexed. | Informational. Pin descriptions in provenance explain how. | | `pad_dimensions_from_example_layout` | Footprint dims came from an "Example Board Layout" rather than a formal recommended land pattern. | Informational; recommend cross-check vs IPC-7351. | | `boot_strapping_pins`, `vdd_spi_voltage_dependency`, `psram_variant_conflict`, `non_uniform_pad_pitch`, `power_supply_configuration`, `thermal_management`, `i2c_address_multidevice` | Engineering nuances Claude flagged for human review. | Surface to schematic designer. |@@ -157,10 +157,10 @@ Always written. Stable schema (`schema_version: "1"`): ### Agent calling recipe ```rust-// (Pseudo-Rust for adom-chip-fetcher's caller side.)+// (Pseudo-Rust for chip-fetcher's caller side.) fn run_ds2sf(chip_dir: &Path, pdf_override: Option<&Path>) -> ResultDoc {- let mut cmd = Command::new("adom-ds2sf");+ let mut cmd = Command::new("ds2sf"); cmd.arg("extract").arg(chip_dir).arg("--force").arg("--json-only"); if let Some(p) = pdf_override { cmd.arg("--pdf").arg(p); } let _ = cmd.status(); // exit code mirrored in result.json@@ -179,7 +179,7 @@ while r.status == "needs_better_pdf" { .flatten() .find(|c| !tried.contains(*c)); let Some(next) = next else {- // Out of candidates â escalate to human.+ // Out of candidates — escalate to human. log_for_review(&r); break; }; tried.insert(next.clone());@@ -203,4 +203,4 @@ match r.status.as_str() { ### Caching behavior under cache-hit -If `<MPN>-extraction.provenance.json` already exists and `--force` is not passed, adom-ds2sf skips the Claude calls entirely and exits with the cached result.json's exit code. Re-running is idempotent.+If `<MPN>-extraction.provenance.json` already exists and `--force` is not passed, ds2sf skips the Claude calls entirely and exits with the cached result.json's exit code. Re-running is idempotent.
build.shadded+9@@ -0,0 +1,9 @@+#!/usr/bin/env bash+# Build + install ds2sf locally for development.+set -euo pipefail+cd "$(dirname "$0")"++cargo build --release+sudo cp target/release/ds2sf /usr/local/bin/ds2sf+ds2sf install+echo "OK: ds2sf $(ds2sf --version | awk '{print $2}') installed → /usr/local/bin/ds2sf"
install.sh+1−1@@ -1,4 +1,4 @@ #!/bin/bash set -euo pipefail DIR="$(cd "$(dirname "$0")" && pwd)"; BIN="${HOME}/.local/bin"; mkdir -p "$BIN"-install -m 0755 "$DIR/bin/adom-ds2sf" "$BIN/adom-ds2sf"; echo "Installed adom-ds2sf"+install -m 0755 "$DIR/bin/adom-ds2sf" "$BIN/adom-ds2sf"; echo "Installed adom-ds2sf -> $BIN/adom-ds2sf"
src/cli.rsadded+685@@ -0,0 +1,685 @@+use anyhow::Context as _;+#[allow(unused_imports)]+use anyhow::Result;+use clap::{Parser, Subcommand};+use serde_json::{json, Value};+use std::path::PathBuf;+use std::process::Command;++use std::path::Path;+use crate::context::Context;+use crate::extract::SymbolOutcome;+use crate::io::{err, hint, note, ok, set_json_only};+use crate::result::{self, Hint, Outputs, ResultDoc, Status};+use crate::{emit, extract, normalize};++#[derive(Parser)]+#[command(name = "adom-ds2sf", version, about = "Datasheet → symbol + footprint + provenance JSON")]+pub struct Cli {+ #[command(subcommand)]+ pub command: Cmd,+}++#[derive(Subcommand)]+pub enum Cmd {+ /// Read a chip's datasheet PDF and emit symbol + footprint + provenance JSONs.+ Extract {+ /// Path to a chip-fetcher chip directory (e.g. /home/adom/project/chip-fetcher/library/ADS1115IDGSR).+ chip_dir: PathBuf,++ /// Override auto-detected datasheet PDF.+ #[arg(long)]+ pdf: Option<PathBuf>,++ /// Override the MPN (defaults to chip-dir basename or info.json).+ #[arg(long)]+ mpn: Option<String>,++ /// Override the manufacturer name.+ #[arg(long)]+ manufacturer: Option<String>,++ /// Override the datasheet URL.+ #[arg(long)]+ datasheet_url: Option<String>,++ /// Override the datasheet source tier.+ #[arg(long, value_parser = ["mfr","snapmagic","mouser","digikey","arrow","cse","lcsc","unknown"])]+ source_tier: Option<String>,++ /// Output directory (defaults to chip-dir).+ #[arg(long)]+ out_dir: Option<PathBuf>,++ /// Claude model to use.+ #[arg(long, default_value = "claude-opus-4-7")]+ model: String,++ /// Bypass the cache and re-run Claude even if outputs exist.+ #[arg(long)]+ force: bool,++ /// Suppress progress text; only emit OK:/ERROR: lines.+ #[arg(long)]+ json_only: bool,++ /// Skip Stage 1b (footprint pass) — useful for symbol-only sanity checks.+ #[arg(long)]+ symbol_only: bool,++ /// Skip Stage 1a (symbol pass) — re-run only the footprint pass against existing cached symbol output.+ #[arg(long)]+ footprint_only: bool,+ },+ /// Resolve a package string against the service-kicad standard library.+ Normalize {+ /// Package string to resolve, e.g. "VSSOP-10" or "QFN-32 5x5 0.5mm".+ package: String,+ },+ /// Print a summary of an existing extraction in a chip directory.+ Inspect {+ chip_dir: PathBuf,+ },+ /// Regenerate `<MPN>-footprint.authoritative.svg` from the cached+ /// footprint JSON without re-running Claude. Useful for downstream+ /// tooling that wants the visual reference without paying for an+ /// extraction, and for iterating on the SVG generator itself.+ Svg {+ chip_dir: PathBuf,+ },+ /// Probe `claude` CLI, service-kicad reachability.+ Health,+ /// Drop SKILL.md to ~/.claude/skills/adom-ds2sf/.+ Install,+}++pub fn run(cli: Cli) -> i32 {+ match cli.command {+ Cmd::Extract {+ chip_dir,+ pdf,+ mpn,+ manufacturer,+ datasheet_url,+ source_tier,+ out_dir,+ model,+ force,+ json_only,+ symbol_only,+ footprint_only,+ } => {+ set_json_only(json_only);+ run_extract(+ chip_dir, pdf, mpn, manufacturer, datasheet_url, source_tier,+ out_dir, model, force, symbol_only, footprint_only,+ )+ }+ Cmd::Normalize { package } => match normalize::resolve(&package, None) {+ Ok(resolved) => {+ match resolved.kicad_baseline {+ Some(b) => ok(format!("{} → {}:{} (service-kicad stdlib)", package, b.library, b.name)),+ None => {+ crate::io::warn(format!(+ "{} → no service-kicad stdlib match (tried {} libraries)",+ package, resolved.search_attempts.len()+ ));+ for a in &resolved.search_attempts {+ note(format!(" tried {}:{} → {}", a.library, a.name, a.result));+ }+ }+ }+ 0+ }+ Err(e) => { err(e); 1 }+ },+ Cmd::Inspect { chip_dir } => {+ let prov_path = chip_dir.join(format!(+ "{}-extraction.provenance.json",+ chip_dir.file_name().and_then(|s| s.to_str()).unwrap_or("?")+ ));+ if !prov_path.exists() {+ err(format!("no provenance at {}", prov_path.display()));+ hint(format!(+ "run `ds2sf extract {}` first",+ chip_dir.display()+ ));+ return 1;+ }+ match std::fs::read_to_string(&prov_path)+ .context("reading provenance")+ .and_then(|raw| serde_json::from_str::<Value>(&raw).context("parsing provenance"))+ {+ Ok(prov) => { print_inspect(&prov); 0 }+ Err(e) => { err(e); 1 }+ }+ }+ Cmd::Svg { chip_dir } => match run_svg_only(&chip_dir) {+ Ok(r) => {+ if let Some(p) = &r.ds2sf_svg {+ ok(format!("wrote {} (ds2sf-datasheet-derived)", p.display()));+ }+ if let Some(p) = &r.pcbnew_svg {+ ok(format!("wrote {} (pcbnew-stdlib)", p.display()));+ }+ if r.ds2sf_svg.is_none() {+ err("SVG generation failed"); 1+ } else { 0 }+ }+ Err(e) => { err(e); 1 }+ },+ Cmd::Health => {+ let mut all_ok = true;++ match Command::new("claude").arg("--version").output() {+ Ok(o) if o.status.success() => {+ let v = String::from_utf8_lossy(&o.stdout).trim().to_string();+ ok(format!("claude CLI {v}"));+ }+ _ => {+ err("claude CLI not on PATH");+ hint("install from claude.ai/code or ensure it is in $PATH");+ all_ok = false;+ }+ }++ // service-kicad probe — informational only; extraction runs without it+ let url = "https://kicad-rk5ue5pcfemi.adom.cloud/health";+ match ureq::get(url).timeout(std::time::Duration::from_secs(3)).call() {+ Ok(r) if r.status() == 200 => ok(format!("service-kicad reachable ({url})")),+ Ok(r) => {+ crate::io::warn(format!("service-kicad returned status {} — stdlib match disabled", r.status()));+ }+ Err(e) => {+ crate::io::warn(format!("service-kicad unreachable ({e}) — stdlib match disabled"));+ }+ }++ if all_ok {+ ok("ds2sf ready");+ 0+ } else {+ 1+ }+ }+ Cmd::Install => {+ let home = match std::env::var("HOME") {+ Ok(h) => h,+ Err(_) => { err("HOME not set"); return 1; }+ };+ let dest_dir = std::path::PathBuf::from(&home).join(".claude/skills/adom-ds2sf");+ if let Err(e) = std::fs::create_dir_all(&dest_dir) {+ err(format!("creating skill dir: {e}")); return 1;+ }+ let dest = dest_dir.join("SKILL.md");++ // SAFETY NET — do not rely on for release propagation.+ const SKILL_MD: &str = include_str!("../SKILL.md");+ if let Err(e) = std::fs::write(&dest, SKILL_MD) {+ err(format!("writing SKILL.md: {e}")); return 1;+ }+ ok(format!("installed SKILL.md → {}", dest.display()));+ 0+ }+ }+}++#[allow(clippy::too_many_arguments)]+fn run_extract(+ chip_dir: PathBuf,+ pdf: Option<PathBuf>,+ mpn: Option<String>,+ manufacturer: Option<String>,+ datasheet_url: Option<String>,+ source_tier: Option<String>,+ out_dir: Option<PathBuf>,+ model: String,+ force: bool,+ symbol_only: bool,+ footprint_only: bool,+) -> i32 {+ let ctx = match Context::ingest(+ &chip_dir,+ pdf.as_deref(),+ mpn.as_deref(),+ manufacturer.as_deref(),+ datasheet_url.as_deref(),+ source_tier.as_deref(),+ out_dir.as_deref(),+ ) {+ Ok(c) => c,+ Err(e) => {+ err(&e);+ // Best-effort: emit a result.json describing the pre-flight failure.+ // We can't write into out_dir without ctx, but we can still log.+ return Status::Unrecoverable.exit_code();+ }+ };++ if !force && match ctx.outputs_exist() { Ok(b) => b, Err(_) => false }+ && !symbol_only && !footprint_only+ {+ // Cache hit: the prior result.json (if any) stands. Return its exit code,+ // or 0 if the legacy run pre-dates result.json.+ let cached_status = std::fs::read_to_string(result::result_path(&ctx))+ .ok()+ .and_then(|raw| serde_json::from_str::<ResultDoc>(&raw).ok())+ .map(|d| d.exit_code)+ .unwrap_or(0);+ ok(format!(+ "{} — cached extraction at {} (use --force to re-run)",+ ctx.mpn,+ ctx.provenance_path().display()+ ));+ return cached_status;+ }++ if let Err(e) = ctx.cache_dir_create() {+ err(e);+ return Status::Unrecoverable.exit_code();+ }+ let _ = ctx.write_pdf_sha_marker();++ note(format!(+ "ds2sf {}: {} ({} pages, sha256 {}…)",+ ctx.mpn,+ ctx.datasheet_path.display(),+ ctx.page_count.unwrap_or(0),+ &ctx.datasheet_sha256[..12],+ ));++ // Stage 1a — symbol pass (or load cached for --footprint-only).+ let symbol = if footprint_only {+ match extract::load_cached_symbol(&ctx) {+ Ok(s) => s,+ Err(e) => {+ err(format!("--footprint-only requires a cached symbol pass: {e}"));+ return finalize_unrecoverable(&ctx, &model, "no_cached_symbol", &e.to_string());+ }+ }+ } else {+ match extract::run_symbol_pass(&ctx, &model) {+ Ok(SymbolOutcome::Ok(s)) => {+ let _ = extract::cache_symbol(&ctx, &s);+ s+ }+ Ok(SymbolOutcome::Refused { review_required, raw_response }) => {+ let _ = std::fs::write(+ ctx.cache_dir().join("claude-symbol-refusal.txt"),+ raw_response,+ );+ return finalize_refusal(&ctx, &model, review_required);+ }+ Err(e) => {+ err(format!("symbol-pass failed: {e}"));+ return finalize_unrecoverable(&ctx, &model, "symbol_pass_error", &e.to_string());+ }+ }+ };++ // Stage 1b — footprint pass (skipped if --symbol-only).+ let footprint = if symbol_only {+ None+ } else {+ match extract::run_footprint_pass(&ctx, &model, &symbol) {+ Ok(fp) => {+ let _ = extract::cache_footprint(&ctx, &fp);+ Some(fp)+ }+ Err(e) => {+ err(format!("footprint-pass failed: {e}"));+ return finalize_unrecoverable(&ctx, &model, "footprint_pass_error", &e.to_string());+ }+ }+ };++ let normalized = footprint+ .as_ref()+ .map(|fp| normalize::resolve(&fp.package_name, ctx.package_hint.as_deref()).ok())+ .unwrap_or(None);++ if let Err(e) = emit::write_all(&ctx, &symbol, footprint.as_ref(), normalized.as_ref(), &model) {+ err(format!("emit failed: {e}"));+ return finalize_unrecoverable(&ctx, &model, "emit_error", &e.to_string());+ }++ // Generate footprint SVGs — two independent renderings:+ // 1. ds2sf SVG: synthesized from the datasheet mechanical drawing+ // 2. pcbnew SVG: fetched from service-kicad (when stdlib match exists)+ let svg_result = if let Some(fp) = footprint.as_ref() {+ let r = crate::fpsvg::generate(&ctx, fp, normalized.as_ref());+ if let Some(p) = &r.ds2sf_svg {+ note(format!("[svg] datasheet-derived SVG → {}", p.display()));+ }+ if let Some(p) = &r.pcbnew_svg {+ note(format!("[svg] pcbnew comparison SVG → {}", p.display()));+ }+ if r.ds2sf_svg.is_none() {+ crate::io::warn(format!("[svg] ds2sf SVG generation failed for {}", ctx.mpn));+ }+ Some(r)+ } else {+ None+ };++ let summary = emit::summary_line(&ctx, &symbol, footprint.as_ref(), normalized.as_ref());+ ok(&summary);++ let merged_review = extract::merge_review_required(&symbol, footprint.as_ref());+ for entry in &merged_review {+ let reason = entry.get("reason").and_then(|v| v.as_str()).unwrap_or("unspecified");+ let detail = serde_json::to_string(entry).unwrap_or_default();+ crate::io::warn(format!("review needed — {reason}: {detail}"));+ }++ // Build hints[] from review_required + normalize attempts + package mismatch.+ let mut hints = build_ok_hints(&ctx, &symbol, footprint.as_ref(), normalized.as_ref(), &merged_review);++ let result_doc = ResultDoc {+ schema_version: "1".into(),+ status: Status::Ok,+ exit_code: Status::Ok.exit_code(),+ mpn: ctx.mpn.clone(),+ manufacturer: ctx.manufacturer.clone(),+ datasheet_used: ctx.datasheet_path.clone(),+ datasheet_sha256: ctx.datasheet_sha256.clone(),+ summary,+ review_required: merged_review,+ hints: std::mem::take(&mut hints),+ outputs: Outputs {+ symbol_json: Some(ctx.symbol_out_path()),+ footprint_json: footprint.as_ref().map(|_| ctx.footprint_out_path()),+ provenance_json: Some(ctx.provenance_path()),+ footprint_svg_ds2sf: svg_result.as_ref().and_then(|r| r.ds2sf_svg.clone()),+ footprint_svg_pcbnew: svg_result.as_ref().and_then(|r| r.pcbnew_svg.clone()),+ footprint_svg: svg_result.as_ref().and_then(|r| r.ds2sf_svg.clone()),+ footprint_svg_source: Some("ds2sf-datasheet-derived".to_string()),+ },+ extractor_version: env!("CARGO_PKG_VERSION").into(),+ claude_model: model.clone(),+ };+ if let Err(e) = result::write(&ctx, &result_doc) {+ err(format!("writing result.json: {e}"));+ }++ Status::Ok.exit_code()+}++fn finalize_refusal(ctx: &Context, model: &str, review_required: Vec<Value>) -> i32 {+ let alts = result::find_alternate_pdfs(ctx);+ let mut hints: Vec<Hint> = Vec::new();+ if !alts.is_empty() {+ hints.push(Hint::TryAlternatePdf {+ reason: result::reasons::MODULE_EXTERNAL_PINOUT_MISSING.into(),+ candidates: alts,+ });+ }+ let summary = format!(+ "{} — extractor refused: supplied PDF doesn't contain the right pinout (likely a module's SoC datasheet was passed instead of the module datasheet, or the wrong variant). Re-call with a different --pdf.",+ ctx.mpn+ );+ crate::io::warn(&summary);++ let doc = ResultDoc {+ schema_version: "1".into(),+ status: Status::NeedsBetterPdf,+ exit_code: Status::NeedsBetterPdf.exit_code(),+ mpn: ctx.mpn.clone(),+ manufacturer: ctx.manufacturer.clone(),+ datasheet_used: ctx.datasheet_path.clone(),+ datasheet_sha256: ctx.datasheet_sha256.clone(),+ summary,+ review_required,+ hints,+ outputs: Outputs { symbol_json: None, footprint_json: None, provenance_json: None, footprint_svg_ds2sf: None, footprint_svg_pcbnew: None, footprint_svg: None, footprint_svg_source: None },+ extractor_version: env!("CARGO_PKG_VERSION").into(),+ claude_model: model.into(),+ };+ let _ = result::write(ctx, &doc);+ Status::NeedsBetterPdf.exit_code()+}++fn finalize_unrecoverable(ctx: &Context, model: &str, reason: &str, notes: &str) -> i32 {+ let summary = format!("{} — unrecoverable: {reason}", ctx.mpn);+ let review_required = vec![json!({+ "reason": result::reasons::UNRECOVERABLE_EXTRACTION_ERROR,+ "stage_reason": reason,+ "notes": notes,+ })];+ let doc = ResultDoc {+ schema_version: "1".into(),+ status: Status::Unrecoverable,+ exit_code: Status::Unrecoverable.exit_code(),+ mpn: ctx.mpn.clone(),+ manufacturer: ctx.manufacturer.clone(),+ datasheet_used: ctx.datasheet_path.clone(),+ datasheet_sha256: ctx.datasheet_sha256.clone(),+ summary,+ review_required,+ hints: vec![Hint::HumanReview {+ reason: reason.into(),+ notes: notes.into(),+ }],+ outputs: Outputs { symbol_json: None, footprint_json: None, provenance_json: None, footprint_svg_ds2sf: None, footprint_svg_pcbnew: None, footprint_svg: None, footprint_svg_source: None },+ extractor_version: env!("CARGO_PKG_VERSION").into(),+ claude_model: model.into(),+ };+ let _ = result::write(ctx, &doc);+ Status::Unrecoverable.exit_code()+}++fn build_ok_hints(+ ctx: &Context,+ _sym: &extract::SymbolPass,+ fp: Option<&extract::FootprintPass>,+ norm: Option<&normalize::Resolved>,+ review: &[Value],+) -> Vec<Hint> {+ let mut hints = Vec::new();++ // Package mismatch between info.json and datasheet → upstream metadata fix.+ for entry in review {+ let reason = entry.get("reason").and_then(|v| v.as_str()).unwrap_or("");+ if reason == result::reasons::PACKAGE_MISMATCH_IN_HINT {+ let info_json = ctx.chip_dir.join("info.json");+ let extracted = fp+ .map(|f| f.package_name.clone())+ .unwrap_or_default();+ hints.push(Hint::UpdateUpstreamMetadata {+ file: info_json,+ key: "package".into(),+ current: ctx.package_hint.clone().unwrap_or_default(),+ extracted,+ });+ } else {+ // Generic human-review surfacing for everything else.+ let notes = entry+ .get("notes")+ .or_else(|| entry.get("note"))+ .and_then(|v| v.as_str())+ .unwrap_or("")+ .to_string();+ hints.push(Hint::HumanReview {+ reason: reason.to_string(),+ notes,+ });+ }+ }++ // Custom package → caller should use datasheet-extracted dimensions.+ if let Some(fp) = fp {+ let no_match = norm.map(|n| n.kicad_baseline.is_none()).unwrap_or(true);+ if no_match {+ hints.push(Hint::UseDatasheetExtractedFootprint {+ package: fp.package_name.clone(),+ });+ }+ }++ hints+}++fn print_inspect(prov: &serde_json::Value) {+ let mpn = prov.get("mpn").and_then(|v| v.as_str()).unwrap_or("?");+ let manuf = prov.get("manufacturer").and_then(|v| v.as_str()).unwrap_or("?");+ let pkg = prov+ .pointer("/footprint/package_name_provenance/value")+ .and_then(|v| v.as_str())+ .unwrap_or("?");+ let baseline = prov+ .pointer("/footprint/kicad_baseline")+ .map(|v| {+ if v.is_null() {+ "no stdlib match".to_string()+ } else {+ let lib = v.get("library").and_then(|x| x.as_str()).unwrap_or("?");+ let name = v.get("name").and_then(|x| x.as_str()).unwrap_or("?");+ format!("{lib}:{name}")+ }+ })+ .unwrap_or_else(|| "unknown".to_string());+ let pin_count = prov+ .pointer("/symbol/pin_provenance")+ .and_then(|v| v.as_array())+ .map(|a| a.len())+ .unwrap_or(0);+ let review = prov+ .get("review_required")+ .and_then(|v| v.as_array())+ .map(|a| a.len())+ .unwrap_or(0);++ note(format!("MPN: {mpn} (manufacturer: {manuf})"));+ note(format!("Package: {pkg}"));+ note(format!("Stdlib match: {baseline}"));+ note(format!("Pins extracted: {pin_count}"));+ note(format!("review_required entries: {review}"));++ if let Some(arr) = prov.get("review_required").and_then(|v| v.as_array()) {+ for r in arr {+ note(format!(" - {}", serde_json::to_string(r).unwrap_or_default()));+ }+ }+}++/// `ds2sf svg <CHIP-DIR>` — regenerate the authoritative footprint SVG+/// from the cached `<MPN>-footprint.extracted.json`. Useful for iterating+/// on the SVG generator without re-running Claude, and for downstream+/// tooling that wants the visual reference.+fn run_svg_only(chip_dir: &Path) -> anyhow::Result<crate::fpsvg::SvgResult> {+ let chip_dir = chip_dir.canonicalize()?;+ let mpn = chip_dir+ .file_name()+ .and_then(|s| s.to_str())+ .unwrap_or("")+ .to_string();+ let fp_path = chip_dir.join(format!("{mpn}-footprint.extracted.json"));+ let fp_raw = std::fs::read_to_string(&fp_path).map_err(|e| {+ anyhow::anyhow!("reading {} — run `ds2sf extract` first: {e}", fp_path.display())+ })?;+ let fp_json: serde_json::Value = serde_json::from_str(&fp_raw)?;++ // Reconstruct a minimal FootprintPass + Resolved from the cached JSON.+ let pkg_name = fp_json+ .get("package")+ .and_then(|v| v.as_str())+ .unwrap_or("UNKNOWN")+ .to_string();+ let body_x = fp_json.pointer("/bodyDimensions/x").and_then(|v| v.as_f64()).unwrap_or(2.0);+ let body_y = fp_json.pointer("/bodyDimensions/y").and_then(|v| v.as_f64()).unwrap_or(2.0);+ let body_t = fp_json.pointer("/bodyDimensions/thickness_max").and_then(|v| v.as_f64());+ let pad_count = fp_json.get("padCount").and_then(|v| v.as_u64()).unwrap_or(0) as u32;+ let lead_dimensions = fp_json+ .get("leadDimensions").cloned()+ .unwrap_or(serde_json::Value::Null);+ let pad_descriptions = fp_json+ .get("padDescriptions")+ .and_then(|v| v.as_object()).cloned()+ .unwrap_or_default();+ let symbol_pin_map = fp_json+ .get("symbolPinMap")+ .and_then(|v| v.as_object()).cloned()+ .unwrap_or_default();++ let fp = crate::extract::FootprintPass {+ package_name: pkg_name,+ is_standard_jedec: fp_json.get("kicad_baseline").map(|v| !v.is_null()).unwrap_or(false),+ package_name_source_page: 0,+ pad_count,+ body_dimensions: crate::extract::BodyDims {+ x: body_x,+ y: body_y,+ thickness_max: body_t,+ source_page: 0,+ figure_label: String::new(),+ },+ lead_dimensions,+ mechanical: fp_json.get("mechanical").cloned(),+ pad_descriptions,+ symbol_pin_map,+ review_required: vec![],+ };++ let kicad_baseline = fp_json+ .get("kicad_baseline")+ .and_then(|v| if v.is_null() { None } else { Some(v) })+ .and_then(|v| {+ let lib = v.get("library").and_then(|x| x.as_str())?.to_string();+ let name = v.get("name").and_then(|x| x.as_str())?.to_string();+ Some(crate::normalize::KicadBaseline {+ library: lib,+ name,+ source: "service-kicad-stdlib".into(),+ })+ });+ let resolved = crate::normalize::Resolved {+ input: fp_json.get("package").and_then(|v| v.as_str()).unwrap_or("").to_string(),+ kicad_baseline,+ search_attempts: vec![],+ };++ // Build a minimal Context with just the fields fpsvg needs.+ let mpn_buf = chip_dir.file_name().and_then(|s| s.to_str()).unwrap_or("?").to_string();+ let ctx = crate::context::Context {+ mpn: mpn_buf.clone(),+ manufacturer: None,+ package_hint: None,+ description_hint: None,+ family_hint: None,+ datasheet_path: chip_dir.join(format!("{mpn_buf}.pdf")),+ datasheet_url: None,+ datasheet_source_tier: None,+ datasheet_source_host: None,+ datasheet_sha256: String::new(),+ fetched_at: None,+ extracted_at: String::new(),+ chip_dir: chip_dir.clone(),+ out_dir: chip_dir.clone(),+ page_count: None,+ };++ let r = crate::fpsvg::generate(&ctx, &fp, Some(&resolved));+ // Back-stamp SVG paths onto result.json if it exists.+ let result_path = chip_dir.join(format!("{mpn_buf}-extraction.result.json"));+ if result_path.exists() && r.ds2sf_svg.is_some() {+ if let Ok(raw) = std::fs::read_to_string(&result_path) {+ if let Ok(mut doc) = serde_json::from_str::<serde_json::Value>(&raw) {+ if let Some(outputs) = doc.get_mut("outputs").and_then(|v| v.as_object_mut()) {+ if let Some(p) = &r.ds2sf_svg {+ outputs.insert("footprint_svg_ds2sf".into(), serde_json::json!(p.display().to_string()));+ outputs.insert("footprint_svg".into(), serde_json::json!(p.display().to_string()));+ }+ if let Some(p) = &r.pcbnew_svg {+ outputs.insert("footprint_svg_pcbnew".into(), serde_json::json!(p.display().to_string()));+ }+ outputs.insert("footprint_svg_source".into(), serde_json::json!("ds2sf-datasheet-derived"));+ let _ = std::fs::write(+ &result_path,+ serde_json::to_string_pretty(&doc).unwrap_or(raw) + "\n",+ );+ }+ }+ }+ }+ Ok(r)+}
src/context.rsadded+275@@ -0,0 +1,275 @@+use anyhow::{anyhow, Context as _, Result};+use serde::{Deserialize, Serialize};+use sha2::{Digest, Sha256};+use std::fs;+use std::path::{Path, PathBuf};++#[derive(Debug, Clone, Serialize, Deserialize)]+pub struct Context {+ pub mpn: String,+ pub manufacturer: Option<String>,+ pub package_hint: Option<String>,+ pub description_hint: Option<String>,+ pub family_hint: Option<String>,+ pub datasheet_path: PathBuf,+ pub datasheet_url: Option<String>,+ pub datasheet_source_tier: Option<String>,+ pub datasheet_source_host: Option<String>,+ pub datasheet_sha256: String,+ pub fetched_at: Option<String>,+ pub extracted_at: String,+ pub chip_dir: PathBuf,+ pub out_dir: PathBuf,+ pub page_count: Option<u32>,+}++#[derive(Debug, Deserialize)]+struct InfoJson {+ mpn: Option<String>,+ manufacturer: Option<String>,+ package: Option<String>,+ description: Option<String>,+ family: Option<String>,+ datasheet_url: Option<String>,+ datasheet_source: Option<String>,+ /// Accept either a string ("2026-05-03") or a number (Unix timestamp+ /// 1778065044). chip-fetcher writes both forms across vendors.+ #[serde(default, deserialize_with = "de_string_or_number")]+ fetched_at: Option<String>,+}++fn de_string_or_number<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {+ let v = serde_json::Value::deserialize(d)?;+ Ok(match v {+ serde_json::Value::Null => None,+ serde_json::Value::String(s) => Some(s),+ serde_json::Value::Number(n) => Some(n.to_string()),+ _ => None,+ })+}++impl Context {+ pub fn ingest(+ chip_dir: &Path,+ pdf_override: Option<&Path>,+ mpn_override: Option<&str>,+ manuf_override: Option<&str>,+ url_override: Option<&str>,+ source_tier_override: Option<&str>,+ out_dir_override: Option<&Path>,+ ) -> Result<Self> {+ let chip_dir = chip_dir.canonicalize().with_context(|| {+ format!("chip dir not found: {}", chip_dir.display())+ })?;+ let basename = chip_dir+ .file_name()+ .and_then(|s| s.to_str())+ .ok_or_else(|| anyhow!("chip dir has no basename"))?+ .to_string();++ let info_path = chip_dir.join("info.json");+ let info: Option<InfoJson> = if info_path.exists() {+ let raw = fs::read_to_string(&info_path).context("reading info.json")?;+ Some(serde_json::from_str(&raw).context("parsing info.json")?)+ } else {+ None+ };++ let mpn = mpn_override+ .map(|s| s.to_string())+ .or_else(|| info.as_ref().and_then(|i| i.mpn.clone()))+ .unwrap_or(basename.clone());++ let datasheet_path = pdf_override+ .map(|p| p.to_path_buf())+ .unwrap_or_else(|| chip_dir.join(format!("{basename}.pdf")));+ if !datasheet_path.exists() {+ return Err(anyhow!(+ "datasheet PDF not found at {} — pass --pdf to override",+ datasheet_path.display()+ ));+ }++ let datasheet_sha256 = sha256_file(&datasheet_path)?;+ let page_count = pdf_page_count(&datasheet_path);++ let manufacturer = manuf_override+ .map(|s| s.to_string())+ .or_else(|| info.as_ref().and_then(|i| i.manufacturer.clone()));+ let package_hint = info.as_ref().and_then(|i| i.package.clone());+ let description_hint = info.as_ref().and_then(|i| i.description.clone());+ let family_hint = info.as_ref().and_then(|i| i.family.clone());+ let datasheet_url = url_override+ .map(|s| s.to_string())+ .or_else(|| info.as_ref().and_then(|i| i.datasheet_url.clone()));+ let datasheet_source_host = info.as_ref().and_then(|i| i.datasheet_source.clone());+ let datasheet_source_tier = source_tier_override+ .map(|s| s.to_string())+ .or_else(|| infer_source_tier(datasheet_source_host.as_deref()));+ let fetched_at = info.as_ref().and_then(|i| i.fetched_at.clone());++ let out_dir = out_dir_override+ .map(|p| p.to_path_buf())+ .unwrap_or_else(|| chip_dir.clone());++ let extracted_at = now_iso8601();++ Ok(Context {+ mpn,+ manufacturer,+ package_hint,+ description_hint,+ family_hint,+ datasheet_path,+ datasheet_url,+ datasheet_source_tier,+ datasheet_source_host,+ datasheet_sha256,+ fetched_at,+ extracted_at,+ chip_dir,+ out_dir,+ page_count,+ })+ }++ pub fn cache_dir(&self) -> PathBuf {+ self.out_dir.join(".ds2sf-cache")+ }+ pub fn cache_dir_create(&self) -> Result<()> {+ fs::create_dir_all(self.cache_dir()).context("creating cache dir")+ }+ pub fn write_pdf_sha_marker(&self) -> Result<()> {+ let p = self.cache_dir().join("pdf-sha256.txt");+ fs::write(&p, &self.datasheet_sha256).context("writing pdf-sha256.txt")+ }++ pub fn symbol_out_path(&self) -> PathBuf {+ self.out_dir.join(format!("{}-symbol.extracted.json", self.mpn))+ }+ pub fn footprint_out_path(&self) -> PathBuf {+ self.out_dir.join(format!("{}-footprint.extracted.json", self.mpn))+ }+ pub fn provenance_path(&self) -> PathBuf {+ self.out_dir+ .join(format!("{}-extraction.provenance.json", self.mpn))+ }+ pub fn footprint_svg_path(&self) -> PathBuf {+ self.out_dir+ .join(format!("{}-footprint.authoritative.svg", self.mpn))+ }+ pub fn symbol_cache_path(&self) -> PathBuf {+ self.cache_dir().join("claude-symbol-response.json")+ }+ pub fn footprint_cache_path(&self) -> PathBuf {+ self.cache_dir().join("claude-footprint-response.json")+ }++ pub fn outputs_exist(&self) -> Result<bool> {+ Ok(self.symbol_out_path().exists()+ && self.footprint_out_path().exists()+ && self.provenance_path().exists())+ }+}++fn sha256_file(path: &Path) -> Result<String> {+ let bytes = fs::read(path).with_context(|| format!("reading {}", path.display()))?;+ let mut h = Sha256::new();+ h.update(&bytes);+ Ok(hex(&h.finalize()))+}++fn hex(bytes: &[u8]) -> String {+ let mut s = String::with_capacity(bytes.len() * 2);+ for b in bytes {+ use std::fmt::Write;+ let _ = write!(s, "{b:02x}");+ }+ s+}++fn pdf_page_count(path: &Path) -> Option<u32> {+ // Use `pdfinfo` if available (poppler-utils). Fail-soft: None.+ let out = std::process::Command::new("pdfinfo")+ .arg(path)+ .output()+ .ok()?;+ if !out.status.success() {+ return None;+ }+ for line in String::from_utf8_lossy(&out.stdout).lines() {+ if let Some(rest) = line.strip_prefix("Pages:") {+ return rest.trim().parse().ok();+ }+ }+ None+}++fn now_iso8601() -> String {+ // Avoid pulling in chrono — the date-string format is informational.+ // Use SystemTime with a tiny formatter.+ let secs = std::time::SystemTime::now()+ .duration_since(std::time::UNIX_EPOCH)+ .map(|d| d.as_secs() as i64)+ .unwrap_or(0);+ // Convert to UTC components without chrono.+ format_utc(secs)+}++fn format_utc(secs: i64) -> String {+ // Days from 1970-01-01.+ let days = secs.div_euclid(86_400);+ let secs_of_day = secs.rem_euclid(86_400);+ let h = secs_of_day / 3600;+ let m = (secs_of_day % 3600) / 60;+ let s = secs_of_day % 60;+ let (y, mo, d) = days_to_ymd(days);+ format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}Z")+}++fn days_to_ymd(days: i64) -> (i32, u32, u32) {+ // Public-domain algorithm from Howard Hinnant.+ let z = days + 719_468;+ let era = z.div_euclid(146_097);+ let doe = (z - era * 146_097) as u64;+ let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;+ let y = yoe as i64 + era * 400;+ let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);+ let mp = (5 * doy + 2) / 153;+ let d = doy - (153 * mp + 2) / 5 + 1;+ let m = if mp < 10 { mp + 3 } else { mp - 9 };+ let y = if m <= 2 { y + 1 } else { y };+ (y as i32, m as u32, d as u32)+}++fn infer_source_tier(host: Option<&str>) -> Option<String> {+ let h = host?.to_lowercase();+ let mfr_hosts = [+ "ti.com", "ti.com/lit", "st.com", "nxp.com", "espressif.com",+ "raspberrypi.com", "diodes.com", "renesas.com", "infineon.com",+ "rohm.com", "onsemi.com", "microchip.com", "analog.com",+ "vishay.com", "skyworksinc.com", "silabs.com",+ ];+ if mfr_hosts.iter().any(|m| h.contains(m)) || h.contains("manufacturer_direct") {+ return Some("mfr".to_string());+ }+ if h.contains("snapeda") || h.contains("snapmagic") {+ return Some("snapmagic".to_string());+ }+ if h.contains("mouser") {+ return Some("mouser".to_string());+ }+ if h.contains("digikey") {+ return Some("digikey".to_string());+ }+ if h.contains("arrow") {+ return Some("arrow".to_string());+ }+ if h.contains("componentsearchengine") || h.contains("ultralibrarian") {+ return Some("cse".to_string());+ }+ if h.contains("lcsc") {+ return Some("lcsc".to_string());+ }+ None+}
src/emit.rsadded+354@@ -0,0 +1,354 @@+use anyhow::{Context as _, Result};+use serde_json::{json, Map, Value};+use std::fs;++use crate::context::Context;+use crate::extract::{FootprintPass, SymbolPass};+use crate::normalize::Resolved;++pub fn write_all(+ ctx: &Context,+ sym: &SymbolPass,+ fp: Option<&FootprintPass>,+ norm: Option<&Resolved>,+ model: &str,+) -> Result<()> {+ write_symbol_json(ctx, sym)?;+ if let Some(f) = fp {+ write_footprint_json(ctx, sym, f, norm)?;+ }+ write_provenance_json(ctx, sym, fp, norm, model)?;+ Ok(())+}++fn write_symbol_json(ctx: &Context, sym: &SymbolPass) -> Result<()> {+ // Strip provenance fields from pins for the sym_create-shaped output —+ // those fields belong only in the provenance JSON, not the input to sym_create.+ let pins: Vec<Value> = sym+ .pins+ .iter()+ .map(|p| {+ let mut o = Map::new();+ o.insert("number".into(), Value::String(p.number.clone()));+ o.insert("name".into(), Value::String(p.name.clone()));+ o.insert("type".into(), Value::String(p.kind.clone()));+ if !p.group.is_empty() {+ o.insert("group".into(), Value::String(p.group.clone()));+ }+ if !p.description.is_empty() {+ o.insert("description".into(), Value::String(p.description.clone()));+ }+ Value::Object(o)+ })+ .collect();++ let mut out = Map::new();+ out.insert("symbolName".into(), Value::String(sym.symbol_name.clone()));+ if let Some(m) = sym.manufacturer.clone() {+ out.insert("manufacturer".into(), Value::String(m));+ }+ if let Some(p) = sym.package.clone() {+ out.insert("package".into(), Value::String(p));+ }+ if let Some(d) = sym.description.clone() {+ out.insert("description".into(), Value::String(d));+ }+ if let Some(u) = ctx.datasheet_url.clone() {+ out.insert("datasheetUrl".into(), Value::String(u));+ }+ out.insert("category".into(), Value::String(sym.category.clone()));+ out.insert("referencePrefix".into(), Value::String(sym.reference_prefix.clone()));+ if let Some(db) = sym.discrete_baseline.as_ref() {+ let mut baseline = Map::new();+ baseline.insert("library".into(), Value::String(db.library.clone()));+ baseline.insert("symbol".into(), Value::String(db.symbol.clone()));+ out.insert("discreteBaseline".into(), Value::Object(baseline));+ }+ out.insert("pins".into(), Value::Array(pins));+ out.insert(+ "groups".into(),+ Value::Array(+ sym.groups+ .iter()+ .map(|g| {+ let mut o = Map::new();+ o.insert("name".into(), Value::String(g.name.clone()));+ if !g.description.is_empty() {+ o.insert("description".into(), Value::String(g.description.clone()));+ }+ Value::Object(o)+ })+ .collect(),+ ),+ );++ if !sym.variants.is_empty() {+ out.insert("variants".into(), Value::Array(sym.variants.clone()));+ }++ let path = ctx.symbol_out_path();+ fs::write(&path, serde_json::to_string_pretty(&out)? + "\n")+ .with_context(|| format!("writing {}", path.display()))?;+ Ok(())+}++fn write_footprint_json(+ ctx: &Context,+ sym: &SymbolPass,+ fp: &FootprintPass,+ norm: Option<&Resolved>,+) -> Result<()> {+ let mut body = Map::new();+ body.insert("x".into(), json!(fp.body_dimensions.x));+ body.insert("y".into(), json!(fp.body_dimensions.y));+ if let Some(t) = fp.body_dimensions.thickness_max {+ body.insert("thickness_max".into(), json!(t));+ }++ // Strip provenance keys from leadDimensions so the output is consumer-clean.+ let mut lead_clean = Map::new();+ if let Some(obj) = fp.lead_dimensions.as_object() {+ for (k, v) in obj {+ if matches!(k.as_str(), "source_page" | "figure_label") {+ continue;+ }+ lead_clean.insert(k.clone(), v.clone());+ }+ }++ let mut out = Map::new();+ out.insert("footprintName".into(), Value::String(sym.symbol_name.clone()));+ if let Some(m) = sym.manufacturer.clone() {+ out.insert("manufacturer".into(), Value::String(m));+ }+ out.insert("package".into(), Value::String(fp.package_name.clone()));+ if let Some(d) = sym.description.clone() {+ out.insert("description".into(), Value::String(d));+ }+ if let Some(u) = ctx.datasheet_url.clone() {+ out.insert("datasheetUrl".into(), Value::String(u));+ }+ out.insert("padCount".into(), json!(fp.pad_count));+ out.insert("bodyDimensions".into(), Value::Object(body));+ out.insert("leadDimensions".into(), Value::Object(lead_clean));+ out.insert("padDescriptions".into(), Value::Object(fp.pad_descriptions.clone()));+ out.insert("symbolPinMap".into(), Value::Object(fp.symbol_pin_map.clone()));++ // Mechanical section — JEDEC dimensions, lead shape, pin-1 indicator,+ // extracted directly from the datasheet mechanical drawing by Claude.+ if let Some(mech) = &fp.mechanical {+ out.insert("mechanical".into(), mech.clone());+ }++ // Per-pad position array for chipsmith cross-validation.+ let computed = crate::fpsvg::compute_pads(fp);+ if !computed.is_empty() {+ out.insert("pads".into(), serde_json::to_value(&computed)?);+ }++ // 3D chip geometry — enough for chipsmith/OCCT to construct the chip+ // model from scratch without a manufacturer STEP file. When mechanical.jedec_dims+ // is present, chip_3d uses measured values; otherwise heuristic defaults.+ let chip_3d = crate::fpsvg::compute_chip_3d(fp);+ out.insert("chip_3d".into(), serde_json::to_value(&chip_3d)?);++ // Lead annotations — per-pin metadata ordered for service-step2glb /create-chip.+ // Embeds signal names into the STEP file as XCAF annotations.+ let annotations = crate::fpsvg::compute_lead_annotations(fp);+ if !annotations.is_empty() {+ out.insert("lead_annotations".into(), serde_json::to_value(&annotations)?);+ }++ if let Some(n) = norm {+ if let Some(b) = n.kicad_baseline.as_ref() {+ out.insert(+ "kicad_baseline".into(),+ json!({ "library": b.library, "name": b.name, "source": b.source }),+ );+ out.insert(+ "package_outline_source".into(),+ Value::String("service-kicad-stdlib".into()),+ );+ } else {+ out.insert("kicad_baseline".into(), Value::Null);+ out.insert(+ "package_outline_source".into(),+ Value::String("datasheet-extracted".into()),+ );+ }+ }++ let path = ctx.footprint_out_path();+ fs::write(&path, serde_json::to_string_pretty(&out)? + "\n")+ .with_context(|| format!("writing {}", path.display()))?;+ Ok(())+}++fn write_provenance_json(+ ctx: &Context,+ sym: &SymbolPass,+ fp: Option<&FootprintPass>,+ norm: Option<&Resolved>,+ model: &str,+) -> Result<()> {+ let pin_provenance: Vec<Value> = sym+ .pins+ .iter()+ .map(|p| {+ let mut o = Map::new();+ o.insert("pin_number".into(), Value::String(p.number.clone()));+ o.insert("pin_name".into(), Value::String(p.name.clone()));+ o.insert("page".into(), json!(p.source_page));+ o.insert(+ "table_caption".into(),+ Value::String(p.source_table_caption.clone()),+ );+ o.insert("confidence".into(), Value::String(p.confidence.clone()));+ if !p.notes.is_empty() {+ o.insert("notes".into(), Value::String(p.notes.clone()));+ }+ Value::Object(o)+ })+ .collect();++ let symbol_block = json!({+ "pin_provenance": pin_provenance,+ "groups_emitted": sym.groups.iter().map(|g| g.name.clone()).collect::<Vec<_>>(),+ });++ let footprint_block = match fp {+ Some(f) => {+ let mut dim_prov: Vec<Value> = Vec::new();+ dim_prov.push(json!({+ "field": "bodyDimensions.x",+ "value": f.body_dimensions.x,+ "page": f.body_dimensions.source_page,+ "figure_label": f.body_dimensions.figure_label,+ }));+ dim_prov.push(json!({+ "field": "bodyDimensions.y",+ "value": f.body_dimensions.y,+ "page": f.body_dimensions.source_page,+ "figure_label": f.body_dimensions.figure_label,+ }));+ if let Some(t) = f.body_dimensions.thickness_max {+ dim_prov.push(json!({+ "field": "bodyDimensions.thickness_max",+ "value": t,+ "page": f.body_dimensions.source_page,+ "figure_label": f.body_dimensions.figure_label,+ }));+ }+ if let Some(obj) = f.lead_dimensions.as_object() {+ let lead_page = obj.get("source_page").and_then(|v| v.as_u64()).unwrap_or(0);+ let lead_label = obj.get("figure_label").and_then(|v| v.as_str()).unwrap_or("").to_string();+ for (k, v) in obj {+ if matches!(k.as_str(), "source_page" | "figure_label") {+ continue;+ }+ dim_prov.push(json!({+ "field": format!("leadDimensions.{k}"),+ "value": v,+ "page": lead_page,+ "figure_label": lead_label,+ }));+ }+ }++ let kicad_baseline = norm+ .and_then(|n| n.kicad_baseline.as_ref())+ .map(|b| json!({ "library": b.library, "name": b.name }))+ .unwrap_or(Value::Null);++ let attempts = norm+ .map(|n| {+ n.search_attempts+ .iter()+ .map(|a| {+ json!({ "library": a.library, "name": a.name, "result": a.result })+ })+ .collect::<Vec<_>>()+ })+ .unwrap_or_default();++ json!({+ "package_name_provenance": {+ "value": f.package_name,+ "page": f.package_name_source_page,+ "info_json_value": ctx.package_hint,+ "info_json_used": ctx.package_hint.is_some(),+ "is_standard_jedec": f.is_standard_jedec+ },+ "kicad_baseline": kicad_baseline,+ "kicad_baseline_search_attempts": attempts,+ "dimension_provenance": dim_prov+ })+ }+ None => Value::Null,+ };++ let merged_review = crate::extract::merge_review_required(sym, fp);++ let prov = json!({+ "schema_version": "1",+ "mpn": ctx.mpn,+ "manufacturer": ctx.manufacturer,+ "datasheet": {+ "path": ctx.datasheet_path,+ "url": ctx.datasheet_url,+ "sha256": ctx.datasheet_sha256,+ "source_tier": ctx.datasheet_source_tier,+ "source_host": ctx.datasheet_source_host,+ "fetched_at": ctx.fetched_at,+ "page_count": ctx.page_count+ },+ "tooling": {+ "extractor": "ds2sf",+ "extractor_version": env!("CARGO_PKG_VERSION"),+ "extracted_at": ctx.extracted_at,+ "claude_model": model,+ "service_kicad_used": norm.is_some(),+ "service_kicad_baseline_match":+ norm.map(|n| n.kicad_baseline.is_some()).unwrap_or(false)+ },+ "symbol": symbol_block,+ "footprint": footprint_block,+ "review_required": merged_review+ });++ let path = ctx.provenance_path();+ fs::write(&path, serde_json::to_string_pretty(&prov)? + "\n")+ .with_context(|| format!("writing {}", path.display()))?;+ Ok(())+}++pub fn summary_line(+ ctx: &Context,+ sym: &SymbolPass,+ fp: Option<&FootprintPass>,+ norm: Option<&Resolved>,+) -> String {+ let baseline = match norm.and_then(|n| n.kicad_baseline.as_ref()) {+ Some(b) => format!("{}:{}", b.library, b.name),+ None => "no stdlib match".to_string(),+ };+ let groups: Vec<&str> = sym.groups.iter().map(|g| g.name.as_str()).collect();+ let groups_str = if groups.is_empty() {+ "(no groups)".to_string()+ } else {+ groups.join(", ")+ };+ let pkg = fp.map(|f| f.package_name.as_str()).unwrap_or("(no footprint pass)");+ let pad_count = fp.map(|f| f.pad_count).unwrap_or(0);+ let review = crate::extract::merge_review_required(sym, fp).len();+ format!(+ "{} — {} pins, package {} ({} pads), {}, groups: {}, review_required: {}",+ ctx.mpn,+ sym.pins.len(),+ pkg,+ pad_count,+ baseline,+ groups_str,+ review,+ )+}
src/extract.rsadded+720@@ -0,0 +1,720 @@+use anyhow::{anyhow, Context as _, Result};+use serde::{Deserialize, Serialize};+use serde_json::{json, Value};+use std::fs;+use std::io::Write;+use std::process::{Command, Stdio};++use crate::context::Context;+use crate::io::{note, warn};++/// Outcome of the symbol pass. Lets cli.rs distinguish "model refused with explanation"+/// (which is recoverable — try a different PDF) from a hard parse/CLI failure.+pub enum SymbolOutcome {+ Ok(SymbolPass),+ Refused {+ review_required: Vec<Value>,+ raw_response: String,+ },+}++#[derive(Debug, Clone, Serialize, Deserialize)]+pub struct SymbolPass {+ #[serde(rename = "symbolName")]+ pub symbol_name: String,+ pub manufacturer: Option<String>,+ pub package: Option<String>,+ pub description: Option<String>,+ #[serde(default = "default_category")]+ pub category: String,+ #[serde(rename = "referencePrefix", default = "default_ref_prefix")]+ pub reference_prefix: String,+ pub pins: Vec<Pin>,+ #[serde(default)]+ pub groups: Vec<Group>,+ /// Optional KiCad standard-library baseline for discrete parts+ /// (transistors / diodes / passives). Lets downstream sym_create reuse+ /// the canonical symbol shape instead of authoring a generic rectangle.+ #[serde(default, rename = "discreteBaseline", skip_serializing_if = "Option::is_none")]+ pub discrete_baseline: Option<DiscreteBaseline>,+ #[serde(default)]+ pub variants: Vec<Value>,+ #[serde(default, rename = "review_required")]+ pub review_required: Vec<Value>,+}++#[derive(Debug, Clone, Serialize, Deserialize)]+pub struct DiscreteBaseline {+ pub library: String,+ pub symbol: String,+}++fn default_category() -> String { "ic".to_string() }+fn default_ref_prefix() -> String { "U".to_string() }++#[derive(Debug, Clone, Serialize, Deserialize)]+pub struct Pin {+ pub number: String,+ pub name: String,+ #[serde(rename = "type")]+ pub kind: String,+ #[serde(default)]+ pub group: String,+ #[serde(default)]+ pub description: String,+ #[serde(default)]+ pub source_page: u32,+ #[serde(default)]+ pub source_table_caption: String,+ #[serde(default = "default_confidence")]+ pub confidence: String,+ #[serde(default, skip_serializing_if = "String::is_empty")]+ pub notes: String,+}++fn default_confidence() -> String { "medium".to_string() }++#[derive(Debug, Clone, Serialize, Deserialize)]+pub struct Group {+ pub name: String,+ #[serde(default)]+ pub description: String,+}++#[derive(Debug, Clone, Serialize, Deserialize)]+pub struct FootprintPass {+ pub package_name: String,+ #[serde(default)]+ pub is_standard_jedec: bool,+ #[serde(default)]+ pub package_name_source_page: u32,+ #[serde(rename = "padCount")]+ pub pad_count: u32,+ #[serde(rename = "bodyDimensions")]+ pub body_dimensions: BodyDims,+ #[serde(rename = "leadDimensions")]+ pub lead_dimensions: Value,+ #[serde(default)]+ pub mechanical: Option<Value>,+ #[serde(rename = "padDescriptions", default)]+ pub pad_descriptions: serde_json::Map<String, Value>,+ #[serde(rename = "symbolPinMap", default)]+ pub symbol_pin_map: serde_json::Map<String, Value>,+ #[serde(default, rename = "review_required")]+ pub review_required: Vec<Value>,+}++#[derive(Debug, Clone, Serialize, Deserialize)]+pub struct BodyDims {+ pub x: f64,+ pub y: f64,+ pub thickness_max: Option<f64>,+ #[serde(default)]+ pub source_page: u32,+ #[serde(default)]+ pub figure_label: String,+}++const KICAD_ERC_TYPES: &[&str] = &[+ "power_in", "power_out", "input", "output", "bidirectional",+ "passive", "unconnected", "tri_state",+];++const ADOM_GROUPS: &[&str] = &[+ "POWER", "GROUND", "I2C", "SPI", "UART", "USB", "QSPI", "GPIO",+ "ADC", "DAC", "CONTROL", "TIMING", "ALERT", "NC", "DEBUG", "RF",+ "CAN", "ETHERNET", "PWM", "AUDIO", "VIDEO", "OTHER",+];++pub fn run_symbol_pass(ctx: &Context, model: &str) -> Result<SymbolOutcome> {+ note(format!("[1a] symbol-pass calling claude (model={model})"));+ let prompt = build_symbol_prompt(ctx);+ let raw = call_claude(&prompt, model, "symbol-pass-1", &ctx.chip_dir)?;+ fs::write(ctx.cache_dir().join("claude-symbol-raw.txt"), &raw).ok();++ let parsed_json = extract_json(&raw)?;++ // Refusal path: empty pins + populated review_required = the model declined to+ // hallucinate (e.g. wrong PDF). This is recoverable upstream — bubble out as+ // a Refused outcome with the review_required so the result.json carries it.+ if let Some(rr) = is_refusal(&parsed_json) {+ return Ok(SymbolOutcome::Refused {+ review_required: rr,+ raw_response: raw,+ });+ }++ match validate_symbol(&parsed_json) {+ Ok(()) => {+ let pass: SymbolPass = serde_json::from_value(parsed_json)+ .context("deserializing symbol-pass JSON")?;+ Ok(SymbolOutcome::Ok(pass))+ }+ Err(e) => {+ warn(format!("symbol-pass validation failed: {e} — retrying once"));+ let retry_prompt = format!(+ "{}\n\nYour previous response failed validation:\n{}\n\nRespond again with corrected JSON only.",+ prompt, e+ );+ let raw2 = call_claude(&retry_prompt, model, "symbol-pass-2", &ctx.chip_dir)?;+ fs::write(ctx.cache_dir().join("claude-symbol-raw-retry.txt"), &raw2).ok();+ let parsed2 = extract_json(&raw2)?;+ if let Some(rr) = is_refusal(&parsed2) {+ return Ok(SymbolOutcome::Refused {+ review_required: rr,+ raw_response: raw2,+ });+ }+ validate_symbol(&parsed2)+ .with_context(|| "symbol-pass validation failed after retry")?;+ let pass: SymbolPass = serde_json::from_value(parsed2)+ .context("deserializing symbol-pass JSON (retry)")?;+ Ok(SymbolOutcome::Ok(pass))+ }+ }+}++/// Detects a "refusal" — empty pins[] but review_required[] is populated, meaning+/// Claude explained why it can't extract (typically wrong PDF for a module).+fn is_refusal(v: &Value) -> Option<Vec<Value>> {+ let pins_empty = v+ .get("pins")+ .and_then(|p| p.as_array())+ .map(|a| a.is_empty())+ .unwrap_or(true);+ if !pins_empty {+ return None;+ }+ let rr = v+ .get("review_required")+ .and_then(|r| r.as_array())+ .cloned()+ .unwrap_or_default();+ if rr.is_empty() { None } else { Some(rr) }+}++pub fn run_footprint_pass(ctx: &Context, model: &str, sym: &SymbolPass) -> Result<FootprintPass> {+ note(format!("[1b] footprint-pass calling claude (model={model})"));+ let prompt = build_footprint_prompt(ctx, sym);+ let raw = call_claude(&prompt, model, "footprint-pass-1", &ctx.chip_dir)?;+ fs::write(ctx.cache_dir().join("claude-footprint-raw.txt"), &raw).ok();++ let parsed = extract_json(&raw)?;+ match validate_footprint(&parsed, sym) {+ Ok(()) => {+ let fp: FootprintPass = serde_json::from_value(parsed)+ .context("deserializing footprint-pass JSON")?;+ Ok(fp)+ }+ Err(e) => {+ warn(format!("footprint-pass validation failed: {e} — retrying once"));+ let retry_prompt = format!(+ "{}\n\nYour previous response failed validation:\n{}\n\nRespond again with corrected JSON only.",+ prompt, e+ );+ let raw2 = call_claude(&retry_prompt, model, "footprint-pass-2", &ctx.chip_dir)?;+ fs::write(ctx.cache_dir().join("claude-footprint-raw-retry.txt"), &raw2).ok();+ let parsed2 = extract_json(&raw2)?;+ validate_footprint(&parsed2, sym)+ .with_context(|| "footprint-pass validation failed after retry")?;+ let fp: FootprintPass = serde_json::from_value(parsed2)+ .context("deserializing footprint-pass JSON (retry)")?;+ Ok(fp)+ }+ }+}++pub fn cache_symbol(ctx: &Context, sym: &SymbolPass) -> Result<()> {+ let p = ctx.symbol_cache_path();+ fs::write(&p, serde_json::to_string_pretty(sym)?)+ .with_context(|| format!("writing {}", p.display()))?;+ Ok(())+}++pub fn cache_footprint(ctx: &Context, fp: &FootprintPass) -> Result<()> {+ let p = ctx.footprint_cache_path();+ fs::write(&p, serde_json::to_string_pretty(fp)?)+ .with_context(|| format!("writing {}", p.display()))?;+ Ok(())+}++pub fn load_cached_symbol(ctx: &Context) -> Result<SymbolPass> {+ let p = ctx.symbol_cache_path();+ let raw = fs::read_to_string(&p)+ .with_context(|| format!("reading cached symbol at {}", p.display()))?;+ serde_json::from_str(&raw).context("parsing cached symbol JSON")+}++pub fn merge_review_required(sym: &SymbolPass, fp: Option<&FootprintPass>) -> Vec<Value> {+ let mut all = sym.review_required.clone();+ if let Some(f) = fp {+ all.extend(f.review_required.clone());+ }+ all+}++fn build_symbol_prompt(ctx: &Context) -> String {+ let manuf = ctx.manufacturer.as_deref().unwrap_or("(unknown manufacturer)");+ let pkg_hint = ctx.package_hint.as_deref().unwrap_or("(no package hint)");+ let desc = ctx.description_hint.as_deref().unwrap_or("");+ let ds_path = ctx.datasheet_path.display();+ let mpn = &ctx.mpn;++ format!(+ r#"You are extracting symbol/pinout data for the chip "{mpn}" from its datasheet PDF.++CONTEXT (from upstream chip-fetcher):+- MPN: {mpn}+- Manufacturer: {manuf}+- Package hint: {pkg_hint}+- Family/description hint: {desc}++DATASHEET PDF (please use the Read tool to read this file):+ {ds_path}++THIS CALL IS ONLY ABOUT PIN/SYMBOL DATA. Ignore mechanical drawings and footprint+land patterns — a separate call will handle those. Your output must be valid JSON+matching the schema below; do not return explanatory text outside the JSON block.++If the datasheet covers multiple package variants, restrict the pinout to the+variant matching the package hint above.++SCHEMA:+{{+ "symbolName": "string (use the MPN, possibly stripped of suffixes)",+ "manufacturer": "string",+ "package": "string (concise package name, e.g. 'VSSOP-10')",+ "description": "1-3 sentence chip summary",+ "category": "ic | discrete",+ "referencePrefix": "U (IC) | Q (transistor) | D (diode/LED) | R | C | L | Y (crystal) | F (fuse) | BT | J/P (connector) | SW | K (relay) | T (transformer)",+ "discreteBaseline": {{ "library": "Transistor_FET | Device | ...", "symbol": "Q_PMOS_GSD | LED | ..." }},+ "pins": [+ {{+ "number": "string (pin number as it appears on the package; e.g. '1' or 'A4')",+ "name": "string (e.g. 'VDD', 'SCL', 'GPIO0')",+ "type": "one of: power_in, power_out, input, output, bidirectional, passive, unconnected, tri_state",+ "group": "one of: POWER, GROUND, I2C, SPI, UART, USB, QSPI, GPIO, ADC, DAC, CONTROL, TIMING, ALERT, NC, DEBUG, RF, CAN, ETHERNET, PWM, AUDIO, VIDEO, OTHER",+ "description": "1-3 sentence engineering description with V/I/Ω where applicable",+ "source_page": 0,+ "source_table_caption": "string (e.g. 'Table 7-1. Pin description')",+ "confidence": "high | medium | low",+ "notes": "optional — muxed-pin protocols, alt-functions, reset-state, etc."+ }}+ ],+ "groups": [+ {{ "name": "POWER", "description": "2-4 sentence group description" }}+ ],+ "variants": [+ {{+ "package": "string (e.g. 'VSSOP-10')",+ "ordering_suffixes": ["IDGSR", "IDGS"],+ "pin_count": 10,+ "body_dims_approx_mm": [3.0, 3.0, 1.1],+ "source_pages": [1, 48],+ "compatible_pinout": true,+ "notes": "optional — differences from the main extraction (thermal pad, extra pins, etc.)"+ }}+ ],+ "review_required": [+ {{ "reason": "muxed_pin_protocol_select", "pins": ["A4","A5"] }}+ ]+}}++MULTI-VARIANT DISCOVERY:+- Most datasheets describe the chip in multiple package variants (e.g. SOIC-8 + TSSOP-8,+ or QFN-32 + LQFP-48). List ALL variants you find in the "variants" array.+- For each variant: the package name, any ordering code suffixes that map to it+ (check the ordering information or package marking table), approximate body+ dimensions if visible, and which pages describe it.+- compatible_pinout = true when the variant has the same pin-name assignments as+ the main extraction (just a different physical package). Set false when pin+ assignments differ (different number of pins, or pins rearranged).+- The main pins[] / groups[] still focus on the variant matching package_hint above.+ The variants[] array is a discovery catalog for chip-fetcher to offer the user+ alternative packages (smaller, cheaper, different thermal characteristics).+- Include at least the variant matching package_hint in the array.++RULES:+- Every pin MUST include source_page (the 1-indexed PDF page where the pin's row appears in the pinout table).+- Use canonical Adom group names exactly. Collapse separate VCC/GND tables into POWER unless the datasheet has a clear distinction.+- For muxed pins (e.g. SDA/MOSI), keep the slash-delimited name and add a note explaining how the protocol is selected.+- When uncertain, set confidence to "low" and add a row to review_required.+- If the data isn't in the datasheet, omit the field and surface it in review_required — DO NOT hallucinate.+- IF THE PART IS A MODULE (chip + supporting components packaged together with castellated edge pads, e.g. ESP32-WROOM, NORA, ATWINC, SARA-R5, etc.), extract the MODULE's external pinout — typically 20-60 castellated pads — NOT the internal SoC's pinout. The module's pin numbers are usually 1..N matching silkscreen labels on the module's edge. Add a review_required entry of reason "module_external_pinout" so downstream tools know this symbol represents a module.++- IF THE DATASHEET DESCRIBES SIGNAL CLASSES BUT DEFERS PER-PACKAGE PIN-NUMBER ASSIGNMENTS to a separate supplement (Lattice iCE40 family is the textbook case — main datasheet has only signal-level descriptions like CRESET_B, SPI_SCK, G0–G6, RGB0/1/2, generic PIOT_xx/PIOB_xx, with the SG48/UG121-pin-number mapping deferred to "iCE40 UltraPlus Pinout Files" or a "Package Diagrams" supplement) — DO NOT EXTRACT PARTIAL SIGNAL DATA. Instead emit `pins: []` and add a `review_required` entry with reason `pinout_supplement_required` listing the supplement filename or document title the datasheet references. The upstream caller (chip-fetcher) will walk to a sibling PDF named `*-package-diagrams.pdf` / `*-pinout*.pdf` / `*-hardware-checklist.pdf` and re-call.++DISCRETE COMPONENT CLASSIFICATION (CRITICAL — gets the schematic refdes right):++If the part is a discrete component (NOT an integrated circuit), set:+ - category: "discrete" (NOT "ic")+ - referencePrefix to the EE-canonical letter:++ Part type | category | referencePrefix+ --------------------------------------------|------------|----------------+ MOSFET / BJT / JFET / IGBT / transistor | discrete | Q+ Diode / LED / Schottky / TVS / Zener / SBD | discrete | D+ Resistor (chip resistor, current sense) | discrete | R+ Capacitor (cap, varactor, trimcap) | discrete | C+ Inductor / choke / ferrite bead | discrete | L+ Crystal / resonator / TCXO / oscillator | discrete | Y+ Fuse | discrete | F+ Battery | discrete | BT+ Connector / jack / receptacle / header | discrete | J or P+ Switch / button / tactile | discrete | SW+ Relay | discrete | K+ Transformer | discrete | T++ - For 3-lead transistors, pins are conventionally G/D/S (MOSFET/JFET) or B/C/E (BJT)+ - For 2-lead diodes/LEDs, pins are conventionally Anode/A and Cathode/K (or numbered 1/2)+ - For 2-lead passives (resistor/cap/inductor), pins are conventionally numbered 1/2++If the part has a control input + multiple outputs / inputs / interfaces, it's an IC: keep+ category: "ic" and referencePrefix: "U". Op-amps, comparators, voltage references,+ ADCs, DACs, microcontrollers, FPGAs, sensors with I2C/SPI — all ICs. Don't be fooled+ by low pin counts: an 8-pin op-amp is an IC, not a discrete.++For discrete parts you may also propose a discreteBaseline so downstream sym_create can+reuse a KiCad standard symbol. Examples:+ - P-channel MOSFET → discreteBaseline: {{ library: "Transistor_FET", symbol: "Q_PMOS_GSD" }}+ - N-channel MOSFET → {{ library: "Transistor_FET", symbol: "Q_NMOS_GDS" }}+ - LED → {{ library: "Device", symbol: "LED" }}+ - Diode → {{ library: "Device", symbol: "D" }}+ - TVS / Zener → {{ library: "Device", symbol: "D_TVS" }} or "D_Zener"++Return ONLY the JSON object. No prose before or after.+"#,+ )+}++fn build_footprint_prompt(ctx: &Context, sym: &SymbolPass) -> String {+ let manuf = ctx.manufacturer.as_deref().unwrap_or("(unknown)");+ let pkg_hint = ctx.package_hint.as_deref().unwrap_or("(no hint)");+ let ds_path = ctx.datasheet_path.display();+ let mpn = &ctx.mpn;++ let pin_map = serde_json::to_string_pretty(+ &sym.pins+ .iter()+ .map(|p| json!({"number": p.number, "name": p.name}))+ .collect::<Vec<_>>(),+ )+ .unwrap_or_default();++ format!(+ r#"You are extracting package outline + recommended land-pattern data for "{mpn}".++CONTEXT:+- MPN: {mpn}+- Manufacturer: {manuf}+- Package hint (from upstream chip-fetcher): {pkg_hint}++DATASHEET PDF (use the Read tool):+ {ds_path}++SYMBOL PIN MAP (canonical from the prior symbol-pass — DO NOT re-derive these):+{pin_map}++THIS CALL IS ONLY ABOUT THE PACKAGE OUTLINE AND RECOMMENDED LAND PATTERN.+Ignore pin functional descriptions; you already have the pin name → number map.+Mechanical drawings + recommended-solder-pattern figures are usually near the back+of the datasheet (e.g. §12, §13, §"Mechanical data", §"Package information").++SCHEMA:+{{+ "package_name": "string (e.g. 'VSSOP-10', 'VQFN-32', 'OLGA-16'). Drop dimensions/marking-codes from this — keep the canonical JEDEC/IPC name when one exists.",+ "is_standard_jedec": true,+ "package_name_source_page": 0,+ "padCount": 0,+ "bodyDimensions": {{ "x": 0.0, "y": 0.0, "thickness_max": 0.0,+ "source_page": 0, "figure_label": "string" }},+ "leadDimensions": {{ "pitch_x": 0.0, "pitch_y": 0.0,+ "pad_w": 0.0, "pad_h": 0.0,+ "thermal_pad": null,+ "source_page": 0, "figure_label": "string" }},+ "mechanical": {{+ "jedec_dims": {{+ "A": {{ "value": 0.0, "min": null, "max": null, "label": "Total package height", "source_page": 0, "figure_label": "string" }},+ "A1": {{ "value": 0.0, "label": "Standoff height (body bottom to PCB)", "source_page": 0, "figure_label": "string" }},+ "A2": {{ "value": 0.0, "label": "Body height (without leads)", "source_page": 0, "figure_label": "string" }},+ "D": {{ "value": 0.0, "label": "Body length", "source_page": 0, "figure_label": "string" }},+ "E": {{ "value": 0.0, "label": "Body width (across leads/pads)", "source_page": 0, "figure_label": "string" }},+ "E1": {{ "value": 0.0, "label": "Body width (molded body only, excluding leads)", "source_page": 0, "figure_label": "string" }},+ "b": {{ "value": 0.0, "label": "Lead width", "source_page": 0, "figure_label": "string" }},+ "e": {{ "value": 0.0, "label": "Lead pitch", "source_page": 0, "figure_label": "string" }},+ "L": {{ "value": 0.0, "label": "Lead foot length (toe to heel)", "source_page": 0, "figure_label": "string" }},+ "L1": {{ "value": 0.0, "label": "Lead length from body edge", "source_page": 0, "figure_label": "string" }}+ }},+ "lead_shape": "gull_wing | j_lead | flat | ball | straight_pin | castellated | no_lead",+ "pin1_indicator": "chamfer | dot | bar | notch | none",+ "pin1_indicator_page": 0,+ "exposed_pad_paste_coverage_pct": null,+ "keepout_distance_mm": null,+ "courtyard_source_page": null,+ "courtyard_figure_label": null+ }},+ "padDescriptions": {{ "1": "VDD — 1-line pad description, may include footprint-specific notes (thermal pad, drill, plating)" }},+ "symbolPinMap": {{ "1": "VDD" }},+ "review_required": [+ {{ "reason": "non_standard_package_no_kicad_baseline", "package": "..." }}+ ]+}}++IMPORTANT — THE `mechanical` SECTION:+- Read the mechanical drawing page(s) carefully. Look for the JEDEC dimension+ callout letters (A, A1, A2, D, E, E1, b, e, L, L1). Most datasheets label+ them in a table adjacent to the mechanical drawing.+- Not every dimension applies to every package. Include only what's present.+ QFN has A/A1/D/E/b/e but no L (flat pads). SOIC has A/A1/D/E/E1/b/e/L.+ BGA has A/A1/D/E/e + ball diameter (report as b). DIP has A/A1/D/E/b/e/L.+- Use nominal values in mm. When only min/max are given, set value to the midpoint.+- lead_shape: look at the mechanical drawing's side view. Gull-wing leads+ bend outward from the body and down. J-leads curl under. Flat pads sit+ under the body (QFN/LGA). Balls are solder spheres (BGA).+- pin1_indicator: look at the TOP view for a chamfer, dot, bar, or notch+ marking pin 1. Report the type and the page.+- exposed_pad_paste_coverage_pct: some datasheets specify solder paste+ coverage % for the center EP (e.g. "apply 50% paste coverage"). Include+ if stated.++RULES:+- Every dimension entry MUST include source_page and figure_label (e.g. "Figure 12-1. Mechanical Drawing", "Figure 13. Recommended Land Pattern").+- If the package is a standard JEDEC/IPC package (SOIC, SOT, QFN, LQFP, BGA, etc.) and you can identify it confidently, set is_standard_jedec: true.+- If the package is non-standard (e.g. a custom optical LGA, a module with castellated edges), set is_standard_jedec: false and fill bodyDimensions + leadDimensions from the mechanical drawing precisely.+- padDescriptions and symbolPinMap MUST reuse the pin numbers + names from the SYMBOL PIN MAP above. padCount MUST equal the length of that map.+- Dimensions in millimeters. If the datasheet lists nominal/min/max, use the nominal.+- When data isn't in the datasheet, omit the field and surface in review_required — DO NOT hallucinate dimensions.++Return ONLY the JSON object. No prose before or after.+"#,+ )+}++fn call_claude(prompt: &str, model: &str, tag: &str, add_dir: &std::path::Path) -> Result<String> {+ // Shell `claude -p --output-format json --model <model> --add-dir <chip-dir>`.+ // Feed the prompt on stdin. The --add-dir flag whitelists the chip directory so+ // Claude's Read tool can read the PDF without permission prompts.+ note(format!(" → claude -p ({tag}, {} chars prompt, add-dir={})", prompt.len(), add_dir.display()));++ let mut child = Command::new("claude")+ .arg("-p")+ .arg("--output-format").arg("json")+ .arg("--model").arg(model)+ .arg("--add-dir").arg(add_dir)+ .arg("--max-budget-usd").arg("5.00")+ .stdin(Stdio::piped())+ .stdout(Stdio::piped())+ .stderr(Stdio::piped())+ .spawn()+ .context("spawning `claude -p` — is the claude CLI on PATH?")?;++ {+ let stdin = child.stdin.as_mut().ok_or_else(|| anyhow!("claude stdin"))?;+ stdin.write_all(prompt.as_bytes())?;+ }++ let output = child.wait_with_output().context("waiting for claude")?;+ if !output.status.success() {+ let stderr = String::from_utf8_lossy(&output.stderr);+ return Err(anyhow!("claude CLI exited {}: {}", output.status, stderr));+ }+ let stdout = String::from_utf8_lossy(&output.stdout).to_string();++ // The --output-format json envelope wraps the model's text response in a JSON object.+ if let Ok(envelope) = serde_json::from_str::<Value>(&stdout) {+ let is_error = envelope.get("is_error").and_then(|v| v.as_bool()).unwrap_or(false);+ let result_text = envelope.get("result").and_then(|v| v.as_str()).unwrap_or("");+ if is_error {+ return Err(anyhow!(+ "claude CLI returned an error envelope: {}",+ result_text+ ));+ }+ if !result_text.is_empty() {+ note(format!(+ " ← claude finished ({} duration_ms, ${} cost)",+ envelope.get("duration_ms").map(|v| v.to_string()).unwrap_or_default(),+ envelope.get("total_cost_usd").map(|v| v.to_string()).unwrap_or_default(),+ ));+ return Ok(result_text.to_string());+ }+ if let Some(s) = envelope.get("text").and_then(|v| v.as_str()) {+ return Ok(s.to_string());+ }+ }+ Ok(stdout)+}++fn extract_json(raw: &str) -> Result<Value> {+ // Strip code fences if present, then locate the outermost {...} block.+ let s = raw.trim();+ let s = s.strip_prefix("```json").unwrap_or(s);+ let s = s.strip_prefix("```").unwrap_or(s);+ let s = s.strip_suffix("```").unwrap_or(s).trim();++ let start = match s.find('{') {+ Some(i) => i,+ None => {+ // Haiku sometimes returns a prose-only refusal ("Unable to proceed+ // — this PDF is the Adafruit user guide, not the NXP datasheet")+ // without a JSON wrapper. Detect refusal language and synthesize+ // an envelope so the upstream pipeline still gets a structured+ // needs_better_pdf result with try_alternate_pdf hints.+ let lower = raw.to_lowercase();+ let refusal_markers = [+ "unable to proceed",+ "user guide",+ "shield",+ "not the actual",+ "not the datasheet",+ "wrong document",+ "different chip",+ "different variant",+ "module datasheet is required",+ "this guide",+ "manufacturer datasheet is required",+ // Per-package-pinout-supplement refusal (Lattice iCE40 case+ // — main datasheet describes signal classes only and defers+ // SG48/SG78/UG121-pin assignments to a separate Pinout+ // Files supplement).+ "external pinout file",+ "supplement document",+ "pinout files",+ "package-specific pinout",+ "package-diagram",+ "pinout supplement",+ "deferred to the external",+ "pinout file is referenced",+ ];+ if refusal_markers.iter().any(|m| lower.contains(m)) {+ return Ok(serde_json::json!({+ "pins": [],+ "review_required": [{+ "reason": "module_external_pinout_missing",+ "details": raw.lines().take(10).collect::<Vec<_>>().join(" "),+ }]+ }));+ }+ return Err(anyhow!("no JSON object in claude response"));+ }+ };+ let mut depth = 0i32;+ let mut end = None;+ let bytes = s.as_bytes();+ let mut in_str = false;+ let mut esc = false;+ for (i, &b) in bytes.iter().enumerate().skip(start) {+ if esc {+ esc = false;+ continue;+ }+ match b {+ b'\\' if in_str => esc = true,+ b'"' => in_str = !in_str,+ b'{' if !in_str => depth += 1,+ b'}' if !in_str => {+ depth -= 1;+ if depth == 0 {+ end = Some(i + 1);+ break;+ }+ }+ _ => {}+ }+ }+ let end = end.ok_or_else(|| anyhow!("unbalanced JSON braces"))?;+ let obj = &s[start..end];+ serde_json::from_str::<Value>(obj).with_context(|| {+ let snippet: String = obj.chars().take(400).collect();+ format!("parsing JSON: {snippet}…")+ })+}++fn validate_symbol(v: &Value) -> Result<()> {+ let pins = v.get("pins").and_then(|x| x.as_array()).ok_or_else(|| anyhow!("missing pins[]"))?;+ if pins.is_empty() {+ // Empty pins[] + review_required explanation = Claude refused to hallucinate+ // (e.g. wrong PDF for a module product). Treat this as a non-retry-able state;+ // the caller surfaces it via review_required.+ let has_explanation = v+ .get("review_required")+ .and_then(|r| r.as_array())+ .map(|a| !a.is_empty())+ .unwrap_or(false);+ if has_explanation {+ return Err(anyhow!(+ "pins[] is empty but review_required is populated — extractor refused to hallucinate; consult review_required and try --pdf <path> to a more-specific datasheet"+ ));+ }+ return Err(anyhow!("pins[] is empty"));+ }+ let mut seen = std::collections::HashSet::new();+ for (i, p) in pins.iter().enumerate() {+ let num = p.get("number").and_then(|x| x.as_str())+ .ok_or_else(|| anyhow!("pins[{i}].number missing"))?;+ if !seen.insert(num.to_string()) {+ return Err(anyhow!("duplicate pin number '{num}'"));+ }+ let name = p.get("name").and_then(|x| x.as_str())+ .ok_or_else(|| anyhow!("pins[{i}].name missing"))?;+ let kind = p.get("type").and_then(|x| x.as_str())+ .ok_or_else(|| anyhow!("pins[{i}].type missing for pin {num}"))?;+ if !KICAD_ERC_TYPES.contains(&kind) {+ return Err(anyhow!(+ "pins[{i}].type '{kind}' invalid for pin {num} ({name}); must be one of {KICAD_ERC_TYPES:?}"+ ));+ }+ let group = p.get("group").and_then(|x| x.as_str()).unwrap_or("");+ if !group.is_empty() && !ADOM_GROUPS.contains(&group) {+ return Err(anyhow!(+ "pins[{i}].group '{group}' invalid for pin {num} ({name}); must be one of {ADOM_GROUPS:?}"+ ));+ }+ let page = p.get("source_page").and_then(|x| x.as_u64()).unwrap_or(0);+ if page == 0 {+ return Err(anyhow!(+ "pins[{i}] '{name}' (pin {num}) missing source_page"+ ));+ }+ }+ Ok(())+}++fn validate_footprint(v: &Value, sym: &SymbolPass) -> Result<()> {+ let pad_count = v.get("padCount").and_then(|x| x.as_u64())+ .ok_or_else(|| anyhow!("padCount missing"))?;+ if (pad_count as usize) != sym.pins.len() {+ // Soft-warn: modules can legitimately have a different padCount from the+ // symbol's pin count (e.g. ESP32-WROOM module exposes 41 castellated pads+ // while the internal SoC has more pins). Surface in review_required, do+ // not error — the extraction is still useful.+ warn(format!(+ "padCount {} differs from symbol pin count {} — proceeding (likely a module symbol/module footprint mismatch; surfaced in review_required)",+ pad_count, sym.pins.len()+ ));+ }+ let body = v.get("bodyDimensions").ok_or_else(|| anyhow!("bodyDimensions missing"))?;+ if body.get("source_page").and_then(|x| x.as_u64()).unwrap_or(0) == 0 {+ return Err(anyhow!("bodyDimensions.source_page missing"));+ }+ let lead = v.get("leadDimensions").ok_or_else(|| anyhow!("leadDimensions missing"))?;+ if lead.get("source_page").and_then(|x| x.as_u64()).unwrap_or(0) == 0 {+ return Err(anyhow!("leadDimensions.source_page missing"));+ }++ // padDescriptions keys: warn (don't error) if there's a key the symbol map doesn't+ // know about — same module-vs-IC reason.+ let valid_nums: std::collections::HashSet<&str> =+ sym.pins.iter().map(|p| p.number.as_str()).collect();+ if let Some(desc) = v.get("padDescriptions").and_then(|x| x.as_object()) {+ for k in desc.keys() {+ if !valid_nums.contains(k.as_str()) {+ warn(format!(+ "padDescriptions key '{k}' is not in the symbol pin map (module/SoC mismatch?)"+ ));+ break; // one warning is enough+ }+ }+ }+ Ok(())+}
src/fpsvg.rsadded+1022@@ -0,0 +1,1022 @@+//! Footprint SVG generator — produces an authoritative top-down rendering+//! of the chip's PCB footprint that downstream 3D / 2D tooling (chipsmith,+//! adom-tsci, sym_create previews, wiki page hero, etc.) can use as a+//! visual ground-truth reference.+//!+//! Two paths:+//! - **service-kicad-stdlib**: when ds2sf's normalize step matched a KiCad+//! standard-library footprint, fetch the canonical SVG from+//! `service-kicad/fp/export/svg/<library>/<name>`. Same SVG every other+//! Adom tool would render — this is the authoritative form.+//! - **ds2sf-synthesized**: when there's no stdlib match (custom OLGA,+//! SMD modules, vendor-specific outline codes), generate a minimal SVG+//! from the extracted body + lead dimensions and pad descriptions. The+//! layout heuristic picks a pad arrangement based on the package family+//! (QFN/SOIC/SOT/BGA/LGA/DIP/VSSOP/TSSOP/module). Always stamped as+//! "synthesized" so consumers know this is a best-effort reference, not+//! a manufacturer-authoritative drawing.++use anyhow::{Context as _, Result};+use serde::Serialize;+use std::fs;+use std::io::Read;+#[allow(unused_imports)]+use std::path::Path;++use crate::context::Context;+use crate::extract::FootprintPass;+use crate::normalize::Resolved;++// ── per-pad position data (consumed by chipsmith cross-validation) ───────++#[derive(Debug, Clone, Serialize)]+pub struct ComputedPad {+ pub number: String,+ #[serde(skip_serializing_if = "Option::is_none")]+ pub name: Option<String>,+ #[serde(skip_serializing_if = "Option::is_none")]+ pub function: Option<String>,+ #[serde(rename = "type")]+ pub pad_type: &'static str,+ pub shape: &'static str,+ pub at: [f64; 2],+ pub size: [f64; 2],+ #[serde(skip_serializing_if = "Option::is_none")]+ pub corner_radius: Option<f64>,+ pub layers: &'static [&'static str],+ #[serde(skip_serializing_if = "Option::is_none")]+ pub drill: Option<DrillInfo>,+ #[serde(skip_serializing_if = "std::ops::Not::not")]+ pub is_ep: bool,+ #[serde(skip_serializing_if = "Option::is_none")]+ pub annular_ring: Option<f64>,+ #[serde(skip_serializing_if = "Vec::is_empty")]+ pub flags: Vec<&'static str>,+}++#[derive(Debug, Clone, Serialize)]+pub struct DrillInfo {+ pub shape: &'static str,+ pub diameter: f64,+}++const SMD_LAYERS: &[&str] = &["F.Cu", "F.Paste", "F.Mask"];+const TH_LAYERS: &[&str] = &["*.Cu", "*.Paste", "*.Mask"];++/// Compute per-pad positions from the extracted footprint dimensions.+/// Coordinate origin at footprint center, Y-down (KiCad convention).+/// Shapes: "rect", "circle", "oval", "roundrect".+pub fn compute_pads(fp: &FootprintPass) -> Vec<ComputedPad> {+ let family = detect_family(&fp.package_name);+ let body_x = fp.body_dimensions.x.max(1.0);+ let body_y = fp.body_dimensions.y.max(1.0);+ let pad_count = fp.pad_count as usize;++ let raw = match family {+ Family::Qfn => layout_qfn(pad_count, body_x, body_y, &fp.lead_dimensions),+ Family::DualSide => layout_dual_side(pad_count, body_x, body_y, &fp.lead_dimensions),+ Family::Sot => layout_sot(pad_count, body_x, body_y),+ Family::Bga => layout_bga_lga(&fp.pad_descriptions, body_x, body_y, &fp.lead_dimensions, true),+ Family::Lga => {+ let grid = layout_bga_lga(&fp.pad_descriptions, body_x, body_y, &fp.lead_dimensions, false);+ if !grid.is_empty() { grid } else { layout_qfn(pad_count, body_x, body_y, &fp.lead_dimensions) }+ }+ Family::Dip => layout_dip(pad_count, body_x, body_y, &fp.lead_dimensions),+ Family::Discrete => layout_discrete(pad_count, body_x, body_y),+ Family::Connector => layout_connector(pad_count, body_x, body_y, &fp.lead_dimensions),+ Family::Module => layout_module(&fp.pad_descriptions, body_x, body_y),+ Family::Unknown => Vec::new(),+ };++ let is_th = matches!(family, Family::Dip | Family::Connector);+ let base_shape: &str = match family {+ Family::Bga => "circle",+ Family::Dip => "oval",+ Family::Connector => "circle",+ _ => "rect",+ };+ // IPC-7351 recommends rounded corners (roundrect) for SMD pads at+ // fine pitch (<= 0.65mm) to improve paste release and reduce bridging.+ // At coarser pitches, sharp rect is traditional but roundrect is still+ // preferable for copper adhesion to the FR4 substrate.+ let pitch = lead_pitch(family, &fp.lead_dimensions);+ let recommend_roundrect = !is_th && base_shape == "rect" && pitch > 0.0;++ raw.into_iter().map(|(num, x, y, w, h)| {+ let ep = !is_th && is_exposed_pad(&num, pad_count, family);+ let shape = if ep {+ "rect"+ } else if recommend_roundrect && !ep {+ "roundrect"+ } else {+ base_shape+ };+ // Corner radius: 25% of the smaller pad dimension (IPC-7351 guideline).+ let cr = if shape == "roundrect" {+ Some(round3(w.min(h) * 0.25))+ } else {+ None+ };+ let drill_dia = if is_th { round3(w.min(h) * 0.55) } else { 0.0 };+ let annular = if is_th { Some(round3((w.min(h) - drill_dia) / 2.0)) } else { None };+ // Look up pin name + function from padDescriptions / symbolPinMap.+ let pin_name = fp.symbol_pin_map+ .get(&num)+ .and_then(|v| v.as_str())+ .map(|s| s.to_string());+ let pin_func = fp.pad_descriptions+ .get(&num)+ .and_then(|v| v.as_str())+ .map(|s| {+ if s.len() > 120 { format!("{}...", &s[..117]) } else { s.to_string() }+ });+ let mut flags: Vec<&'static str> = Vec::new();+ if ep { flags.push("exposed_pad"); }+ if is_th { flags.push("plated_through_hole"); }+ if shape == "roundrect" && pitch <= 0.5 {+ flags.push("fine_pitch");+ }++ ComputedPad {+ number: num,+ name: pin_name,+ function: pin_func,+ pad_type: if is_th { "thru_hole" } else { "smd" },+ shape,+ at: [round3(x), round3(y)],+ size: [round3(w), round3(h)],+ corner_radius: cr,+ layers: if is_th { TH_LAYERS } else { SMD_LAYERS },+ drill: if is_th {+ Some(DrillInfo { shape: "circle", diameter: drill_dia })+ } else {+ None+ },+ is_ep: ep,+ annular_ring: annular,+ flags,+ }+ }).collect()+}++fn is_exposed_pad(num: &str, pad_count: usize, family: Family) -> bool {+ match family {+ Family::Qfn => {+ // QFN periphery uses pads_per_side * 4; anything beyond is EP.+ if let Ok(n) = num.parse::<usize>() {+ let pps = pad_count / 4;+ n > pps * 4+ } else {+ false+ }+ }+ _ => false,+ }+}++fn round3(v: f64) -> f64 {+ (v * 1000.0).round() / 1000.0+}++// ── lead annotations for service-step2glb /create-chip ──────────────────++#[derive(Debug, Clone, Serialize)]+pub struct LeadAnnotation {+ pub number: String,+ pub name: String,+ pub function: String,+ pub side: &'static str,+}++/// Emit lead_annotations[] ordered to match chipsmith's lead creation order.+/// Dual-side: right top→bottom, then left top→bottom.+/// Quad (QFN/QFP): right top→bottom, left top→bottom, top left→right, bottom left→right.+/// BGA/LGA: row-major (A1, A2, ..., B1, B2, ...).+pub fn compute_lead_annotations(fp: &FootprintPass) -> Vec<LeadAnnotation> {+ let family = detect_family(&fp.package_name);+ let body_x = fp.body_dimensions.x.max(1.0);+ let body_y = fp.body_dimensions.y.max(1.0);+ let pad_count = fp.pad_count as usize;++ let raw = match family {+ Family::Qfn => layout_qfn(pad_count, body_x, body_y, &fp.lead_dimensions),+ Family::DualSide => layout_dual_side(pad_count, body_x, body_y, &fp.lead_dimensions),+ Family::Sot => layout_sot(pad_count, body_x, body_y),+ Family::Bga => layout_bga_lga(&fp.pad_descriptions, body_x, body_y, &fp.lead_dimensions, true),+ Family::Lga => {+ let grid = layout_bga_lga(&fp.pad_descriptions, body_x, body_y, &fp.lead_dimensions, false);+ if !grid.is_empty() { grid } else { layout_qfn(pad_count, body_x, body_y, &fp.lead_dimensions) }+ }+ Family::Dip => layout_dip(pad_count, body_x, body_y, &fp.lead_dimensions),+ Family::Discrete => layout_discrete(pad_count, body_x, body_y),+ Family::Connector => layout_connector(pad_count, body_x, body_y, &fp.lead_dimensions),+ Family::Module => layout_module(&fp.pad_descriptions, body_x, body_y),+ Family::Unknown => Vec::new(),+ };++ // Classify each pad by side based on its position relative to body center.+ let mut annotated: Vec<(String, f64, f64, &'static str)> = raw.iter().map(|(num, x, y, _w, _h)| {+ let side = classify_side(*x, *y, body_x, body_y, family);+ (num.clone(), *x, *y, side)+ }).collect();++ // Sort into chipsmith's creation order: right→left→top→bottom.+ // Within each side, sort by the axis parallel to the edge.+ let side_order = |s: &str| -> u8 {+ match s { "right" => 0, "left" => 1, "top" => 2, "bottom" => 3, _ => 4 }+ };+ annotated.sort_by(|a, b| {+ let sa = side_order(a.3);+ let sb = side_order(b.3);+ if sa != sb { return sa.cmp(&sb); }+ match a.3 {+ "right" | "left" => a.2.partial_cmp(&b.2).unwrap_or(std::cmp::Ordering::Equal),+ "top" | "bottom" => a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal),+ _ => std::cmp::Ordering::Equal,+ }+ });++ annotated.into_iter().map(|(num, _x, _y, side)| {+ let name = fp.symbol_pin_map+ .get(&num)+ .and_then(|v| v.as_str())+ .unwrap_or(&num)+ .to_string();+ let function = fp.pad_descriptions+ .get(&num)+ .and_then(|v| v.as_str())+ .unwrap_or("")+ .to_string();+ LeadAnnotation { number: num, name, function, side }+ }).collect()+}++fn classify_side(x: f64, y: f64, body_x: f64, body_y: f64, family: Family) -> &'static str {+ match family {+ Family::DualSide | Family::Sot | Family::Dip => {+ if x < 0.0 { "left" } else { "right" }+ }+ Family::Qfn => {+ let hx = body_x / 2.0;+ let hy = body_y / 2.0;+ // EP sits at center+ if x.abs() < hx * 0.5 && y.abs() < hy * 0.5 { return "bottom"; }+ let dx = x.abs() - hx;+ let dy = y.abs() - hy;+ if dx > dy {+ if x < 0.0 { "left" } else { "right" }+ } else {+ if y < 0.0 { "top" } else { "bottom" }+ }+ }+ Family::Bga | Family::Lga => "bottom",+ Family::Connector => "bottom",+ Family::Module => {+ if y.abs() > body_y / 2.0 * 0.8 {+ if y < 0.0 { "top" } else { "bottom" }+ } else if x > 0.0 { "right" } else { "left" }+ }+ Family::Discrete => {+ if x < 0.0 { "left" } else { "right" }+ }+ Family::Unknown => "bottom",+ }+}++// ── 3D chip geometry for OCCT construction ──────────────────────────────++#[derive(Debug, Clone, Serialize)]+pub struct Chip3D {+ pub body_width_mm: f64,+ pub body_depth_mm: f64,+ pub body_height_mm: f64,+ pub package_family: &'static str,+ pub structural_type: &'static str,+ pub lead_shape: &'static str,+ pub lead_count: u32,+ pub lead_pitch_mm: f64,+ pub lead_width_mm: f64,+ pub lead_length_mm: f64,+ pub lead_span_mm: f64,+ pub standoff_height_mm: f64,+ pub has_ep: bool,+ #[serde(skip_serializing_if = "Option::is_none")]+ pub ep_width_mm: Option<f64>,+ #[serde(skip_serializing_if = "Option::is_none")]+ pub ep_depth_mm: Option<f64>,+ #[serde(skip_serializing_if = "Option::is_none")]+ pub ball_diameter_mm: Option<f64>,+ #[serde(skip_serializing_if = "Option::is_none")]+ pub pin_diameter_mm: Option<f64>,+ #[serde(skip_serializing_if = "Option::is_none")]+ pub pin_length_below_body_mm: Option<f64>,+ pub pin1_indicator: &'static str,+ pub corner_chamfer_mm: f64,+ pub body_color: &'static str,+ pub lead_color: &'static str,+}++pub fn compute_chip_3d(fp: &FootprintPass) -> Chip3D {+ let family = detect_family(&fp.package_name);+ let lead = &fp.lead_dimensions;+ let pitch = lead_pitch(family, lead);+ let pad_w = lead.get("pad_w").and_then(|v| v.as_f64()).unwrap_or(0.3);+ let pad_h = lead.get("pad_h").and_then(|v| v.as_f64()).unwrap_or(0.6);++ // Prefer JEDEC dimensions from the mechanical section when available;+ // fall back to bodyDimensions + heuristics.+ let jd = fp.mechanical.as_ref()+ .and_then(|m| m.get("jedec_dims"));+ let jedec_val = |key: &str| -> Option<f64> {+ jd.and_then(|j| j.get(key))+ .and_then(|v| v.get("value"))+ .and_then(|v| v.as_f64())+ };+ let body_w = jedec_val("D").unwrap_or(fp.body_dimensions.x.max(0.5));+ let body_d = jedec_val("E1").or(jedec_val("E")).unwrap_or(fp.body_dimensions.y.max(0.5));+ let body_h = jedec_val("A2")+ .or(jedec_val("A"))+ .or(fp.body_dimensions.thickness_max)+ .unwrap_or_else(|| default_height(family));+ let mech_lead_shape = fp.mechanical.as_ref()+ .and_then(|m| m.get("lead_shape"))+ .and_then(|v| v.as_str());+ let mech_pin1 = fp.mechanical.as_ref()+ .and_then(|m| m.get("pin1_indicator"))+ .and_then(|v| v.as_str());+ let tp = lead.get("thermal_pad").and_then(|v| if v.is_null() { None } else { Some(v) });+ let has_ep = tp.is_some() || (matches!(family, Family::Qfn) && fp.pad_count as usize > (fp.pad_count as usize / 4) * 4);++ let (pkg_family, struct_type, lead_shape, standoff, _lead_len) = match family {+ Family::Qfn => ("qfn", "smd_flat_pad", "flat", 0.02, pad_h),+ Family::DualSide => {+ let span_candidate = lead.get("pitch_y").and_then(|v| v.as_f64())+ .or_else(|| lead.get("pitch_x").and_then(|v| v.as_f64()))+ .unwrap_or(0.0);+ let span = if span_candidate > body_w.max(body_d) { span_candidate } else { body_w + pad_h * 2.0 + 0.2 };+ let _ = span;+ ("dual_side", "smd_gull_wing", "gull_wing", 0.1, pad_h)+ }+ Family::Sot => ("sot", "smd_gull_wing", "gull_wing", 0.1, pad_h),+ Family::Bga => ("bga", "smd_ball", "ball", pad_w * 0.4, 0.0),+ Family::Lga => ("lga", "smd_flat_pad", "flat", 0.02, pad_h),+ Family::Dip => ("dip", "thru_hole_pin", "straight_pin", 0.0, 0.0),+ Family::Module => ("module", "smd_castellated", "castellated", 0.0, pad_h),+ Family::Discrete => ("discrete", "smd_flat_pad", "flat", 0.02, pad_h),+ Family::Connector => ("connector", "thru_hole_pin", "straight_pin", 0.0, 0.0),+ Family::Unknown => ("unknown", "unknown", "unknown", 0.05, pad_h),+ };++ let lead_span = match family {+ Family::DualSide | Family::Sot => {+ let px = lead.get("pitch_x").and_then(|v| v.as_f64()).unwrap_or(0.0);+ let py = lead.get("pitch_y").and_then(|v| v.as_f64()).unwrap_or(0.0);+ let span = px.max(py);+ if span > body_w.min(body_d) { span } else { body_w + pad_h * 2.0 }+ }+ Family::Qfn | Family::Lga => body_w,+ _ => 0.0,+ };++ Chip3D {+ body_width_mm: round3(body_w),+ body_depth_mm: round3(body_d),+ body_height_mm: round3(body_h),+ package_family: pkg_family,+ structural_type: struct_type,+ lead_shape: match mech_lead_shape {+ Some("gull_wing") => "gull_wing",+ Some("j_lead") => "j_lead",+ Some("flat") => "flat",+ Some("ball") => "ball",+ Some("straight_pin") => "straight_pin",+ Some("castellated") => "castellated",+ Some("no_lead") => "no_lead",+ _ => lead_shape,+ },+ lead_count: fp.pad_count,+ lead_pitch_mm: round3(pitch),+ lead_width_mm: jedec_val("b").map(round3).unwrap_or_else(|| round3(pad_w.min(pad_h))),+ lead_length_mm: jedec_val("L").map(round3).unwrap_or_else(|| round3(pad_w.max(pad_h))),+ lead_span_mm: round3(lead_span),+ standoff_height_mm: jedec_val("A1").map(round3).unwrap_or_else(|| round3(standoff)),+ has_ep,+ ep_width_mm: tp.and_then(|v| v.get("x").and_then(|x| x.as_f64())).map(round3),+ ep_depth_mm: tp.and_then(|v| v.get("y").and_then(|y| y.as_f64())).map(round3),+ ball_diameter_mm: if matches!(family, Family::Bga) { Some(round3(pad_w)) } else { None },+ pin_diameter_mm: if matches!(family, Family::Dip | Family::Connector) {+ Some(round3(pad_w.min(pad_h) * 0.55))+ } else { None },+ pin_length_below_body_mm: if matches!(family, Family::Dip | Family::Connector) {+ Some(3.0)+ } else { None },+ pin1_indicator: match mech_pin1 {+ Some("chamfer") => "chamfer_top_left",+ Some("dot") => "dot_top_left",+ Some("bar") => "bar_pin1_side",+ Some("notch") => "notch_top_center",+ Some(other) => {+ // leak the string so we get a &'static str — acceptable for+ // a small number of distinct values per process lifetime.+ let s: &'static str = Box::leak(other.to_string().into_boxed_str());+ s+ }+ None => "chamfer_top_left",+ },+ corner_chamfer_mm: round3(body_w.min(body_d) * 0.08),+ body_color: "#333333",+ lead_color: if matches!(family, Family::Bga) { "#c0c0c0" } else { "#d4a84b" },+ }+}++fn default_height(family: Family) -> f64 {+ match family {+ Family::Qfn | Family::Lga => 0.85,+ Family::DualSide => 1.2,+ Family::Sot => 1.1,+ Family::Bga => 0.6,+ Family::Dip => 5.0,+ Family::Module => 3.0,+ Family::Discrete => 0.55,+ Family::Connector => 8.5,+ Family::Unknown => 1.0,+ }+}++fn lead_pitch(family: Family, lead: &serde_json::Value) -> f64 {+ let px = lead.get("pitch_x").and_then(|v| v.as_f64()).unwrap_or(0.0);+ let py = lead.get("pitch_y").and_then(|v| v.as_f64()).unwrap_or(0.0);+ let p = lead.get("pitch").and_then(|v| v.as_f64()).unwrap_or(0.0);+ match family {+ Family::DualSide | Family::Sot => {+ // Smaller of pitch_x/pitch_y is pin pitch (larger is row span).+ if px > 0.01 && py > 0.01 { px.min(py) }+ else if px > 0.01 { px }+ else if py > 0.01 { py }+ else { p }+ }+ _ => {+ if px > 0.01 { px }+ else if py > 0.01 { py }+ else { p }+ }+ }+}++const KICAD_BASE: &str = "https://kicad-rk5ue5pcfemi.adom.cloud";++/// Result of SVG generation — ds2sf now produces TWO SVGs:+/// - `<MPN>-footprint.ds2sf.svg` — synthesized purely from datasheet dimensions+/// - `<MPN>-footprint.pcbnew.svg` — fetched from service-kicad (when stdlib match exists)+///+/// The ds2sf SVG is the manufacturer's voice; the pcbnew SVG is KiCad's+/// interpretation. chipsmith diffs them visually. Neither replaces the other.+#[derive(Debug, Clone)]+pub struct SvgResult {+ pub ds2sf_svg: Option<std::path::PathBuf>,+ pub pcbnew_svg: Option<std::path::PathBuf>,+}++/// Generate both footprint SVGs.+pub fn generate(ctx: &Context, fp: &FootprintPass, norm: Option<&Resolved>) -> SvgResult {+ let mut result = SvgResult { ds2sf_svg: None, pcbnew_svg: None };++ // Always synthesize from datasheet-extracted dimensions — this is ds2sf's+ // independent voice and the whole point of the tool.+ let ds2sf_path = ctx.out_dir.join(format!("{}-footprint.ds2sf.svg", ctx.mpn));+ let svg = synthesize_svg(ctx, fp);+ if fs::write(&ds2sf_path, svg).is_ok() {+ result.ds2sf_svg = Some(ds2sf_path);+ }++ // Also write to the legacy .authoritative.svg path for backwards compat.+ let legacy_path = ctx.footprint_svg_path();+ if let Some(ref p) = result.ds2sf_svg {+ let _ = fs::copy(p, &legacy_path);+ }++ // Optionally fetch from service-kicad for visual comparison in chipsmith.+ if let Some(n) = norm {+ if let Some(b) = n.kicad_baseline.as_ref() {+ let pcbnew_path = ctx.out_dir.join(format!("{}-footprint.pcbnew.svg", ctx.mpn));+ if let Ok(svg) = fetch_stdlib_svg(&b.library, &b.name) {+ if fs::write(&pcbnew_path, &svg).is_ok() {+ result.pcbnew_svg = Some(pcbnew_path);+ }+ }+ }+ }++ result+}++fn fetch_stdlib_svg(library: &str, name: &str) -> Result<Vec<u8>> {+ let url = format!("{KICAD_BASE}/fp/export/svg/{library}/{name}");+ let resp = ureq::get(&url)+ .timeout(std::time::Duration::from_secs(10))+ .call()+ .with_context(|| format!("GET {url}"))?;+ if resp.status() != 200 {+ anyhow::bail!("service-kicad returned status {}", resp.status());+ }+ let mut bytes = Vec::new();+ resp.into_reader().read_to_end(&mut bytes)?;+ if bytes.len() < 100 {+ anyhow::bail!("service-kicad returned suspiciously small SVG ({} bytes)", bytes.len());+ }+ Ok(bytes)+}++// ── synthesis ────────────────────────────────────────────────────────────++#[derive(Debug, Clone, Copy)]+enum Family {+ Qfn, // 4-sided periphery, square-ish body, optional EP+ DualSide, // 2-sided periphery (SOIC, VSSOP, TSSOP, SSOP, MSOP)+ Sot, // small SOT-23 family (3, 5, 6 pins)+ Bga, // 2D ball grid (DSBGA, BGA)+ Lga, // 2D pad grid (LGA, OLGA)+ Dip, // through-hole DIP, 2 columns+ Module, // module / castellated edge — minimal placeholder+ Discrete, // 2-pin SMD passive (LED, resistor, cap)+ Connector, // single-row through-hole/SMD connector (JST PH/XH, headers, terminal blocks)+ Unknown, // bounding-box-only fallback+}++fn detect_family(pkg: &str) -> Family {+ let u = pkg.to_uppercase();+ if u.contains("VQFN") || u.contains("QFN") || u.contains("DFN") || u.contains("WSON") { return Family::Qfn; }+ if u.contains("LQFP") || u.contains("TQFP") || u.contains("QFP") { return Family::Qfn; }+ // Analog Devices' "Lead Frame Chip Scale Package" — same body+EP+periphery+ // pads as a QFN; ADI just brands it differently. Same for TI's HVQFN /+ // MicroLeadFrame variants.+ if u.contains("LFCSP") || u.contains("MICROLEADFRAME") || u.contains("HVQFN") { return Family::Qfn; }+ if u.contains("SOIC") || u.contains("VSSOP") || u.contains("TSSOP") || u.contains("SSOP") || u.contains("MSOP") || u.contains("SOIJ") { return Family::DualSide; }+ if u.contains("DSBGA") || u.contains("WLCSP") || u.contains("BGA") || u.contains("CSP") { return Family::Bga; }+ if u.contains("OLGA") || u.contains("LGA") { return Family::Lga; }+ if u.contains("SOT") || u.contains("SC-70") || u.contains("SC70") { return Family::Sot; }+ if u.contains("DIP") || u.contains("PDIP") { return Family::Dip; }+ if u.contains("WROOM") || u.contains("WROVER") || u.contains("MODULE") || u.contains("CASTELLATED") { return Family::Module; }+ if u.contains("0402") || u.contains("0603") || u.contains("0805") || u.contains("1206") || u.contains("2010") || u.contains("2512") || u.contains("CHIPLED") { return Family::Discrete; }+ // Connectors: single-row pin headers, JST PH/XH/SH, Molex, terminal blocks.+ // Caught by B3B-PH-K-S (2026-05-07): JST PH connectors fell through to+ // Unknown and rendered as body-only SVG.+ if u.contains("JST") || u.contains("HEADER") || u.contains("CONNECTOR")+ || u.contains("MOLEX") || u.contains("RECEPTACLE") || u.contains("TERMINAL")+ || u.contains("WAGO") || u.contains("PIN STRIP") || u.contains("PINHEADER")+ { return Family::Connector; }+ Family::Unknown+}++/// Build a top-down SVG of the footprint. Coordinates in millimeters; we+/// scale into the SVG viewBox at the end. Adom brand:+/// - background: #0d1117+/// - body fab: #2c5f2d (PCB green)+/// - copper pad: #d68830+/// - silk: #ffffff+/// - pin-1 dot: #ff5555+/// - text: #c9d1d9+fn synthesize_svg(ctx: &crate::context::Context, fp: &FootprintPass) -> Vec<u8> {+ let family = detect_family(&fp.package_name);+ let body_x = fp.body_dimensions.x.max(1.0);+ let body_y = fp.body_dimensions.y.max(1.0);+ let pad_count = fp.pad_count as usize;++ // Compute pads list: (label, x_mm, y_mm, w_mm, h_mm)+ let pads = match family {+ Family::Qfn => layout_qfn(pad_count, body_x, body_y, &fp.lead_dimensions),+ Family::DualSide => layout_dual_side(pad_count, body_x, body_y, &fp.lead_dimensions),+ Family::Sot => layout_sot(pad_count, body_x, body_y),+ Family::Bga => layout_bga_lga(&fp.pad_descriptions, body_x, body_y, &fp.lead_dimensions, true),+ Family::Lga => {+ // True LGA grids (OLGA, true LGA) use alphanumeric pin keys (A1, B7).+ // Peripheral LGAs (Bosch BME/BMI MEMS LGA-8/14) use numeric keys+ // (1..N) with pads only on the body edges — fall back to QFN-style+ // 4-side periphery, no EP.+ let grid = layout_bga_lga(&fp.pad_descriptions, body_x, body_y, &fp.lead_dimensions, false);+ if !grid.is_empty() {+ grid+ } else {+ layout_qfn(pad_count, body_x, body_y, &fp.lead_dimensions)+ }+ }+ Family::Dip => layout_dip(pad_count, body_x, body_y, &fp.lead_dimensions),+ Family::Discrete => layout_discrete(pad_count, body_x, body_y),+ Family::Module => layout_module(&fp.pad_descriptions, body_x, body_y),+ Family::Connector => layout_connector(pad_count, body_x, body_y, &fp.lead_dimensions),+ Family::Unknown => Vec::new(),+ };++ // Bounding region — body + 1 mm courtyard.+ let courtyard = 1.0_f64;+ let half_x = (body_x / 2.0) + courtyard;+ let half_y = (body_y / 2.0) + courtyard;+ let view_w = half_x * 2.0;+ let view_h = half_y * 2.0;++ let scale = 80.0_f64; // SVG units per mm+ let svg_w = (view_w * scale) as i32;+ let svg_h = (view_h * scale) as i32 + 60; // +60 px for caption at bottom++ let mut buf = String::new();+ buf.push_str(&format!(+ r#"<?xml version="1.0" encoding="UTF-8"?>+<svg xmlns="http://www.w3.org/2000/svg" width="{svg_w}" height="{svg_h}" viewBox="0 0 {svg_w} {svg_h}">+ <style>+ .bg {{ fill: #0d1117; }}+ .body {{ fill: #2c5f2d; stroke: #ffffff; stroke-width: 0.6; }}+ .courtyard {{ fill: none; stroke: #4a5560; stroke-width: 0.5; stroke-dasharray: 4 3; }}+ .pad {{ fill: #d68830; stroke: #5a3a14; stroke-width: 0.4; }}+ .pin-num {{ fill: #ffffff; font: 600 9px 'Liberation Sans', sans-serif; text-anchor: middle; dominant-baseline: central; }}+ .pin1 {{ fill: #ff5555; }}+ .pin1-pad {{ fill: #e8a040; stroke: #ff5555; stroke-width: 1.0; }}+ .drill {{ fill: #0d1117; stroke: #333; stroke-width: 0.3; }}+ .ep {{ fill: #c0a050; stroke: #5a3a14; stroke-width: 0.4; opacity: 0.85; }}+ .caption {{ fill: #c9d1d9; font: 11px 'Liberation Sans', sans-serif; }}+ .stamp {{ fill: #f0883e; font: 600 10px 'Liberation Sans', sans-serif; }}+ </style>+ <rect class="bg" width="{svg_w}" height="{svg_h}"/>+"#));++ // Coordinate transform: mm → SVG px, with body centered.+ let mm_to_px_x = |x: f64| (x + half_x) * scale;+ let mm_to_px_y = |y: f64| (y + half_y) * scale;++ // Courtyard.+ buf.push_str(&format!(+ r#" <rect class="courtyard" x="{:.1}" y="{:.1}" width="{:.1}" height="{:.1}"/>+"#,+ mm_to_px_x(-half_x), mm_to_px_y(-half_y),+ view_w * scale, view_h * scale,+ ));+ // Body.+ buf.push_str(&format!(+ r#" <rect class="body" x="{:.1}" y="{:.1}" width="{:.1}" height="{:.1}" rx="2"/>+"#,+ mm_to_px_x(-body_x / 2.0), mm_to_px_y(-body_y / 2.0),+ body_x * scale, body_y * scale,+ ));+ // Pin-1 indicator dot — top-left of body, slightly inside.+ let dot_x = -body_x / 2.0 + 0.4;+ let dot_y = -body_y / 2.0 + 0.4;+ buf.push_str(&format!(+ r#" <circle class="pin1" cx="{:.1}" cy="{:.1}" r="6"/>+"#,+ mm_to_px_x(dot_x), mm_to_px_y(dot_y),+ ));++ // Pads — shape-aware rendering.+ let pitch = lead_pitch(family, &fp.lead_dimensions);+ let use_roundrect = !matches!(family, Family::Dip | Family::Connector) && pitch > 0.0;+ for (label, x, y, w, h) in &pads {+ let is_pin1 = *label == "1" || label.eq_ignore_ascii_case("a1");+ let extra = if is_pin1 { " pin1-pad" } else { "" };+ let is_bga = matches!(family, Family::Bga);+ let is_th = matches!(family, Family::Dip | Family::Connector);+ let ep = is_exposed_pad(label, pad_count, family);+ let cx = mm_to_px_x(*x);+ let cy = mm_to_px_y(*y);+ let pw = *w * scale;+ let ph = *h * scale;++ if is_bga {+ // Circle pad for BGA balls.+ let r = (pw.min(ph)) / 2.0;+ buf.push_str(&format!(+ r#" <circle class="pad{extra}" cx="{cx:.1}" cy="{cy:.1}" r="{r:.1}"/>+"#));+ } else if is_th {+ // Oval / circle pad with drill hole for through-hole.+ let r = (pw.min(ph)) / 2.0;+ let drill_r = r * 0.55;+ buf.push_str(&format!(+ r#" <circle class="pad{extra}" cx="{cx:.1}" cy="{cy:.1}" r="{r:.1}"/>+ <circle class="drill" cx="{cx:.1}" cy="{cy:.1}" r="{drill_r:.1}"/>+"#));+ } else {+ // Rect or roundrect pad.+ let rx = if use_roundrect && !ep {+ pw.min(ph) * 0.25+ } else {+ 0.0+ };+ let rx_attr = if rx > 0.5 { format!(r#" rx="{rx:.1}""#) } else { String::new() };+ buf.push_str(&format!(+ r#" <rect class="pad{extra}" x="{:.1}" y="{:.1}" width="{pw:.1}" height="{ph:.1}"{rx_attr}/>+"#,+ cx - pw / 2.0, cy - ph / 2.0,+ ));+ }+ // Pin number label.+ buf.push_str(&format!(+ r#" <text class="pin-num" x="{cx:.1}" y="{cy:.1}">{label}</text>+"#));+ }++ // Caption — branded as "ds2sf DATASHEET-DERIVED" (not "synthesized").+ let caption = format!(+ "{} — {} ({} pads, {:.2}×{:.2} mm)",+ ctx.mpn, fp.package_name, fp.pad_count, body_x, body_y,+ );+ let stamp = "ds2sf — derived from manufacturer datasheet mechanical drawing";+ buf.push_str(&format!(+ r#" <text class="caption" x="20" y="{}">{caption}</text>+ <text class="stamp" x="20" y="{}">{stamp}</text>+</svg>+"#,+ svg_h - 30, svg_h - 12,+ ));++ buf.into_bytes()+}++// ── per-family pad-layout heuristics ─────────────────────────────────────++type Pad = (String, f64, f64, f64, f64);++fn layout_qfn(pad_count: usize, body_x: f64, body_y: f64, lead: &serde_json::Value) -> Vec<Pad> {+ // Distribute pad_count evenly across 4 sides.+ let pads_per_side = pad_count / 4;+ let pitch = lead.get("pitch_x").and_then(|v| v.as_f64())+ .or_else(|| lead.get("pitch_y").and_then(|v| v.as_f64()))+ .or_else(|| lead.get("pitch").and_then(|v| v.as_f64()))+ .unwrap_or(0.5);+ let pad_w = lead.get("pad_w").and_then(|v| v.as_f64()).unwrap_or(0.25);+ let pad_h = lead.get("pad_h").and_then(|v| v.as_f64()).unwrap_or(0.65);++ let mut pads = Vec::new();+ let half_x = body_x / 2.0;+ let half_y = body_y / 2.0;+ let edge_offset = 0.2; // pad center 0.2 mm outside body edge++ // QFN convention: pin 1 at top-left, going CCW.+ let mut pin = 1usize;+ // Left side: top → bottom+ for i in 0..pads_per_side {+ let y = -((pads_per_side as f64 - 1.0) / 2.0 - i as f64) * pitch;+ pads.push((pin.to_string(), -(half_x + edge_offset), y, pad_h, pad_w));+ pin += 1;+ }+ // Bottom side: left → right+ for i in 0..pads_per_side {+ let x = -((pads_per_side as f64 - 1.0) / 2.0 - i as f64) * pitch;+ pads.push((pin.to_string(), x, half_y + edge_offset, pad_w, pad_h));+ pin += 1;+ }+ // Right side: bottom → top+ for i in 0..pads_per_side {+ let y = ((pads_per_side as f64 - 1.0) / 2.0 - i as f64) * pitch;+ pads.push((pin.to_string(), half_x + edge_offset, y, pad_h, pad_w));+ pin += 1;+ }+ // Top side: right → left+ for i in 0..pads_per_side {+ let x = ((pads_per_side as f64 - 1.0) / 2.0 - i as f64) * pitch;+ pads.push((pin.to_string(), x, -(half_y + edge_offset), pad_w, pad_h));+ pin += 1;+ }++ // Center EP if pad_count > pads_per_side * 4.+ if pad_count > pads_per_side * 4 {+ let ep_size = (body_x.min(body_y) * 0.6).max(1.0);+ pads.push((pin.to_string(), 0.0, 0.0, ep_size, ep_size));+ }+ pads+}++fn layout_dual_side(pad_count: usize, _body_x: f64, body_y: f64, lead: &serde_json::Value) -> Vec<Pad> {+ let half = pad_count / 2;+ // Claude reports pitch_x as pin-to-pin pitch within a row and pitch_y+ // as the row-to-row span (or vice versa). The pin pitch is always the+ // smaller value when both are present.+ let pitch = {+ let px = lead.get("pitch_x").and_then(|v| v.as_f64());+ let py = lead.get("pitch_y").and_then(|v| v.as_f64());+ let p = lead.get("pitch").and_then(|v| v.as_f64());+ match (px, py, p) {+ (Some(x), Some(y), _) => x.min(y),+ (Some(x), None, _) => x,+ (None, Some(y), _) => y,+ (_, _, Some(p)) => p,+ _ => 1.27,+ }+ };+ let pad_w = lead.get("pad_w").and_then(|v| v.as_f64()).unwrap_or(0.6);+ let pad_h = lead.get("pad_h").and_then(|v| v.as_f64()).unwrap_or(1.55);++ let mut pads = Vec::new();+ let half_y = body_y / 2.0;+ let edge_offset = pad_h / 2.0 + 0.1;++ // SOIC convention: pin 1 top-left, going CCW (down-left, then up-right).+ for i in 0..half {+ let y = -((half as f64 - 1.0) / 2.0 - i as f64) * pitch;+ pads.push(((i + 1).to_string(), -(half_y + edge_offset), y, pad_h, pad_w));+ }+ for i in 0..half {+ let y = ((half as f64 - 1.0) / 2.0 - i as f64) * pitch;+ pads.push(((half + i + 1).to_string(), half_y + edge_offset, y, pad_h, pad_w));+ }+ pads+}++fn layout_sot(pad_count: usize, body_x: f64, body_y: f64) -> Vec<Pad> {+ let pad_w = 0.6;+ let pad_h = 0.5;+ let pitch = 0.95;+ let mut pads = Vec::new();+ match pad_count {+ 3 => {+ pads.push(("1".into(), -(body_x / 2.0 + 0.3), -pitch / 2.0, pad_h, pad_w));+ pads.push(("2".into(), -(body_x / 2.0 + 0.3), pitch / 2.0, pad_h, pad_w));+ pads.push(("3".into(), body_x / 2.0 + 0.3, 0.0, pad_h, pad_w));+ }+ 5 => {+ pads.push(("1".into(), -(body_x / 2.0 + 0.3), -pitch, pad_h, pad_w));+ pads.push(("2".into(), -(body_x / 2.0 + 0.3), 0.0, pad_h, pad_w));+ pads.push(("3".into(), -(body_x / 2.0 + 0.3), pitch, pad_h, pad_w));+ pads.push(("4".into(), body_x / 2.0 + 0.3, pitch, pad_h, pad_w));+ pads.push(("5".into(), body_x / 2.0 + 0.3, -pitch, pad_h, pad_w));+ }+ 6 => {+ for i in 0..3 {+ let y = -pitch + (i as f64) * pitch;+ pads.push(((i + 1).to_string(), -(body_x / 2.0 + 0.3), y, pad_h, pad_w));+ }+ for i in 0..3 {+ let y = pitch - (i as f64) * pitch;+ pads.push(((i + 4).to_string(), body_x / 2.0 + 0.3, y, pad_h, pad_w));+ }+ }+ _ => return layout_dual_side(pad_count, body_x, body_y, &serde_json::Value::Null),+ }+ pads+}++fn layout_bga_lga(+ pad_descriptions: &serde_json::Map<String, serde_json::Value>,+ body_x: f64,+ body_y: f64,+ lead: &serde_json::Value,+ is_bga: bool,+) -> Vec<Pad> {+ // Parse A1, A2, B1 etc keys to determine grid dimensions.+ let pitch = lead.get("pitch_x").and_then(|v| v.as_f64())+ .or_else(|| lead.get("pitch").and_then(|v| v.as_f64()))+ .unwrap_or(0.5);+ let pad_size = lead.get("pad_w").and_then(|v| v.as_f64()).unwrap_or(0.25);++ let mut max_row = 0u32;+ let mut max_col = 0u32;+ for k in pad_descriptions.keys() {+ if let Some((row, col)) = parse_alpha_numeric_key(k) {+ max_row = max_row.max(row);+ max_col = max_col.max(col);+ }+ }+ if max_row == 0 || max_col == 0 {+ return Vec::new();+ }++ let mut pads = Vec::new();+ let center_offset_x = ((max_col as f64 - 1.0) / 2.0) * pitch;+ let center_offset_y = ((max_row as f64 - 1.0) / 2.0) * pitch;++ for k in pad_descriptions.keys() {+ if let Some((row, col)) = parse_alpha_numeric_key(k) {+ let x = (col as f64 - 1.0) * pitch - center_offset_x;+ let y = (row as f64 - 1.0) * pitch - center_offset_y;+ pads.push((k.clone(), x, y, pad_size, pad_size));+ }+ }++ // If the body is too small for the grid, the caller will scale up.+ let _ = (body_x, body_y, is_bga);+ pads+}++fn parse_alpha_numeric_key(k: &str) -> Option<(u32, u32)> {+ // "A1" → (1, 1), "B7" → (2, 7), "C12" → (3, 12)+ let mut chars = k.chars();+ let row_char = chars.next()?;+ if !row_char.is_ascii_alphabetic() { return None; }+ let row = (row_char.to_ascii_uppercase() as u32) - ('A' as u32) + 1;+ let col_str: String = chars.collect();+ let col: u32 = col_str.parse().ok()?;+ Some((row, col))+}++fn layout_dip(pad_count: usize, body_x: f64, _body_y: f64, lead: &serde_json::Value) -> Vec<Pad> {+ let half = pad_count / 2;+ let pitch = lead.get("pitch_y").and_then(|v| v.as_f64())+ .or_else(|| lead.get("pitch").and_then(|v| v.as_f64()))+ .unwrap_or(2.54);+ let pad_size = 1.6;+ let mut pads = Vec::new();+ let half_x = body_x / 2.0 + 1.0;++ for i in 0..half {+ let y = -((half as f64 - 1.0) / 2.0 - i as f64) * pitch;+ pads.push(((i + 1).to_string(), -half_x, y, pad_size, pad_size));+ }+ for i in 0..half {+ let y = ((half as f64 - 1.0) / 2.0 - i as f64) * pitch;+ pads.push(((half + i + 1).to_string(), half_x, y, pad_size, pad_size));+ }+ pads+}++fn layout_discrete(pad_count: usize, body_x: f64, _body_y: f64) -> Vec<Pad> {+ let mut pads = Vec::new();+ if pad_count == 2 {+ let pad_w = body_x * 0.25;+ let pad_h = 0.6;+ pads.push(("1".into(), -body_x / 2.0 - pad_w / 2.0, 0.0, pad_w, pad_h));+ pads.push(("2".into(), body_x / 2.0 + pad_w / 2.0, 0.0, pad_w, pad_h));+ } else if pad_count == 4 {+ // Kelvin-sense 4-terminal — render as dual-side with 2 pads each.+ let pad_w = body_x * 0.2;+ let pad_h = 0.5;+ let py = 0.6;+ pads.push(("1".into(), -body_x / 2.0 - pad_w / 2.0, -py, pad_w, pad_h));+ pads.push(("2".into(), -body_x / 2.0 - pad_w / 2.0, py, pad_w, pad_h));+ pads.push(("3".into(), body_x / 2.0 + pad_w / 2.0, py, pad_w, pad_h));+ pads.push(("4".into(), body_x / 2.0 + pad_w / 2.0, -py, pad_w, pad_h));+ }+ pads+}++fn layout_connector(pad_count: usize, body_x: f64, body_y: f64, lead: &serde_json::Value) -> Vec<Pad> {+ // Single-row connector: pads spaced at `pitch` along whichever axis has+ // the larger pitch. Body's longer axis is the row direction when the+ // pitch isn't reported. Pads numbered 1..N left-to-right (or top-to-bottom).+ let pitch_x = lead.get("pitch_x").and_then(|v| v.as_f64()).unwrap_or(0.0);+ let pitch_y = lead.get("pitch_y").and_then(|v| v.as_f64()).unwrap_or(0.0);+ let along_x = if pitch_x > 0.01 || pitch_y > 0.01 {+ pitch_x >= pitch_y+ } else {+ body_x >= body_y+ };+ let pitch = if along_x && pitch_x > 0.01 {+ pitch_x+ } else if !along_x && pitch_y > 0.01 {+ pitch_y+ } else {+ 2.54+ };+ let pad_size = (pitch * 0.55).clamp(0.5, 1.6);+ let n = pad_count as f64;+ let mut pads = Vec::new();+ for i in 0..pad_count {+ let offset = ((i as f64) - (n - 1.0) / 2.0) * pitch;+ let (x, y) = if along_x { (offset, 0.0) } else { (0.0, offset) };+ pads.push(((i + 1).to_string(), x, y, pad_size, pad_size));+ }+ pads+}++fn layout_module(+ pad_descriptions: &serde_json::Map<String, serde_json::Value>,+ body_x: f64,+ body_y: f64,+) -> Vec<Pad> {+ // Heuristic: distribute pads as castellations along 3 sides (top, left,+ // right). The fourth side (bottom) usually holds the antenna or RF+ // shield. Pad numbers 1..N go around the perimeter starting from the+ // top-left corner.+ let n = pad_descriptions.len();+ if n == 0 { return Vec::new(); }+ let per_side = n / 3;+ let pitch_top = body_x * 0.9 / (per_side as f64).max(1.0);+ let pitch_lr = body_y * 0.9 / (per_side as f64).max(1.0);+ let pad_w = pitch_top * 0.6;+ let pad_h = 0.6;++ let mut pads = Vec::new();+ let mut keys: Vec<&String> = pad_descriptions.keys().collect();+ keys.sort_by_key(|k| k.parse::<u32>().unwrap_or(u32::MAX));++ for (i, key) in keys.iter().enumerate() {+ let side = i / per_side.max(1);+ let idx = (i % per_side.max(1)) as f64;+ let (x, y, pw, ph) = match side {+ 0 => {+ // Top side, left → right+ let x = -body_x / 2.0 + idx * pitch_top + pitch_top / 2.0;+ (x, -body_y / 2.0, pad_w, pad_h)+ }+ 1 => {+ // Right side, top → bottom+ let y = -body_y / 2.0 + idx * pitch_lr + pitch_lr / 2.0;+ (body_x / 2.0, y, pad_h, pad_w)+ }+ _ => {+ // Bottom or left, depending on count+ let x = body_x / 2.0 - idx * pitch_top - pitch_top / 2.0;+ (x, body_y / 2.0, pad_w, pad_h)+ }+ };+ pads.push(((*key).clone(), x, y, pw, ph));+ }+ pads+}
src/io.rsadded+91@@ -0,0 +1,91 @@+use std::io::IsTerminal;+use std::sync::OnceLock;++pub const GREEN: &str = "\x1b[32m";+pub const RED: &str = "\x1b[31m";+pub const YELLOW: &str = "\x1b[33m";+pub const CYAN: &str = "\x1b[36m";+pub const BOLD: &str = "\x1b[1m";+pub const DIM: &str = "\x1b[2m";+pub const RESET: &str = "\x1b[0m";++static STDOUT_IS_TTY: OnceLock<bool> = OnceLock::new();+static STDERR_IS_TTY: OnceLock<bool> = OnceLock::new();+static JSON_ONLY: OnceLock<bool> = OnceLock::new();++pub fn stdout_is_tty() -> bool {+ *STDOUT_IS_TTY.get_or_init(|| std::io::stdout().is_terminal())+}+pub fn stderr_is_tty() -> bool {+ *STDERR_IS_TTY.get_or_init(|| std::io::stderr().is_terminal())+}+pub fn set_json_only(v: bool) {+ let _ = JSON_ONLY.set(v);+}+pub fn json_only() -> bool {+ *JSON_ONLY.get().unwrap_or(&false)+}++pub fn strip_ansi(s: &str) -> String {+ let mut out = String::with_capacity(s.len());+ let mut chars = s.chars().peekable();+ while let Some(c) = chars.next() {+ if c == '\x1b' && chars.peek() == Some(&'[') {+ chars.next();+ for c2 in chars.by_ref() {+ if c2.is_ascii_alphabetic() {+ break;+ }+ }+ continue;+ }+ out.push(c);+ }+ out+}++pub fn ok(msg: impl std::fmt::Display) {+ if stdout_is_tty() {+ println!("{GREEN}{BOLD}OK:{RESET} {msg}");+ } else {+ println!("OK: {}", strip_ansi(&msg.to_string()));+ }+}++pub fn err(msg: impl std::fmt::Display) {+ if stderr_is_tty() {+ eprintln!("{RED}{BOLD}ERROR:{RESET} {msg}");+ } else {+ eprintln!("ERROR: {}", strip_ansi(&msg.to_string()));+ }+}++pub fn warn(msg: impl std::fmt::Display) {+ if json_only() {+ return;+ }+ if stderr_is_tty() {+ eprintln!("{YELLOW}{BOLD}WARN:{RESET} {msg}");+ } else {+ eprintln!("WARN: {}", strip_ansi(&msg.to_string()));+ }+}++pub fn hint(msg: impl std::fmt::Display) {+ if stderr_is_tty() {+ eprintln!("{DIM}Hint:{RESET} {msg}");+ } else {+ eprintln!("Hint: {}", strip_ansi(&msg.to_string()));+ }+}++pub fn note(msg: impl std::fmt::Display) {+ if json_only() {+ return;+ }+ if stdout_is_tty() {+ println!("{CYAN}{msg}{RESET}");+ } else {+ println!("{}", strip_ansi(&msg.to_string()));+ }+}
src/main.rsadded+16@@ -0,0 +1,16 @@+use clap::Parser;++mod cli;+mod context;+mod emit;+mod extract;+mod fpsvg;+mod io;+mod normalize;+mod result;++fn main() {+ let cli = cli::Cli::parse();+ let exit_code = cli::run(cli);+ std::process::exit(exit_code);+}
src/normalize.rsadded+261@@ -0,0 +1,261 @@+use anyhow::Result;+use serde::{Deserialize, Serialize};++const KICAD_BASE: &str = "https://kicad-rk5ue5pcfemi.adom.cloud";++#[derive(Debug, Clone, Serialize, Deserialize)]+pub struct Resolved {+ pub input: String,+ pub kicad_baseline: Option<KicadBaseline>,+ pub search_attempts: Vec<SearchAttempt>,+}++#[derive(Debug, Clone, Serialize, Deserialize)]+pub struct KicadBaseline {+ pub library: String,+ pub name: String,+ pub source: String,+}++#[derive(Debug, Clone, Serialize, Deserialize)]+pub struct SearchAttempt {+ pub library: String,+ pub name: String,+ pub result: String,+}++/// Resolve a free-form package string against the service-kicad standard library.+/// Tries a hand-curated normalize map first, then probes service-kicad for each candidate.+pub fn resolve(package: &str, info_json_hint: Option<&str>) -> Result<Resolved> {+ let combined = match info_json_hint {+ Some(h) => format!("{package} | {h}"),+ None => package.to_string(),+ };++ let mut candidates = candidate_map(&combined);+ // Dedup while preserving order.+ let mut seen = std::collections::HashSet::new();+ candidates.retain(|c| seen.insert(format!("{}:{}", c.0, c.1)));++ let mut attempts = Vec::new();+ for (lib, name) in &candidates {+ let result = probe_kicad(lib, name);+ attempts.push(SearchAttempt {+ library: lib.clone(),+ name: name.clone(),+ result: result.clone(),+ });+ if result == "200" {+ return Ok(Resolved {+ input: package.to_string(),+ kicad_baseline: Some(KicadBaseline {+ library: lib.clone(),+ name: name.clone(),+ source: "service-kicad-stdlib".to_string(),+ }),+ search_attempts: attempts,+ });+ }+ }++ Ok(Resolved {+ input: package.to_string(),+ kicad_baseline: None,+ search_attempts: attempts,+ })+}++fn probe_kicad(library: &str, name: &str) -> String {+ let url = format!("{KICAD_BASE}/fp/export/svg/{library}/{name}");+ match ureq::get(&url)+ .timeout(std::time::Duration::from_secs(4))+ .call()+ {+ Ok(r) if r.status() == 200 => "200".to_string(),+ Ok(r) => r.status().to_string(),+ Err(ureq::Error::Status(code, _)) => code.to_string(),+ Err(e) => format!("err:{e}"),+ }+}++/// Generate plausible (library, name) candidates for a free-form package string.+/// Hand-curated heuristics; expand as we observe misses on real datasheets.+fn candidate_map(s: &str) -> Vec<(String, String)> {+ let upper = s.to_uppercase();+ let mut out: Vec<(String, String)> = Vec::new();++ // VSSOP / MSOP small-outline.+ if upper.contains("VSSOP-10") || upper.contains("VSSOP10") || upper.contains("MSOP-10") {+ out.push(("Package_SO".into(), "VSSOP-10_3x3mm_P0.5mm".into()));+ out.push(("Package_SO".into(), "MSOP-10_3x3mm_P0.5mm".into()));+ }+ if upper.contains("VSSOP-8") || upper.contains("MSOP-8") {+ out.push(("Package_SO".into(), "VSSOP-8_3.0x3.0mm_P0.65mm".into()));+ out.push(("Package_SO".into(), "MSOP-8_3x3mm_P0.65mm".into()));+ }++ // SOIC narrow / wide.+ if upper.contains("SOIC-8") || upper.contains("SO-8") || upper.contains("SOIC8") {+ if upper.contains("WIDE") || upper.contains("208") || upper.contains("5.3X5.3") {+ out.push(("Package_SO".into(), "SOIC-8W_5.3x5.3mm_P1.27mm".into()));+ } else {+ out.push(("Package_SO".into(), "SOIC-8_3.9x4.9mm_P1.27mm".into()));+ }+ }+ if upper.contains("SOIC-14") {+ out.push(("Package_SO".into(), "SOIC-14_3.9x8.7mm_P1.27mm".into()));+ }+ if upper.contains("SOIC-16") {+ if upper.contains("WIDE") || upper.contains("7.5") {+ out.push(("Package_SO".into(), "SOIC-16W_7.5x10.3mm_P1.27mm".into()));+ } else {+ out.push(("Package_SO".into(), "SOIC-16_3.9x9.9mm_P1.27mm".into()));+ }+ }++ // SOT-23 family.+ if upper.contains("SOT-23-5") || upper.contains("SOT23-5") || upper.contains("SOT-25") {+ out.push(("Package_TO_SOT_SMD".into(), "SOT-23-5".into()));+ }+ if upper.contains("SOT-23-6") || upper.contains("SOT23-6") {+ out.push(("Package_TO_SOT_SMD".into(), "SOT-23-6".into()));+ }+ if upper.contains("SOT-23") && !upper.contains("-5") && !upper.contains("-6") {+ out.push(("Package_TO_SOT_SMD".into(), "SOT-23".into()));+ }+ if upper.contains("SOT-563") {+ out.push(("Package_TO_SOT_SMD".into(), "SOT-563".into()));+ }+ if upper.contains("SOT-25") {+ out.push(("Package_TO_SOT_SMD".into(), "SOT-25".into()));+ }++ // QFN / VQFN / DFN.+ if let Some((pin_count, body)) = parse_qfn(&upper) {+ // Try with EP (most QFNs have one), then without.+ out.push((+ "Package_DFN_QFN".into(),+ format!("QFN-{pin_count}-1EP_{body}_P0.5mm_EP3.2x3.2mm"),+ ));+ out.push((+ "Package_DFN_QFN".into(),+ format!("QFN-{pin_count}-1EP_{body}_P0.4mm_EP3.2x3.2mm"),+ ));+ out.push((+ "Package_DFN_QFN".into(),+ format!("QFN-{pin_count}_{body}_P0.5mm"),+ ));+ }++ // LQFP / TQFP.+ if let Some((pin_count, body)) = parse_lqfp(&upper) {+ out.push((+ "Package_LQFP".into(),+ format!("LQFP-{pin_count}_{body}_P0.5mm"),+ ));+ out.push((+ "Package_QFP".into(),+ format!("TQFP-{pin_count}_{body}_P0.5mm"),+ ));+ }++ // BGA — very lookup-heavy; just register the search attempt for now.+ if upper.contains("BGA") {+ if let Some(p) = parse_bga(&upper) {+ out.push(("Package_BGA".into(), p));+ }+ }++ // DIP / PDIP.+ if upper.contains("DIP-8") || upper.contains("PDIP-8") {+ out.push(("Package_DIP".into(), "DIP-8_W7.62mm".into()));+ }+ if upper.contains("DIP-14") || upper.contains("PDIP-14") {+ out.push(("Package_DIP".into(), "DIP-14_W7.62mm".into()));+ }+ if upper.contains("DIP-16") || upper.contains("PDIP-16") {+ out.push(("Package_DIP".into(), "DIP-16_W7.62mm".into()));+ }++ // OLGA / non-standard LGA — not in stdlib; leave blank so caller falls through.+ // (Empty candidate list means kicad_baseline will be None.)++ out+}++fn parse_qfn(upper: &str) -> Option<(u32, String)> {+ // Match patterns like "QFN-32 5x5", "VQFN-32 (5×5)", "VQFN32-5X5", "QFN 32 5X5MM"+ let pin_count = find_after(upper, "QFN").or_else(|| find_after(upper, "QFN-"))?;+ let body = parse_body_dims(upper)?;+ Some((pin_count, body))+}++fn parse_lqfp(upper: &str) -> Option<(u32, String)> {+ let key = if upper.contains("LQFP") {+ "LQFP"+ } else if upper.contains("TQFP") {+ "TQFP"+ } else {+ return None;+ };+ let pin_count = find_after(upper, key)?;+ let body = parse_body_dims(upper)?;+ Some((pin_count, body))+}++fn parse_bga(upper: &str) -> Option<String> {+ let pin_count = find_after(upper, "BGA")?;+ Some(format!("BGA-{pin_count}"))+}++fn find_after(s: &str, prefix: &str) -> Option<u32> {+ let idx = s.find(prefix)?;+ let rest = &s[idx + prefix.len()..];+ let rest = rest.trim_start_matches(['-', ' ']);+ let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();+ if digits.is_empty() {+ None+ } else {+ digits.parse().ok()+ }+}++fn parse_body_dims(upper: &str) -> Option<String> {+ // Find patterns like "5X5", "5x5", "7X7", "5×5", "10×10".+ // Boundary rule: the char before the first digit MUST be non-digit+ // (so "VQFN-32 5X5" → "5x5", not "325x5"; "32" gets boundary-rejected).+ let s = upper.replace('×', "X");+ let bytes = s.as_bytes();+ let mut i = 0;+ while i < bytes.len() {+ // Boundary check before reading a number.+ let prev_ok = i == 0+ || !(bytes[i - 1].is_ascii_digit() || bytes[i - 1] == b'.');+ if prev_ok && bytes[i].is_ascii_digit() {+ let n1_start = i;+ while i < bytes.len() && (bytes[i].is_ascii_digit() || bytes[i] == b'.') {+ i += 1;+ }+ let n1 = &s[n1_start..i];+ if i < bytes.len() && bytes[i] == b'X' {+ i += 1;+ let n2_start = i;+ while i < bytes.len() && (bytes[i].is_ascii_digit() || bytes[i] == b'.') {+ i += 1;+ }+ if i > n2_start {+ let n2 = &s[n2_start..i];+ if let (Ok(a), Ok(b)) = (n1.parse::<f64>(), n2.parse::<f64>()) {+ // Sanity: package body dims are typically 1–30 mm.+ if (0.5..=50.0).contains(&a) && (0.5..=50.0).contains(&b) {+ return Some(format!("{n1}x{n2}mm"));+ }+ }+ }+ }+ } else {+ i += 1;+ }+ }+ None+}
src/result.rsadded+228@@ -0,0 +1,228 @@+//! Unified result JSON — the agent-to-agent contract.+//!+//! Every `ds2sf extract` invocation writes `<MPN>-extraction.result.json` containing+//! a single object whose shape is stable. Upstream callers (chip-fetcher, automation)+//! switch on `status` + `exit_code` and walk `hints[]` to decide what to do next.+//!+//! Exit codes:+//! 0 → status=ok (extraction completed; review_required may still have entries)+//! 2 → status=needs_better_pdf (recoverable: try a different datasheet PDF)+//! 3 → status=unrecoverable (give up; surface to a human)++use anyhow::{Context as _, Result};+use serde::{Deserialize, Serialize};+use serde_json::Value;+use std::fs;+use std::path::PathBuf;++use crate::context::Context;++#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]+#[serde(rename_all = "snake_case")]+pub enum Status {+ Ok,+ NeedsBetterPdf,+ Unrecoverable,+}++impl Status {+ pub fn exit_code(self) -> i32 {+ match self {+ Status::Ok => 0,+ Status::NeedsBetterPdf => 2,+ Status::Unrecoverable => 3,+ }+ }+}++/// Canonical `review_required.reason` strings. Kept in sync with what we instruct+/// the model to emit. Documented in SKILL.md so callers can switch on them.+pub mod reasons {+ /// Symbol-pass returned empty pins because the supplied PDF was the internal+ /// SoC datasheet, not the module's external castellated-pad datasheet.+ pub const MODULE_EXTERNAL_PINOUT_MISSING: &str = "module_external_pinout_missing";++ /// info.json's `package` hint disagreed with the datasheet's actual package.+ /// Caller should update info.json's package field to match.+ pub const PACKAGE_MISMATCH_IN_HINT: &str = "package_mismatch_in_hint";++ /// Datasheet covers many variants; the right one couldn't be picked from+ /// info.json + MPN alone. Caller should pass --variant or a more-specific PDF.+ #[allow(dead_code)]+ pub const VARIANT_DISAMBIGUATION_NEEDED: &str = "variant_disambiguation_needed";++ /// Claude's response failed schema validation twice in a row. Inspect+ /// `.ds2sf-cache/claude-symbol-raw*.txt` for the raw response.+ pub const UNRECOVERABLE_EXTRACTION_ERROR: &str = "unrecoverable_extraction_error";++ /// Datasheet PDF wasn't found at the auto-detected or --pdf-supplied path.+ #[allow(dead_code)]+ pub const MISSING_DATASHEET_PDF: &str = "missing_datasheet_pdf";+}++#[derive(Debug, Clone, Serialize, Deserialize)]+pub struct ResultDoc {+ pub schema_version: String,+ pub status: Status,+ pub exit_code: i32,+ pub mpn: String,+ pub manufacturer: Option<String>,+ pub datasheet_used: PathBuf,+ pub datasheet_sha256: String,+ pub summary: String,+ /// Merged review_required from symbol + footprint passes (whichever ran).+ pub review_required: Vec<Value>,+ /// Actionable next-steps for an agent caller.+ pub hints: Vec<Hint>,+ /// Paths to the three output JSONs. Null when that file wasn't written.+ pub outputs: Outputs,+ pub extractor_version: String,+ pub claude_model: String,+}++#[derive(Debug, Clone, Serialize, Deserialize)]+pub struct Outputs {+ pub symbol_json: Option<PathBuf>,+ pub footprint_json: Option<PathBuf>,+ pub provenance_json: Option<PathBuf>,+ /// ds2sf's own SVG — synthesized purely from the manufacturer's datasheet+ /// mechanical drawing. This is the independent "manufacturer's voice" in+ /// chipsmith's cross-validation and the primary visual output of ds2sf.+ #[serde(default, skip_serializing_if = "Option::is_none")]+ pub footprint_svg_ds2sf: Option<PathBuf>,+ /// KiCad pcbnew render of the stdlib footprint (when a match exists).+ /// A third-party interpretation for visual comparison — NOT the same+ /// data source as ds2sf's extraction. chipsmith diffs these two.+ #[serde(default, skip_serializing_if = "Option::is_none")]+ pub footprint_svg_pcbnew: Option<PathBuf>,+ /// Legacy field (pre-v0.7). Points to the ds2sf SVG for backwards compat.+ #[serde(default, skip_serializing_if = "Option::is_none")]+ pub footprint_svg: Option<PathBuf>,+ #[serde(default, skip_serializing_if = "Option::is_none")]+ pub footprint_svg_source: Option<String>,+}++#[derive(Debug, Clone, Serialize, Deserialize)]+#[serde(tag = "action", rename_all = "snake_case")]+pub enum Hint {+ /// Try a different PDF in the same chip-dir. `candidates` lists plausible+ /// alternates ds2sf already located on disk.+ TryAlternatePdf {+ reason: String,+ candidates: Vec<PathBuf>,+ },+ /// Update an upstream metadata file (e.g. chip-fetcher's info.json) and+ /// re-run. `key` + `current` + `extracted` describe the disagreement.+ UpdateUpstreamMetadata {+ file: PathBuf,+ key: String,+ current: String,+ extracted: String,+ },+ /// The output is good but a human should inspect the listed pins/fields+ /// (muxed protocols, thermal pads, power sequencing, etc.).+ HumanReview {+ reason: String,+ notes: String,+ },+ /// The package isn't in service-kicad's standard library; downstream+ /// footprint-authoring should use the datasheet-extracted dimensions.+ UseDatasheetExtractedFootprint {+ package: String,+ },+}++pub fn write(ctx: &Context, doc: &ResultDoc) -> Result<()> {+ let path = result_path(ctx);+ fs::write(&path, serde_json::to_string_pretty(doc)? + "\n")+ .with_context(|| format!("writing {}", path.display()))?;+ Ok(())+}++pub fn result_path(ctx: &Context) -> PathBuf {+ ctx.out_dir+ .join(format!("{}-extraction.result.json", ctx.mpn))+}++/// Scan the chip-dir for alternate PDFs that might be the right datasheet.+/// Returns paths relative to absolute, ordered by likelihood (most-specific first).+pub fn find_alternate_pdfs(ctx: &Context) -> Vec<PathBuf> {+ let mut out = Vec::new();+ let entries = match fs::read_dir(&ctx.chip_dir) {+ Ok(e) => e,+ Err(_) => return out,+ };++ let used = ctx.datasheet_path.clone();+ let mut all_pdfs: Vec<PathBuf> = entries+ .filter_map(|e| e.ok())+ .map(|e| e.path())+ .filter(|p| {+ p.extension().and_then(|s| s.to_str()) == Some("pdf")+ && p != &used+ })+ .collect();++ // Sort by relevance score (lower = better). The most-canonical-looking+ // datasheet filenames win. `<MPN>-data-sheet.pdf` and+ // `<MPN>-datasheet.pdf` outrank module-guideline / hardware-guideline+ // PDFs (which themselves outrank application notes / shield guides /+ // terms-of-use / etc.).+ //+ // Real-world catch (PCA9685, 2026-05-06): chip-fetcher pulled the+ // Adafruit shield user guide as the auto-detected `<MPN>.pdf`. The+ // canonical NXP datasheet was sitting next to it as+ // `PCA9685-data-sheet.pdf` but ranked too low, so the alt-PDF loop+ // suggested the LED-driver-selector-guide first. v0.3 ranks the actual+ // datasheet ahead of the family-specific guides.+ all_pdfs.sort_by_key(|p| {+ let name = p.file_name().and_then(|s| s.to_str()).unwrap_or("").to_lowercase();+ let mut score: i32 = 0;+ // Strongest positive signal: filename literally contains "data-sheet"+ // or "datasheet" — these are the canonical IC datasheets.+ if name.contains("data-sheet") || name.contains("datasheet") {+ score -= 200;+ }+ // Per-package pinout supplements. Many vendors (notably Lattice for+ // iCE40) split the per-package pin-number assignments out of the+ // main datasheet into a separate "Pinout Files" / "Package Diagrams"+ // / "Hardware Checklist" PDF. When the main datasheet refuses with+ // a `pinout_supplement_required` review_required, this is the path+ // chip-fetcher should walk.+ if name.contains("package-diagrams") || name.contains("package-diagram")+ || name.contains("pinout") || name.contains("pin-out")+ || name.contains("hardware-checklist") || name.contains("hwchecklist")+ || name.contains("package-information") || name.contains("pkg-info")+ {+ score -= 150;+ }+ // Module-specific datasheets (ESP32 case) — second-best when looking+ // at a module product.+ if name.contains("hardware") || name.contains("guideline") { score -= 100; }+ if name.contains("original") { score -= 50; }+ if name.contains("module") { score -= 40; }+ if name.contains("wroom") || name.contains("wrover") { score -= 30; }+ // Negative signals: marketing / non-datasheet documents.+ if name.contains("brief") || name.contains("ti-applies") || name.contains("terms-of-use") {+ score += 100; // these are NOT what we want+ }+ if name.contains("application-note") || name.contains("an-")+ || name.contains("errata") || name.contains("trm")+ || name.contains("reference-manual") || name.contains("user-guide")+ || name.contains("user-manual")+ || name.contains("selector-guide") || name.contains("shield")+ || name.contains("flat-panel") || name.contains("white-good")+ || name.contains("application-guide")+ || name.contains("led-controllers") || name.contains("pwm-click")+ || name.contains("reel-pack") || name.contains("get-diagram")+ || name.contains("check-to-add")+ {+ score += 50;+ }+ score+ });++ // Cap to top 5 candidates so the JSON stays digestible.+ out.extend(all_pdfs.into_iter().take(5));+ out+}
uninstall.sh+2−1@@ -1,2 +1,3 @@ #!/bin/bash-rm -f "${HOME}/.local/bin/adom-ds2sf"; echo "Removed adom-ds2sf"+set -euo pipefail+rm -f "${HOME}/.local/bin/ds2sf"; echo "Uninstalled ds2sf"