← Commit history
.gitignoreadded+3
@@ -0,0 +1,3 @@+target/+*.swp+.DS_Store
Cargo.lockadded+826
@@ -0,0 +1,826 @@+# This file is automatically @generated by Cargo.+# It is not intended for manual editing.+version = 4++[[package]]+name = "adom-chip-thumbnailer"+version = "0.5.1"+dependencies = [+ "anyhow",+ "clap",+ "clap_complete",+ "regex",+ "serde",+ "serde_json",+ "ureq",+]++[[package]]+name = "aho-corasick"+version = "1.1.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"+dependencies = [+ "memchr",+]++[[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 = "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_complete"+version = "4.6.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "660c0520455b1013b9bcb0393d5f643d7e4454fb69c915b8d6d2aa0e9a45acc3"+dependencies = [+ "clap",+]++[[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 = "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 = "form_urlencoded"+version = "1.2.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"+dependencies = [+ "percent-encoding",+]++[[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 = "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 = "regex"+version = "1.12.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"+dependencies = [+ "aho-corasick",+ "memchr",+ "regex-automata",+ "regex-syntax",+]++[[package]]+name = "regex-automata"+version = "0.4.14"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"+dependencies = [+ "aho-corasick",+ "memchr",+ "regex-syntax",+]++[[package]]+name = "regex-syntax"+version = "0.8.10"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"++[[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 = "shlex"+version = "1.3.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"++[[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 = "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",+ "log",+ "once_cell",+ "rustls",+ "rustls-pki-types",+ "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 = "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+26
@@ -0,0 +1,26 @@+[package]+name = "adom-chip-thumbnailer"+version = "0.5.1"+edition = "2021"+description = "Renders symbol / footprint / 3D thumbnails for chip-fetcher library entries (Adom-themed SVGs + OCCT-direct PNGs via service-step2glb)."+license = "MIT"++[[bin]]+name = "adom-chip-thumbnailer"+path = "src/main.rs"++[dependencies]+clap = { version = "4", features = ["derive", "env"] }+clap_complete = "4"+serde = { version = "1", features = ["derive"] }+serde_json = "1"+anyhow = "1"+regex = "1"+# Wiki-fetch SKILL.md at install time. No fallback to embedded content+# is allowed (see gallia/skills/tool-publisher/SKILL.md "install rule").+ureq = { version = "2", default-features = false, features = ["tls"] }++[profile.release]+opt-level = "z"+lto = true+strip = true
LICENSEadded+21
@@ -0,0 +1,21 @@+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:++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, 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+32−64
@@ -1,75 +1,43 @@-# adom-chip-thumbnailer+# chip-thumbnailer -## What it renders, per chip+Renders symbol / footprint / 3D thumbnails for every chip in the+[chip-fetcher](https://github.com/adom-inc/chip-fetcher) library. -- **True OCCT 3D from six angles** — Z-up (canonical) plus X/Y/Z up & down, as transparent-background icons sized for cards, tooltips and full-screen.-- **Part number lasered onto the chip face** — the MPN is rendered as *real raised geometry* on the 3D die's top surface (the `-3d-iso-named` / `-named-icon` renders), so the model that ships to KiCad already wears its name. This is perspective-correct, not a flat text overlay.-- **Five vector-outline icon styles** — `teal`, `blueprint`, `bold`, `dark` and `thin` (each available plain or name-marked), as crisp infinitely-scalable SVGs for symbol-corner badges and schematic decoration.-- **Themed symbol & footprint SVGs** — the Adom dark palette + PCB grid baked in, the same tiles every Adom EDA card shows.+- **Symbol** → `<MPN>-symbol.svg` — Adom dark theme via the same+  `applyDarkTheme()` palette gallia/symbol-creator uses (ported to Rust).+- **Footprint** → `<MPN>-footprint.svg` — kicad-cli's native KiCad+  palette painted on the canonical FpView dark canvas + 20-unit PCB+  grid.+- **3D** → `<MPN>-3d-iso.png` — 320×240 OCCT-direct render via+  `step2glb thumbnail` → `service-step2glb` POST `/thumbnail` (no+  pyrender; just OCCT V3d offscreen). -**The rendering engine behind adom-chip-fetcher's library — it renders every thumbnail kind a chip needs: themed symbol & footprint SVGs, OCCT-direct 3D renders from six angles, and crisp vector-outline icons in five styles. No local CAD install.**+## Install -adom-chip-thumbnailer walks the adom-chip-fetcher library and, for every part, renders the full set of thumbnails its cards, carats, and the adom-symbol/footprint studios consume — straight from the part's `.kicad_sym`, `.kicad_mod`, and STEP, through the shared `service-kicad` and `service-step2glb` services. Standalone Rust, no Node, no gallia. Below is **every thumbnail type it produces**, rendered for the same part (Nordic **nRF54L15**, 49-pin QFN).--# Every thumbnail it produces--## 1. Symbol — 2 variants--![Symbol thumbnails: canonical and adom-ds2sf](docs/cat-symbol.png)--The schematic symbol, Adom-themed onto the dark canvas. Two sources are rendered so the adom-chip-fetcher carat can switch between them: **`-symbol.svg`** (the canonical symbol from the part's `.kicad_sym`) and **`-symbol.adom-ds2sf.svg`** (a symbol generated from the **datasheet-extracted** pin list via adom-ds2sf, for when you want the logical, datasheet-true grouping).--## 2. Footprint — 3 variants--![Footprint thumbnails: canonical, authoritative, adom-ds2sf](docs/cat-footprint.png)--The land pattern on a dark PCB grid, cropped tight to the part. Three sources are rendered: **`-footprint.svg`** (canonical), **`-footprint.authoritative.svg`** (the manufacturer/vendor-authoritative pattern, verbatim), and **`-footprint.adom-ds2sf.svg`** (reconstructed from datasheet dimensions). Legacy KiCad-5 `(module …)` footprints render as cleanly as modern ones.--## 3. 3D shaded render — 4 forms--![Shaded 3D: full, named, icon, named icon](docs/cat-shaded.png)--OCCT-direct shaded isometric renders of the real STEP model via `service-step2glb` — a true offscreen render, not a screenshot of a viewer. Four forms ship: **`-3d-iso.png`** (the full render), **`-3d-iso-named.png`** (with the part number **laser-marked onto the top face**), and **`-3d-iso-icon.png`** / **`-3d-iso-named-icon.png`** (tight square crops for small card/badge use).--## 4. 3D orientations — 6 angles--![Six 3D orientations of the package](docs/cat-orient.png)--The same package rendered from **every principal angle** so any tool can show the side it needs — the default **iso**, plus **x-up / x-down**, **y-up / y-down**, and **z-down**. `y-up` reveals the marked top face; `y-down` shows the pad/pin underside. These power the orientation switching in the adom-chip-fetcher 3D view and the adom-symbol chip overlay.--## 5. 3D vector outline — 5 styles--![Five vector-outline styles: thin, bold, blueprint, teal, dark](docs/cat-outline.png)--Crisp black-&-white (or accent) **line art** produced by **OCCT hidden-line removal** — schematic-native icons that print cleanly and stay legible at any size. Five styles cover light and dark contexts: **thin** and **bold** (dark strokes, for light/print backgrounds), **blueprint** (blue), **teal** (brand accent), and **dark** (white strokes, for dark UI). Each style also renders a **`-named`** variant with the part number laser-marked on the chip — so the icon you drop into a symbol carries its own label.--> **In total ~24 files per part**: 2 symbol + 3 footprint + 4 shaded-3D + 6 orientation + (5 styles × plain/named) outline. They're written next to the source files and cached until the part changes.--## How it runs--- **`once <MPN>`** — render the full set above for one chip, written alongside the source files.-- **`scan`** — render every chip carrying a `.thumb-pending` marker (the batch path adom-chip-fetcher triggers).-- **`rerender <MPN> <sym|fp|3d|all>`** — re-do just one family.-- **`status` / `health` / `config`** — show what exists, probe `kicad-cli` + `step2glb` + `service-step2glb`, print the resolved library root.--## Open source — check it out, vibe-code it, send it back--adom-chip-thumbnailer is MIT and **fully open**. The complete Rust source is in this page's **Files** tab — browse it, or grab `src/` + `Cargo.toml`.+```bash+curl -fsSL https://wiki-ufypy5dpx93o.adom.cloud/static/apps/chip-thumbnailer/chip-thumbnailer \+  -o /tmp/chip-thumbnailer && chmod +x /tmp/chip-thumbnailer && \+  sudo install -m 0755 /tmp/chip-thumbnailer /usr/local/bin/chip-thumbnailer && \+  chip-thumbnailer install+``` -1. **Build it.** `cargo build --release` drops a standalone binary (no Node, no gallia).-2. **Point your install at your checkout.** `adompkg link john/adom-chip-thumbnailer ./adom-chip-thumbnailer` so the `adom-chip-thumbnailer` you run is your local copy.-3. **Vibe-code a change.** Open the folder in Claude Code and describe what you want — a new render style, a different crop, another orientation, another thumbnail kind. The symbol / footprint / 3D renderers are each one small, readable module (`render_sym.rs`, `render_fp.rs`, `render_3d.rs`).-4. **Send it back.** Open a **pull request** on this page (the wiki reviews and merges per-repo PRs), or start a discussion thread.+## Usage -## Part of the Adom EDA family+```bash+chip-thumbnailer once VL53L8CX             # render all three thumbnails+chip-thumbnailer scan                      # process every .thumb-pending marker+chip-thumbnailer status ADS131M04IPBSR     # what's there, what's missing+chip-thumbnailer health                    # probe deps+``` -The rendering engine for **adom-chip-fetcher**; produces the artwork **adom-symbol**, **adom-footprint**, and the carat switcher display. Pairs with **adom-lbr** and **adom-ds2sf**.+Outputs land in `chip-fetcher/library/<MPN>/<MPN>-{symbol,footprint,3d-iso}.{svg,png}`. -## Run it+## Build  ```bash-adom-chip-thumbnailer once nRF54L15          # render the full thumbnail set for one chip-adom-chip-thumbnailer scan                   # render every chip marked .thumb-pending-adom-chip-thumbnailer health                 # probe kicad-cli + step2glb + service-step2glb+cargo build --release ```-MIT, contributions welcome.++Run as `target/release/chip-thumbnailer`. No system deps for build;+runtime needs `kicad-cli` (10.0.x), `step2glb` (0.6.2+), and the+shared `service-step2glb` reachable.
SKILL.md+59−59
@@ -1,19 +1,19 @@ ----name: adom-chip-thumbnailer-description: "Render symbol / footprint / 3D thumbnails for adom-chip-fetcher library entries — Adom-themed SVGs (symbol via gallia/symbol-creator/svg-theme + footprint via FpView dark canvas + PCB grid) and OCCT-direct PNGs in three sizes (sm/md/lg) via service-step2glb /thumbnail. Writes a self-describing <MPN>-thumbs.json manifest per chip listing every file + the canonical Adom palette + which app to launch for full-fidelity inspection. Use when the user wants thumbnails for chips, wants to populate wiki chip pages, batch-renders the adom-chip-fetcher library, or asks 'render thumbnails / make thumbnails / chip thumbs / preview a chip / generate symbol+footprint+3d previews'. Triggers: chip thumbnail, chip thumbs, render chip, generate chip preview, symbol thumbnail, footprint thumbnail, 3d thumbnail, preview chip in library, batch render chips, .thumb-pending, chip-thumbs.json, chip wiki hero, library card, adom-chip-thumbnailer."+name: chip-thumbnailer+description: "Render symbol / footprint / 3D thumbnails for chip-fetcher library entries — Adom-themed SVGs (symbol via gallia/symbol-creator/svg-theme + footprint via FpView dark canvas + PCB grid) and OCCT-direct PNGs in three sizes (sm/md/lg) via service-step2glb /thumbnail. Writes a self-describing <MPN>-thumbs.json manifest per chip listing every file + the canonical Adom palette + which app to launch for full-fidelity inspection. Use when the user wants thumbnails for chips, wants to populate wiki chip pages, batch-renders the chip-fetcher library, or asks 'render thumbnails / make thumbnails / chip thumbs / preview a chip / generate symbol+footprint+3d previews'. Triggers: chip thumbnail, chip thumbs, render chip, generate chip preview, symbol thumbnail, footprint thumbnail, 3d thumbnail, preview chip in library, batch render chips, .thumb-pending, chip-thumbs.json, chip wiki hero, library card, chip-thumbnailer." --- -# adom-chip-thumbnailer+# chip-thumbnailer -Renders three thumbnail kinds per chip in the adom-chip-fetcher library and+Renders three thumbnail kinds per chip in the chip-fetcher library and drops them next to the source files, plus a manifest:  ``` library/<MPN>/<MPN>-symbol.svg        Adom-dark-themed KiCad symbol library/<MPN>/<MPN>-footprint.svg     FpView-styled footprint-library/<MPN>/<MPN>-3d-iso-sm.png     160 × 120 — icon / list view-library/<MPN>/<MPN>-3d-iso.png        320 × 240 — card / index (canonical, back-compat)-library/<MPN>/<MPN>-3d-iso-lg.png     800 × 600 — wiki hero / detail page+library/<MPN>/<MPN>-3d-iso-sm.png     160 × 120 — icon / list view+library/<MPN>/<MPN>-3d-iso.png        320 × 240 — card / index (canonical, back-compat)+library/<MPN>/<MPN>-3d-iso-lg.png     800 × 600 — wiki hero / detail page library/<MPN>/<MPN>-thumbs.json       self-describing manifest (READ THIS) ``` @@ -27,11 +27,11 @@ through for full-fidelity inspection.  | Suffix | Dimensions | Typical use | Approx file size | |---|---|---|---|-| `-sm.png` | 160 × 120 | icon, list-view, inline preview | 1–4 KB |-| `.png` (no suffix) | 320 × 240 | card / library-index thumbnail (canonical) | 4–12 KB |-| `-lg.png` | 800 × 600 | wiki hero, detail page, fallback hero | 12–35 KB |+| `-sm.png` | 160 × 120 | icon, list-view, inline preview | 1–4 KB |+| `.png` (no suffix) | 320 × 240 | card / library-index thumbnail (canonical) | 4–12 KB |+| `-lg.png` | 800 × 600 | wiki hero, detail page, fallback hero | 12–35 KB | -Symbol + footprint are SVG — vectors scale infinitely, one file each.+Symbol + footprint are SVG — vectors scale infinitely, one file each.  ## Palette (canonical, baked into every output) @@ -49,7 +49,7 @@ Sourced from `gallia/symbol-creator/lib/svg-theme.js` `THEME_COLORS` + | `pin_number` | `#8b949e` | symbol pin number labels (muted gray) | | `group_label` | `#E6B450` | symbol pin-group section label (warm amber) | | `group_label_hover` | `#FFD180` | group label on hover (lighter amber) |-| `accent_teal` | `#00b8b0` | Adom brand teal — link / accent |+| `accent_teal` | `#00b8b0` | Adom brand teal — link / accent | | `accent_teal_bright` | `#00e6dc` | hover / focus highlight | | `pad_fill` | `#C83434` | footprint pad fill (KiCad F.Cu red) | | `pad_stroke` | `#ef5350` | footprint pad outline |@@ -63,7 +63,7 @@ Sourced from `gallia/symbol-creator/lib/svg-theme.js` `THEME_COLORS` + The dark canvas + PCB grid + radial teal glow are **baked into every themed SVG** so the thumbnail looks correct standalone. If your host container (e.g. SymView) already provides these via CSS, the SVG's-own atmosphere will simply layer on top — usually fine, occasionally+own atmosphere will simply layer on top — usually fine, occasionally double up. In that case, strip the `<defs>` `adom-canvas-*` IDs from the SVG. @@ -72,7 +72,7 @@ the SVG. ```json {   "mpn": "VL53L8CX",-  "generated_by": "adom-chip-thumbnailer",+  "generated_by": "chip-thumbnailer",   "generated_by_version": "0.4.1",   "generated_at_iso": "2026-05-05T16:57:00Z", @@ -85,8 +85,8 @@ the SVG.       "size_bytes": 84023,       "full_fidelity": {         "tool": "symbol-creator (SymView)",-        "command": "POST http://127.0.0.1:8781/sym/create … or open <MPN>-viewer.html",-        "description": "Interactive SymView — pin tooltips, group highlights, click-to-pin info, datasheet link, zoom/fit."+        "command": "POST http://127.0.0.1:8781/sym/create … or open <MPN>-viewer.html",+        "description": "Interactive SymView — pin tooltips, group highlights, click-to-pin info, datasheet link, zoom/fit."       }     },     "footprint": {@@ -95,8 +95,8 @@ the SVG.       "size_bytes": 11852,       "full_fidelity": {         "tool": "FpView (gallia/viewer/kicad-footprint-viewer.js)",-        "command": "generateFootprintViewer(<MPN>.kicad_mod) → <MPN>-fpview.html",-        "description": "Interactive FpView — pad tooltips with signal + dimensions, layer toggle, measure tool, solder-blob jet preview."+        "command": "generateFootprintViewer(<MPN>.kicad_mod) → <MPN>-fpview.html",+        "description": "Interactive FpView — pad tooltips with signal + dimensions, layer toggle, measure tool, solder-blob jet preview."       }     },     "threeD": {@@ -109,14 +109,14 @@ the SVG.       },       "orientations": {         "asIs": {-          "label": "As-is (Z-up — STEP canonical)",+          "label": "As-is (Z-up — STEP canonical)",           "description": "File rendered exactly as authored. STEP is Z-up canonically (ISO 10303-21 / KiCad / Solidworks / FreeCAD / AutoCAD), so this is the right answer for ~99% of chips.",-          "sizes": { "sm": { /* … */ }, "md": { /* … */ }, "lg": { /* … */ } }+          "sizes": { "sm": { /* … */ }, "md": { /* … */ }, "lg": { /* … */ } }         },         "yUp": {           "label": "Force Y-up source (fallback)",-          "description": "Source pre-rotated -90° about X. Use only when as-is renders the chip tipped on its side — happens with rare third-party exports authored Y-up (Maya / Blender / three.js convention).",-          "sizes": { "sm": { /* … */ }, "md": { /* … */ }, "lg": { /* … */ } }+          "description": "Source pre-rotated -90° about X. Use only when as-is renders the chip tipped on its side — happens with rare third-party exports authored Y-up (Maya / Blender / three.js convention).",+          "sizes": { "sm": { /* … */ }, "md": { /* … */ }, "lg": { /* … */ } }         }       },       "inferredSourceUpAxis": "z",@@ -130,7 +130,7 @@ the SVG.     }   }, -  "hints": [ "…" ]+  "hints": [ "…" ] } ``` @@ -141,30 +141,30 @@ isn't in the chip dir at render time will be `null` in the manifest.  STEP is Z-up by convention (ISO 10303-21 / KiCad / Solidworks / FreeCAD / AutoCAD all author Z-up), so the **as-is** render IS-already Z-up in our pipeline — that's the right answer for ~99% of+already Z-up in our pipeline — that's the right answer for ~99% of chips. Use `library/<MPN>/<MPN>-3d-iso.png` (the unsuffixed file) in every default surface.  The **Y-up fallback** (`<MPN>-3d-iso-yup.png`) exists for the genuine subset of manufacturer STEPs that author Y-up (Bosch BME688 / BMI270, TI INA226, Nordic nRF52840-CKAA, Espressif ESP32-S3-WROOM-modules — verified in adom-chip-fetcher's library). The fallback applies-`R_x(+π/2)` server-side (canonical glTF / three.js / Blender / Adom+modules — verified in chip-fetcher's library). The fallback applies+`R_x(+π/2)` server-side (canonical glTF / three.js / Blender / Adom convention) so the chip lands upright.  The manifest's `inferredSourceUpAxis` field is a bbox-aspect heuristic (`"z"` / `"y"` / `"unknown"`); use it as a hint when building an orientation picker UI, but the user always wins. -**For anyone implementing or modifying a Y-up ↔ Z-up transform+**For anyone implementing or modifying a Y-up ↔ Z-up transform elsewhere in the Adom stack**, read [`gallia/skills/up-axis-conventions/SKILL.md`](https://github.com/adom-inc/gallia/blob/main/skills/up-axis-conventions/SKILL.md)-first. It captures the correct rotation sign (`R_x(+π/2)`, always+first. It captures the correct rotation sign (`R_x(+π/2)`, always positive), reference implementations across Babylon.js / three.js / OCCT (Python) / glTF, and the testing trap that lets sign errors-slip past visual review on Z-up sources. adom-chip-thumbnailer 0.4.0 →+slip past visual review on Z-up sources. chip-thumbnailer 0.4.0 → 0.4.1 itself shipped a sign error caught only when the user pushed-back on the rotation correctness — the gallia skill exists so+back on the rotation correctness — the gallia skill exists so nobody else hits it.  ## How AI agents should use this@@ -172,12 +172,12 @@ nobody else hits it. When the user asks anything like *"show me VL53L8CX"* / *"open this chip"* / *"preview ADS131M04IPBSR"*: -1. `cat library/<MPN>/<MPN>-thumbs.json` — read the manifest first.+1. `cat library/<MPN>/<MPN>-thumbs.json` — read the manifest first. 2. Use the right thumbnail for the surface:-   - List / index card → `.thumbnails.3d.iso.md.path` (320×240 PNG) ++   - List / index card → `.thumbnails.3d.iso.md.path` (320×240 PNG) +      `.thumbnails.symbol.path` (svg)-   - Tooltip / inline preview → `.thumbnails.3d.iso.sm.path`-   - Wiki hero / detail page → `.thumbnails.3d.iso.lg.path`+   - Tooltip / inline preview → `.thumbnails.3d.iso.sm.path`+   - Wiki hero / detail page → `.thumbnails.3d.iso.lg.path` 3. When the user wants more than the thumbnail (clicks through, asks    to inspect, asks to measure), launch the full-fidelity app named    in `.thumbnails.<kind>.full_fidelity.tool` via the suggested@@ -185,22 +185,22 @@ chip"* / *"preview ADS131M04IPBSR"*: 4. If your UI has its own background, adopt `.palette.canvas_bg` so    thumbnails sit cleanly. If your UI has accents, use    `.palette.accent_teal` (Adom brand teal) so it looks consistent.-5. Watch the `_hints` array in `adom-chip-thumbnailer once`'s JSON output+5. Watch the `_hints` array in `chip-thumbnailer once`'s JSON output    for next-step suggestions specific to the chip you just rendered.  ## CLI  ```bash-adom-chip-thumbnailer once <MPN>             # render all kinds + all 3 sizes; write manifest-adom-chip-thumbnailer scan                   # process every .thumb-pending in the library-adom-chip-thumbnailer status <MPN>           # which thumbs/sources/manifest exist-adom-chip-thumbnailer rerender <MPN>         # re-render all (or --kind sym|fp|3d to limit)-adom-chip-thumbnailer touch-pending <MPN>    # mark a chip as out-of-date-adom-chip-thumbnailer health                 # probe kicad-cli + step2glb + service-step2glb-adom-chip-thumbnailer config                 # print library root + dependency paths+chip-thumbnailer once <MPN>             # render all kinds + all 3 sizes; write manifest+chip-thumbnailer scan                   # process every .thumb-pending in the library+chip-thumbnailer status <MPN>           # which thumbs/sources/manifest exist+chip-thumbnailer rerender <MPN>         # re-render all (or --kind sym|fp|3d to limit)+chip-thumbnailer touch-pending <MPN>    # mark a chip as out-of-date+chip-thumbnailer health                 # probe kicad-cli + step2glb + service-step2glb+chip-thumbnailer config                 # print library root + dependency paths ``` -Every command emits `OK: …` lines for humans and a final JSON line+Every command emits `OK: …` lines for humans and a final JSON line for AI / scripted callers. The JSON of `once` includes the just-written manifest inline + a `_hints` block with concrete next steps so a single command-and-parse gives the AI everything it@@ -209,30 +209,30 @@ needs. ## Install  ```bash-curl -fsSL https://wiki-ufypy5dpx93o.adom.cloud/static/apps/adom-chip-thumbnailer/adom-chip-thumbnailer \-  -o /tmp/adom-chip-thumbnailer && chmod +x /tmp/adom-chip-thumbnailer && \-  sudo install -m 0755 /tmp/adom-chip-thumbnailer /usr/local/bin/adom-chip-thumbnailer && \-  adom-chip-thumbnailer install+curl -fsSL https://wiki-ufypy5dpx93o.adom.cloud/static/apps/chip-thumbnailer/chip-thumbnailer \+  -o /tmp/chip-thumbnailer && chmod +x /tmp/chip-thumbnailer && \+  sudo install -m 0755 /tmp/chip-thumbnailer /usr/local/bin/chip-thumbnailer && \+  chip-thumbnailer install ``` -`adom-chip-thumbnailer install` fetches `SKILL.md` fresh from the wiki at-install time — no `include_str!` of SKILL.md in the binary, per the+`chip-thumbnailer install` fetches `SKILL.md` fresh from the wiki at+install time — no `include_str!` of SKILL.md in the binary, per the canonical no-fallback rule in `gallia/skills/tool-publisher/SKILL.md`. On any wiki error the install exits non-zero with a clear hint.  ## Pipeline (per chip) -1. **Symbol** — `kicad-cli sym export svg` → port of gallia's+1. **Symbol** — `kicad-cli sym export svg` → port of gallia's    `applyDarkTheme()` regex pass (case-insensitive on hex tails to-   match `#FFFFC2` and `#ffffc2` both — the JS source uses `/.../gi`).-   `<style>` block wrapped in `<![CDATA[…]]>` so the literal+   match `#FFFFC2` and `#ffffc2` both — the JS source uses `/.../gi`).+   `<style>` block wrapped in `<![CDATA[…]]>` so the literal    `<g class="stroked-text">` in the CSS comment doesn't trip strict    XML parsers when the SVG is loaded via `<object>` or as a wiki    asset. Canvas bg + 2.54 mm PCB grid + radial teal glow injected    right after `<svg>` so the SVG looks correct standalone. -2. **Footprint** — stage `.kicad_mod` into a temp `.pretty/`,+2. **Footprint** — stage `.kicad_mod` into a temp `.pretty/`,    `kicad-cli fp export svg --footprint <name>`, strip    `<title>`/`<desc>`/`opacity="0"` text + `<g class="stroked-text">`    groups (KiCad inflates the viewBox with these), inject FpView's@@ -240,20 +240,20 @@ install exits non-zero with a clear hint.    Pad / silk / courtyard colors stay as kicad-cli's native palette    (which already matches FpView). -3. **3D** — `step2glb thumbnail <MPN>.step --pose iso --width N-   --height N` for each of sm (160×120), md (320×240), lg (800×600).+3. **3D** — `step2glb thumbnail <MPN>.step --pose iso --width N+   --height N` for each of sm (160×120), md (320×240), lg (800×600).    step2glb POSTs to `service-step2glb` 0.4.x `/thumbnail` which    renders server-side via OCCT V3d offscreen + Xvfb (no pyrender,    no per-render Hydrogen browser tab).  ## Failure handling -If a render fails, adom-chip-thumbnailer:+If a render fails, chip-thumbnailer: - Writes `library/<MPN>/.thumb-errors.json` with which kind failed   and why. - **Leaves the `.thumb-pending` marker in place** so the next `scan`   retries.-- Still writes the manifest — but with `null` for kinds that didn't+- Still writes the manifest — but with `null` for kinds that didn't   render, so downstream apps know what's available.  The marker is only cleared when ALL three kinds (or all the sizes@@ -261,14 +261,14 @@ that were attempted) render successfully.  ## Related -- **adom-chip-fetcher** writes the source files (`.kicad_sym`,+- **chip-fetcher** writes the source files (`.kicad_sym`,   `.kicad_mod`, `.step`) and touches `.thumb-pending` after each   successful import. - **service-step2glb** v0.4.x provides the OCCT-direct `/thumbnail`   endpoint (replaced the prior pyrender stack). - **step2glb** (Rust client) wraps `/thumbnail` for the 3D path. - **gallia/symbol-creator** owns the canonical symbol dark-theme-  palette; adom-chip-thumbnailer ports the regex pass into Rust to avoid+  palette; chip-thumbnailer ports the regex pass into Rust to avoid   a node shell-out at render time. - **gallia/viewer/kicad-footprint-viewer.js** owns the FpView   footprint styling.
VERSIONadded+1
@@ -0,0 +1 @@+0.5.1
build.shadded+25
@@ -0,0 +1,25 @@+#!/usr/bin/env bash+# chip-thumbnailer build script. Builds the release binary; doesn't+# upload to the wiki — that's a separate `adom-wiki asset upload`+# step gated on user approval per the wiki-publish rule.+set -euo pipefail+cd "$(dirname "$0")"++VERSION=$(cat VERSION | tr -d '[:space:]')+echo "[chip-thumbnailer] Building v${VERSION}..."+cargo build --release+echo "[adom-chip-thumbnailer] Built target/release/adom-chip-thumbnailer ($(stat -c%s target/release/adom-chip-thumbnailer 2>/dev/null || stat -f%z target/release/adom-chip-thumbnailer) bytes)"++echo+echo "Next, to ship:"+echo "  1. Upload SKILL.md to wiki:"+echo "     adom-wiki asset upload app/chip-thumbnailer --asset-type file --file SKILL.md \\"+echo "       --caption 'Skill content fetched at install time' \\"+echo "       --changelog 'v${VERSION}'"+echo "  2. Upload binary:"+echo "     adom-wiki asset upload app/chip-thumbnailer --asset-type docker-binary \\"+echo "       --file target/release/adom-chip-thumbnailer \\"+echo "       --caption 'v${VERSION} binary for adom docker containers' \\"+echo "       --changelog 'v${VERSION}'"+echo "  3. Bump page version:"+echo "     adom-wiki page publish app/chip-thumbnailer ... --version ${VERSION}"
src/cli.rsadded+337
@@ -0,0 +1,337 @@+use crate::library;+use crate::queue;+use crate::ui::{err, hint, ok, warn, CYAN, DIM, RESET};+use crate::{install, manifest, note, render_3d, render_fp, render_sym};+use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum};+use std::process::Command as ProcCommand;++#[derive(Parser, Debug)]+#[command(+    name = "adom-chip-thumbnailer",+    version,+    about = "Render symbol/footprint/3D thumbnails for chip-fetcher library entries.",+    long_about = "\+chip-thumbnailer renders three thumbnails per chip in the chip-fetcher\n\+library and drops them next to the source files:\n  \n\+  <MPN>-symbol.svg     Adom-themed KiCad symbol (kicad-cli + svg-theme)\n\+  <MPN>-footprint.svg  FpView-styled footprint (kicad-cli + dark grid)\n\+  <MPN>-3d-iso.png     OCCT-direct render via service-step2glb /thumbnail\n\+"+)]+pub struct Cli {+    #[command(subcommand)]+    pub command: Command,+}++#[derive(Subcommand, Debug)]+pub enum Command {+    /// Render all three thumbnails for one chip (writes alongside the source files).+    Once(OnceArgs),+    /// Render every chip in the library that has a `.thumb-pending` marker.+    Scan,+    /// Show which thumbnails exist + which sources are present for one chip.+    Status(StatusArgs),+    /// Re-render only one kind for one chip (sym, fp, 3d, or all).+    Rerender(RerenderArgs),+    /// Probe local kicad-cli + step2glb + service-step2glb reachability.+    Health,+    /// Print resolved library root + step2glb binary location.+    Config,+    /// Manually mark a chip's thumbnails as out-of-date (touches `.thumb-pending`).+    TouchPending(TouchPendingArgs),+    /// Install SKILL.md + bash completions (fetches fresh SKILL.md from the wiki).+    Install,+    /// Print shell completions to stdout: `chip-thumbnailer completions bash`+    Completions(CompletionsArgs),+}++#[derive(Args, Debug)]+pub struct OnceArgs {+    pub mpn: String,+}++#[derive(Args, Debug)]+pub struct StatusArgs {+    pub mpn: String,+}++#[derive(Args, Debug)]+pub struct RerenderArgs {+    pub mpn: String,+    /// Which thumbnail to re-render. Default = all three.+    #[arg(long, default_value = "all")]+    pub kind: String,+}++#[derive(Args, Debug)]+pub struct TouchPendingArgs {+    pub mpn: String,+}++#[derive(Args, Debug)]+pub struct CompletionsArgs {+    #[arg(value_enum)]+    pub shell: ShellArg,+}++#[derive(Debug, Clone, Copy, ValueEnum)]+pub enum ShellArg {+    Bash,+    Zsh,+    Fish,+}++impl From<ShellArg> for clap_complete::Shell {+    fn from(s: ShellArg) -> Self {+        match s {+            ShellArg::Bash => clap_complete::Shell::Bash,+            ShellArg::Zsh => clap_complete::Shell::Zsh,+            ShellArg::Fish => clap_complete::Shell::Fish,+        }+    }+}++// ────────────────────────────────────────────────────────────────────────+// command implementations+// ────────────────────────────────────────────────────────────────────────++pub fn once_cmd(args: OnceArgs) {+    match queue::render_one(&args.mpn) {+        Ok(report) => {+            if report.all_ok() {+                ok(format!(+                    "{CYAN}{}{RESET} — sym/fp/3d all rendered, .thumb-pending cleared",+                    args.mpn+                ));+            } else {+                warn(format!(+                    "{CYAN}{}{RESET} — partial render ({} sym, {} fp, {} 3d). See .thumb-errors.json",+                    args.mpn,+                    if report.sym_ok { "ok" } else { "FAIL" },+                    if report.fp_ok { "ok" } else { "FAIL" },+                    if report.three_d_ok { "ok" } else { "FAIL" },+                ));+            }++            // Build the AI-hint payload by reading back the freshly-written+            // manifest so the JSON output of `once` is self-describing —+            // any AI agent driving chip-thumbnailer learns from one+            // command both what was rendered AND how to use it.+            let chip = library::ChipPaths::for_mpn(&args.mpn).ok();+            let m = chip.as_ref().map(manifest::build);++            let mut next_steps: Vec<String> = Vec::new();+            if report.all_ok() {+                if let Some(ref c) = chip {+                    next_steps.push(format!(+                        "Manifest written: cat library/{}/{}-thumbs.json",+                        c.mpn, c.mpn+                    ));+                    next_steps.push(format!(+                        "Open the 3D in interactive mode: adom-step view library/{}/{}.step",+                        c.mpn, c.mpn+                    ));+                    next_steps.push(format!(+                        "Open all thumbnails as VS Code tabs: adom-vscode open library/{}/{}-symbol.svg && adom-vscode open library/{}/{}-footprint.svg && adom-vscode open library/{}/{}-3d-iso.png",+                        c.mpn, c.mpn, c.mpn, c.mpn, c.mpn, c.mpn+                    ));+                }+            }+            if !report.all_ok() {+                next_steps.push(format!(+                    "Inspect failures: cat library/{}/.thumb-errors.json",+                    args.mpn+                ));+                next_steps.push(format!(+                    "Retry just the failed kinds: chip-thumbnailer rerender {} --kind sym|fp|3d",+                    args.mpn+                ));+            }++            println!(+                "{}",+                serde_json::to_string(&serde_json::json!({+                    "ok": report.all_ok(),+                    "mpn": report.mpn,+                    "sym": report.sym_ok,+                    "fp": report.fp_ok,+                    "3d": report.three_d_ok,+                    "errors": report.errors,+                    "manifest": m,+                    "_hints": {+                        "purpose": "These thumbnails are sized for card / index / tooltip surfaces. For full-fidelity inspection, see manifest.thumbnails.<kind>.full_fidelity.tool / .command.",+                        "palette": "All thumbnails use the canonical Adom palette in manifest.palette — match your surrounding UI to canvas_bg #0d1117 and accent_teal #00b8b0 to avoid clashing.",+                        "sizes": "3D PNGs come in sm (160×120), md (320×240, no suffix — back-compat), lg (800×600). SVGs scale infinitely; one file each.",+                        "atmosphere": "Themed SVGs bake in the dark canvas + 2.54 mm PCB grid + faint teal radial glow. If your container already provides those (e.g. SymView), don't double them.",+                        "next_steps": next_steps,+                    }+                })).unwrap()+            );+            if !report.all_ok() {+                std::process::exit(3);+            }+        }+        Err(e) => {+            err(format!("once failed for {}: {e}", args.mpn));+            hint("Run `chip-thumbnailer status <MPN>` to inspect the chip dir.");+            std::process::exit(1);+        }+    }+}++pub fn scan_cmd() {+    match queue::render_pending() {+        Ok(0) => {+            ok("no chips with .thumb-pending markers — nothing to do");+        }+        Ok(n) => {+            ok(format!("processed {n} pending chip(s)"));+        }+        Err(e) => {+            err(format!("scan failed: {e}"));+            std::process::exit(1);+        }+    }+}++pub fn status_cmd(args: StatusArgs) {+    let chip = match library::ChipPaths::for_mpn(&args.mpn) {+        Ok(c) => c,+        Err(e) => {+            err(format!("{e}"));+            std::process::exit(1);+        }+    };+    let yes = "✓";+    let no = "·";+    note!("{CYAN}{}{RESET}", chip.mpn);+    note!("  {DIM}dir{RESET}        {}", chip.dir.display());+    note!(+        "  {DIM}sources{RESET}    sym={}  mod={}  step={}",+        if chip.kicad_sym.is_some() { yes } else { no },+        if chip.kicad_mod.is_some() { yes } else { no },+        if chip.step.is_some() { yes } else { no },+    );+    note!(+        "  {DIM}thumbs{RESET}     sym={}  fp={}  3d={}",+        if chip.symbol_thumb().is_file() { yes } else { no },+        if chip.footprint_thumb().is_file() { yes } else { no },+        if chip.three_d_thumb().is_file() { yes } else { no },+    );+    note!(+        "  {DIM}pending{RESET}    {}",+        if chip.thumb_pending { "yes (.thumb-pending)" } else { "no" }+    );+}++pub fn rerender_cmd(args: RerenderArgs) {+    let chip = match library::ChipPaths::for_mpn(&args.mpn) {+        Ok(c) => c,+        Err(e) => {+            err(format!("{e}"));+            std::process::exit(1);+        }+    };+    let want_all = args.kind == "all";+    let mut had_failure = false;++    if want_all || args.kind == "sym" {+        match render_sym::render(&chip) {+            Ok(_) => ok(format!("sym  → {}", chip.symbol_thumb().display())),+            Err(e) => { err(format!("sym failed: {e}")); had_failure = true; }+        }+    }+    if want_all || args.kind == "fp" {+        match render_fp::render(&chip) {+            Ok(_) => ok(format!("fp   → {}", chip.footprint_thumb().display())),+            Err(e) => { err(format!("fp failed: {e}")); had_failure = true; }+        }+    }+    if want_all || args.kind == "3d" {+        match render_3d::render(&chip) {+            Ok(_) => ok(format!("3d   → {}", chip.three_d_thumb().display())),+            Err(e) => { err(format!("3d failed: {e}")); had_failure = true; }+        }+    }+    if !["all", "sym", "fp", "3d"].contains(&args.kind.as_str()) {+        err(format!("unknown --kind `{}`; want all|sym|fp|3d", args.kind));+        std::process::exit(1);+    }+    // Refresh the manifest so file lists, palette, version stay in sync+    // with what's on disk — same contract as `once`.+    match manifest::write(&chip) {+        Ok(path) => ok(format!("manifest → {}", path.display())),+        Err(e) => warn(format!("manifest write failed: {e}")),+    }+    if had_failure {+        std::process::exit(3);+    }+}++pub fn health_cmd() {+    let kicad_ok = ProcCommand::new("kicad-cli").arg("version").output().is_ok();+    let step2glb_ok = ProcCommand::new("step2glb").arg("--version").output().is_ok();+    let svc_ok = ureq::get("https://step2glb-gmdoncpxdwx0.adom.cloud/health")+        .timeout(std::time::Duration::from_secs(5))+        .call()+        .is_ok();+    let lib_ok = library::root().is_dir();++    note!("{DIM}kicad-cli{RESET}         {}", if kicad_ok { "ok" } else { "MISSING" });+    note!("{DIM}step2glb{RESET}          {}", if step2glb_ok { "ok" } else { "MISSING" });+    note!("{DIM}service-step2glb{RESET}  {}", if svc_ok { "reachable" } else { "UNREACHABLE" });+    note!("{DIM}library{RESET}           {} ({})",+        library::root().display(),+        if lib_ok { "exists" } else { "MISSING" }+    );+    if !(kicad_ok && step2glb_ok && svc_ok && lib_ok) {+        std::process::exit(2);+    }+}++pub fn config_cmd() {+    note!("{DIM}library root:{RESET}     {}", library::root().display());+    note!("{DIM}kicad-cli:{RESET}        {}", which_or("kicad-cli"));+    note!("{DIM}step2glb:{RESET}         {}", which_or("step2glb"));+    note!("{DIM}service-step2glb:{RESET} https://step2glb-gmdoncpxdwx0.adom.cloud");+}++pub fn touch_pending_cmd(args: TouchPendingArgs) {+    match library::ChipPaths::for_mpn(&args.mpn) {+        Ok(c) => match library::touch_pending(&args.mpn) {+            Ok(_) => ok(format!(+                "marked {} as pending — next `scan` will re-render",+                c.mpn+            )),+            Err(e) => {+                err(format!("{e}"));+                std::process::exit(1);+            }+        },+        Err(e) => {+            err(format!("{e}"));+            std::process::exit(1);+        }+    }+}++pub fn install_cmd() {+    install::run();+}++pub fn completions_cmd(args: CompletionsArgs) {+    let shell: clap_complete::Shell = args.shell.into();+    let mut cmd = Cli::command();+    clap_complete::generate(shell, &mut cmd, "chip-thumbnailer", &mut std::io::stdout());+}++fn which_or(name: &str) -> String {+    ProcCommand::new("which")+        .arg(name)+        .output()+        .ok()+        .filter(|o| o.status.success())+        .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())+        .filter(|s| !s.is_empty())+        .unwrap_or_else(|| format!("(not on PATH)"))+}
src/install.rsadded+95
@@ -0,0 +1,95 @@+//! Install command — fetches SKILL.md from the Adom Wiki at install+//! time. NEVER include_str! the SKILL.md (rule from+//! gallia/skills/tool-publisher/SKILL.md "install rule"). On any wiki+//! error the install exits non-zero with a clear message; no fallback.++use crate::ui::{err, hint, ok};+use clap::CommandFactory;+use std::fs;+use std::io::Write;+use std::path::PathBuf;+use std::time::Duration;++const SKILL_URL: &str =+    "https://wiki-ufypy5dpx93o.adom.cloud/static/apps/chip-thumbnailer/SKILL.md";++fn fetch_skill_md() -> ! {+    panic!("never called — fetch_skill_md returns into main; here for type-only docs");+}++pub fn run() {+    let home = std::env::var("HOME").unwrap_or_else(|_| "/home/adom".into());++    // 1. Skill file → ~/.claude/skills/adom-chip-thumbnailer/SKILL.md+    let skill_md = match ureq::get(SKILL_URL).timeout(Duration::from_secs(10)).call() {+        Ok(r) => match r.into_string() {+            Ok(s) => s,+            Err(e) => fail(&format!("read SKILL.md body from {SKILL_URL}: {e}")),+        },+        Err(ureq::Error::Status(code, _)) => {+            fail(&format!("wiki returned HTTP {code} for {SKILL_URL}"))+        }+        Err(e) => fail(&format!("could not reach {SKILL_URL}: {e}")),+    };++    let skill_dir = PathBuf::from(&home).join(".claude/skills/adom-chip-thumbnailer");+    if let Err(e) = fs::create_dir_all(&skill_dir) {+        fail(&format!("mkdir {}: {e}", skill_dir.display()));+    }+    let skill_path = skill_dir.join("SKILL.md");+    if let Err(e) = fs::write(&skill_path, &skill_md) {+        fail(&format!("write {}: {e}", skill_path.display()));+    }+    ok(format!(+        "skill installed at {} ({} bytes from wiki)",+        skill_path.display(),+        skill_md.len()+    ));++    // 2. Bash completions → ~/.local/share/bash-completion/completions/chip-thumbnailer+    let comp_dir = PathBuf::from(&home).join(".local/share/bash-completion/completions");+    let _ = fs::create_dir_all(&comp_dir);+    let mut cmd = crate::cli::Cli::command();+    let mut buf = Vec::new();+    clap_complete::generate(+        clap_complete::Shell::Bash,+        &mut cmd,+        "chip-thumbnailer",+        &mut buf,+    );+    let _ = fs::write(comp_dir.join("chip-thumbnailer"), &buf);+    ok(format!(+        "bash completions at {}/chip-thumbnailer",+        comp_dir.display()+    ));++    // 3. .bashrc nudge (idempotent).+    let bashrc_path = PathBuf::from(&home).join(".bashrc");+    let existing = fs::read_to_string(&bashrc_path).unwrap_or_default();+    if !existing.contains("# chip-thumbnailer completions") {+        if let Ok(mut f) = fs::OpenOptions::new().append(true).open(&bashrc_path) {+            let _ = f.write_all(+                b"\n# chip-thumbnailer completions\neval \"$(chip-thumbnailer completions bash 2>/dev/null)\"\n",+            );+            ok(".bashrc: completion hook appended (open a new shell or `source ~/.bashrc`)");+        }+    }++    println!();+    ok("chip-thumbnailer installed. Try `chip-thumbnailer health`.");+}++fn fail(msg: &str) -> ! {+    err(format!("install aborted — {msg}"));+    hint("SKILL.md is fetched from the wiki at install time (no embedded fallback).");+    hint("Check connectivity to wiki-ufypy5dpx93o.adom.cloud and retry `chip-thumbnailer install`.");+    std::process::exit(2);+}++// Keep fetch_skill_md private hint above; the trick is `fail` returns `!`+// so callers can use it in expression position. The dummy fn above keeps+// the docstring discoverable; lint silenced.+#[allow(dead_code)]+fn _doc_fetch_skill_md() {+    let _ = fetch_skill_md;+}
src/library.rsadded+296
@@ -0,0 +1,296 @@+//! Locate chip-fetcher library entries on disk and inspect their files.+//!+//! chip-fetcher stores each chip at:+//!   /home/adom/project/chip-fetcher/library/<MPN>/+//! with files named <MPN>.{step,kicad_sym,kicad_mod,pdf,...}.+//!+//! This module finds the library root and surfaces per-chip metadata+//! about which source files exist (so the renderers can decide which+//! thumbnails are even possible).++use anyhow::{anyhow, Result};+use std::path::{Path, PathBuf};++/// Marker file written by chip-fetcher after a successful import. Presence+/// means "thumbnails are out of date; re-render." chip-thumbnailer deletes+/// the marker after a successful render.+pub const THUMB_PENDING_MARKER: &str = ".thumb-pending";++/// Errors-from-last-render sidecar so chip-thumbnailer can resume failed+/// renders without re-running the whole pipeline.+pub const THUMB_ERRORS_FILE: &str = ".thumb-errors.json";++pub fn root() -> PathBuf {+    if let Ok(p) = std::env::var("CHIP_FETCHER_LIBRARY") {+        return PathBuf::from(p);+    }+    PathBuf::from("/home/adom/project/chip-fetcher/library")+}++#[derive(Debug, Clone)]+pub struct ChipPaths {+    pub mpn: String,+    pub dir: PathBuf,+    pub kicad_sym: Option<PathBuf>,+    pub kicad_mod: Option<PathBuf>,+    pub step: Option<PathBuf>,+    pub thumb_pending: bool,+}++impl ChipPaths {+    pub fn for_mpn(mpn: &str) -> Result<Self> {+        let dir = root().join(mpn);+        if !dir.is_dir() {+            return Err(anyhow!("chip not in library: {}", dir.display()));+        }+        let pick = |ext: &str| {+            let p = dir.join(format!("{mpn}.{ext}"));+            if p.is_file() {+                Some(p)+            } else {+                None+            }+        };+        Ok(Self {+            mpn: mpn.to_string(),+            kicad_sym: pick("kicad_sym"),+            kicad_mod: pick("kicad_mod"),+            step: pick("step").or_else(|| pick("stp")),+            thumb_pending: dir.join(THUMB_PENDING_MARKER).exists(),+            dir,+        })+    }++    pub fn symbol_thumb(&self) -> PathBuf {+        self.dir.join(format!("{}-symbol.svg", self.mpn))+    }+    pub fn footprint_thumb(&self) -> PathBuf {+        self.dir.join(format!("{}-footprint.svg", self.mpn))+    }+    /// Canonical 3D thumbnail (as-is orientation, medium / 320×240).+    /// Kept un-suffixed so back-compat consumers still find it.+    pub fn three_d_thumb(&self) -> PathBuf {+        self.three_d_thumb_path(ThreeDOrientation::AsIs, ThreeDSize::Md)+    }+    /// 3D thumbnail at a specific size, as-is orientation.+    pub fn three_d_thumb_size(&self, size: ThreeDSize) -> PathBuf {+        self.three_d_thumb_path(ThreeDOrientation::AsIs, size)+    }+    /// 3D thumbnail at a specific (orientation, size).+    /// As-is keeps the original un-suffixed name; yUp/zUp get a token.+    pub fn three_d_thumb_path(&self, orient: ThreeDOrientation, size: ThreeDSize) -> PathBuf {+        let osuffix = orient.file_suffix();+        let ssuffix = size.suffix();+        self.dir+            .join(format!("{}-3d-iso{osuffix}{ssuffix}.png", self.mpn))+    }+    /// Iterator over all three rendered 3D sizes (as-is orientation).+    pub fn three_d_thumbs(&self) -> Vec<(ThreeDSize, PathBuf)> {+        ThreeDSize::all()+            .into_iter()+            .map(|s| (s, self.three_d_thumb_size(s)))+            .collect()+    }+}++/// Up-axis interpretation applied to the source STEP before the 3D+/// render. Six face-up cube orientations cover every "which side is+/// up" question; the consumer (e.g. chip-fetcher's UI) picks the+/// right one for each chip.+///+/// Math + reference impls + per-format catalog:+/// `gallia/skills/up-axis-conventions/SKILL.md`.+#[derive(Debug, Clone, Copy, PartialEq, Eq)]+pub enum ThreeDOrientation {+    /// Identity. Source +Z maps to render +Z. STEP / KiCad / Solidworks+    /// canonical — right for ~80% of chips.+    AsIs,+    /// `R_x(π)`. Source -Z maps to render +Z. Use when STEP was+    /// authored upside-down in Z (rare).+    ZDown,+    /// `R_x(+π/2)`. Source +Y maps to render +Z. Canonical Y-up,+    /// matches glTF / three.js / Blender / Adom-canonical-viewer+    /// (`rotateYUpToZUp` uses Math.PI/2). Right for chips authored+    /// Y-up — Bosch sensors, Espressif modules, Nordic WLCSPs, etc.+    YUp,+    /// `R_x(-π/2)`. Source -Y maps to render +Z. Inverted Y-up — a+    /// chip authored "Y-down" (pins along +Y instead of -Y). WAGO+    /// connectors are the canonical real-world example.+    YDown,+    /// `R_y(-π/2)`. Source +X maps to render +Z. Rare — some+    /// industrial parts.+    XUp,+    /// `R_y(+π/2)`. Source -X maps to render +Z.+    XDown,+}++impl ThreeDOrientation {+    pub fn all() -> [ThreeDOrientation; 6] {+        [+            ThreeDOrientation::AsIs,+            ThreeDOrientation::ZDown,+            ThreeDOrientation::YUp,+            ThreeDOrientation::YDown,+            ThreeDOrientation::XUp,+            ThreeDOrientation::XDown,+        ]+    }+    /// Filename token appended between `-3d-iso` and the size suffix.+    /// AsIs is empty (canonical un-suffixed name for back-compat).+    pub fn file_suffix(self) -> &'static str {+        match self {+            ThreeDOrientation::AsIs => "",+            ThreeDOrientation::ZDown => "-zdown",+            ThreeDOrientation::YUp => "-yup",+            ThreeDOrientation::YDown => "-ydown",+            ThreeDOrientation::XUp => "-xup",+            ThreeDOrientation::XDown => "-xdown",+        }+    }+    /// Label used in the JSON manifest.+    pub fn json_key(self) -> &'static str {+        match self {+            ThreeDOrientation::AsIs => "asIs",+            ThreeDOrientation::ZDown => "zDown",+            ThreeDOrientation::YUp => "yUp",+            ThreeDOrientation::YDown => "yDown",+            ThreeDOrientation::XUp => "xUp",+            ThreeDOrientation::XDown => "xDown",+        }+    }+    /// Value passed to `step2glb thumbnail --up-axis ...`.+    pub fn step2glb_arg(self) -> &'static str {+        match self {+            ThreeDOrientation::AsIs => "asIs",+            ThreeDOrientation::ZDown => "zDown",+            ThreeDOrientation::YUp => "yUp",+            ThreeDOrientation::YDown => "yDown",+            ThreeDOrientation::XUp => "xUp",+            ThreeDOrientation::XDown => "xDown",+        }+    }+    /// Human-readable label for picker UIs.+    pub fn label(self) -> &'static str {+        match self {+            ThreeDOrientation::AsIs => "as-is (Z-up canonical)",+            ThreeDOrientation::ZDown => "Z-down (R_x(π))",+            ThreeDOrientation::YUp => "Y-up (R_x(+π/2))",+            ThreeDOrientation::YDown => "Y-down (R_x(-π/2))",+            ThreeDOrientation::XUp => "X-up (R_y(-π/2))",+            ThreeDOrientation::XDown => "X-down (R_y(+π/2))",+        }+    }+}++/// Standard 3D thumbnail sizes. `Md` is the canonical filename+/// (no suffix) — kept for back-compat with chip-thumbnailer 0.1.x+/// and downstream consumers that expect `<MPN>-3d-iso.png`.+#[derive(Debug, Clone, Copy, PartialEq, Eq)]+pub enum ThreeDSize {+    Sm,+    Md,+    Lg,+}++impl ThreeDSize {+    pub fn all() -> [ThreeDSize; 3] {+        [ThreeDSize::Sm, ThreeDSize::Md, ThreeDSize::Lg]+    }+    /// (width, height)+    pub fn dims(self) -> (u32, u32) {+        match self {+            ThreeDSize::Sm => (160, 120),+            ThreeDSize::Md => (320, 240),+            ThreeDSize::Lg => (800, 600),+        }+    }+    /// Filename suffix appended after `-3d-iso` and before `.png`.+    /// Md → "" (canonical, no suffix). Sm → "-sm". Lg → "-lg".+    pub fn suffix(self) -> &'static str {+        match self {+            ThreeDSize::Sm => "-sm",+            ThreeDSize::Md => "",+            ThreeDSize::Lg => "-lg",+        }+    }+    pub fn label(self) -> &'static str {+        match self {+            ThreeDSize::Sm => "sm",+            ThreeDSize::Md => "md",+            ThreeDSize::Lg => "lg",+        }+    }+    pub fn parse(s: &str) -> Option<Self> {+        match s.to_ascii_lowercase().as_str() {+            "sm" | "small" => Some(ThreeDSize::Sm),+            "md" | "medium" => Some(ThreeDSize::Md),+            "lg" | "large" => Some(ThreeDSize::Lg),+            _ => None,+        }+    }+}++/// Walk the library and return every MPN dir.+pub fn list_mpns() -> Result<Vec<String>> {+    let root = root();+    if !root.exists() {+        return Ok(vec![]);+    }+    let mut out = Vec::new();+    for entry in std::fs::read_dir(&root)? {+        let entry = entry?;+        if entry.file_type()?.is_dir() {+            if let Some(name) = entry.file_name().to_str() {+                if !name.starts_with('.') {+                    out.push(name.to_string());+                }+            }+        }+    }+    out.sort();+    Ok(out)+}++pub fn pending_chips() -> Result<Vec<String>> {+    let mut out = Vec::new();+    for mpn in list_mpns()? {+        if root().join(&mpn).join(THUMB_PENDING_MARKER).exists() {+            out.push(mpn);+        }+    }+    Ok(out)+}++pub fn touch_pending(mpn: &str) -> Result<()> {+    let path = root().join(mpn).join(THUMB_PENDING_MARKER);+    std::fs::write(&path, b"").map_err(|e| anyhow!("touch {}: {e}", path.display()))+}++pub fn clear_pending(mpn: &str) -> Result<()> {+    let path = root().join(mpn).join(THUMB_PENDING_MARKER);+    if path.exists() {+        std::fs::remove_file(&path).map_err(|e| anyhow!("rm {}: {e}", path.display()))?;+    }+    Ok(())+}++pub fn write_errors(mpn: &str, kinds: &[(&str, String)]) -> Result<PathBuf> {+    let path = root().join(mpn).join(THUMB_ERRORS_FILE);+    let mut obj = serde_json::Map::new();+    for (k, v) in kinds {+        obj.insert((*k).to_string(), serde_json::Value::String(v.clone()));+    }+    let blob = serde_json::Value::Object(obj);+    std::fs::write(&path, serde_json::to_vec_pretty(&blob)?)?;+    Ok(path)+}++pub fn clear_errors(mpn: &str) {+    let _ = std::fs::remove_file(root().join(mpn).join(THUMB_ERRORS_FILE));+}++pub fn relative_to_workspace(p: &Path) -> String {+    p.strip_prefix("/home/adom/project")+        .map(|x| format!("/home/adom/project/{}", x.display()))+        .unwrap_or_else(|_| p.display().to_string())+}
src/main.rsadded+27
@@ -0,0 +1,27 @@+mod cli;+mod install;+mod library;+mod manifest;+mod queue;+mod render_3d;+mod render_fp;+mod render_sym;+mod theme;+mod ui;++use clap::Parser;++fn main() {+    let args = cli::Cli::parse();+    match args.command {+        cli::Command::Once(a) => cli::once_cmd(a),+        cli::Command::Scan => cli::scan_cmd(),+        cli::Command::Status(a) => cli::status_cmd(a),+        cli::Command::Rerender(a) => cli::rerender_cmd(a),+        cli::Command::Health => cli::health_cmd(),+        cli::Command::Config => cli::config_cmd(),+        cli::Command::TouchPending(a) => cli::touch_pending_cmd(a),+        cli::Command::Install => cli::install_cmd(),+        cli::Command::Completions(a) => cli::completions_cmd(a),+    }+}
src/manifest.rsadded+435
@@ -0,0 +1,435 @@+//! Per-chip thumbnail manifest. Written as `<MPN>-thumbs.json` next to+//! the source files after every render. Self-describing — downstream+//! apps and AI callers should be able to consume the manifest alone+//! to know:+//!+//!   1. What thumbnail files exist + their dimensions / sizes+//!   2. What palette colors are baked in (so the host UI can match)+//!   3. Which "full-fidelity" app to launch when the user wants more+//!      than the thumbnail (adom-step for 3D, SymView for symbol,+//!      FpView for footprint)+//!   4. Inline hints — short imperative sentences the AI can echo to+//!      the user without re-deriving them++use crate::library::{ChipPaths, ThreeDOrientation, ThreeDSize};+use anyhow::Result;+use serde::Serialize;+use std::path::PathBuf;++const VERSION: &str = env!("CARGO_PKG_VERSION");++// All manifest types serialize to camelCase JSON. This matches the+// rest of the Adom CLI ecosystem (step2glb's `sizeBytes` /+// `durationMs` / `srcKind`, chip-fetcher's `infoJson`, etc.) so+// downstream apps can use one casing convention everywhere. Rust+// fields stay snake_case (the language norm); the wire format is+// camelCase via serde rename.++#[derive(Serialize)]+#[serde(rename_all = "camelCase")]+pub struct Manifest {+    pub mpn: String,+    pub generated_by: String,+    pub generated_by_version: &'static str,+    pub generated_at_iso: String,+    pub palette: Palette,+    pub thumbnails: Thumbnails,+    pub hints: Vec<String>,+}++#[derive(Serialize)]+#[serde(rename_all = "camelCase")]+pub struct Palette {+    // Symbol (gallia/symbol-creator/lib/svg-theme.js THEME_COLORS)+    pub canvas_bg: &'static str,+    pub body_fill: &'static str,+    pub body_fill_grad: &'static str,+    pub body_stroke: &'static str,+    pub pin_wire: &'static str,+    pub pin_name: &'static str,+    pub pin_number: &'static str,+    pub group_label: &'static str,+    pub group_label_hover: &'static str,+    // Adom brand accent (gallia/viewer/adom-theme.js THEME)+    pub accent_teal: &'static str,+    pub accent_teal_bright: &'static str,+    // Footprint (gallia/viewer/kicad-footprint-viewer.js)+    pub pad_fill: &'static str,+    pub pad_stroke: &'static str,+    pub pad_number: &'static str,+    pub courtyard: &'static str,+    pub silkscreen: &'static str,+    pub fab_outline: &'static str,+    // Canvas atmosphere (baked into themed SVGs by chip-thumbnailer)+    pub grid_line: &'static str,+    pub glow: &'static str,+}++impl Palette {+    pub fn canonical() -> Self {+        Self {+            canvas_bg: "#0d1117",+            body_fill: "#1a2332",+            body_fill_grad: "#141c27",+            body_stroke: "#30363d",+            pin_wire: "#4a5568",+            pin_name: "#ffffff",+            pin_number: "#8b949e",+            group_label: "#E6B450",+            group_label_hover: "#FFD180",+            accent_teal: "#00b8b0",+            accent_teal_bright: "#00e6dc",+            pad_fill: "#C83434",+            pad_stroke: "#ef5350",+            pad_number: "#ffffff",+            courtyard: "#FF26E2",+            silkscreen: "#00cccc",+            fab_outline: "#6e7681",+            grid_line: "rgba(255,255,255,0.025)",+            glow: "rgba(0,184,176,0.06)",+        }+    }+}++#[derive(Serialize)]+#[serde(rename_all = "camelCase")]+pub struct Thumbnails {+    pub symbol: Option<SvgEntry>,+    pub footprint: Option<SvgEntry>,+    #[serde(rename = "threeD")]+    pub three_d: Option<ThreeD>,+}++#[derive(Serialize)]+#[serde(rename_all = "camelCase")]+pub struct SvgEntry {+    pub format: &'static str, // "svg"+    pub path: String,         // relative to chip dir+    pub size_bytes: u64,+    pub full_fidelity: FullFidelity,+}++#[derive(Serialize)]+#[serde(rename_all = "camelCase")]+pub struct ThreeD {+    pub format: &'static str, // "png"+    pub pose: &'static str,   // "iso" (default; iso2/top/front/side available)+    /// Back-compat alias: the `asIs` orientation's PNG sizes. Older+    /// consumers (chip-thumbnailer 0.2.x) that look up+    /// `thumbnails.threeD.iso.md.path` keep working.+    pub iso: PngSizes,+    /// All six face-up orientation variants of the same chip. chip-+    /// thumbnailer is intentionally neutral about which is "correct" —+    /// every variant gets rendered, and downstream tooling+    /// (chipsmith, chipfit, etc.) picks the right one per chip.+    /// See gallia/skills/up-axis-conventions for the math.+    pub orientations: Orientations,+    /// Source-STEP bounding box in millimeters (canonical Adom unit per+    /// gallia/skills/electrical-engineering §1a). `bbox_mils` carries the+    /// same value pre-converted so downstream UIs can render mm + mils+    /// side-by-side without recomputing (the human-ui-patterns §4+    /// multi-unit rule).+    pub bbox_mm: Option<[[f64; 2]; 3]>,+    pub bbox_mils: Option<[[f64; 2]; 3]>,+    pub full_fidelity: FullFidelity,+}++#[derive(Serialize)]+#[serde(rename_all = "camelCase")]+pub struct Orientations {+    /// Identity. Source +Z up. STEP / KiCad / Solidworks canonical.+    pub as_is: OrientationVariant,+    /// `R_x(π)`. Source -Z up.+    pub z_down: OrientationVariant,+    /// `R_x(+π/2)`. Source +Y up. Canonical Y-up (glTF / three.js /+    /// Blender) — Bosch sensors, Espressif modules, Nordic WLCSPs.+    pub y_up: OrientationVariant,+    /// `R_x(-π/2)`. Source -Y up. Inverted Y-up — WAGO connectors.+    pub y_down: OrientationVariant,+    /// `R_y(-π/2)`. Source +X up.+    pub x_up: OrientationVariant,+    /// `R_y(+π/2)`. Source -X up.+    pub x_down: OrientationVariant,+}++#[derive(Serialize)]+#[serde(rename_all = "camelCase")]+pub struct OrientationVariant {+    /// Human-readable label; UIs should use this in radio button text.+    pub label: &'static str,+    /// Math notation describing the rotation applied (or "identity").+    pub rotation: &'static str,+    /// PNG sizes — sm, md, lg.+    pub sizes: PngSizes,+}++#[derive(Serialize)]+#[serde(rename_all = "camelCase")]+pub struct PngSizes {+    pub sm: Option<PngEntry>,+    pub md: Option<PngEntry>,+    pub lg: Option<PngEntry>,+}++#[derive(Serialize)]+#[serde(rename_all = "camelCase")]+pub struct PngEntry {+    pub path: String,+    pub width: u32,+    pub height: u32,+    pub size_bytes: u64,+}++#[derive(Serialize)]+#[serde(rename_all = "camelCase")]+pub struct FullFidelity {+    /// Adom tool that renders this kind interactively at full fidelity.+    pub tool: &'static str,+    /// Concrete command the user (or an AI agent) can run.+    pub command: String,+    /// One-line description of what extra functionality the full-fidelity+    /// viewer provides over the thumbnail.+    pub description: &'static str,+}++pub fn build(chip: &ChipPaths) -> Manifest {+    let now = std::time::SystemTime::now()+        .duration_since(std::time::UNIX_EPOCH)+        .map(|d| iso8601(d.as_secs()))+        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".into());++    Manifest {+        mpn: chip.mpn.clone(),+        generated_by: "chip-thumbnailer".into(),+        generated_by_version: VERSION,+        generated_at_iso: now,+        palette: Palette::canonical(),+        thumbnails: Thumbnails {+            symbol: svg_entry(&chip.symbol_thumb(), &chip.dir, FullFidelity {+                tool: "symbol-creator (SymView)",+                command: format!(+                    "POST http://127.0.0.1:8781/sym/create with this chip's pin metadata, then open <MPN>-viewer.html. Or for an existing .kicad_sym, run the gallia generateBrandedViewer pipeline (gallia/symbol-creator/lib/viewer-gen.js)."+                ),+                description: "Interactive SymView — pin hover tooltips, group highlights, click-to-pin sticky info, datasheet link, zoom/fit controls.",+            }),+            footprint: svg_entry(&chip.footprint_thumb(), &chip.dir, FullFidelity {+                tool: "FpView (gallia/viewer/kicad-footprint-viewer.js)",+                command: "Run generateFootprintViewer(<MPN>.kicad_mod) to produce <MPN>-fpview.html — wraps the SVG with toolbar, layer toggle, measure tool, pad tooltips, solder-blob preview.".into(),+                description: "Interactive FpView — pad hover tooltips with signal + dimensions, layer toggle (silk / fab / courtyard / solder), Fusion-style measure tool, solder-blob jet-placement preview.",+            }),+            three_d: three_d_entry(chip),+        },+        hints: build_hints(chip),+    }+}++fn svg_entry(path: &PathBuf, _chip_dir: &PathBuf, full_fidelity: FullFidelity) -> Option<SvgEntry> {+    if !path.is_file() {+        return None;+    }+    let size_bytes = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);+    Some(SvgEntry {+        format: "svg",+        path: path+            .file_name()+            .map(|n| n.to_string_lossy().to_string())+            .unwrap_or_default(),+        size_bytes,+        full_fidelity,+    })+}++fn three_d_entry(chip: &ChipPaths) -> Option<ThreeD> {+    // Walk every (orientation, size) and collect what landed on disk.+    let as_is  = collect_sizes_for(chip, ThreeDOrientation::AsIs);+    let z_down = collect_sizes_for(chip, ThreeDOrientation::ZDown);+    let y_up   = collect_sizes_for(chip, ThreeDOrientation::YUp);+    let y_down = collect_sizes_for(chip, ThreeDOrientation::YDown);+    let x_up   = collect_sizes_for(chip, ThreeDOrientation::XUp);+    let x_down = collect_sizes_for(chip, ThreeDOrientation::XDown);+    let any = as_is.has_any() || z_down.has_any() || y_up.has_any()+           || y_down.has_any() || x_up.has_any() || x_down.has_any();+    if !any {+        return None;+    }++    // Pull bbox from the sidecar features.json (auto-populated by+    // render_3d::render_orient_size on first render).+    let (bbox_mm, bbox_mils) = read_bbox_for(chip);++    Some(ThreeD {+        format: "png",+        pose: "iso",+        iso: as_is.clone(), // back-compat alias for 0.2.x consumers+        orientations: Orientations {+            as_is:  OrientationVariant { label: ThreeDOrientation::AsIs.label(),  rotation: "identity", sizes: as_is },+            z_down: OrientationVariant { label: ThreeDOrientation::ZDown.label(), rotation: "R_x(π)",   sizes: z_down },+            y_up:   OrientationVariant { label: ThreeDOrientation::YUp.label(),   rotation: "R_x(+π/2)", sizes: y_up },+            y_down: OrientationVariant { label: ThreeDOrientation::YDown.label(), rotation: "R_x(-π/2)", sizes: y_down },+            x_up:   OrientationVariant { label: ThreeDOrientation::XUp.label(),   rotation: "R_y(-π/2)", sizes: x_up },+            x_down: OrientationVariant { label: ThreeDOrientation::XDown.label(), rotation: "R_y(+π/2)", sizes: x_down },+        },+        bbox_mm,+        bbox_mils,+        full_fidelity: FullFidelity {+            tool: "adom-step",+            command: format!("adom-step view library/{}/{}.step", chip.mpn, chip.mpn),+            description: "Interactive STEP viewer — Fusion 360-feel components browser, smart-pick measure tool (mm + mils + angle), hover-inspect, geometry-based pin/contact detection (LGA, BGA, QFN, SOIC, through-hole). Backed by service-step2glb's same OCCT pipeline.",+        },+    })+}++fn collect_sizes_for(chip: &ChipPaths, orient: ThreeDOrientation) -> PngSizes {+    let mut s = PngSizes { sm: None, md: None, lg: None };+    for size in ThreeDSize::all() {+        let path = chip.three_d_thumb_path(orient, size);+        if !path.is_file() {+            continue;+        }+        let (w, h) = size.dims();+        let entry = Some(PngEntry {+            path: path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(),+            width: w,+            height: h,+            size_bytes: std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0),+        });+        match size {+            ThreeDSize::Sm => s.sm = entry,+            ThreeDSize::Md => s.md = entry,+            ThreeDSize::Lg => s.lg = entry,+        }+    }+    s+}++impl PngSizes {+    fn has_any(&self) -> bool {+        self.sm.is_some() || self.md.is_some() || self.lg.is_some()+    }+}++impl Clone for PngSizes {+    fn clone(&self) -> Self {+        PngSizes {+            sm: self.sm.clone(),+            md: self.md.clone(),+            lg: self.lg.clone(),+        }+    }+}++impl Clone for PngEntry {+    fn clone(&self) -> Self {+        PngEntry {+            path: self.path.clone(),+            width: self.width,+            height: self.height,+            size_bytes: self.size_bytes,+        }+    }+}+++/// Look for a `<MPN>-features.json` sidecar (left by `step2glb features`)+/// and pull the chip's bbox in mm. Convert to mils for the multi-unit+/// display rule (1 mm = 39.3700787 mils, per electrical-engineering §1a).+fn read_bbox_for(chip: &ChipPaths) -> (Option<[[f64; 2]; 3]>, Option<[[f64; 2]; 3]>) {+    let path = chip.dir.join(format!("{}-features.json", chip.mpn));+    let bytes = match std::fs::read(&path) {+        Ok(b) => b,+        Err(_) => return (None, None),+    };+    let v: serde_json::Value = match serde_json::from_slice(&bytes) {+        Ok(v) => v,+        Err(_) => return (None, None),+    };+    let bb = match v.get("bbox_mm") {+        Some(b) => b,+        None => return (None, None),+    };+    let pair = |key: &str| -> Option<[f64; 2]> {+        let arr = bb.get(key)?.as_array()?;+        Some([arr.get(0)?.as_f64()?, arr.get(1)?.as_f64()?])+    };+    let (Some(x), Some(y), Some(z)) = (pair("x"), pair("y"), pair("z")) else {+        return (None, None);+    };+    let mm = [x, y, z];+    let to_mils = |v: f64| (v * 39.370_078_7 * 100.0).round() / 100.0;+    let mils = [+        [to_mils(x[0]), to_mils(x[1])],+        [to_mils(y[0]), to_mils(y[1])],+        [to_mils(z[0]), to_mils(z[1])],+    ];+    (Some(mm), Some(mils))+}++fn build_hints(chip: &ChipPaths) -> Vec<String> {+    let mut hints = vec![+        "These thumbnails are sized for index / card / tooltip views. When the user clicks on one to see the full version, hand off to the matching full-fidelity tool listed in `thumbnails.<kind>.full_fidelity`.".into(),+        "Vector thumbnails (symbol, footprint) scale infinitely — render at any size with no quality loss. The 3D PNG comes in three preset sizes (sm/md/lg); pick by the consuming UI's container width.".into(),+        "All thumbnails share the canonical Adom dark palette declared in `palette` — match your surrounding UI to it (e.g. card bg `palette.canvas_bg`, accent links `palette.accent_teal_bright`) so thumbnails don't visually clash.".into(),+        "The dark canvas + 2.54 mm PCB grid + faint teal radial glow are baked into the SVGs (so they look correct standalone). If your container already provides those (e.g. SymView), don't double up.".into(),+    ];+    if chip.step.is_some() {+        hints.push(format!(+            "For 3D inspection: `adom-step view library/{}/{}.step` opens the source STEP in an interactive viewer with measure tool + components browser.",+            chip.mpn, chip.mpn+        ));+    }+    if chip.kicad_sym.is_some() {+        hints.push(format!(+            "For the interactive symbol with pin tooltips and group highlights, point a SymView at library/{}/{}.kicad_sym.",+            chip.mpn, chip.mpn+        ));+    }+    if chip.kicad_mod.is_some() {+        hints.push(format!(+            "For the FpView footprint viewer (measure, pad tooltips, solder-blob jet preview), point at library/{}/{}.kicad_mod.",+            chip.mpn, chip.mpn+        ));+    }+    hints.push(+        "If a thumbnail entry is `null` in this manifest, the source file (.kicad_sym / .kicad_mod / .step) wasn't present in the chip dir at render time. chip-fetcher should populate it.".into(),+    );+    hints+}++pub fn write(chip: &ChipPaths) -> Result<PathBuf> {+    let manifest = build(chip);+    let path = chip.dir.join(format!("{}-thumbs.json", chip.mpn));+    let pretty = serde_json::to_vec_pretty(&manifest)?;+    std::fs::write(&path, pretty)?;+    Ok(path)+}++fn iso8601(secs_since_epoch: u64) -> String {+    // Lightweight UTC ISO-8601 formatter (no chrono dep). Enough precision+    // for "when was this rendered".+    let s = secs_since_epoch;+    let days = (s / 86400) as i64;+    let mut secs_of_day = (s % 86400) as i64;+    let hour = secs_of_day / 3600;+    secs_of_day -= hour * 3600;+    let minute = secs_of_day / 60;+    let second = secs_of_day - minute * 60;+    let (year, month, day) = days_to_ymd(days + 719468); // shift epoch+    format!(+        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",+        year, month, day, hour, minute, second+    )+}++fn days_to_ymd(z: i64) -> (i64, u32, u32) {+    // Howard Hinnant's date algorithm. Input: days since 0000-03-01.+    let era = if z >= 0 { z / 146097 } else { (z - 146096) / 146097 };+    let doe = (z - era * 146097) as u64;+    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 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) as u32;+    let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;+    let y = if m <= 2 { y + 1 } else { y };+    (y, m, d)+}
src/queue.rsadded+109
@@ -0,0 +1,109 @@+//! Pipeline runner: render all three thumbnails for one MPN, write a+//! `.thumb-errors.json` sidecar on partial failure, clear the+//! `.thumb-pending` marker only when ALL three succeed.++use crate::library::{self, ChipPaths};+use crate::ui::{ok, warn};+use crate::{manifest, render_3d, render_fp, render_sym};+use anyhow::Result;++#[derive(Debug, Default)]+pub struct RenderReport {+    pub mpn: String,+    pub sym_ok: bool,+    pub fp_ok: bool,+    pub three_d_ok: bool,+    pub errors: Vec<(&'static str, String)>,+}++impl RenderReport {+    pub fn all_ok(&self) -> bool {+        self.sym_ok && self.fp_ok && self.three_d_ok+    }+}++pub fn render_one(mpn: &str) -> Result<RenderReport> {+    let chip = ChipPaths::for_mpn(mpn)?;+    let mut report = RenderReport {+        mpn: mpn.to_string(),+        ..Default::default()+    };++    if chip.kicad_sym.is_some() {+        match render_sym::render(&chip) {+            Ok(_) => {+                report.sym_ok = true;+                ok(format!("sym  → {}", chip.symbol_thumb().display()));+            }+            Err(e) => {+                report.errors.push(("sym", format!("{e}")));+                warn(format!("sym  failed: {e}"));+            }+        }+    } else {+        report.errors.push(("sym", "no .kicad_sym in chip dir".into()));+        warn("sym  skipped: no .kicad_sym");+    }++    if chip.kicad_mod.is_some() {+        match render_fp::render(&chip) {+            Ok(_) => {+                report.fp_ok = true;+                ok(format!("fp   → {}", chip.footprint_thumb().display()));+            }+            Err(e) => {+                report.errors.push(("fp", format!("{e}")));+                warn(format!("fp   failed: {e}"));+            }+        }+    } else {+        report.errors.push(("fp", "no .kicad_mod in chip dir".into()));+        warn("fp   skipped: no .kicad_mod");+    }++    if chip.step.is_some() {+        match render_3d::render(&chip) {+            Ok(_) => {+                report.three_d_ok = true;+                ok(format!("3d   → {}", chip.three_d_thumb().display()));+            }+            Err(e) => {+                report.errors.push(("3d", format!("{e}")));+                warn(format!("3d   failed: {e}"));+            }+        }+    } else {+        report.errors.push(("3d", "no .step in chip dir".into()));+        warn("3d   skipped: no .step");+    }++    if report.all_ok() {+        library::clear_pending(mpn)?;+        library::clear_errors(mpn);+    } else if !report.errors.is_empty() {+        let kinds: Vec<(&str, String)> = report+            .errors+            .iter()+            .map(|(k, v)| (*k, v.clone()))+            .collect();+        let _ = library::write_errors(mpn, &kinds);+    }++    // Always (re)write the manifest — even on partial render, so downstream+    // apps can see what's available and what's missing.+    match manifest::write(&chip) {+        Ok(path) => ok(format!("manifest → {}", path.display())),+        Err(e) => warn(format!("manifest write failed: {e}")),+    }++    Ok(report)+}++pub fn render_pending() -> Result<usize> {+    let mut n = 0;+    for mpn in library::pending_chips()? {+        let _ = render_one(&mpn);+        n += 1;+    }+    Ok(n)+}
src/render_3d.rsadded+184
@@ -0,0 +1,184 @@+//! 3D thumbnail: call service-step2glb's POST /thumbnail directly (OCCT+//! V3d offscreen). We talk to the service over HTTP rather than shelling+//! to the `step2glb` CLI, because the deployed CLI's thumbnail path does+//! NOT poll the service's async (202 + job) response — it bails with+//! "service returned HTTP 202". Calling the service directly lets us poll+//! the job to completion, which is what actually works.+//!+//! Renders the as-is + 5 other face-up orientations at the medium size the+//! dashboard displays. Manufacturer chip STEPs ship in inconsistent up-axis+//! conventions; chip-thumbnailer is intentionally neutral and renders every+//! face-up variant — downstream tools pick the right one per chip.++use crate::library::{ChipPaths, ThreeDOrientation, ThreeDSize};+use anyhow::{anyhow, Context, Result};+use std::path::Path;+use std::time::Duration;++const SERVICE_URL_DEFAULT: &str = "https://step2glb-gmdoncpxdwx0.adom.cloud";++fn service_url() -> String {+    std::env::var("STEP2GLB_SERVICE_API")+        .unwrap_or_else(|_| SERVICE_URL_DEFAULT.to_string())+        .trim_end_matches('/')+        .to_string()+}++/// Render every face-up orientation at the displayed (Md) size. This covers+/// the dashboard's Z↑/Y↑ tiles plus the 6-orientation popover. (The sm/lg+/// size variants are rendered on demand via `render_orient_size`.)+pub fn render(chip: &ChipPaths) -> Result<()> {+    let step = chip+        .step+        .as_ref()+        .ok_or_else(|| anyhow!("no .step in {}", chip.dir.display()))?+        .clone();++    let mut first_err: Option<anyhow::Error> = None;+    for orient in ThreeDOrientation::all() {+        let out = chip.three_d_thumb_path(orient, ThreeDSize::Md);+        let (w, h) = ThreeDSize::Md.dims();+        if let Err(e) = service_thumbnail(&step, &out, orient.step2glb_arg(), w, h, &chip.mpn) {+            // Keep rendering the other orientations; report the first failure.+            if first_err.is_none() {+                first_err = Some(anyhow!(+                    "step2glb service render failed for {} ({}): {e}",+                    chip.mpn,+                    orient.json_key()+                ));+            }+        }+    }++    // Lazily populate <MPN>-features.json (manifest pulls bbox from it).+    ensure_features(&step, chip);++    match first_err {+        Some(e) => Err(e),+        None => Ok(()),+    }+}++/// Back-compat single-size render at the AsIs orientation.+pub fn render_size(chip: &ChipPaths, size: ThreeDSize) -> Result<()> {+    render_orient_size(chip, ThreeDOrientation::AsIs, size)+}++/// Render a single (orientation, size) to disk via the service.+pub fn render_orient_size(+    chip: &ChipPaths,+    orient: ThreeDOrientation,+    size: ThreeDSize,+) -> Result<()> {+    let step = chip+        .step+        .as_ref()+        .ok_or_else(|| anyhow!("no .step in {}", chip.dir.display()))?+        .clone();+    let out = chip.three_d_thumb_path(orient, size);+    let (w, h) = size.dims();+    service_thumbnail(&step, &out, orient.step2glb_arg(), w, h, &chip.mpn)+        .with_context(|| format!("3D render {} ({}/{})", chip.mpn, orient.json_key(), size.label()))?;+    ensure_features(&step, chip);+    Ok(())+}++fn ensure_features(step: &Path, chip: &ChipPaths) {+    let features_path = chip.dir.join(format!("{}-features.json", chip.mpn));+    if features_path.is_file() {+        return;+    }+    // features still goes through the CLI. Its render path can return the+    // service's async 202 (which the CLI doesn't poll) — that's a non-fatal+    // metadata miss, so capture output rather than letting a misleading+    // "feature extraction failed" line leak to the user.+    let _ = std::process::Command::new("step2glb")+        .arg("features")+        .arg(step)+        .arg("--out")+        .arg(&features_path)+        .output();+}++/// POST a STEP to service-step2glb's /thumbnail, handling both the legacy+/// synchronous (200 → PNG) and the current async (202 → job → poll) shapes,+/// then write the PNG to `out`.+fn service_thumbnail(+    step: &Path,+    out: &Path,+    up_axis: &str,+    w: u32,+    h: u32,+    mpn: &str,+) -> Result<()> {+    let svc = service_url();+    let body = std::fs::read(step).with_context(|| format!("read {}", step.display()))?;+    let job_name = format!("thumbnail-iso-{up_axis}-{mpn}-for-chip-thumbnailer");+    let url = format!("{svc}/thumbnail?pose=iso&kind=step&upAxis={up_axis}&width={w}&height={h}");++    let resp = ureq::post(&url)+        .set("Content-Type", "application/step")+        .set("X-Client", "chip-thumbnailer/chip-fetcher")+        .set("X-Job-Name", &job_name)+        .timeout(Duration::from_secs(180))+        .send_bytes(&body)+        .map_err(|e| anyhow!("POST {url}: {e}"))?;++    match resp.status() {+        200 => write_reader(resp, out),+        202 => {+            let txt = resp.into_string().context("read 202 job body")?;+            let v: serde_json::Value =+                serde_json::from_str(&txt).context("parse 202 job json")?;+            let job_id = v+                .get("job_id")+                .and_then(|s| s.as_str())+                .ok_or_else(|| anyhow!("202 response had no job_id"))?+                .to_string();+            poll_and_write(&svc, &job_id, out)+        }+        code => Err(anyhow!("service returned HTTP {code}")),+    }+}++fn poll_and_write(svc: &str, job_id: &str, out: &Path) -> Result<()> {+    for _ in 0..30 {+        if let Ok(r) = ureq::get(&format!("{svc}/jobs/{job_id}"))+            .set("X-Client", "chip-thumbnailer/chip-fetcher")+            .set("X-Job-Name", "poll")+            .timeout(Duration::from_secs(15))+            .call()+        {+            let v: serde_json::Value = r+                .into_string()+                .ok()+                .and_then(|t| serde_json::from_str(&t).ok())+                .unwrap_or(serde_json::Value::Null);+            match v.get("status").and_then(|s| s.as_str()) {+                Some("complete") => {+                    let res = ureq::get(&format!("{svc}/jobs/{job_id}/result"))+                        .set("X-Client", "chip-thumbnailer/chip-fetcher")+                        .set("X-Job-Name", "result")+                        .timeout(Duration::from_secs(30))+                        .call()+                        .map_err(|e| anyhow!("GET job result: {e}"))?;+                    return write_reader(res, out);+                }+                Some("failed") => {+                    let msg = v.get("error").and_then(|s| s.as_str()).unwrap_or("unknown");+                    return Err(anyhow!("service job failed: {msg}"));+                }+                _ => {}+            }+        }+        std::thread::sleep(Duration::from_secs(2));+    }+    Err(anyhow!("service job {job_id} timed out"))+}++fn write_reader(resp: ureq::Response, out: &Path) -> Result<()> {+    let mut reader = resp.into_reader();+    let mut f = std::fs::File::create(out).with_context(|| format!("create {}", out.display()))?;+    std::io::copy(&mut reader, &mut f).with_context(|| format!("write {}", out.display()))?;+    Ok(())+}
src/render_fp.rsadded+94
@@ -0,0 +1,94 @@+//! Footprint thumbnail: shell to local kicad-cli, then apply the+//! footprint theme (dark canvas + PCB grid behind kicad-cli's native+//! KiCad-palette geometry).+//!+//! kicad-cli's footprint SVG export expects a `.pretty/` directory and+//! `--footprint <name>` flag, *not* a single .kicad_mod file. We stage+//! a temp `.pretty/` symlink-free directory.++use crate::library::ChipPaths;+use crate::theme;+use anyhow::{anyhow, Context, Result};+use std::path::Path;+use std::process::Command;++pub fn render(chip: &ChipPaths) -> Result<()> {+    // Prefer the ds2sf-rendered authoritative footprint SVG when present+    // (validate.rs writes <MPN>-footprint.authoritative.svg via service-kicad).+    // Adom containers have no local kicad-cli, so this is the path that+    // actually works; the kicad-cli branch below is a dev-only fallback.+    let authoritative = chip+        .dir+        .join(format!("{}-footprint.authoritative.svg", chip.mpn));+    if authoritative.is_file() {+        let raw = std::fs::read_to_string(&authoritative)+            .with_context(|| format!("reading {}", authoritative.display()))?;+        // Apply the Adom footprint theme (dark canvas + PCB grid behind the+        // KiCad-palette pads), same as the kicad-cli path did — otherwise the+        // tile is a raw light render that clashes with the symbol/3D tiles.+        let themed = theme::theme_footprint_svg(&raw);+        std::fs::write(chip.footprint_thumb(), themed)+            .with_context(|| format!("writing {}", chip.footprint_thumb().display()))?;+        return Ok(());+    }++    let kicad_mod = chip+        .kicad_mod+        .as_ref()+        .ok_or_else(|| anyhow!("no .kicad_mod in {}", chip.dir.display()))?;++    let pretty = tempdir(&format!("chip-thumb-fp-{}-pretty", chip.mpn))?;+    let staged_mod = pretty.join(format!("{}.kicad_mod", chip.mpn));+    std::fs::copy(kicad_mod, &staged_mod)+        .with_context(|| format!("staging {} → {}", kicad_mod.display(), staged_mod.display()))?;++    let out_dir = tempdir(&format!("chip-thumb-fp-{}-out", chip.mpn))?;+    let status = Command::new("kicad-cli")+        .arg("fp")+        .arg("export")+        .arg("svg")+        .arg("--footprint")+        .arg(&chip.mpn)+        .arg("--output")+        .arg(&out_dir)+        .arg(&pretty)+        .status()+        .context("running kicad-cli — install KiCad to PATH if missing")?;+    if !status.success() {+        return Err(anyhow!("kicad-cli fp export svg failed for {}", chip.mpn));+    }++    let exported = pick_first_svg(&out_dir)+        .ok_or_else(|| anyhow!("kicad-cli produced no .svg in {}", out_dir.display()))?;++    let raw = std::fs::read_to_string(&exported)+        .with_context(|| format!("reading {}", exported.display()))?;+    let themed = theme::theme_footprint_svg(&raw);++    let out = chip.footprint_thumb();+    std::fs::write(&out, themed)+        .with_context(|| format!("writing {}", out.display()))?;++    let _ = std::fs::remove_dir_all(&pretty);+    let _ = std::fs::remove_dir_all(&out_dir);+    Ok(())+}++fn pick_first_svg(dir: &Path) -> Option<std::path::PathBuf> {+    if let Ok(read) = std::fs::read_dir(dir) {+        for entry in read.flatten() {+            let p = entry.path();+            if p.extension().and_then(|e| e.to_str()) == Some("svg") {+                return Some(p);+            }+        }+    }+    None+}++fn tempdir(prefix: &str) -> Result<std::path::PathBuf> {+    let base = std::env::temp_dir().join(format!("{prefix}-{}", std::process::id()));+    let _ = std::fs::remove_dir_all(&base);+    std::fs::create_dir_all(&base)?;+    Ok(base)+}
src/render_sym.rsadded+184
@@ -0,0 +1,184 @@+//! Symbol thumbnail: render the `.kicad_sym` via the canonical sch-wrap path.+//!+//! Adom containers have no local `kicad-cli`, and service-kicad's symbol SVG+//! export only renders parts already IN the shared library (`:library/:name`)+//! — not an arbitrary fetched `.kicad_sym`. So we mirror what gallia symview's+//! `kicad-svg.js` does: wrap the symbol in a minimal one-instance schematic,+//! render THAT to SVG via `service-kicad sch export svg`, then crop the+//! full-page sheet down to the symbol's bounding box.+//!+//! (This is the same canonical algorithm adom-symbol uses internally. Folding+//! it behind an `adom-symbol export-svg` subcommand is a follow-up — see the+//! chip-fetcher dashboard work notes.)++use crate::library::ChipPaths;+use crate::theme;+use anyhow::{anyhow, Context, Result};+use regex::Regex;+use std::process::Command;++pub fn render(chip: &ChipPaths) -> Result<()> {+    let kicad_sym = chip+        .kicad_sym+        .as_ref()+        .ok_or_else(|| anyhow!("no .kicad_sym in {}", chip.dir.display()))?;+    let content = std::fs::read_to_string(kicad_sym)+        .with_context(|| format!("reading {}", kicad_sym.display()))?;++    let (name, block) = extract_first_symbol(&content)+        .ok_or_else(|| anyhow!("no (symbol ...) block in {}", kicad_sym.display()))?;++    let sch_path = std::env::temp_dir()+        .join(format!("chip-thumb-sym-{}-{}.kicad_sch", chip.mpn, std::process::id()));+    std::fs::write(&sch_path, build_schematic(&name, &block))+        .with_context(|| format!("writing {}", sch_path.display()))?;++    let svg_tmp = std::env::temp_dir()+        .join(format!("chip-thumb-sym-{}-{}.svg", chip.mpn, std::process::id()));+    let status = Command::new("service-kicad")+        .args(["sch", "export", "svg"])+        .arg(&sch_path)+        .arg("--out")+        .arg(&svg_tmp)+        .status()+        .context("running service-kicad — install via wiki if missing")?;+    if !status.success() {+        let _ = std::fs::remove_file(&sch_path);+        return Err(anyhow!("service-kicad sch export svg failed for {}", chip.mpn));+    }++    let svg = std::fs::read_to_string(&svg_tmp)+        .with_context(|| format!("reading {}", svg_tmp.display()))?;+    // Drop KiCad 10's schematic page background (warm-white #F5F4EF <g>) so the+    // dark canvas + teal glow + PCB grid the theme injects show through. Without+    // this, that page rect sits on top of the dark canvas and the tile renders+    // light. The symbol body/pin/text colors are KiCad's standard palette, which+    // the theme already recolors.+    let svg = svg+        .replace("fill:#F5F4EF", "fill:none")+        .replace("stroke:#F5F4EF", "stroke:none");+    // Crop to the symbol FIRST (rewrites the viewBox), then apply the Adom dark+    // theme — it sizes its canvas/glow/grid to the (now-cropped) viewBox. This+    // is the styling that makes the symbol thumbnail gorgeous and match the FP/3D+    // tiles; dropping it is what made them look like raw KiCad sheets.+    let cropped = crop_to_symbol(&svg, &block);+    let themed = theme::apply_dark_theme_to_symbol(&cropped);+    std::fs::write(chip.symbol_thumb(), themed)+        .with_context(|| format!("writing {}", chip.symbol_thumb().display()))?;++    let _ = std::fs::remove_file(&sch_path);+    let _ = std::fs::remove_file(&svg_tmp);+    Ok(())+}++/// Extract the first top-level `(symbol "NAME" ...)` block by paren balance.+fn extract_first_symbol(content: &str) -> Option<(String, String)> {+    let start = content.find("(symbol \"")?;+    let name_start = start + "(symbol \"".len();+    let name_end = content[name_start..].find('"')? + name_start;+    let name = content[name_start..name_end].to_string();++    let bytes = content.as_bytes();+    let mut depth = 0i32;+    let mut end = None;+    for (i, &b) in bytes.iter().enumerate().skip(start) {+        match b {+            b'(' => depth += 1,+            b')' => {+                depth -= 1;+                if depth == 0 {+                    end = Some(i + 1);+                    break;+                }+            }+            _ => {}+        }+    }+    Some((name, content[start..end?].to_string()))+}++/// Minimal one-instance schematic embedding the symbol at (100,100).+fn build_schematic(name: &str, block: &str) -> String {+    format!(+        "(kicad_sch (version 20211123) (generator eeschema)\n  \+         (uuid \"00000000-0000-0000-0000-000000000001\")\n  (paper \"A4\")\n  \+         (lib_symbols\n    {block}\n  )\n  \+         (symbol (lib_id \"{name}\") (at 100 100 0) (unit 1)\n    \+         (in_bom yes) (on_board yes)\n    \+         (uuid \"00000000-0000-0000-0000-000000000002\")\n  )\n  \+         (sheet_instances (path \"/\" (page \"1\")))\n)\n"+    )+}++/// Rewrite the rendered full-sheet SVG's viewBox/width/height to crop to the+/// symbol. The SVG is in mm (matching schematic coords); the symbol is placed+/// at (100,100) with KiCad's placement Y-flip. Falls back to the full sheet if+/// no geometry is found.+fn crop_to_symbol(svg: &str, block: &str) -> String {+    let (minx, maxx, miny, maxy) = match symbol_local_bbox(block) {+        Some(b) => b,+        None => return svg.to_string(),+    };+    const M: f64 = 4.0;+    const PX: f64 = 100.0;+    const PY: f64 = 100.0;+    let (sx0, sx1) = (PX + minx - M, PX + maxx + M);+    // KiCad flips Y when placing a symbol on a schematic.+    let (sy0, sy1) = (PY - maxy - M, PY - miny + M);+    let vbx = sx0.min(sx1);+    let vby = sy0.min(sy1);+    let vbw = (sx1 - sx0).abs();+    let vbh = (sy1 - sy0).abs();++    let re = Regex::new(r#"width="[^"]*mm" height="[^"]*mm" viewBox="[^"]*""#).unwrap();+    let replacement = format!(+        "width=\"{vbw:.3}mm\" height=\"{vbh:.3}mm\" viewBox=\"{vbx:.4} {vby:.4} {vbw:.4} {vbh:.4}\""+    );+    re.replace(svg, replacement.as_str()).into_owned()+}++/// Bounding box of the symbol body + pin tips in symbol-local mm.+fn symbol_local_bbox(block: &str) -> Option<(f64, f64, f64, f64)> {+    let mut xs: Vec<f64> = Vec::new();+    let mut ys: Vec<f64> = Vec::new();++    let xy = Regex::new(r"\(xy (-?[\d.]+) (-?[\d.]+)\)").unwrap();+    for c in xy.captures_iter(block) {+        if let (Ok(x), Ok(y)) = (c[1].parse(), c[2].parse()) {+            xs.push(x);+            ys.push(y);+        }+    }+    let se = Regex::new(r"\((?:start|end) (-?[\d.]+) (-?[\d.]+)\)").unwrap();+    for c in se.captures_iter(block) {+        if let (Ok(x), Ok(y)) = (c[1].parse(), c[2].parse()) {+            xs.push(x);+            ys.push(y);+        }+    }+    // Pins: (pin ... (at x y a) ... (length L) ...) — the (at) is the outer tip.+    let pin = Regex::new(r"(?s)\(pin\b.*?\(at (-?[\d.]+) (-?[\d.]+) (-?[\d.]+)\).*?\(length (-?[\d.]+)\)").unwrap();+    for c in pin.captures_iter(block) {+        if let (Ok(x), Ok(y), Ok(a), Ok(l)) = (+            c[1].parse::<f64>(),+            c[2].parse::<f64>(),+            c[3].parse::<f64>(),+            c[4].parse::<f64>(),+        ) {+            let r = a.to_radians();+            for (px, py) in [(x, y), (x + l * r.cos(), y + l * r.sin()), (x - l * r.cos(), y - l * r.sin())] {+                xs.push(px);+                ys.push(py);+            }+        }+    }++    if xs.is_empty() {+        return None;+    }+    let minx = xs.iter().cloned().fold(f64::INFINITY, f64::min);+    let maxx = xs.iter().cloned().fold(f64::NEG_INFINITY, f64::max);+    let miny = ys.iter().cloned().fold(f64::INFINITY, f64::min);+    let maxy = ys.iter().cloned().fold(f64::NEG_INFINITY, f64::max);+    Some((minx, maxx, miny, maxy))+}
src/theme.rsadded+270
@@ -0,0 +1,270 @@+//! SVG theming for symbols + footprints.+//!+//! Symbol theming ports the regex-replacement pass from+//! gallia/symbol-creator/lib/svg-theme.js applyDarkTheme() into Rust.+//! It transforms KiCad CLI's default-themed symbol SVG into the Adom+//! dark palette (canvas #0d1117, body gradient, white pin labels,+//! amber group labels). The `<style>` body is wrapped in CDATA so the+//! output is valid standalone XML — applyDarkTheme as written embeds a+//! literal `<g class="stroked-text">` inside a CSS comment, which trips+//! strict XML parsers when SVG is loaded via <object> or as a wiki+//! asset.+//!+//! Footprint theming keeps kicad-cli's native KiCad palette (the same+//! red/yellow/magenta the FpView app shows) and just paints them on+//! the canonical FpView dark canvas with a 20-unit PCB-style grid+//! (rgba(255,255,255,0.03) lines), mirroring+//! gallia/viewer/kicad-footprint-viewer.js generateFootprintViewer().++use regex::Regex;++const KICAD_BODY_STROKE: &str = "#840000";+const KICAD_BODY_FILL: &str = "#ffffc2";+const KICAD_TEXT_STROKE: &str = "#A90000";+const KICAD_HIDDEN_TEXT: &str = "#006464";++// Adom dark theme target colors.+const CANVAS_BG: &str = "#0d1117";+const BODY_STROKE: &str = "#30363d";+const BODY_FILL: &str = "#1a2332";+const BODY_FILL_GRAD: &str = "#141c27";+const PIN_WIRE: &str = "#4a5568";+const PIN_NAME: &str = "#ffffff";+const REF_TEXT: &str = "#484f58";+const PIN_DOT: &str = "#4a5568";++/// Apply the Adom dark theme to a KiCad-CLI-exported symbol SVG.+pub fn apply_dark_theme_to_symbol(raw: &str) -> String {+    let mut s: String = raw+        .trim_start_matches('\u{feff}')+        .replace("\r\n", "\n");++    // Strip XML decl and DOCTYPE.+    s = strip_first_match(&s, r#"<\?xml[^?]*\?>"#);+    s = strip_first_match(&s, r#"<!DOCTYPE[^>]*>"#);+    s = strip_all_matches(&s, r#"<title>[^<]*</title>"#);+    s = strip_all_matches(&s, r#"<desc>[^<]*</desc>"#);+    let s = s.trim();++    // Replace KiCad's default white background rect (if present) — older+    // KiCad outputs may include one, newer ones don't. We also unconditionally+    // inject a viewBox-sized canvas <rect> below so the symbol always has+    // an opaque Adom-bg behind it.+    let bg_rect_re = Regex::new(r#"(<rect[^>]*fill=")(?i)white("[^>]*>)"#).unwrap();+    let s = bg_rect_re.replace(s, format!("${{1}}{}${{2}}", CANVAS_BG)).to_string();++    // Pull viewBox so we know how big to make the canvas bg rect.+    let (vb_x, vb_y, vb_w, vb_h) =+        match Regex::new(r#"viewBox="(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)""#)+            .unwrap()+            .captures(&s)+        {+            Some(c) => (+                c[1].parse::<f64>().unwrap_or(0.0),+                c[2].parse::<f64>().unwrap_or(0.0),+                c[3].parse::<f64>().unwrap_or(100.0),+                c[4].parse::<f64>().unwrap_or(100.0),+            ),+            None => (0.0, 0.0, 100.0, 100.0),+        };+    // Canvas: solid Adom dark + the same atmosphere SymView's+    // .canvas-wrap div adds via CSS — radial teal glow at center ++    // 20-unit PCB grid. Baked into the SVG so the thumbnail looks+    // identical to SymView when shown standalone (no HTML wrapper).+    let cx = vb_x + vb_w / 2.0;+    let cy = vb_y + vb_h / 2.0;+    let glow_r = (vb_w.max(vb_h)) * 0.7;+    let canvas_bg_rect = format!(+r##"<defs>+  <radialGradient id="adom-canvas-glow" cx="{cx}" cy="{cy}" r="{r}" gradientUnits="userSpaceOnUse">+    <stop offset="0%"  stop-color="#00b8b0" stop-opacity="0.06"/>+    <stop offset="70%" stop-color="#00b8b0" stop-opacity="0"/>+  </radialGradient>+  <pattern id="adom-canvas-grid" x="0" y="0" width="2.54" height="2.54" patternUnits="userSpaceOnUse">+    <line x1="0" y1="0" x2="2.54" y2="0" stroke="rgba(255,255,255,0.025)" stroke-width="0.06"/>+    <line x1="0" y1="0" x2="0" y2="2.54" stroke="rgba(255,255,255,0.025)" stroke-width="0.06"/>+  </pattern>+</defs>+<rect x="{x}" y="{y}" width="{w}" height="{h}" fill="{bg}"/>+<rect x="{x}" y="{y}" width="{w}" height="{h}" fill="url(#adom-canvas-grid)"/>+<rect x="{x}" y="{y}" width="{w}" height="{h}" fill="url(#adom-canvas-glow)"/>"##,+        x = vb_x, y = vb_y, w = vb_w, h = vb_h,+        cx = cx, cy = cy, r = glow_r, bg = CANVAS_BG,+    );++    // Inject a <defs> + <style> right after the opening <svg ...>.+    // CDATA-wrap the style body so the literal `<g class=...>` inside the+    // CSS comment doesn't trip strict XML parsers.+    let defs_and_style = format!(+        r#"+<defs>+  <linearGradient id="adom-body-grad" x1="0" y1="0" x2="0" y2="1">+    <stop offset="0%" stop-color="{body_fill}"/>+    <stop offset="100%" stop-color="{body_fill_grad}"/>+  </linearGradient>+</defs>+<style><![CDATA[+  /* Stroked text groups (KiCad renders text as paths in <g class="stroked-text">) */+  g.stroked-text path {{+    stroke: {pin_name} !important;+    fill: {pin_name} !important;+  }}+  text[opacity="0"] {{+    fill: {pin_name} !important;+  }}+  .pin-highlight path {{+    stroke: #00e6dc !important;+  }}+  .pin-highlight g.stroked-text path {{+    stroke: #00e6dc !important;+  }}+  .group-highlight g.stroked-text path {{+    stroke: #FFD180 !important;+  }}+]]></style>"#,+        body_fill = BODY_FILL,+        body_fill_grad = BODY_FILL_GRAD,+        pin_name = PIN_NAME,+    );++    let svg_open = Regex::new(r#"(<svg[^>]*>)"#).unwrap();+    let s = svg_open+        .replace(s.as_ref(), format!("${{1}}\n{}\n{}", defs_and_style, canvas_bg_rect))+        .to_string();++    // Direct color replacements — CASE-INSENSITIVE on the hex digits+    // because kicad-cli emits both #840000 and #FFFFC2 (uppercase) and+    // the gallia source pattern uses /gi. Use Regex with `(?i)` for the+    // hex tail; literal prefix stays case-sensitive.+    let s = ci_replace_all(&s, &[+        (format!("stroke:{}", KICAD_BODY_STROKE),      format!("stroke:{}", PIN_WIRE)),+        (format!("stroke=\"{}\"", KICAD_BODY_STROKE),  format!("stroke=\"{}\"", PIN_WIRE)),+        (format!("fill:{}", KICAD_BODY_STROKE),        format!("fill:{}", PIN_DOT)),+        (format!("fill=\"{}\"", KICAD_BODY_STROKE),    format!("fill=\"{}\"", PIN_DOT)),+        (format!("fill:{}", KICAD_BODY_FILL),          "fill:url(#adom-body-grad)".to_string()),+        (format!("fill=\"{}\"", KICAD_BODY_FILL),      "fill=\"url(#adom-body-grad)\"".to_string()),+        (format!("stroke:{}", KICAD_TEXT_STROKE),      format!("stroke:{}", PIN_NAME)),+        (format!("stroke=\"{}\"", KICAD_TEXT_STROKE),  format!("stroke=\"{}\"", PIN_NAME)),+        (format!("fill:{}", KICAD_TEXT_STROKE),        format!("fill:{}", PIN_NAME)),+        (format!("fill=\"{}\"", KICAD_TEXT_STROKE),    format!("fill=\"{}\"", PIN_NAME)),+        (format!("stroke:{}", KICAD_HIDDEN_TEXT),      format!("stroke:{}", REF_TEXT)),+        (format!("fill:{}", KICAD_HIDDEN_TEXT),        format!("fill:{}", REF_TEXT)),+    ]);+    // rgb(...) form for the body fill — kicad-cli sometimes uses rgb()+    // notation for the same #FFFFC2 yellow.+    let rgb_body = Regex::new(r#"fill="rgb\(\s*255\s*,\s*255\s*,\s*194\s*\)""#).unwrap();+    let s = rgb_body.replace_all(&s, "fill=\"url(#adom-body-grad)\"").to_string();++    // Strip the root <g> default fill:#000000 so symbol body shapes don't+    // come out solid black on the dark canvas.+    let root_g_black = Regex::new(r#"(<g\s+style="fill:#000000[^"]*")"#).unwrap();+    let s = root_g_black+        .replace(&s, |c: &regex::Captures| {+            c[1].replace("fill:#000000", "fill:none")+        })+        .to_string();++    s+}++/// Re-theme a KiCad-CLI footprint SVG to match the FpView app:+///   - dark canvas #0d1117 + 20-unit PCB-style grid behind the geometry+///   - strip invisible REF**/value text + stroked-text groups (else they+///     inflate the viewBox)+///   - keep kicad-cli's native pad/silk/courtyard colors as-is, just+///     painted on the dark grid+pub fn theme_footprint_svg(raw: &str) -> String {+    let mut s = raw.replace("\r\n", "\n");+    s = strip_first_match(&s, r#"<\?xml[^?]*\?>"#);+    s = strip_first_match(&s, r#"<!DOCTYPE[^>]*>"#);+    s = strip_all_matches(&s, r#"<title>[^<]*</title>"#);+    s = strip_all_matches(&s, r#"<desc>[^<]*</desc>"#);+    s = strip_all_matches(&s, r#"<text[^>]*opacity="0"[^<]*</text>"#);+    s = strip_all_dotall(&s, r#"<g class="stroked-text">.*?</g>"#);+    let s = s.trim();++    // Pull the SVG's viewBox so the bg rect covers the entire canvas.+    let vb = match Regex::new(r#"viewBox="(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)""#)+        .unwrap()+        .captures(s)+    {+        Some(c) => (+            c[1].parse::<f64>().unwrap_or(-50.0),+            c[2].parse::<f64>().unwrap_or(-50.0),+            c[3].parse::<f64>().unwrap_or(100.0),+            c[4].parse::<f64>().unwrap_or(100.0),+        ),+        None => (-50.0, -50.0, 100.0, 100.0),+    };++    let defs = r#"+<defs>+  <pattern id="adom-pcb-grid" x="0" y="0" width="1" height="1" patternUnits="userSpaceOnUse">+    <line x1="0" y1="0" x2="1" y2="0" stroke="rgba(255,255,255,0.03)" stroke-width="0.05"/>+    <line x1="0" y1="0" x2="0" y2="1" stroke="rgba(255,255,255,0.03)" stroke-width="0.05"/>+  </pattern>+</defs>"#;+    let bg = format!(+        "<rect x=\"{x}\" y=\"{y}\" width=\"{w}\" height=\"{h}\" fill=\"{CANVAS_BG}\"/>\n<rect x=\"{x}\" y=\"{y}\" width=\"{w}\" height=\"{h}\" fill=\"url(#adom-pcb-grid)\"/>",+        x = vb.0, y = vb.1, w = vb.2, h = vb.3,+    );++    let svg_open = Regex::new(r#"(<svg[^>]*>)"#).unwrap();+    let s = svg_open+        .replace(s, format!("${{1}}\n{}\n{}", defs, bg))+        .to_string();++    // Strip any KiCad-CLI default white-ish page-background rect that+    // would render in front of our dark canvas.+    let white_bg_rect = Regex::new(+        r#"<rect[^>]*fill="(?i)#?(?:f{2,6}|white)"[^>]*/?>"#,+    ).unwrap();+    let s = white_bg_rect.replace_all(&s, "").to_string();++    s+}++fn strip_first_match(s: &str, pat: &str) -> String {+    Regex::new(pat).unwrap().replace(s, "").to_string()+}+fn strip_all_matches(s: &str, pat: &str) -> String {+    Regex::new(pat).unwrap().replace_all(s, "").to_string()+}+fn strip_all_dotall(s: &str, pat: &str) -> String {+    Regex::new(&format!(r"(?s){}", pat))+        .unwrap()+        .replace_all(s, "")+        .to_string()+}+fn simple_replace_all(s: &str, pairs: &[(String, String)]) -> String {+    let mut out = s.to_string();+    for (from, to) in pairs {+        out = out.replace(from, to);+    }+    out+}++/// Case-insensitive replace_all on each (from, to) pair. The literal+/// prefix (`fill:`, `stroke="`) stays case-sensitive; only the hex tail+/// after the `#` is matched insensitively. This matches the JS source+/// `/.../gi` semantics from gallia/symbol-creator/lib/svg-theme.js.+fn ci_replace_all(s: &str, pairs: &[(String, String)]) -> String {+    let mut out = s.to_string();+    for (from, to) in pairs {+        // Split prefix-up-to-and-including-#  vs. hex-tail at the first '#'+        // so we can keep the prefix literal and apply (?i) to the hex.+        let pat = if let Some(hash_idx) = from.find('#') {+            let prefix = regex::escape(&from[..hash_idx + 1]);+            let hex = regex::escape(&from[hash_idx + 1..]);+            format!("{prefix}(?i){hex}")+        } else {+            regex::escape(from)+        };+        let re = match Regex::new(&pat) {+            Ok(r) => r,+            Err(_) => continue,+        };+        out = re.replace_all(&out, to.as_str()).to_string();+    }+    out+}
src/ui.rsadded+93
@@ -0,0 +1,93 @@+//! ANSI-colored output helpers, mirroring the ok!/err!/hint!/note! pattern+//! used by step2glb and chip-fetcher so log scraping is consistent across+//! the Adom CLI ecosystem. is_terminal()-gated per gallia/skills/+//! adom-cli-design/SKILL.md — never leak raw ANSI to pipes / log files.++#![allow(dead_code)]++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();++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())+}++/// Strip CSI sequences like `\x1b[36m`, `\x1b[0m`, `\x1b[1;32m` etc.+/// Used when stdout/stderr isn't a TTY so log files don't get raw escape+/// codes leaked into them.+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<S: AsRef<str>>(msg: S) {+    let s = msg.as_ref();+    if stdout_is_tty() {+        println!("{GREEN}{BOLD}OK:{RESET} {s}");+    } else {+        println!("OK: {}", strip_ansi(s));+    }+}+pub fn err<S: AsRef<str>>(msg: S) {+    let s = msg.as_ref();+    if stderr_is_tty() {+        eprintln!("{RED}{BOLD}ERROR:{RESET} {s}");+    } else {+        eprintln!("ERROR: {}", strip_ansi(s));+    }+}+pub fn warn<S: AsRef<str>>(msg: S) {+    let s = msg.as_ref();+    if stderr_is_tty() {+        eprintln!("{YELLOW}{BOLD}WARN:{RESET} {s}");+    } else {+        eprintln!("WARN: {}", strip_ansi(s));+    }+}+pub fn hint<S: AsRef<str>>(msg: S) {+    let s = msg.as_ref();+    if stderr_is_tty() {+        eprintln!("{DIM}Hint:{RESET} {s}");+    } else {+        eprintln!("Hint: {}", strip_ansi(s));+    }+}++#[macro_export]+macro_rules! note {+    ($($arg:tt)*) => {{+        let __s = format!($($arg)*);+        if $crate::ui::stdout_is_tty() {+            println!("{}", __s);+        } else {+            println!("{}", $crate::ui::strip_ansi(&__s));+        }+    }};+}
wiki/page.jsonadded+41
@@ -0,0 +1,41 @@+{+  "type": "app",+  "slug": "chip-thumbnailer",+  "title": "chip-thumbnailer",+  "brief": "Renders symbol/footprint/3D thumbnails for every chip in the chip-fetcher library — Adom-themed SVGs + 320×240 OCCT-direct PNGs.",+  "metadata": {+    "discovery_pitch": "If you want every chip in your chip-fetcher library to show up as a thumbnail in the wiki / library viewer / chip-pads test set, this is the renderer. It writes <MPN>-symbol.svg, <MPN>-footprint.svg, and <MPN>-3d-iso.png next to each chip's source files.",+    "discovery_triggers": [+      "chip-thumbnailer",+      "chip thumbnails",+      "chip thumbs",+      "render chip",+      "generate chip preview",+      "symbol thumbnail",+      "footprint thumbnail",+      "3d thumbnail for chip",+      "preview chip in library",+      "batch render chips",+      "thumb-pending",+      "chip wiki hero",+      "library card thumbnail",+      "make thumbnails for my chips"+    ],+    "releases": {+      "adom_docker": {+        "asset_name": "chip-thumbnailer",+        "install_hint": "curl -fsSL https://wiki-ufypy5dpx93o.adom.cloud/static/apps/chip-thumbnailer/chip-thumbnailer -o /tmp/chip-thumbnailer && chmod +x /tmp/chip-thumbnailer && sudo install -m 0755 /tmp/chip-thumbnailer /usr/local/bin/chip-thumbnailer && chip-thumbnailer install"+      }+    },+    "repo": "adom-inc/chip-thumbnailer",+    "repo_visibility": "private"+  },+  "sample_prompts": [+    "Render thumbnails for VL53L8CX",+    "Generate symbol/footprint/3D thumbs for every chip in my library",+    "What thumbs does ADS131M04IPBSR have?",+    "Re-render only the 3D thumbnail for DMG2305UX-7",+    "Mark BMI270 as thumb-pending and rescan",+    "Check that chip-thumbnailer can reach kicad-cli and the step2glb service"+  ]+}