← Commit history
.gitignoreadded+3
@@ -0,0 +1,3 @@+target/+/tmp/+.DS_Store
Cargo.lockadded+856
@@ -0,0 +1,856 @@+# This file is automatically @generated by Cargo.+# It is not intended for manual editing.+version = 4++[[package]]+name = "adler2"+version = "2.0.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"++[[package]]+name = "adom-step2glb"+version = "1.0.0"+dependencies = [+ "base64",+ "clap",+ "clap_complete",+ "serde",+ "serde_json",+ "tiny_http",+ "ureq",+]++[[package]]+name = "anstream"+version = "1.0.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"+dependencies = [+ "anstyle",+ "anstyle-parse",+ "anstyle-query",+ "anstyle-wincon",+ "colorchoice",+ "is_terminal_polyfill",+ "utf8parse",+]++[[package]]+name = "anstyle"+version = "1.0.14"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"++[[package]]+name = "anstyle-parse"+version = "1.0.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"+dependencies = [+ "utf8parse",+]++[[package]]+name = "anstyle-query"+version = "1.1.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"+dependencies = [+ "windows-sys 0.61.2",+]++[[package]]+name = "anstyle-wincon"+version = "3.0.11"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"+dependencies = [+ "anstyle",+ "once_cell_polyfill",+ "windows-sys 0.61.2",+]++[[package]]+name = "ascii"+version = "1.1.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"++[[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.60"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20"+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 = "chunked_transfer"+version = "1.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901"++[[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.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "3ff7a1dccbdd8b078c2bdebff47e404615151534d5043da397ec50286816f9cb"+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 = "crc32fast"+version = "1.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"+dependencies = [+ "cfg-if",+]++[[package]]+name = "displaydoc"+version = "0.2.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"+dependencies = [+ "proc-macro2",+ "quote",+ "syn",+]++[[package]]+name = "find-msvc-tools"+version = "0.1.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"++[[package]]+name = "flate2"+version = "1.1.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"+dependencies = [+ "crc32fast",+ "miniz_oxide",+]++[[package]]+name = "form_urlencoded"+version = "1.2.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"+dependencies = [+ "percent-encoding",+]++[[package]]+name = "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 = "httpdate"+version = "1.0.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"++[[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.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344"+dependencies = [+ "icu_normalizer",+ "icu_properties",+]++[[package]]+name = "is_terminal_polyfill"+version = "1.70.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"++[[package]]+name = "itoa"+version = "1.0.18"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"++[[package]]+name = "libc"+version = "0.2.186"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"++[[package]]+name = "litemap"+version = "0.8.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"++[[package]]+name = "log"+version = "0.4.29"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"++[[package]]+name = "memchr"+version = "2.8.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"++[[package]]+name = "miniz_oxide"+version = "0.8.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"+dependencies = [+ "adler2",+ "simd-adler32",+]++[[package]]+name = "once_cell"+version = "1.21.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"++[[package]]+name = "once_cell_polyfill"+version = "1.70.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"++[[package]]+name = "percent-encoding"+version = "2.3.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"++[[package]]+name = "potential_utf"+version = "0.1.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"+dependencies = [+ "zerovec",+]++[[package]]+name = "proc-macro2"+version = "1.0.106"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"+dependencies = [+ "unicode-ident",+]++[[package]]+name = "quote"+version = "1.0.45"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"+dependencies = [+ "proc-macro2",+]++[[package]]+name = "ring"+version = "0.17.14"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"+dependencies = [+ "cc",+ "cfg-if",+ "getrandom",+ "libc",+ "untrusted",+ "windows-sys 0.52.0",+]++[[package]]+name = "rustls"+version = "0.23.39"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "7c2c118cb077cca2822033836dfb1b975355dfb784b5e8da48f7b6c5db74e60e"+dependencies = [+ "log",+ "once_cell",+ "ring",+ "rustls-pki-types",+ "rustls-webpki",+ "subtle",+ "zeroize",+]++[[package]]+name = "rustls-pki-types"+version = "1.14.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"+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 = "simd-adler32"+version = "0.3.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"++[[package]]+name = "smallvec"+version = "1.15.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"++[[package]]+name = "stable_deref_trait"+version = "1.2.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"++[[package]]+name = "strsim"+version = "0.11.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"++[[package]]+name = "subtle"+version = "2.6.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"++[[package]]+name = "syn"+version = "2.0.117"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"+dependencies = [+ "proc-macro2",+ "quote",+ "unicode-ident",+]++[[package]]+name = "synstructure"+version = "0.13.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"+dependencies = [+ "proc-macro2",+ "quote",+ "syn",+]++[[package]]+name = "tiny_http"+version = "0.12.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82"+dependencies = [+ "ascii",+ "chunked_transfer",+ "httpdate",+ "log",+]++[[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",+ "flate2",+ "log",+ "once_cell",+ "rustls",+ "rustls-pki-types",+ "serde",+ "serde_json",+ "url",+ "webpki-roots 0.26.11",+]++[[package]]+name = "url"+version = "2.5.8"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"+dependencies = [+ "form_urlencoded",+ "idna",+ "percent-encoding",+ "serde",+]++[[package]]+name = "utf8_iter"+version = "1.0.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"++[[package]]+name = "utf8parse"+version = "0.2.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"++[[package]]+name = "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+24
@@ -0,0 +1,24 @@+[package]+name = "adom-step2glb"+version = "1.0.0"+edition = "2021"+description = "STEP → GLB converter that preserves colors from the source CAD via OCCT's XCAF pipeline. CLI + Hydrogen webview app."+license = "MIT"++[[bin]]+name = "adom-step2glb"+path = "src/main.rs"++[dependencies]+clap = { version = "4", features = ["derive", "env"] }+clap_complete = "4"+serde = { version = "1", features = ["derive"] }+serde_json = "1"+tiny_http = "0.12"+ureq = { version = "2", features = ["json"] }+base64 = "0.22"++[profile.release]+strip = true+lto = "thin"+opt-level = "z"
SKILL.mdadded+103
@@ -0,0 +1,103 @@+---+name: step2glb+description: Thin Rust CLI for converting STEP (.step / .stp) files to color-preserving GLBs. Shells to the shared service-step2glb container (OCCT XCAF pipeline, 4.7GB+ runtime) — the local install is ~3 MB and ships no CAD kernel. Returns `OK:`/`ERROR:` lines + a JSON companion line so humans and AIs can both parse the result, and `preview` opens a Hydrogen webview with a materials HUD so the human verifies visually. Use when the user asks to convert a STEP file to GLB, render a component's 3D model with materials (not white), generate a chipfit input from a KiCad library part, or regenerate broken wiki 3dcomp GLBs. Triggers: step to glb, step2glb, convert step, cad to glb, kicad step to glb, occt gltf, step preview, chip 3d preview, component 3d viewer, regenerate chip glb.+---++# step2glb++Thin Rust client for STEP → GLB conversion. All CAD work happens on the shared **service-step2glb** container (OCCT 7.8 via `cadquery-ocp`, color-preserving XCAF pipeline). This CLI is a ~3 MB HTTP client — no Python, no venv, no OCCT runtime installed locally.++## Install (Tier B)++```+curl -fsSL https://wiki-ufypy5dpx93o.adom.cloud/static/apps/step2glb/step2glb -o /tmp/step2glb \+  && chmod +x /tmp/step2glb \+  && sudo install -m 0755 /tmp/step2glb /usr/local/bin/step2glb \+  && step2glb install \+  && step2glb health+```++`step2glb install` fetches the latest `SKILL.md` from the wiki, deploys it to `~/.claude/skills/step2glb/`, and drops bash completions in `~/.local/share/bash-completion/completions/step2glb`. No heavy deps.++## Commands++Every command follows the adom-cli-design output contract:+- Successes start with `OK:` (green in a TTY, plain in pipes).+- Failures start with `ERROR:` and are followed by a `Hint:` line with the next action.+- Non-trivial commands also emit a machine-readable JSON line after `OK:` so AI callers get structured data without parsing human text.++### `step2glb health`+Round-trip to the shared service, reports version + kicad_base.+```+$ step2glb health+OK: service-step2glb v0.1.0 reachable at https://step2glb-TBD.adom.cloud (kicad_base=https://kicad-rk5ue5pcfemi.adom.cloud, converts=42, uptime=3600s)+```++### `step2glb config`+Print the effective service URL (useful when `STEP2GLB_SERVICE_API` overrides the baked default).++### `step2glb convert <input.step> [-o out.glb]`+POSTs STEP bytes to the service, streams the GLB back.++```+$ step2glb convert SOT-89-3.step -o SOT-89-3.glb+OK: GLB written from SOT-89-3.step → SOT-89-3.glb  (3 meshes, 2 materials, 25.3 KB, 78 ms)+{"durationMs":78,"meshes":3,"nodes":8,"materials":[...],"sizeBytes":25916,"output":"SOT-89-3.glb","ok":true}+```++### `step2glb from-library <Library>/<Name> [-o out.glb]`+Single-call convenience: the service fetches the STEP from **service-kicad** by library + name, converts, and streams the GLB back.++```+$ step2glb from-library Package_TO_SOT_SMD/SOT-89-3+OK: GLB written from Package_TO_SOT_SMD/SOT-89-3 → /tmp/step2glb-SOT-89-3.glb  (3 meshes, 2 materials, 25.3 KB, 87 ms)++$ step2glb from-library Package_QFP/LQFP-100_14x14mm_P0.5mm --out /tmp/stm32.glb+```++### `step2glb preview <source>`+`<source>` is either a local `.step` path or a `<Library>/<Name>` identifier. Converts via the service, then spawns a local HTTP server on a free port and opens a **Hydrogen webview** tab so a human can see the rendered GLB alongside the stats.++```+$ step2glb preview Package_QFP/LQFP-100_14x14mm_P0.5mm+OK: preview ready at https://…adom.cloud/proxy/45231/  (Ctrl-C to stop)+```++The webview shows:+- Pills (top-left): `meshes`, `materials`, `size`, `conv` — green when healthy, red when off.+- **Details drawer** (≡ top-right): per-material color swatches + RGB values so you can see whether the STEP's colors came through.+- Top / Front / Iso camera presets + keyboard shortcuts (T / F / I).++### `step2glb install`+Fetches the latest `SKILL.md` from the wiki, writes it to `~/.claude/skills/step2glb/SKILL.md`, generates bash completions, appends a one-line hook to `~/.bashrc`.++### `step2glb completions bash|zsh|fish`+Print shell completions to stdout (what the install step uses internally; also fine for manual sourcing).++## Exit codes++| Code | Meaning |+|------|---------|+| 0    | OK |+| 1    | Input / argument invalid (missing file, malformed path) |+| 2    | Service unreachable (see `Hint:` for how to recover) |+| 4    | Service returned an error (conversion failed, library part not found) |++## Env vars++| Var | Purpose | Default |+|-----|---------|---------|+| `STEP2GLB_SERVICE_API` | Override the shared-service URL (useful for testing a local instance) | Baked into the binary at build time |++## Architecture (why no local CAD kernel)++`cadquery-ocp` bundles OCCT + its dependencies and weighs **~1.1 GB** in a venv. Installing that on every gallia container would cost gigabytes across the fleet when most users never need it, so step2glb runs as a **shared-service app** on the adom-app-model:++- **adom-inc/service-step2glb** — private repo, default-light container, Python HTTP server wrapping the OCCT XCAF pipeline. Runs `server.py` under a venv, watchdog every 2 min for health + auto-update.+- **adom-inc/step2glb** — this binary. Tier B auto-discovered from the wiki, `/usr/local/bin/step2glb` only, ~3 MB.++Per-call cost from the client: one HTTPS round-trip (KB in, KB–MB out) + ~100 ms–4 s of OCCT work on the shared container. Compared to the ~3.4 s cold-start of running OCCT locally, the shared service is actually faster because the Python interpreter + OCP import stay warm.++## Policy for consumers (chipfit, 3d-component-creator, adom-tsci, InstaPCB)++When a STEP is available and a GLB is needed, call `step2glb from-library <Lib>/<Name>` or `step2glb convert <file.step>` rather than hand-rolling STEP→GLB. This keeps material handling consistent across every downstream viewer and fixes the legacy "all-white GLB" class of bug.
VERSIONadded+1
@@ -0,0 +1 @@+0.9.0
adom-step2glbadded
⋯ 1 unchanged line ⋯
adom-step2glb-billboard.pngadded
⋯ 1 unchanged line ⋯
build.shadded+19
@@ -0,0 +1,19 @@+#!/usr/bin/env bash+# Local development build + install.+set -euo pipefail++cd "$(dirname "$0")"++echo "== cargo build --release =="+cargo build --release++echo "== installing to /usr/local/bin =="+sudo install -m 0755 target/release/adom-step2glb /usr/local/bin/adom-step2glb+sudo ln -sf /usr/local/bin/adom-step2glb /usr/local/bin/step2glb  # back-compat alias+/usr/local/bin/adom-step2glb --version++echo "== running install (skill + completions) =="+adom-step2glb install++echo ""+echo "done. Try: adom-step2glb health"
docs/hero.pngadded
⋯ 1 unchanged line ⋯
docs/screenshots/01-iso-lqfp100.pngadded
⋯ 1 unchanged line ⋯
docs/screenshots/02-materials-drawer.pngadded
⋯ 1 unchanged line ⋯
docs/screenshots/03-bottom-transparent-ground.pngadded
⋯ 1 unchanged line ⋯
docs/screenshots/04-iso-sot-89-3.pngadded
⋯ 1 unchanged line ⋯
install.shadded+18
@@ -0,0 +1,18 @@+#!/usr/bin/env bash+# adompkg install hook for adom-step2glb.+# The binary itself is delivered as a platform release asset by adompkg, not here.+# This hook only installs the optional Claude skill (SKILL.md + skills/*) if present.+set -euo pipefail++cd "$(dirname "$0")"++SKILL_DIR="$HOME/.claude/skills/adom-step2glb"++if [ -f SKILL.md ] || [ -d skills ]; then+  mkdir -p "$SKILL_DIR"+  [ -f SKILL.md ] && cp SKILL.md "$SKILL_DIR/SKILL.md"+  [ -d skills ] && cp -r skills/. "$SKILL_DIR/"+  echo "OK: installed adom-step2glb skill -> $SKILL_DIR"+else+  echo "OK: adom-step2glb (no skill payload; binary delivered via release asset)"+fi
package.jsonadded+11
@@ -0,0 +1,11 @@+{+  "slug": "adom-step2glb",+  "version": "1.0.0",+  "type": "app",+  "description": "Color-preserving STEP (.step/.stp) to GLB converter. Thin Rust CLI shelling to a shared OCCT XCAF service. Formerly 'step2glb'.",+  "tags": ["step", "glb", "cad", "converter", "occt", "3d"],+  "hero": { "type": "image", "path": "adom-step2glb-billboard.png" },+  "dependencies": {},+  "scripts": { "install": "./install.sh", "uninstall": "./uninstall.sh" },+  "license": "MIT"+}
src/cli.rsadded+526
@@ -0,0 +1,526 @@+use crate::client::{self, ConvertResult, ThumbnailResult};+use crate::ui::{err, hint, ok, warn, CYAN, DIM, RESET};+use crate::{note};+use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum};+use std::path::PathBuf;++/// adom-step2glb — STEP → GLB converter client.+///+/// Forwards conversion requests to the shared service-step2glb container+/// (heavy OCCT runtime lives there; this CLI stays ~3 MB). `preview` opens+/// a Hydrogen webview so humans can see the converted model alongside the+/// AI-readable JSON.+#[derive(Parser, Debug)]+#[command(name = "adom-step2glb", version, about, long_about = None,+    after_help = "\+IDENTITY HEADERS (required by service-step2glb):+  Every POST to the service requires X-Client and X-Job-Name headers.+  This CLI sends them automatically:+    X-Client:   adom-step2glb-cli/<version>  (override with STEP2GLB_CLIENT env var)+    X-Job-Name: auto-generated from the command + filename++  If you're calling the service directly (curl, Python, etc.), you must set both:+    X-Client:   <tool-name>/<user-or-container>  e.g. adom-tsci/john-dev+    X-Job-Name: <endpoint>-<what>-<part>-<context>+                e.g. convert-usbc-connector-type-c-31-m-12-to-glb+                     thumbnail-iso-vl53l8cx-for-library+                     features-extract-bme688-pin-detection++ENVIRONMENT:+  STEP2GLB_SERVICE_API   Override service URL (default: shared Adom container)+  STEP2GLB_CLIENT        Override X-Client identity (default: adom-step2glb-cli/<version>)+")]+pub struct Cli {+    #[command(subcommand)]+    pub command: Command,+}++#[derive(Subcommand, Debug)]+pub enum Command {+    /// Reachability + version check against the shared service.+    Health,+    /// Print the service URL this CLI will hit.+    Config,+    /// Convert a local STEP file → GLB.+    Convert(ConvertArgs),+    /// Fetch a STEP from service-kicad by "<Library>/<Name>" and convert.+    FromLibrary(FromLibraryArgs),+    /// Extract geometric features (bbox, floor_z, longest_axis, pin1) from a local STEP without converting.+    Features(FeaturesArgs),+    /// Render a PNG thumbnail of a local STEP (server-side via pyrender; iso pose by default).+    Thumbnail(ThumbnailArgs),+    /// Render multiple (orientation × size) PNG thumbnails of a local STEP in+    /// a single service call. Loads the STEP once on the server and renders+    /// every requested variant into one V3d session — ~5–10× faster than+    /// looping `adom-step2glb thumbnail` once per variant.+    ThumbnailBatch(ThumbnailBatchArgs),+    /// Convert + open a Hydrogen webview so a human can verify the result.+    Preview(PreviewArgs),+    /// Open a Hydrogen webview thumb-playground — drag-drop a .step file,+    /// see all 6 orientations × 3 sizes populate. Drag-drop UI for one-off+    /// renders; tool integrations should hit /thumbnail-batch directly.+    ThumbPlayground(crate::playground::PlaygroundArgs),+    /// Install SKILL.md + bash completions (fetches fresh SKILL.md from the wiki).+    Install(InstallArgs),+    /// Print shell completions to stdout: `adom-step2glb completions bash`+    Completions(CompletionsArgs),+}++#[derive(Args, Debug)]+pub struct ConvertArgs {+    /// Input .step / .stp file.+    pub input: PathBuf,+    /// Output .glb path. Defaults to <input stem>.glb beside the input.+    #[arg(short, long)]+    pub out: Option<PathBuf>,+}++#[derive(Args, Debug)]+pub struct FromLibraryArgs {+    /// "<Library>/<Name>" — e.g. `Package_TO_SOT_SMD/SOT-89-3`+    pub path: String,+    /// Output .glb. Defaults to /tmp/adom-step2glb-<name>.glb.+    #[arg(short, long)]+    pub out: Option<PathBuf>,+}++#[derive(Args, Debug)]+pub struct FeaturesArgs {+    /// Input .step / .stp file.+    pub input: PathBuf,+    /// Output .json path. Defaults to <input stem>-features.json beside the input. Use `-` to print to stdout.+    #[arg(short, long)]+    pub out: Option<PathBuf>,+}++#[derive(Args, Debug)]+pub struct ThumbnailArgs {+    /// Input .step / .stp / .glb file. Kind auto-detected from extension or magic bytes.+    pub input: PathBuf,+    /// Output .png path. Defaults to <input stem>-thumb.png beside the input.+    #[arg(short, long)]+    pub out: Option<PathBuf>,+    /// Image width in pixels (default 320 — thumbnail; pass 800+ for hero size).+    #[arg(long, default_value_t = 320)]+    pub width: u32,+    /// Image height in pixels (default 240 — thumbnail; pass 600+ for hero size).+    #[arg(long, default_value_t = 240)]+    pub height: u32,+    /// Camera pose: iso, iso2, top, front, side.+    #[arg(long, default_value = "iso")]+    pub pose: String,+    /// Force input kind: auto (default), step, or glb.+    #[arg(long, default_value = "auto")]+    pub kind: String,+    /// Pre-rotate the model before rendering. Six face-up variants:+    /// `asIs` (=`as-is`/`z`, default — source +Z up, STEP canonical),+    /// `zDown` (R_x(π) — chip flipped),+    /// `yUp` (=`y`, R_x(+π/2) — source +Y up, glTF / three.js),+    /// `yDown` (R_x(-π/2) — source -Y up, e.g. WAGO connectors),+    /// `xUp` (R_y(-π/2)), `xDown` (R_y(+π/2)).+    /// chip-thumbnailer renders all six per chip so a downstream picker+    /// can compare side-by-side. See gallia/skills/up-axis-conventions.+    #[arg(long, default_value = "asIs")]+    pub up_axis: String,+}++#[derive(Args, Debug)]+pub struct ThumbnailBatchArgs {+    /// Input .step / .stp file.+    pub input: PathBuf,+    /// Directory to write the rendered PNGs into. Defaults to `<input stem>-thumbs/`+    /// beside the input. Files are named `<stem>-3d-iso<orient><size>.png`+    /// to match chip-thumbnailer's canonical layout.+    #[arg(short, long)]+    pub out_dir: Option<PathBuf>,+    /// Comma-separated orientations. `all` (default) = asIs,zDown,yUp,yDown,xUp,xDown.+    #[arg(long, default_value = "all")]+    pub orientations: String,+    /// Comma-separated sizes. `all` (default) = sm,md,lg.+    #[arg(long, default_value = "all")]+    pub sizes: String,+    /// Camera pose. Default `iso`.+    #[arg(long, default_value = "iso")]+    pub pose: String,+}++#[derive(Args, Debug)]+pub struct PreviewArgs {+    /// Either a local .step path or "<Library>/<Name>" (same shape as from-library).+    pub source: String,+    /// Port for the local preview HTTP server. 0 = auto-pick a free port.+    #[arg(long, default_value_t = 0)]+    pub port: u16,+    /// Skip opening the Hydrogen webview tab (still prints the proxy URL).+    #[arg(long)]+    pub no_tab: bool,+}++#[derive(Args, Debug)]+pub struct InstallArgs {}++#[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 health() {+    let url = client::service_url();+    match client::health() {+        Ok(h) => {+            ok(format!(+                "service-step2glb v{} reachable at {CYAN}{}{RESET} (kicad_base={CYAN}{}{RESET}, converts={}, uptime={}s)",+                h.version, url, h.kicad_base, h.converts, h.uptime_seconds+            ));+        }+        Err(e) => {+            err(format!("service-step2glb unreachable at {CYAN}{}{RESET}: {e}", url));+            hint("Check connectivity, or override via `export STEP2GLB_SERVICE_API=<url>`.");+            hint(format!("Current URL: `adom-step2glb config` · Baked default: {}", client::DEFAULT_SERVICE_URL));+            std::process::exit(2);+        }+    }+}++pub fn config() {+    note!("{DIM}service URL:{RESET} {CYAN}{}{RESET}", client::service_url());+    note!("{DIM}  baked default:{RESET} {}", client::DEFAULT_SERVICE_URL);+    if std::env::var("STEP2GLB_SERVICE_API").is_ok() {+        note!("{DIM}  overridden by STEP2GLB_SERVICE_API env var{RESET}");+    }+}++pub fn convert_cmd(args: ConvertArgs) {+    if !args.input.exists() {+        err(format!("input file not found: {}", args.input.display()));+        hint(format!("Check the path exists: `ls {}`", args.input.parent().map(|p|p.display().to_string()).unwrap_or_else(||".".into())));+        std::process::exit(1);+    }+    let out = args.out.unwrap_or_else(|| {+        let mut p = args.input.clone();+        p.set_extension("glb");+        p+    });+    let bytes = match std::fs::read(&args.input) {+        Ok(b) => b,+        Err(e) => { err(format!("read {}: {e}", args.input.display())); std::process::exit(1); }+    };+    match client::convert_bytes(&bytes, &out) {+        Ok(r) => print_success(&r, &out, Some(&args.input.display().to_string())),+        Err(e) => {+            err(format!("conversion failed: {e}"));+            hint("Run `adom-step2glb health` to check the shared service is reachable.");+            std::process::exit(4);+        }+    }+}++pub fn from_library_cmd(args: FromLibraryArgs) {+    let out = args.out.unwrap_or_else(|| {+        let leaf = args.path.rsplit('/').next().unwrap_or("part");+        PathBuf::from(format!("/tmp/adom-step2glb-{leaf}.glb"))+    });+    if !args.path.contains('/') {+        err(format!("path must be `<Library>/<Name>`, got `{}`", args.path));+        hint("Example: `adom-step2glb from-library Package_TO_SOT_SMD/SOT-89-3`");+        std::process::exit(1);+    }+    match client::convert_from_library(&args.path, &out) {+        Ok(r) => print_success(&r, &out, Some(&args.path)),+        Err(e) => {+            err(format!("conversion failed: {e}"));+            if format!("{e}").contains("502") || format!("{e}").contains("404") {+                hint(format!("Is `{}` a real library part? Check service-kicad: `service-kicad model fetch <lib>/<name>.step`", args.path));+            } else {+                hint("Run `adom-step2glb health` to check the shared service is reachable.");+            }+            std::process::exit(4);+        }+    }+}++pub fn features_cmd(args: FeaturesArgs) {+    if !args.input.exists() {+        err(format!("input file not found: {}", args.input.display()));+        std::process::exit(1);+    }+    let bytes = match std::fs::read(&args.input) {+        Ok(b) => b,+        Err(e) => { err(format!("read {}: {e}", args.input.display())); std::process::exit(1); }+    };+    let json_str = match client::extract_features(&bytes) {+        Ok(s) => s,+        Err(e) => {+            err(format!("feature extraction failed: {e}"));+            hint("Run `adom-step2glb health` to check the shared service is reachable.");+            std::process::exit(4);+        }+    };+    let pretty = serde_json::from_str::<serde_json::Value>(&json_str)+        .ok()+        .and_then(|v| serde_json::to_string_pretty(&v).ok())+        .unwrap_or(json_str);++    let out_path = match args.out {+        Some(p) if p.as_os_str() == "-" => {+            println!("{pretty}");+            return;+        }+        Some(p) => p,+        None => {+            let stem = args.input.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "step".into());+            args.input.with_file_name(format!("{stem}-features.json"))+        }+    };+    if let Err(e) = std::fs::write(&out_path, &pretty) {+        err(format!("write {}: {e}", out_path.display()));+        std::process::exit(1);+    }+    ok(format!("features written → {CYAN}{}{RESET}", out_path.display()));+    // Echo a one-line summary so AI callers see the highlights without re-parsing the file.+    if let Ok(v) = serde_json::from_str::<serde_json::Value>(&pretty) {+        let bb = &v["bbox_mm"];+        let pin1_src = v["pin1"]["source"].as_str().unwrap_or("?");+        let extra_src = match pin1_src {+            "step-feature" => format!(" {CYAN}pin1=step-feature{RESET}"),+            _ => format!(" {DIM}pin1={pin1_src}{RESET}"),+        };+        note!(+            "{DIM}bbox{RESET} x={}..{} y={}..{} z={}..{}  {DIM}floor_z{RESET}={}  {DIM}axis{RESET}={}{}",+            bb["x"][0], bb["x"][1], bb["y"][0], bb["y"][1], bb["z"][0], bb["z"][1],+            v["floor_z_mm"], v["longest_axis"].as_str().unwrap_or("?"), extra_src+        );+    }+}++pub fn thumbnail_cmd(args: ThumbnailArgs) {+    if !args.input.exists() {+        err(format!("input file not found: {}", args.input.display()));+        std::process::exit(1);+    }+    let valid_poses = ["iso", "iso2", "top", "front", "side"];+    if !valid_poses.contains(&args.pose.as_str()) {+        err(format!("invalid --pose `{}`; must be one of: {}", args.pose, valid_poses.join(", ")));+        std::process::exit(1);+    }+    let valid_kinds = ["auto", "step", "glb"];+    if !valid_kinds.contains(&args.kind.as_str()) {+        err(format!("invalid --kind `{}`; must be one of: {}", args.kind, valid_kinds.join(", ")));+        std::process::exit(1);+    }+    // Six canonical face-up variants. Aliases: as-is/asis/z → asIs, y → yUp.+    // The service does the same normalization, but reject early here so+    // callers get a clear error instead of a successful-but-wrong render.+    let valid_up = [+        "asIs", "as-is", "asis", "z",       // → asIs+        "zDown",                             // R_x(π)+        "yUp", "y",                          // → yUp+        "yDown",                             // R_x(-π/2)+        "xUp", "xDown",                      // R_y(±π/2)+    ];+    if !valid_up.contains(&args.up_axis.as_str()) {+        err(format!("invalid --up-axis `{}`; must be one of: {}", args.up_axis, valid_up.join(", ")));+        std::process::exit(1);+    }+    // Pass through verbatim — the service normalizes synonyms server-side.+    let up_norm = args.up_axis.clone();++    // If --kind is auto, hint by file extension. The service still magic-byte+    // sniffs, so an unknown extension just falls through to whatever the+    // bytes say. No filesystem read required to pick the hint.+    let kind_hint = if args.kind == "auto" {+        match args.input.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()) {+            Some(ref e) if e == "glb"  => "glb",+            Some(ref e) if e == "step" || e == "stp" => "step",+            _ => "auto",+        }+    } else {+        args.kind.as_str()+    };++    let out = args.out.unwrap_or_else(|| {+        let stem = args.input.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "step".into());+        args.input.with_file_name(format!("{stem}-thumb.png"))+    });+    let bytes = match std::fs::read(&args.input) {+        Ok(b) => b,+        Err(e) => { err(format!("read {}: {e}", args.input.display())); std::process::exit(1); }+    };+    match client::thumbnail_bytes(&bytes, &out, args.width, args.height, &args.pose, kind_hint, &up_norm) {+        Ok(r) => print_thumbnail_success(&r, &out, &args.input.display().to_string()),+        Err(e) => {+            err(format!("thumbnail render failed: {e}"));+            hint("Run `adom-step2glb health` to check the shared service is reachable. /thumbnail requires service-step2glb v0.3.0+ (pyrender + GL libs deployed).");+            std::process::exit(4);+        }+    }+}++pub fn thumbnail_batch_cmd(args: ThumbnailBatchArgs) {+    use base64::Engine;+    if !args.input.exists() {+        err(format!("input file not found: {}", args.input.display()));+        std::process::exit(1);+    }+    let bytes = match std::fs::read(&args.input) {+        Ok(b) => b,+        Err(e) => { err(format!("read {}: {e}", args.input.display())); std::process::exit(1); }+    };+    let stem = args.input.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "step".into());+    let out_dir = args.out_dir.unwrap_or_else(|| {+        args.input.with_file_name(format!("{stem}-thumbs"))+    });+    if let Err(e) = std::fs::create_dir_all(&out_dir) {+        err(format!("create {}: {e}", out_dir.display()));+        std::process::exit(1);+    }++    let result = match client::thumbnail_batch_bytes(&bytes, &args.orientations, &args.sizes, &args.pose) {+        Ok(r) => r,+        Err(e) => {+            err(format!("batch thumbnail render failed: {e}"));+            hint("Run `adom-step2glb health` to check the shared service is reachable. /thumbnail-batch requires service-step2glb v0.8.0+.");+            std::process::exit(4);+        }+    };++    // Write each variant. Naming matches chip-thumbnailer's canonical scheme:+    //   <stem>-3d-iso.png            (asIs / md — back-compat name)+    //   <stem>-3d-iso-yup.png        (yUp / md)+    //   <stem>-3d-iso-yup-sm.png     (yUp / sm)+    //   ... etc.+    let b64 = base64::engine::general_purpose::STANDARD;+    let mut written = 0u32;+    for entry in &result.thumbnails {+        let png = match b64.decode(entry.data_b64.as_bytes()) {+            Ok(b) => b,+            Err(e) => { err(format!("base64 decode {}/{}: {e}", entry.up_axis, entry.size)); continue; }+        };+        let orient_suffix = match entry.up_axis.as_str() {+            "asIs" => "",+            "zDown" => "-zdown",+            "yUp" => "-yup",+            "yDown" => "-ydown",+            "xUp" => "-xup",+            "xDown" => "-xdown",+            other => other,+        };+        let size_suffix = match entry.size.as_str() {+            "md" => "",+            "sm" => "-sm",+            "lg" => "-lg",+            other => other,+        };+        let path = out_dir.join(format!("{stem}-3d-iso{orient_suffix}{size_suffix}.png"));+        if let Err(e) = std::fs::write(&path, &png) {+            err(format!("write {}: {e}", path.display()));+            continue;+        }+        written += 1;+    }++    ok(format!(+        "{written} PNGs written to {CYAN}{}{RESET}  ({DIM}load {} ms · total {} ms · {} variants{RESET})",+        out_dir.display(), result.load_duration_ms, result.total_duration_ms, result.thumbnail_count+    ));+    for w in &result.render_warnings {+        crate::ui::warn(w);+    }++    // Final JSON line for AI / scripted callers.+    println!("{}", serde_json::to_string(&serde_json::json!({+        "ok": true,+        "outDir": out_dir.display().to_string(),+        "written": written,+        "thumbnailCount": result.thumbnail_count,+        "loadDurationMs": result.load_duration_ms,+        "totalDurationMs": result.total_duration_ms,+        "bboxMm": result.bbox_mm,+        "renderWarnings": result.render_warnings,+    })).unwrap_or_default());+}++pub fn preview_cmd(args: PreviewArgs) {+    crate::preview::run(args);+}++pub fn completions(args: CompletionsArgs) {+    let shell: clap_complete::Shell = args.shell.into();+    let mut cmd = Cli::command();+    clap_complete::generate(shell, &mut cmd, "adom-step2glb", &mut std::io::stdout());+}++fn print_thumbnail_success(r: &ThumbnailResult, out: &std::path::Path, source: &str) {+    ok(format!(+        "PNG written from {CYAN}{source}{RESET} ({DIM}{}{RESET}) → {CYAN}{}{RESET}  ({DIM}{}×{} {} pose, {:.1} KB, {} ms{RESET})",+        r.src_kind, out.display(), r.width, r.height, r.pose, r.size_bytes as f64 / 1024.0, r.duration_ms+    ));+    // Echo the chip's bbox in mm so AI callers see the highlights without+    // re-parsing the JSON. Mirrors the summary line in features_cmd.+    if let Some(bb) = &r.bbox_mm {+        if let (Some(xs), Some(ys), Some(zs)) = (bb.get(0), bb.get(1), bb.get(2)) {+            let ext = |v: &serde_json::Value| -> Option<f64> {+                let lo = v.get(0).and_then(|x| x.as_f64())?;+                let hi = v.get(1).and_then(|x| x.as_f64())?;+                Some(hi - lo)+            };+            if let (Some(dx), Some(dy), Some(dz)) = (ext(xs), ext(ys), ext(zs)) {+                note!(+                    "{DIM}bbox{RESET} {:.2}×{:.2}×{:.2} mm  {DIM}pose{RESET}={CYAN}{}{RESET}  {DIM}src{RESET}={}",+                    dx, dy, dz, r.pose, r.src_kind+                );+            }+        }+    }+    println!("{}", serde_json::to_string(&serde_json::json!({+        "ok": true,+        "output": out,+        "sizeBytes": r.size_bytes,+        "width": r.width,+        "height": r.height,+        "pose": r.pose,+        "srcKind": r.src_kind,+        "bboxMm": r.bbox_mm,+        "durationMs": r.duration_ms,+    })).unwrap());+}++fn print_success(r: &ConvertResult, out: &std::path::Path, source: Option<&str>) {+    let mats_n = r.materials.len();+    let src_label = source.map(|s| format!(" from {CYAN}{s}{RESET}")).unwrap_or_default();+    ok(format!(+        "GLB written{src_label} → {CYAN}{}{RESET}  ({DIM}{} meshes, {} materials, {:.1} KB, {} ms{RESET})",+        out.display(), r.meshes, mats_n, r.size_bytes as f64 / 1024.0, r.duration_ms+    ));+    if mats_n == 0 {+        warn("GLB has no materials — the source STEP may lack color assignments. Downstream viewers will render it white.");+    }+    // Also emit the structured JSON on a separate line so AI callers still get machine-readable output.+    println!("{}", serde_json::to_string(&serde_json::json!({+        "ok": true,+        "output": out,+        "sizeBytes": r.size_bytes,+        "meshes": r.meshes,+        "nodes": r.nodes,+        "materials": r.materials,+        "durationMs": r.duration_ms,+    })).unwrap());+}
src/client.rsadded+341
@@ -0,0 +1,341 @@+//! Thin HTTP client to service-step2glb. No CAD logic lives here.+//!+//! service-step2glb v1.2+ is ASYNC: every endpoint returns `202 {job_id, poll}`,+//! you poll `GET /jobs/<id>` until `status == complete`, then `GET+//! /jobs/<id>/result` streams the real body (GLB/PNG/JSON) carrying the same+//! `X-*` metadata headers. Every request must also carry `X-Client` ++//! `X-Job-Name` identity headers or the service 400s. `drive_to_result` handles+//! BOTH the async job protocol and the legacy sync `200` (so this client works+//! against old and new service builds).+use serde::{Deserialize, Serialize};+use std::io::Write;+use std::path::Path;+use std::time::{Duration, Instant};++/// Baked-in default. Override at runtime with `STEP2GLB_SERVICE_API`.+pub const DEFAULT_SERVICE_URL: &str = "https://step2glb-gmdoncpxdwx0.adom.cloud";++pub fn service_url() -> String {+    std::env::var("STEP2GLB_SERVICE_API")+        .unwrap_or_else(|_| DEFAULT_SERVICE_URL.to_string())+        .trim_end_matches('/')+        .to_string()+}++fn client_id() -> String {+    std::env::var("STEP2GLB_CLIENT")+        .unwrap_or_else(|_| format!("adom-step2glb/{}", env!("CARGO_PKG_VERSION")))+}++fn set_identity(req: ureq::Request, job_name: &str) -> ureq::Request {+    req.set("X-Client", &client_id())+       .set("X-Job-Name", job_name)+}++/// Given the initial response from an endpoint, return the FINAL response whose+/// body is the real payload and whose `X-*` headers carry the metadata. Handles+/// the legacy sync `200` path AND the v1.2 async job protocol (`202` -> poll+/// `GET /jobs/<id>` -> `GET /jobs/<id>/result`).+fn drive_to_result(resp: ureq::Response, job_name: &str) -> Result<ureq::Response, String> {+    match resp.status() {+        200 => Ok(resp),+        202 => {+            let base = service_url();+            let v: serde_json::Value = resp+                .into_json()+                .map_err(|e| format!("parse 202 job descriptor: {e}"))?;+            let job_id = v+                .get("job_id")+                .and_then(|s| s.as_str())+                .ok_or_else(|| "adom-step2glb 202 response missing job_id".to_string())?+                .to_string();+            let deadline = Instant::now() + Duration::from_secs(300);+            loop {+                if Instant::now() > deadline {+                    return Err(format!("adom-step2glb job {job_id} timed out after 300s"));+                }+                std::thread::sleep(Duration::from_millis(1200));+                let status_url = format!("{base}/jobs/{job_id}");+                let js: serde_json::Value = set_identity(ureq::get(&status_url), job_name)+                    .timeout(Duration::from_secs(20))+                    .call()+                    .map_err(|e| format!("poll {status_url}: {e}"))?+                    .into_json()+                    .map_err(|e| format!("parse job status: {e}"))?;+                match js.get("status").and_then(|s| s.as_str()).unwrap_or("") {+                    "complete" | "done" | "succeeded" => break,+                    "failed" | "error" => {+                        let err = js+                            .get("error")+                            .and_then(|s| s.as_str())+                            .unwrap_or("unknown error");+                        return Err(format!("adom-step2glb job {job_id} failed: {err}"));+                    }+                    _ => {}+                }+            }+            let result_url = format!("{base}/jobs/{job_id}/result");+            set_identity(ureq::get(&result_url), job_name)+                .timeout(Duration::from_secs(180))+                .call()+                .map_err(|e| format!("fetch {result_url} failed: {e}"))+        }+        s => {+            let body = resp.into_string().unwrap_or_default();+            Err(format!("service returned HTTP {s}: {body}"))+        }+    }+}++#[derive(Debug, Deserialize)]+pub struct Health {+    pub ok: bool,+    pub service: String,+    pub version: String,+    pub uptime_seconds: u64,+    pub converts: u64,+    pub kicad_base: String,+}++#[derive(Debug, Serialize, Deserialize, Clone)]+pub struct Material {+    pub name: Option<String>,+    #[serde(rename = "baseColor")]+    pub base_color: Option<Vec<f64>>,+    pub metal: Option<f64>,+    pub roughness: Option<f64>,+}++#[derive(Debug, Clone)]+pub struct ConvertResult {+    pub size_bytes: u64,+    pub meshes: u32,+    pub nodes: u32,+    pub materials: Vec<Material>,+    pub duration_ms: u64,+}++#[derive(Debug, Clone)]+pub struct ThumbnailResult {+    pub size_bytes: u64,+    pub width: u32,+    pub height: u32,+    pub pose: String,+    pub src_kind: String,+    pub up_axis: String,+    pub bbox_mm: Option<serde_json::Value>,+    pub duration_ms: u64,+}++pub fn health() -> Result<Health, String> {+    let url = format!("{}/health", service_url());+    let r = ureq::get(&url).timeout(Duration::from_secs(5)).call()+        .map_err(|e| format!("GET {url} failed: {e}"))?;+    r.into_json::<Health>().map_err(|e| format!("health JSON parse: {e}"))+}++/// POST /convert with STEP body. Streams the GLB to `out_path`.+pub fn convert_bytes(step: &[u8], out_path: &Path) -> Result<ConvertResult, String> {+    let job_name = format!("convert-{}", out_path.file_stem().and_then(|s| s.to_str()).unwrap_or("unknown"));+    let url = format!("{}/convert", service_url());+    let r = set_identity(ureq::post(&url), &job_name)+        .set("Content-Type", "application/step")+        .timeout(Duration::from_secs(120))+        .send_bytes(step)+        .map_err(|e| format!("POST {url} failed: {e}"))?;+    let r = drive_to_result(r, &job_name)?;+    write_response(r, out_path)+}++/// POST /features with STEP body. Returns the features JSON as a string+/// (the service emits the canonical format directly; we don't re-shape it+/// here so future schema additions land without a CLI rebuild).+pub fn extract_features(step: &[u8]) -> Result<String, String> {+    let url = format!("{}/features", service_url());+    let r = set_identity(ureq::post(&url), "extract-features")+        .set("Content-Type", "application/step")+        .timeout(Duration::from_secs(60))+        .send_bytes(step)+        .map_err(|e| format!("POST {url} failed: {e}"))?;+    let r = drive_to_result(r, "extract-features")?;+    r.into_string().map_err(|e| format!("read body: {e}"))+}++/// POST /thumbnail with STEP or GLB body. Streams the PNG to `out_path` and+/// parses the X-* response headers into a `ThumbnailResult`. The service+/// magic-byte sniffs by default; pass `kind` to force ("step", "glb", or+/// "auto").+pub fn thumbnail_bytes(+    body: &[u8],+    out_path: &Path,+    width: u32,+    height: u32,+    pose: &str,+    kind: &str,+    up_axis: &str,+) -> Result<ThumbnailResult, String> {+    // Pick the Content-Type from the kind hint so HTTP middleware /+    // proxies have a real type to log. The body content always wins on+    // the server side via magic-byte sniff.+    let content_type = match kind {+        "glb" => "model/gltf-binary",+        _ => "application/step",+    };+    let url = format!(+        "{}/thumbnail?width={}&height={}&pose={}&kind={}&upAxis={}",+        service_url(),+        width,+        height,+        urlencode(pose),+        urlencode(kind),+        urlencode(up_axis),+    );+    let job_name = format!("thumbnail-{}-{}", pose, out_path.file_stem().and_then(|s| s.to_str()).unwrap_or("unknown"));+    let r = set_identity(ureq::post(&url), &job_name)+        .set("Content-Type", content_type)+        .timeout(Duration::from_secs(180))+        .send_bytes(body)+        .map_err(|e| format!("POST {url} failed: {e}"))?;+    let r = drive_to_result(r, &job_name)?;++    let width_h = header_u32(&r, "X-Width");+    let height_h = header_u32(&r, "X-Height");+    let duration_ms = u64::from(header_u32(&r, "X-Duration-Ms"));+    let pose_h = r.header("X-Pose").unwrap_or(pose).to_string();+    let src_kind_h = r.header("X-Src-Kind").unwrap_or(kind).to_string();+    let up_axis_h = r.header("X-Up-Axis").unwrap_or(up_axis).to_string();+    let bbox_mm = r+        .header("X-Bbox-Mm")+        .and_then(|s| serde_json::from_str(s).ok());++    let mut file = std::fs::File::create(out_path)+        .map_err(|e| format!("create {}: {e}", out_path.display()))?;+    let mut reader = r.into_reader();+    let size_bytes = std::io::copy(&mut reader, &mut file)+        .map_err(|e| format!("write {}: {e}", out_path.display()))?;+    file.flush().ok();++    Ok(ThumbnailResult {+        size_bytes,+        width: width_h,+        height: height_h,+        pose: pose_h,+        src_kind: src_kind_h,+        up_axis: up_axis_h,+        bbox_mm,+        duration_ms,+    })+}++#[derive(Debug, Clone, Deserialize)]+pub struct BatchThumbnailEntry {+    #[serde(rename = "upAxis")]+    pub up_axis: String,+    pub size: String,+    pub width: u32,+    pub height: u32,+    #[serde(rename = "sizeBytes")]+    pub size_bytes: u64,+    #[serde(rename = "dataB64")]+    pub data_b64: String,+}++#[derive(Debug, Clone, Deserialize)]+pub struct BatchThumbnailResult {+    pub ok: bool,+    #[serde(rename = "srcKind")]+    pub src_kind: String,+    pub pose: String,+    #[serde(rename = "bboxMm")]+    pub bbox_mm: Option<serde_json::Value>,+    #[serde(rename = "loadDurationMs")]+    pub load_duration_ms: u64,+    #[serde(rename = "totalDurationMs")]+    pub total_duration_ms: u64,+    #[serde(rename = "thumbnailCount")]+    pub thumbnail_count: u32,+    pub thumbnails: Vec<BatchThumbnailEntry>,+    #[serde(default, rename = "renderWarnings")]+    pub render_warnings: Vec<String>,+}++/// POST /thumbnail-batch with STEP body — load STEP once, render N×M variants+/// in one V3d session. ~5–10× faster than per-variant /thumbnail calls.+pub fn thumbnail_batch_bytes(+    body: &[u8],+    orientations: &str,+    sizes: &str,+    pose: &str,+) -> Result<BatchThumbnailResult, String> {+    let url = format!(+        "{}/thumbnail-batch?orientations={}&sizes={}&pose={}&kind=step",+        service_url(),+        urlencode(orientations),+        urlencode(sizes),+        urlencode(pose),+    );+    let r = set_identity(ureq::post(&url), "thumbnail-batch")+        .set("Content-Type", "application/step")+        .timeout(Duration::from_secs(300))+        .send_bytes(body)+        .map_err(|e| format!("POST {url} failed: {e}"))?;+    let r = drive_to_result(r, "thumbnail-batch")?;+    r.into_json::<BatchThumbnailResult>()+        .map_err(|e| format!("batch JSON parse: {e}"))+}++/// GET /convert?library=<L>&name=<N> — service fetches STEP from service-kicad+/// and converts in one round-trip.+pub fn convert_from_library(lib_slash_name: &str, out_path: &Path) -> Result<ConvertResult, String> {+    let (lib, name) = lib_slash_name.rsplit_once('/').ok_or_else(|| {+        format!("bad path {lib_slash_name:?}; expected <Library>/<Name>")+    })?;+    let url = format!(+        "{}/convert?library={}&name={}",+        service_url(),+        urlencode(lib),+        urlencode(name),+    );+    let job_name = format!("convert-lib-{name}");+    let r = set_identity(ureq::get(&url), &job_name)+        .timeout(Duration::from_secs(120))+        .call()+        .map_err(|e| format!("GET {url} failed: {e}"))?;+    let r = drive_to_result(r, &job_name)?;+    write_response(r, out_path)+}++fn write_response(r: ureq::Response, out_path: &Path) -> Result<ConvertResult, String> {+    let status = r.status();+    if status != 200 {+        let body = r.into_string().unwrap_or_default();+        return Err(format!("service returned HTTP {status}: {body}"));+    }+    let meshes = header_u32(&r, "X-Meshes");+    let nodes = header_u32(&r, "X-Nodes");+    let duration_ms = u64::from(header_u32(&r, "X-Duration-Ms"));+    let mats_json = r.header("X-Materials-Json").unwrap_or("[]").to_string();+    let materials: Vec<Material> = serde_json::from_str(&mats_json).unwrap_or_default();+    // Stream body to disk.+    let mut file = std::fs::File::create(out_path).map_err(|e| format!("create {}: {e}", out_path.display()))?;+    let mut reader = r.into_reader();+    let size_bytes = std::io::copy(&mut reader, &mut file).map_err(|e| format!("write {}: {e}", out_path.display()))?;+    file.flush().ok();+    Ok(ConvertResult { size_bytes, meshes, nodes, materials, duration_ms })+}++fn header_u32(r: &ureq::Response, name: &str) -> u32 {+    r.header(name).and_then(|s| s.parse().ok()).unwrap_or(0)+}++fn urlencode(s: &str) -> String {+    let mut out = String::with_capacity(s.len());+    for b in s.bytes() {+        if matches!(b, b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~') {+            out.push(b as char);+        } else {+            out.push_str(&format!("%{:02X}", b));+        }+    }+    out+}
src/install.rsadded+57
@@ -0,0 +1,57 @@+use crate::cli::InstallArgs;+use crate::ui::{ok, warn};+use clap::CommandFactory;+use std::borrow::Cow;+use std::fs;+use std::io::Write;+use std::path::PathBuf;+use std::time::Duration;++/// Bundled SKILL.md — used only when the wiki is unreachable.+/// SAFETY NET — do not rely on for release propagation.+const FALLBACK_SKILL_MD: &str = include_str!("../SKILL.md");++const SKILL_URL: &str = "https://wiki-ufypy5dpx93o.adom.cloud/static/apps/adom-step2glb/SKILL.md";++fn fetch_skill_md() -> Cow<'static, str> {+    match ureq::get(SKILL_URL).timeout(Duration::from_secs(5)).call() {+        Ok(r) => r.into_string().map(Cow::Owned).unwrap_or(Cow::Borrowed(FALLBACK_SKILL_MD)),+        Err(_) => {+            warn("wiki unreachable — installing bundled SKILL.md (may be stale)");+            Cow::Borrowed(FALLBACK_SKILL_MD)+        }+    }+}++pub fn run(_args: InstallArgs) {+    let home = std::env::var("HOME").unwrap_or_else(|_| "/home/adom".into());++    // 1. Skill file → ~/.claude/skills/adom-step2glb/SKILL.md (fetched fresh from wiki).+    let skill_dir = PathBuf::from(&home).join(".claude/skills/adom-step2glb");+    fs::create_dir_all(&skill_dir).expect("mkdir skill_dir");+    fs::write(skill_dir.join("SKILL.md"), fetch_skill_md().as_ref()).expect("write skill");+    ok(format!("skill installed at {}/SKILL.md", skill_dir.display()));++    // 2. Bash completions → ~/.local/share/bash-completion/completions/adom-step2glb+    let comp_dir = PathBuf::from(&home).join(".local/share/bash-completion/completions");+    fs::create_dir_all(&comp_dir).ok();+    let mut cmd = crate::cli::Cli::command();+    let mut buf = Vec::new();+    clap_complete::generate(clap_complete::Shell::Bash, &mut cmd, "adom-step2glb", &mut buf);+    fs::write(comp_dir.join("adom-step2glb"), &buf).ok();+    ok(format!("bash completions at {}/adom-step2glb", comp_dir.display()));++    // 3. .bashrc nudge (append once, idempotent).+    let bashrc_path = PathBuf::from(&home).join(".bashrc");+    let existing = fs::read_to_string(&bashrc_path).unwrap_or_default();+    if !existing.contains("# adom-step2glb completions") {+        if let Ok(mut f) = fs::OpenOptions::new().append(true).open(&bashrc_path) {+            let _ = f.write_all(b"\n# adom-step2glb completions\neval \"$(adom-step2glb completions bash 2>/dev/null)\"\n");+            ok(".bashrc: completion hook appended (open a new shell or `source ~/.bashrc`)");+        }+    }++    // No venv, no Python. That lives on the shared service-step2glb container now.+    println!();+    ok("adom-step2glb installed. Try `adom-step2glb health` to reach the shared service.");+}
src/main.rsadded+26
@@ -0,0 +1,26 @@+mod cli;+mod client;+mod install;+mod playground;+mod preview;+mod tab;+mod ui;++use clap::Parser;++fn main() {+    let args = cli::Cli::parse();+    match args.command {+        cli::Command::Health        => cli::health(),+        cli::Command::Config        => cli::config(),+        cli::Command::Convert(c)    => cli::convert_cmd(c),+        cli::Command::FromLibrary(c)=> cli::from_library_cmd(c),+        cli::Command::Features(c)   => cli::features_cmd(c),+        cli::Command::Thumbnail(c)  => cli::thumbnail_cmd(c),+        cli::Command::ThumbnailBatch(c) => cli::thumbnail_batch_cmd(c),+        cli::Command::Preview(c)    => cli::preview_cmd(c),+        cli::Command::ThumbPlayground(c) => playground::run(c),+        cli::Command::Install(c)    => install::run(c),+        cli::Command::Completions(c)=> cli::completions(c),+    }+}
src/playground.htmladded+216
@@ -0,0 +1,216 @@+<!doctype html>+<html><head><meta charset="utf-8"><title>step2glb — thumb playground</title>+<style>+  :root { color-scheme: dark; }+  * { box-sizing: border-box; }+  body { margin: 0; background: #0d1117; color: #c9d1d9; font: 13px/1.5 system-ui, -apple-system, sans-serif; min-height: 100vh; }+  header { padding: 14px 24px; border-bottom: 1px solid #30363d; display: flex; align-items: center; gap: 14px; }+  h1 { margin: 0; font-size: 16px; color: #e6edf3; font-weight: 600; }+  .pill { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 10px; letter-spacing: 0.04em; background: rgba(0,184,176,0.15); color: #00e6dc; border: 1px solid rgba(0,184,176,0.3); font-family: ui-monospace, monospace; }+  .meta { color: #7d8590; font-size: 11px; font-family: ui-monospace, monospace; margin-left: auto; }++  .drop {+    margin: 24px;+    padding: 60px 24px;+    border: 2px dashed #30363d;+    border-radius: 8px;+    background: #161b22;+    text-align: center;+    color: #8b949e;+    transition: border-color 0.15s, background 0.15s;+    cursor: pointer;+  }+  .drop.drag { border-color: #00b8b0; background: rgba(0,184,176,0.05); }+  .drop strong { color: #e6edf3; font-size: 16px; display: block; margin-bottom: 8px; }+  .drop input { display: none; }+  .drop .hint { font-size: 11px; color: #7d8590; margin-top: 12px; }++  .status { margin: 16px 24px; padding: 12px 16px; background: #161b22; border: 1px solid #30363d; border-radius: 6px; font-family: ui-monospace, monospace; font-size: 12px; display: none; }+  .status.show { display: block; }+  .status.err { border-color: rgba(248,81,73,0.4); color: #f85149; }+  .status.ok { color: #3fb950; }++  main { padding: 0 24px 24px; }+  section.chip { padding: 12px 0; }+  h2 { margin: 0 0 10px; font-size: 14px; color: #e6b450; font-family: ui-monospace, monospace; }+  /* Cells size to the chosen PNG dimensions so picking sm / md / lg+     actually shows the tile at its real rendered size — that's the+     point of the toggle. The grid wraps so lg lays out 1-2 per row,+     md 3-4, sm 6-8. */+  .row { display: grid; gap: 10px; }+  .row.sm { grid-template-columns: repeat(auto-fit, minmax(160px, max-content)); }+  .row.md { grid-template-columns: repeat(auto-fit, minmax(320px, max-content)); }+  .row.lg { grid-template-columns: repeat(auto-fit, minmax(600px, max-content)); }+  .cell { background: #161b22; border: 1px solid #30363d; border-radius: 4px; display: flex; flex-direction: column; cursor: pointer; transition: border-color 0.1s; }+  .cell:hover { border-color: #00b8b0; }+  .cell .label { padding: 4px 8px; font-size: 11px; color: #79c0ff; font-family: ui-monospace, monospace; border-bottom: 1px solid #30363d; background: #0d1117; display: flex; justify-content: space-between; align-items: center; }+  .cell .label .dim { color: #7d8590; font-size: 10px; }+  .cell .frame { padding: 6px; background: #000; display: flex; align-items: center; justify-content: center; }+  /* Render the PNG at its natural pixel dimensions; CSS doesn't shrink+     a 160×120 thumb up to 320×240 so the user actually sees the size+     difference. */+  .cell img { display: block; width: auto; height: auto; }+  .empty { color: #7d8590; font-style: italic; font-size: 11px; padding: 60px 0; }++  .sizes-toggle { margin: 8px 0 14px; display: inline-flex; gap: 4px; }+  .sizes-toggle button { background: #161b22; border: 1px solid #30363d; color: #8b949e; padding: 4px 10px; font-size: 11px; font-family: ui-monospace, monospace; border-radius: 4px; cursor: pointer; }+  .sizes-toggle button.active { background: rgba(0,184,176,0.15); border-color: rgba(0,184,176,0.3); color: #00e6dc; }+</style></head><body>+<header>+  <h1>step2glb thumb playground</h1>+  <span class="pill">/thumbnail-batch</span>+  <span class="meta" id="meta">drop a .step file to render 6 orientations × 3 sizes in one shot</span>+</header>++<div class="drop" id="drop">+  <strong>Drop a .step / .stp file here</strong>+  <span>or click to choose</span>+  <div class="hint">renders all 6 face-up orientations × 3 sizes (sm / md / lg) in a single service call</div>+  <input type="file" id="file" accept=".step,.stp">+</div>++<div class="status" id="status"></div>++<main id="main"></main>++<script>+const drop = document.getElementById('drop');+const file = document.getElementById('file');+const status = document.getElementById('status');+const main = document.getElementById('main');+const meta = document.getElementById('meta');++let selectedSize = 'md';++drop.addEventListener('click', () => file.click());+file.addEventListener('change', (e) => { if (e.target.files[0]) handleFile(e.target.files[0]); });+drop.addEventListener('dragover', (e) => { e.preventDefault(); drop.classList.add('drag'); });+drop.addEventListener('dragleave', () => drop.classList.remove('drag'));+drop.addEventListener('drop', (e) => {+  e.preventDefault(); drop.classList.remove('drag');+  if (e.dataTransfer.files[0]) handleFile(e.dataTransfer.files[0]);+});++async function handleFile(f) {+  setStatus(`rendering ${f.name} (${(f.size/1024).toFixed(1)} KB)…`, '');+  main.innerHTML = '';+  try {+    const buf = await f.arrayBuffer();+    const t0 = performance.now();+    const r = await fetch('render', { method: 'POST', body: buf });+    const json = await r.json();+    const wallMs = Math.round(performance.now() - t0);+    if (!json.ok) {+      setStatus(`error: ${json.error || 'render failed'}`, 'err');+      return;+    }+    render(f.name, json, wallMs);+    setStatus(+      `${json.thumbnailCount} thumbnails · service ${json.totalDurationMs} ms (load ${json.loadDurationMs} ms) · wall ${wallMs} ms`,+      'ok'+    );+  } catch (e) {+    setStatus(`error: ${e.message}`, 'err');+  }+}++function setStatus(text, kind) {+  status.textContent = text;+  status.className = 'status show' + (kind ? ' ' + kind : '');+}++function render(filename, json, wallMs) {+  const stem = filename.replace(/\.(step|stp)$/i, '');+  const orients = ['asIs', 'zDown', 'yUp', 'yDown', 'xUp', 'xDown'];+  const sizes = ['sm', 'md', 'lg'];++  // Group thumbnails by size for size-toggled view+  const byOrientSize = {};+  for (const t of json.thumbnails) {+    byOrientSize[t.upAxis + '/' + t.size] = t;+  }++  main.innerHTML = '';++  // Size toggles+  const sizesToggle = document.createElement('div');+  sizesToggle.className = 'sizes-toggle';+  sizesToggle.innerHTML = sizes.map(s =>+    `<button data-size="${s}" class="${s === selectedSize ? 'active' : ''}">${s}</button>`+  ).join('');+  sizesToggle.addEventListener('click', (e) => {+    if (e.target.tagName === 'BUTTON') {+      selectedSize = e.target.dataset.size;+      render(filename, json, wallMs);+    }+  });+  main.appendChild(sizesToggle);++  // Chip section: all 6 orientations at the chosen size+  const sec = document.createElement('section');+  sec.className = 'chip';+  const h2 = document.createElement('h2');+  h2.textContent = `${stem} — ${selectedSize}`;+  sec.appendChild(h2);+  const row = document.createElement('div');+  row.className = 'row ' + selectedSize;+  for (const orient of orients) {+    const t = byOrientSize[orient + '/' + selectedSize];+    const cell = document.createElement('div');+    cell.className = 'cell';+    const label = document.createElement('div');+    label.className = 'label';+    label.innerHTML = `${orient}<span class="dim">${t ? t.width + '×' + t.height : '—'}</span>`;+    cell.appendChild(label);+    const frame = document.createElement('div');+    frame.className = 'frame';+    if (t) {+      const img = document.createElement('img');+      img.src = 'data:image/png;base64,' + t.dataB64;+      img.alt = `${stem} ${orient}`;+      img.title = `${stem}-3d-iso${suffix(orient)}${sizeSuffix(t.size)}.png — click to download`;+      img.onclick = () => download(`${stem}-3d-iso${suffix(orient)}${sizeSuffix(t.size)}.png`, t.dataB64);+      frame.appendChild(img);+    } else {+      frame.innerHTML = '<span class="empty">missing</span>';+    }+    cell.appendChild(frame);+    row.appendChild(cell);+  }+  sec.appendChild(row);+  main.appendChild(sec);++  // bbox info+  if (json.bboxMm) {+    const bb = json.bboxMm;+    const dx = (bb[0][1] - bb[0][0]).toFixed(2);+    const dy = (bb[1][1] - bb[1][0]).toFixed(2);+    const dz = (bb[2][1] - bb[2][0]).toFixed(2);+    // 6 orientations × 3 sizes = 18 PNGs returned, 6 shown at a time+    meta.textContent = `bbox ${dx} × ${dy} × ${dz} mm — pose=${json.pose} — 6 orientations × 3 sizes (showing ${selectedSize})`;+  }++  if (json.renderWarnings && json.renderWarnings.length > 0) {+    const warn = document.createElement('div');+    warn.className = 'status show err';+    warn.style.margin = '12px 0';+    warn.innerHTML = '<strong>render warnings:</strong><br>' + json.renderWarnings.map(w => `• ${w}`).join('<br>');+    main.appendChild(warn);+  }+}++function suffix(orient) {+  return { asIs:'', zDown:'-zdown', yUp:'-yup', yDown:'-ydown', xUp:'-xup', xDown:'-xdown' }[orient] || '';+}+function sizeSuffix(size) {+  return { md:'', sm:'-sm', lg:'-lg' }[size] || '';+}++function download(filename, b64) {+  const a = document.createElement('a');+  a.href = 'data:image/png;base64,' + b64;+  a.download = filename;+  a.click();+}+</script>+</body></html>
src/playground.rsadded+116
@@ -0,0 +1,116 @@+//! Thumbnail playground — drag-drop a STEP into a Hydrogen webview, see all+//! 6 × 3 = 18 PNGs populate live. Hits the /thumbnail-batch endpoint via+//! a tiny local proxy (avoids CORS — webview can't talk to the external+//! service directly, has to go through the user's container-proxy URL).++use crate::client;+use crate::ui::{err, hint, ok, CYAN, RESET};+use std::io::Read;+use tiny_http::{Header, Method, Request, Response, Server};++const UI_HTML: &str = include_str!("playground.html");++#[derive(clap::Args, Debug)]+pub struct PlaygroundArgs {+    /// Port for the local HTTP server. 0 = auto-pick a free port.+    #[arg(long, default_value_t = 0)]+    pub port: u16,+    /// Skip opening the Hydrogen webview tab (still prints the proxy URL).+    #[arg(long)]+    pub no_tab: bool,+}++pub fn run(args: PlaygroundArgs) {+    let port = if args.port == 0 { pick_free_port() } else { args.port };+    let bind = format!("127.0.0.1:{port}");+    let server = match Server::http(&bind) {+        Ok(s) => s,+        Err(e) => {+            err(format!("bind {bind}: {e}"));+            std::process::exit(4);+        }+    };++    let url = proxy_url(port);+    if !args.no_tab {+        if let Err(e) = crate::tab::open_webview_tab("step2glb thumb playground", &url) {+            eprintln!("(warning) could not open Hydrogen tab: {e}");+        }+    }+    ok(format!(+        "thumb playground ready at {CYAN}{url}{RESET}  (Ctrl-C to stop)"+    ));++    for req in server.incoming_requests() {+        handle(req);+    }+}++fn handle(mut req: Request) {+    let full = req.url().to_string();+    let path = full.split('?').next().unwrap_or(&full).to_string();+    match (req.method(), path.as_str()) {+        (Method::Get, "/") | (Method::Get, "/index.html") => {+            let resp = Response::from_string(UI_HTML)+                .with_header(Header::from_bytes("Content-Type", "text/html; charset=utf-8").unwrap());+            let _ = req.respond(resp);+        }+        (Method::Post, "/render") => {+            // Body is raw STEP bytes (the page POSTs the file's ArrayBuffer).+            let mut body = Vec::with_capacity(1024 * 64);+            if let Err(e) = req.as_reader().read_to_end(&mut body) {+                let _ = req.respond(+                    Response::from_string(format!("read body: {e}")).with_status_code(400),+                );+                return;+            }+            // Forward to /thumbnail-batch with all-orientations, all-sizes.+            // Result includes 18 base64 PNGs.+            let result = client::thumbnail_batch_bytes(&body, "all", "all", "iso");+            let body_json = match result {+                Ok(r) => serde_json::to_string(&serde_json::json!({+                    "ok": true,+                    "srcKind": r.src_kind,+                    "pose": r.pose,+                    "bboxMm": r.bbox_mm,+                    "loadDurationMs": r.load_duration_ms,+                    "totalDurationMs": r.total_duration_ms,+                    "thumbnailCount": r.thumbnail_count,+                    "renderWarnings": r.render_warnings,+                    "thumbnails": r.thumbnails.iter().map(|t| serde_json::json!({+                        "upAxis": t.up_axis,+                        "size": t.size,+                        "width": t.width,+                        "height": t.height,+                        "sizeBytes": t.size_bytes,+                        "dataB64": t.data_b64,+                    })).collect::<Vec<_>>(),+                })).unwrap_or_default(),+                Err(e) => serde_json::json!({"ok": false, "error": e}).to_string(),+            };+            let resp = Response::from_string(body_json)+                .with_header(Header::from_bytes("Content-Type", "application/json").unwrap());+            let _ = req.respond(resp);+        }+        (Method::Post, "/shutdown") => {+            let _ = req.respond(Response::from_string("bye"));+            std::process::exit(0);+        }+        _ => {+            let _ = req.respond(Response::from_string("not found").with_status_code(404));+        }+    }+}++fn pick_free_port() -> u16 {+    let l = std::net::TcpListener::bind("127.0.0.1:0").expect("bind 0");+    l.local_addr().unwrap().port()+}++fn proxy_url(port: u16) -> String {+    if let Ok(proxy) = std::env::var("VSCODE_PROXY_URI") {+        proxy.replace("{{port}}", &port.to_string())+    } else {+        format!("http://127.0.0.1:{port}/")+    }+}
src/preview.rsadded+153
@@ -0,0 +1,153 @@+//! Preview mode — fetch GLB via the shared service, stash it locally, then+//! spawn a tiny HTTP server that the Hydrogen webview can load (webviews+//! can't reach the external service directly; they need the user's own+//! container-proxy URL).++use crate::cli::PreviewArgs;+use crate::client;+use crate::ui::{err, hint, ok, CYAN, RESET};+use serde_json::json;+use std::fs;+use std::path::PathBuf;+use std::sync::{Arc, Mutex};+use tiny_http::{Header, Method, Request, Response, Server, StatusCode};++const UI_HTML: &str = include_str!("ui.html");++struct State {+    glb_path: PathBuf,+    source_label: String,+    conversion: serde_json::Value,+}++pub fn run(args: PreviewArgs) {+    let (glb_path, conversion_json, source_label) = match resolve(&args.source) {+        Ok(v) => v,+        Err(e) => {+            err(format!("preview failed: {e}"));+            hint("Run `step2glb health` to verify the service is reachable.");+            std::process::exit(4);+        }+    };++    let port = if args.port == 0 { pick_free_port() } else { args.port };+    let bind = format!("127.0.0.1:{port}");+    let server = match Server::http(&bind) {+        Ok(s) => s,+        Err(e) => { err(format!("bind {bind}: {e}")); std::process::exit(4); }+    };+    let state = Arc::new(Mutex::new(State { glb_path, source_label: source_label.clone(), conversion: conversion_json }));++    let url = proxy_url(port);+    if !args.no_tab {+        let name = format!("step2glb — {}", short_label(&source_label));+        if let Err(e) = crate::tab::open_webview_tab(&name, &url) {+            eprintln!("(warning) could not open Hydrogen tab: {e}");+        }+    }+    ok(format!("preview ready at {CYAN}{url}{RESET}  (Ctrl-C to stop)"));++    for req in server.incoming_requests() {+        handle(req, &state);+    }+}++fn resolve(source: &str) -> Result<(PathBuf, serde_json::Value, String), String> {+    let as_path = PathBuf::from(source);+    if as_path.exists() {+        let out = PathBuf::from(format!("/tmp/step2glb-preview-{}.glb",+            as_path.file_stem().map(|s|s.to_string_lossy().to_string()).unwrap_or_else(||"in".into())));+        let bytes = fs::read(&as_path).map_err(|e| format!("read {}: {e}", as_path.display()))?;+        let r = client::convert_bytes(&bytes, &out)?;+        let label = as_path.file_name().map(|n|n.to_string_lossy().to_string()).unwrap_or_else(||source.to_string());+        Ok((out.clone(), conversion_to_json(&r, &out), label))+    } else {+        let leaf = source.rsplit('/').next().unwrap_or("part");+        let out = PathBuf::from(format!("/tmp/step2glb-preview-{leaf}.glb"));+        let r = client::convert_from_library(source, &out)?;+        Ok((out.clone(), conversion_to_json(&r, &out), source.to_string()))+    }+}++fn conversion_to_json(r: &client::ConvertResult, out: &std::path::Path) -> serde_json::Value {+    json!({+        "output": out.to_string_lossy(),+        "sizeBytes": r.size_bytes,+        "meshes": r.meshes,+        "nodes": r.nodes,+        "materials": r.materials,+        "durationMs": r.duration_ms,+    })+}++fn pick_free_port() -> u16 {+    let l = std::net::TcpListener::bind("127.0.0.1:0").expect("bind 0");+    l.local_addr().unwrap().port()+}++fn proxy_url(port: u16) -> String {+    if let Ok(proxy) = std::env::var("VSCODE_PROXY_URI") {+        proxy.replace("{{port}}", &port.to_string())+    } else {+        format!("http://127.0.0.1:{port}/")+    }+}++fn short_label(s: &str) -> String {+    if s.len() > 40 { format!("…{}", &s[s.len() - 37..]) } else { s.to_string() }+}++fn handle(req: Request, state: &Arc<Mutex<State>>) {+    let full = req.url().to_string();+    let path = full.split('?').next().unwrap_or(&full).to_string();+    match (req.method(), path.as_str()) {+        (Method::Get, "/") | (Method::Get, "/index.html") => {+            let resp = Response::from_string(UI_HTML)+                .with_header(Header::from_bytes("Content-Type", "text/html; charset=utf-8").unwrap());+            let _ = req.respond(resp);+        }+        (Method::Get, "/meta") => {+            let s = state.lock().unwrap();+            let body = json!({+                "source": s.source_label,+                "glbPath": s.glb_path.to_string_lossy(),+                "conversion": s.conversion,+            }).to_string();+            let resp = Response::from_string(body)+                .with_header(Header::from_bytes("Content-Type", "application/json").unwrap());+            let _ = req.respond(resp);+        }+        (Method::Get, "/model.glb") => {+            let path = state.lock().unwrap().glb_path.clone();+            match fs::File::open(&path) {+                Ok(f) => {+                    let len = f.metadata().map(|m| m.len() as usize).unwrap_or(0);+                    let resp = Response::new(+                        StatusCode(200),+                        vec![+                            Header::from_bytes("Content-Type", "model/gltf-binary").unwrap(),+                            Header::from_bytes("Content-Length", len.to_string()).unwrap(),+                        ],+                        f,+                        Some(len),+                        None,+                    );+                    let _ = req.respond(resp);+                }+                Err(e) => {+                    let _ = req.respond(Response::from_string(format!("open {path:?}: {e}")).with_status_code(500));+                }+            }+        }+        (Method::Post, "/console") => {+            let _ = req.respond(Response::empty(204));+        }+        (Method::Post, "/shutdown") => {+            let _ = req.respond(Response::from_string("bye"));+            std::process::exit(0);+        }+        _ => {+            let _ = req.respond(Response::from_string("not found").with_status_code(404));+        }+    }+}
src/tab.rsadded+56
@@ -0,0 +1,56 @@+use std::process::Command;++/// Open a Hydrogen webview tab pointing at the preview URL, on a non-VS-Code pane.+/// Mirrors the adom-workspace-control skill — never land on the pane hosting VS Code.+pub fn open_webview_tab(name: &str, url: &str) -> Result<(), String> {+    // Resolve a non-VS-Code panel id via adom-cli.+    let state = Command::new("adom-cli")+        .args(["hydrogen", "workspace", "state"])+        .output()+        .map_err(|e| format!("adom-cli not available: {e}"))?;+    if !state.status.success() {+        return Err(format!(+            "adom-cli state failed: {}",+            String::from_utf8_lossy(&state.stderr)+        ));+    }+    let panel_id = first_non_vscode_panel(&String::from_utf8_lossy(&state.stdout));++    let mut cmd = Command::new("adom-cli");+    cmd.args([+        "hydrogen", "workspace", "add-tab",+        "--panel-type", "adom/a1b2c3d4-0031-4000-a000-000000000031",+        "--display-name", name,+        "--url", url,+    ]);+    if let Some(pid) = panel_id {+        cmd.args(["--panel-id", &pid]);+    }+    let out = cmd.output().map_err(|e| format!("add-tab exec: {e}"))?;+    if !out.status.success() {+        return Err(format!(+            "add-tab failed: {}",+            String::from_utf8_lossy(&out.stderr)+        ));+    }+    Ok(())+}++fn first_non_vscode_panel(state_json: &str) -> Option<String> {+    let v: serde_json::Value = serde_json::from_str(state_json).ok()?;+    let panels = v.get("panels")?.as_array()?;+    for p in panels {+        let id = p.get("id")?.as_str()?;+        let tabs = p.get("tabs").and_then(|t| t.as_array()).cloned().unwrap_or_default();+        let hosts_vscode = tabs.iter().any(|t| {+            t.get("panelType")+                .and_then(|s| s.as_str())+                .map(|s| s.contains("vscode"))+                .unwrap_or(false)+        });+        if !hosts_vscode {+            return Some(id.to_string());+        }+    }+    None+}
src/ui.htmladded+962
@@ -0,0 +1,962 @@+<!DOCTYPE html>+<html lang="en">+<head>+<meta charset="utf-8">+<title>step2glb preview</title>+<style>+:root {+  --bg: #0d1117; --surface: #161b22; --surface2: #1c2128;+  --border: #30363d; --overlay: #21262d; --text: #e6edf3; --text-dim: #8b949e;+  --accent: #00b8b0; --red: #f85149; --yellow: #d29922; --green: #3fb950;+}+* { box-sizing: border-box; margin: 0; padding: 0; }+html, body { height: 100%; width: 100%; background: var(--bg); color: var(--text);+  font-family: 'Satoshi', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;+  font-size: 13px; overflow: hidden; }+.app {+  position: fixed; inset: 0;+  display: grid; grid-template-rows: auto minmax(0, 1fr) auto;+  overflow: hidden;+}+header { background: var(--surface); border-bottom: 1px solid var(--border);+  padding: 10px 18px; display: flex; align-items: center; gap: 18px; min-height: 44px; flex-wrap: wrap; }+header h1 { font-family: 'Familjen Grotesk', sans-serif; font-weight: 600; font-size: 15px;+  display: inline-flex; align-items: center; gap: 10px; }+header h1 .logo { display: inline-flex; width: 26px; height: 26px; background: rgba(0,184,176,0.1);+  border: 1px solid rgba(0,184,176,0.35); border-radius: 7px; align-items: center; justify-content: center; color: var(--text); }+header h1 .logo svg { width: 16px; height: 16px; }+header .meta { color: var(--text-dim); font-size: 11.5px; font-family: 'JetBrains Mono', monospace;+  display: inline-flex; gap: 8px; align-items: baseline; padding: 3px 9px;+  background: var(--surface2); border: 1px solid var(--border); border-radius: 6px; }+header .meta .label { font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-dim); }+header .meta .val { color: var(--text); }++/* §3b/§3c: vertical gradient — brightened top per 2026-04-24 field feedback+   so black chips / black solder mask / black plastic read clearly against the+   background. Do NOT darken the top back toward --overlay (#21262d) — that+   regresses the black-chip-visibility problem. */+#canvas-wrap {+  position: relative; min-height: 0; min-width: 0; overflow: hidden;+  background:+    radial-gradient(ellipse 60% 40% at 50% 100%, rgba(0, 184, 177, 0.04), transparent 70%),+    linear-gradient(180deg, #3e4a5c 0%, var(--bg) 80%);+}+#canvas { width: 100%; height: 100%; min-height: 0; min-width: 0; max-width: 100%; max-height: 100%;+  display: block; outline: none; background: transparent; }++#hud { position: absolute; top: 12px; left: 12px; display: flex; gap: 6px; flex-wrap: wrap; z-index: 10; }+.pill { display: inline-flex; align-items: center; gap: 6px; padding: 5px 10px;+  background: rgba(22,27,34,0.92); border: 1px solid var(--border); border-radius: 999px;+  font-family: 'JetBrains Mono', monospace; font-size: 10.5px; color: var(--text-dim); letter-spacing: 0.02em; }+.pill .dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--text-dim); }+.pill.ok .dot { background: var(--green); } .pill.ok .lbl { color: var(--green); }+.pill.warn .dot { background: var(--yellow); } .pill.warn .lbl { color: var(--yellow); }+.pill.bad .dot { background: var(--red); } .pill.bad .lbl { color: var(--red); }+.pill .val { color: var(--text); }++/* §7: ViewCube-style panel, top-right under the drawer toggle. */+#viewcube {+  position: absolute; top: 12px; right: 120px; z-index: 10;+  display: grid; grid-template-columns: repeat(4, auto); gap: 4px;+  background: rgba(22,27,34,0.92); border: 1px solid var(--border); border-radius: 8px;+  padding: 6px;+}+#viewcube button {+  font-family: 'JetBrains Mono', monospace; font-size: 10.5px;+  background: var(--surface2); color: var(--text-dim);+  border: 1px solid var(--border); border-radius: 5px;+  padding: 4px 8px; min-width: 42px; cursor: pointer;+  transition: color 120ms ease, border-color 120ms ease, background 120ms ease;+}+#viewcube button:hover { color: var(--text); border-color: var(--accent); }+#viewcube button.active { color: var(--accent); border-color: var(--accent); background: rgba(0,184,176,0.1); }++#drawer-toggle { position: absolute; top: 12px; right: 12px; z-index: 11;+  background: rgba(22,27,34,0.92); border: 1px solid var(--border); border-radius: 8px;+  padding: 6px 12px; font-size: 12px; color: var(--text); cursor: pointer; }+#drawer-toggle:hover { border-color: var(--accent); background: rgba(0,184,176,0.1); }+#drawer { position: absolute; top: 0; right: 0; bottom: 0; width: 380px; background: rgba(13,17,23,0.97);+  border-left: 1px solid var(--border); padding: 54px 16px 16px; overflow-y: auto;+  font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--text-dim); line-height: 1.55;+  transform: translateX(100%); transition: transform 220ms ease; z-index: 9; }+#drawer.open { transform: translateX(0); }+#drawer h2 { font-family: 'Familjen Grotesk', sans-serif; font-size: 11px; color: var(--accent);+  letter-spacing: 1.5px; text-transform: uppercase; margin: 0 0 8px; font-weight: 600; }+#drawer .section { margin-bottom: 18px; }+#drawer .row { display: flex; justify-content: space-between; gap: 14px; padding: 1px 0; }+#drawer .row .val { color: var(--text); }+.mat-row { display: flex; align-items: center; gap: 10px; padding: 6px 0; border-bottom: 1px solid rgba(48,54,61,0.4); }+.mat-row:last-child { border-bottom: none; }+.swatch { width: 22px; height: 22px; border-radius: 4px; border: 1px solid var(--border); flex-shrink: 0; }+.mat-row .mat-name { font-size: 10.5px; color: var(--text); }+.mat-row .mat-details { font-size: 10px; color: var(--text-dim); }++footer { background: var(--surface); border-top: 1px solid var(--border); padding: 8px 16px;+  display: flex; gap: 8px; align-items: center; font-size: 12px; color: var(--text-dim); min-height: 44px; flex-wrap: wrap; }+footer .btn {+  background: var(--surface2); border: 1px solid var(--border); border-radius: 6px;+  color: var(--text-dim); padding: 5px 10px; font-size: 11.5px; font-family: 'Satoshi', sans-serif;+  cursor: pointer; transition: color 120ms ease, border-color 120ms ease, background 120ms ease;+}+footer .btn:hover { color: var(--text); border-color: var(--accent); }+footer .btn.active { color: var(--accent); border-color: var(--accent); background: rgba(0,184,176,0.1); }+footer .spacer { flex: 1; }+footer select.bg-preset {+  background: var(--surface2); color: var(--text-dim); border: 1px solid var(--border);+  border-radius: 6px; padding: 5px 8px; font-size: 11px;+}+footer .kbd-hint { font-family: 'JetBrains Mono', monospace; font-size: 10.5px; color: var(--text-dim); }+.kbd { font-family: 'JetBrains Mono', monospace; background: var(--surface2); border: 1px solid var(--border);+  border-radius: 3px; padding: 1px 6px; font-size: 11px; color: var(--text); }+</style>+<script src="https://cdn.babylonjs.com/babylon.js"></script>+<script src="https://cdn.babylonjs.com/loaders/babylonjs.loaders.min.js"></script>+</head>+<body>+<div class="app">+  <header>+    <h1>+      <span class="logo" aria-hidden="true">+        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">+          <path d="M3 7l9-4 9 4-9 4-9-4z"/><path d="M3 7v10l9 4 9-4V7"/><path d="M12 11v10"/>+        </svg>+      </span>+      step2glb+    </h1>+    <span class="meta"><span class="label">source</span><span class="val" id="meta-source">…</span></span>+    <span class="meta"><span class="label">glb</span><span class="val" id="meta-glb">…</span></span>+  </header>+  <div id="canvas-wrap">+    <div id="hud">+      <span class="pill" id="pill-meshes"><span class="dot"></span><span class="lbl">meshes</span><span class="val" id="p-meshes">—</span></span>+      <span class="pill" id="pill-materials"><span class="dot"></span><span class="lbl">materials</span><span class="val" id="p-materials">—</span></span>+      <span class="pill" id="pill-size"><span class="dot"></span><span class="lbl">size</span><span class="val" id="p-size">—</span></span>+      <span class="pill" id="pill-duration"><span class="dot"></span><span class="lbl">conv</span><span class="val" id="p-duration">—</span></span>+    </div>++    <!-- §7 ViewCube. Matches the Z-up preset angles the canonical viewer uses. -->+    <div id="viewcube" aria-label="View presets — click a face to tween the camera there">+      <button data-view="top"    title="Top view">Top</button>+      <button data-view="front"  title="Front view">Front</button>+      <button data-view="right"  title="Right view">Right</button>+      <button data-view="iso"    title="Isometric view">Iso</button>+      <button data-view="bottom" title="Bottom view">Btm</button>+      <button data-view="back"   title="Back view">Back</button>+      <button data-view="left"   title="Left view">Left</button>+      <button data-view="fit"    title="Fit to model">Fit</button>+    </div>++    <button id="drawer-toggle" onclick="toggleDrawer()">≡ Details</button>+    <div id="drawer">+      <div class="section">+        <h2>Conversion</h2>+        <div class="row"><span>source</span><span class="val" id="d-source">—</span></div>+        <div class="row"><span>glb path</span><span class="val" id="d-glb">—</span></div>+        <div class="row"><span>size</span><span class="val" id="d-size">—</span></div>+        <div class="row"><span>duration</span><span class="val" id="d-duration">—</span></div>+        <div class="row"><span>meshes</span><span class="val" id="d-meshes">—</span></div>+        <div class="row"><span>nodes</span><span class="val" id="d-nodes">—</span></div>+      </div>+      <div class="section">+        <h2>Materials (from STEP colors)</h2>+        <div id="materials-list">…</div>+      </div>+      <div class="section">+        <h2>Keyboard</h2>+        <div class="row"><span>Left-drag</span><span class="val">Orbit (teal sphere shows pivot)</span></div>+        <div class="row"><span>Right-drag</span><span class="val">Pan</span></div>+        <div class="row"><span>Scroll</span><span class="val">Zoom to cursor</span></div>+        <div class="row"><span>Shift+Alt+Click</span><span class="val">Move rotation pivot</span></div>+        <div class="row"><span>A</span><span class="val">Toggle world axes</span></div>+        <div class="row"><span>Shift+A</span><span class="val">Toggle mesh-local origins</span></div>+        <div class="row"><span>T / F / R / I</span><span class="val">Top / Front / Right / Iso</span></div>+        <div class="row"><span>B / L</span><span class="val">Back / Left</span></div>+      </div>+    </div>+    <canvas id="canvas" touch-action="none"></canvas>+  </div>+  <footer>+    <button class="btn active" id="btn-bottom-light"+            title="Boost hemispheric ground color so you can see under chips (§4b)."+            onclick="toggleBottomLight()">Bottom light</button>+    <button class="btn active" id="btn-ground"+            title="Show the medium-gray ground plane. 65% transparent so you can see the chip's bottom surface through it; receives a soft shadow from the chip so depth reads correctly."+            onclick="toggleGround()">Show ground</button>+    <button class="btn active" id="btn-axes"+            title="Always show the world origin (R=X, G=Y, B=Z). Useful when debugging alignment of chips, footprints, and molecules whose local origins may differ. Shortcut: A"+            onclick="toggleAxes('world')">Show axes</button>+    <button class="btn" id="btn-mesh-axes"+            title="Show each loaded mesh's local origin — reveals Y-up / centroid / pad-1 origin mismatches on load. Shortcut: Shift+A"+            onclick="toggleAxes('mesh-local')">Show mesh origins</button>+    <label for="bg-preset" class="kbd-hint">Bg:</label>+    <select id="bg-preset" class="bg-preset" onchange="setBgPreset(this.value)">+      <option value="studio">Studio gradient</option>+      <option value="dark">Dark flat</option>+      <option value="light">Light flat</option>+    </select>+    <div class="spacer"></div>+    <span class="kbd-hint">Drag (teal sphere = pivot) · <span class="kbd">Shift+Alt+Click</span> moves pivot · <span class="kbd">A</span> axes · <span class="kbd">Shift+A</span> mesh origins</span>+  </footer>+</div>++<script>+// ── console forwarding — per §9c ───────────────────────────────────+(function () {+  const orig = { log: console.log, warn: console.warn, error: console.error };+  ['log','warn','error'].forEach(level => {+    console[level] = function () {+      try {+        fetch('console', { method: 'POST', headers: {'Content-Type':'application/json'},+          body: JSON.stringify({ level, ts: Date.now(), message: Array.from(arguments).map(String).join(' ') }) }).catch(()=>{});+        if (window.parent && window.parent !== window) {+          window.parent.postMessage({ source: 'step2glb', type: 'console', level, message: Array.from(arguments).map(String).join(' ') }, '*');+        }+      } catch (_) {}+      orig[level].apply(console, arguments);+    };+  });+  window.addEventListener('error', e => console.error('window.onerror:', e.message, e.filename, e.lineno));+})();++const canvas = document.getElementById('canvas');+const engine = new BABYLON.Engine(canvas, true, { preserveDrawingBuffer: true, stencil: true });+const scene = new BABYLON.Scene(engine);++// §3: transparent clearColor so the CSS gradient shows through.+scene.clearColor = new BABYLON.Color4(0, 0, 0, 0);+scene.useRightHandedSystem = true;++// §10: the canonical viewer's transform — GLB spec is Y-up, Adom is Z-up.+// Handled at load-time below by parenting the GLB under a Z-up-swapped root.++// ── Lighting (§4a + §4c fallback) ─────────────────────────────────+// TODO: once gallia/viewer ships the HDRI at a publicly-fetchable URL+// (or step2glb uploads `environmentSpecular.env` as a wiki asset), wire+// `scene.environmentTexture = BABYLON.CubeTexture.CreateFromPrefilteredData(...)`+// with an onError hook that detaches the texture on load failure. Attempting+// the load with an invalid URL leaves a broken-texture placeholder+// rendered as a red-orange cube, which is worse than no env texture at all.+// Stay hemispheric-only until that asset exists.+const envFallback = true;+const hemi = new BABYLON.HemisphericLight('hemi', new BABYLON.Vector3(0, 0, 1), scene);+hemi.intensity   = 0.9;+hemi.diffuse     = BABYLON.Color3.FromHexString('#e6edf3');+hemi.groundColor = new BABYLON.Color3(0.2, 0.18, 0.15);++// §4c fallback studio rig — key + fill + rim (teal-tinted).+const keyLight = new BABYLON.DirectionalLight('key', new BABYLON.Vector3(-0.4, -0.5, -0.8).normalize(), scene);+keyLight.intensity = 0.9; keyLight.diffuse = BABYLON.Color3.FromHexString('#ffffff');+const fillLight = new BABYLON.DirectionalLight('fill', new BABYLON.Vector3(0.5, 0.4, -0.2).normalize(), scene);+fillLight.intensity = 0.35; fillLight.diffuse = BABYLON.Color3.FromHexString('#e6edf3');+const rimLight = new BABYLON.DirectionalLight('rim', new BABYLON.Vector3(0, 1, -0.3).normalize(), scene);+rimLight.intensity = 0.25; rimLight.diffuse = BABYLON.Color3.FromHexString('#00b8b0');++// §Ground — matches the canonical viewer's `_ground` pattern+// (gallia/viewer/fp-to-3d.js:472–488) + real shadows via ShadowGenerator.+// Medium blue-gray diffuse so black chips don't vanish into the ground,+// α=0.35 (~65% transparent) so the user can see the chip's bottom surface+// through it (critical for "is the chip actually seated or hovering?"),+// specular matte, small emissive so the ground stays visible when the+// directional key light's far side is dim, backface-culling off so it's+// visible from below too. receiveShadows=true pairs with the+// ShadowGenerator fed by the key directional light (keyLight, created+// earlier). Chip meshes (GLB-loaded) are added to the shadow render list+// after load so they cast onto the ground.+let shadowGround = null;+let shadowGen = null;+function buildShadowGround() {+  if (shadowGround) return;+  shadowGround = BABYLON.MeshBuilder.CreatePlane('shadowGround',+    { size: 1, sideOrientation: BABYLON.Mesh.DOUBLESIDE }, scene);+  shadowGround.position.z = -0.01;   // just below z=0 to avoid z-fight with pads/seat planes+  shadowGround.isPickable = false;+  shadowGround.renderingGroupId = 0;+  shadowGround.receiveShadows = true;+  // Canonical uses StandardMaterial for shadow reception — Babylon's shadow+  // system is mature on StandardMaterial and this is the one documented+  // exception to the §5a PBR-default rule (alongside the laser-etch overlay+  // in §5c). PBR shadow reception works but is fussier.+  const mat = new BABYLON.StandardMaterial('shadowGroundMat', scene);+  mat.diffuseColor  = new BABYLON.Color3(0.45, 0.47, 0.50);+  mat.specularColor = new BABYLON.Color3(0.05, 0.05, 0.05);+  // Keep emissive low so cast shadows can actually darken the ground —+  // too much emissive washes shadows out. Canonical fp-to-3d uses 0.12+  // across, but fp-to-3d has a more varied light rig; for step2glb a+  // darker ground lets the shadow read more clearly.+  mat.emissiveColor = new BABYLON.Color3(0.04, 0.045, 0.05);+  mat.alpha = 0.35;                  // ~65% transparent — see chip bottom through it+  mat.backFaceCulling = false;+  shadowGround.material = mat;++  // Key light casts a shadow onto the ground. Previously created as an+  // unconfigured DirectionalLight — repoint it through a ShadowGenerator.+  if (!shadowGen) {+    shadowGen = new BABYLON.ShadowGenerator(2048, keyLight);+    shadowGen.useBlurExponentialShadowMap = true;   // soft edges+    shadowGen.blurScale = 2;+    shadowGen.blurBoxOffset = 2;+    shadowGen.setDarkness(0.25);                     // 0 = jet black (strong), 1 = no shadow. Low = crisp.+    // Give the key light a sensible shadow frustum.+    keyLight.shadowMinZ = 0.1;+    keyLight.shadowMaxZ = 200;+    // Re-aim the key light so its shadow casts directly below the model+    // rather than off to one side — the iso view sees the full contact+    // shadow instead of a long diagonal one falling off the ground.+    keyLight.direction = new BABYLON.Vector3(-0.2, -0.2, -0.95).normalize();+  }+}+function resizeShadowGround() {+  if (!shadowGround) return;+  // Plane size = ~4× scene diameter so shadows have room to fade and the+  // ground extends well past any inspection zoom.+  const s = Math.max(sceneExtentMm * 4, 50);+  shadowGround.scaling = new BABYLON.Vector3(s, s, 1);+}+buildShadowGround();++function addChipShadowCasters(beforeIds) {+  if (!shadowGen) return;+  const list = shadowGen.getShadowMap().renderList;+  scene.meshes.forEach(m => {+    if (beforeIds && beforeIds.has(m.uniqueId)) return;+    if (!m.getTotalVertices || m.getTotalVertices() === 0) return;+    if (list.indexOf(m) < 0) list.push(m);+  });+}++let bottomLightOn = true;+function setBottomLight(enabled) {+  bottomLightOn = enabled;+  if (enabled) {+    hemi.groundColor = new BABYLON.Color3(0.7, 0.65, 0.55);+    hemi.intensity   = Math.max(hemi.intensity, 1.2);+  } else {+    hemi.groundColor = new BABYLON.Color3(0.2, 0.18, 0.15);+    hemi.intensity   = envFallback ? 0.9 : 0.6;+  }+  document.getElementById('btn-bottom-light').classList.toggle('active', enabled);+}+window.toggleBottomLight = () => setBottomLight(!bottomLightOn);+setBottomLight(true);++let groundVisible = true;+function setGroundVisible(on) {+  groundVisible = !!on;+  if (shadowGround) shadowGround.setEnabled(groundVisible);+  const b = document.getElementById('btn-ground');+  if (b) b.classList.toggle('active', groundVisible);+}+window.toggleGround = () => setGroundVisible(!groundVisible);++// ── Camera (§6) — zoomToMouseLocation, soft limits, stripped behaviors ─+const camera = new BABYLON.ArcRotateCamera('cam', -Math.PI/4, Math.PI/3, 40,+  BABYLON.Vector3.Zero(), scene);+camera.upVector = new BABYLON.Vector3(0, 0, 1);  // Z-up+camera.attachControl(canvas, true);+// §6d: FOV 35–40° — tighter than Babylon's ~45° default. Gives a more+// orthographic-feeling inspection camera and reduces chip-body+// fisheye distortion when zoomed close.+camera.fov = 0.6;  // ~34.4°++// §6b — strip every behavior before limits apply+while (camera.behaviors && camera.behaviors.length > 0) camera.removeBehavior(camera.behaviors[0]);+camera.useFramingBehavior = false;+camera.zoomToMouseLocation = true;   // §6a — the non-negotiable++// All distances in mm. GLBs are scaled 1000× on load (meters → mm) so these+// numbers match the canonical viewer's defaults without per-app tweaking.+camera.lowerRadiusLimit = 0.1;+camera.upperRadiusLimit = 200;+camera.lowerBetaLimit   = 0.01;+camera.upperBetaLimit   = Math.PI - 0.01;+// Wheel / pan / pinch precision are recomputed per-load in fitCameraToScene+// using the canonical adaptive formula (wheelPrecision = 100/R,+// panningSensibility = 5000/R). These placeholders are only active until+// the first GLB lands. Hard-coded values tuned for one scene size feel+// 30% too slow on a small chip or 300% too fast on a large assembly;+// the adaptive formula keeps the feel consistent across any model.+camera.wheelPrecision     = 10;+camera.pinchPrecision     = 50;+camera.panningSensibility = 500;+camera.minZ = 0.01;+camera.maxZ = 1000;++// ── §6c / v1.1 — persistent teal pivot sphere ──────────────────────+// Single PBR-unlit sphere. Shown during every left-drag rotate so the user+// forms the mental link "teal sphere = rotation center"; that training is+// what makes Shift+Alt+Click discoverable. The SAME sphere doubles as the+// recenter-flash — consistent visual language, not two different markers.+let pivotSphere = null;+let pivotHideTimer = null;+let sceneExtentMm = 50;  // updated by fitCameraToScene()++function ensurePivotSphere() {+  if (pivotSphere && !pivotSphere.isDisposed()) return pivotSphere;+  const diameter = Math.max(sceneExtentMm * 0.01, 0.2);+  pivotSphere = BABYLON.MeshBuilder.CreateSphere('pivot', { diameter }, scene);+  pivotSphere.isPickable = false;+  // Depth-tested (renderingGroupId 0), not xray. If the pivot is inside a+  // solid chip body the sphere will be occluded — that's the correct+  // signal to the human: "your rotation center is buried in the model."+  // The user then Shift+Alt+Clicks a surface point to move the pivot+  // somewhere visible.+  pivotSphere.renderingGroupId = 0;+  // §5a PBR-default. `unlit` makes it a pure emissive marker without adding+  // a StandardMaterial — self-lit, ignores HDRI, correct class per the skill.+  const mat = new BABYLON.PBRMaterial('pivotMat', scene);+  mat.unlit = true;+  mat.emissiveColor = BABYLON.Color3.FromHexString('#00b8b0');+  mat.albedoColor   = BABYLON.Color3.FromHexString('#00b8b0');+  mat.alpha = 0.7;+  mat.transparencyMode = BABYLON.PBRMaterial.PBRMATERIAL_ALPHABLEND;+  pivotSphere.material = mat;+  pivotSphere.setEnabled(false);+  // Track cam.target each frame so the sphere moves with the pivot even mid-tween.+  scene.onBeforeRenderObservable.add(() => {+    if (pivotSphere && pivotSphere.isEnabled()) pivotSphere.position.copyFrom(camera.target);+  });+  return pivotSphere;+}+function showPivot() {+  ensurePivotSphere();+  if (pivotHideTimer) { clearTimeout(pivotHideTimer); pivotHideTimer = null; }+  pivotSphere.position.copyFrom(camera.target);+  pivotSphere.setEnabled(true);+}+function hidePivot(delayMs) {+  if (pivotHideTimer) clearTimeout(pivotHideTimer);+  pivotHideTimer = setTimeout(() => {+    if (pivotSphere) pivotSphere.setEnabled(false);+    pivotHideTimer = null;+  }, delayMs == null ? 400 : delayMs);+}++// Show pivot during EVERY left-drag rotate — the key bit of §6c v1.1.+canvas.addEventListener('pointerdown', (e) => {+  if (e.button === 0 && !e.shiftKey && !e.altKey) showPivot();+});+canvas.addEventListener('pointerup',   () => hidePivot(400));+canvas.addEventListener('pointerleave',() => hidePivot(400));++// ── §6c Shift+Alt+Click orbit recenter — reuses the same pivot sphere ─+function attachOrbitCenterRecenter() {+  canvas.addEventListener('pointerdown', (e) => {+    if (e.button !== 0 || !e.shiftKey || !e.altKey) return;+    e.preventDefault();+    const pick = scene.pick(scene.pointerX, scene.pointerY);+    let newTarget = null;+    if (pick && pick.hit && pick.pickedPoint) {+      newTarget = pick.pickedPoint.clone();+    } else {+      const ray = scene.createPickingRay(scene.pointerX, scene.pointerY, null, camera);+      for (const planeZ of [camera.target.z, 0]) {+        if (Math.abs(ray.direction.z) < 1e-6) continue;+        const t = (planeZ - ray.origin.z) / ray.direction.z;+        if (t > 0 && t < 1e6) { newTarget = ray.origin.add(ray.direction.scale(t)); break; }+      }+    }+    if (!newTarget) return;++    const ease = new BABYLON.CubicEase();+    ease.setEasingMode(BABYLON.EasingFunction.EASINGMODE_EASEINOUT);+    BABYLON.Animation.CreateAndStartAnimation(+      'orbitRecenter', camera, 'target',+      60, 12,+      camera.target.clone(), newTarget,+      BABYLON.Animation.ANIMATIONLOOPMODE_CONSTANT,+      ease+    );+    // Same sphere as drag-rotate; linger 600 ms so the user sees where the+    // pivot landed. §6c v1.1: "one sphere, one meaning."+    showPivot();+    hidePivot(600);+  });+}+attachOrbitCenterRecenter();++// ── §8a world-origin axis helper (default ON) ──────────────────────+// The gizmo is DEPTH-TESTED, not always-on-top. If the origin is inside a+// solid chip body, the body occludes the gizmo — that's a feature, not a+// bug: it's how the human tells whether the origin sits on the surface,+// below the seat plane, or buried inside the package. Contrast with+// "xray" / "draw-on-top" mode (renderingGroupId 2 in Babylon) which is+// the CAD term for ignoring the depth buffer. X/Y/Z labels are drawn on+// the screen-space corner triad (§8c) instead of here so they never+// clutter the scene.+const axesState = { world: true, meshLocal: false };+let worldAxes = null;+function buildWorldAxes() {+  if (worldAxes) { worldAxes.dispose(); worldAxes = null; }+  const len = sceneExtentMm * 0.15;+  // Third arg = renderingGroupId. 0 = default depth-tested group (the chip+  // occludes the gizmo when the origin is inside the body). Do NOT use+  // renderingGroupId 2 here — see module-level comment above.+  worldAxes = new BABYLON.AxesViewer(scene, len, 0);+  setWorldAxes(axesState.world);+}+function setWorldAxes(visible) {+  axesState.world = !!visible;+  if (!worldAxes) return;+  [worldAxes.xAxis, worldAxes.yAxis, worldAxes.zAxis].forEach(m => m.setEnabled(visible));+  const b = document.getElementById('btn-axes');+  if (b) b.classList.toggle('active', visible);+}++// ── §8b mesh-local axis helpers (default OFF) ──────────────────────+const meshLocalAxes = [];  // { mesh, axes, labels }+function addMeshLocalAxes(rootNode) {+  // Works for both Mesh (has its own bbox) and TransformNode (needs child-aggregate bbox).+  let len = sceneExtentMm * 0.12;+  if (rootNode.getBoundingInfo) {+    const r = rootNode.getBoundingInfo().boundingSphere.radius || 0;+    if (r > 0) len = r * 0.8;+  } else if (rootNode.getHierarchyBoundingVectors) {+    const { min, max } = rootNode.getHierarchyBoundingVectors(true);+    const diag = max.subtract(min).length();+    if (diag > 0) len = diag * 0.4;+  }+  // Depth-tested (renderingGroupId 0) so the mesh geometry can occlude the+  // local-origin gizmo. Same rationale as §8a's module-level note.+  const axes = new BABYLON.AxesViewer(scene, len, 0);+  [axes.xAxis, axes.yAxis, axes.zAxis].forEach(m => { m.parent = rootNode; m.setEnabled(axesState.meshLocal); });+  meshLocalAxes.push({ node: rootNode, axes });+}+function setMeshLocalAxes(visible) {+  axesState.meshLocal = !!visible;+  meshLocalAxes.forEach(({ axes }) =>+    [axes.xAxis, axes.yAxis, axes.zAxis].forEach(m => m.setEnabled(visible)));+  const b = document.getElementById('btn-mesh-axes');+  if (b) b.classList.toggle('active', visible);+}++// ── §8c screen-space corner triad (always on) ──────────────────────+// Second camera on the main scene with a pinned bottom-left viewport.+// Mirrors the main camera's alpha/beta so the orientation always matches.+// Uses layerMask to isolate the triad meshes from the main view — the triad+// renders only in the corner viewport, scene meshes render only in the main.+const CORNER_LAYER = 0x20000000;+let cornerCam = null;+function buildCornerTriad() {+  cornerCam = new BABYLON.ArcRotateCamera('cornerCam', camera.alpha, camera.beta, 3,+    BABYLON.Vector3.Zero(), scene);+  cornerCam.upVector = new BABYLON.Vector3(0, 0, 1);+  cornerCam.minZ = 0.01; cornerCam.maxZ = 100;+  cornerCam.layerMask = CORNER_LAYER;+  cornerCam.viewport = new BABYLON.Viewport(0.005, 0.01, 0.09, 0.16);  // bottom-left ~9%×16%+  // Don't let user input drive this camera.+  cornerCam.inputs.clear();++  // Main camera rendering layer — everything currently in the scene goes here.+  const MAIN_LAYER = 0x0FFFFFFF;+  camera.layerMask = MAIN_LAYER;+  scene.meshes.forEach(m => { if (m.layerMask === 0x0FFFFFFF) m.layerMask = MAIN_LAYER; });+  // Future meshes default to 0x0FFFFFFF which the main cam sees; only the+  // triad lines get CORNER_LAYER below.++  const tAxes = new BABYLON.AxesViewer(scene, 1);+  [tAxes.xAxis, tAxes.yAxis, tAxes.zAxis].forEach(n => {+    n.getChildMeshes(false).forEach(m => { m.layerMask = CORNER_LAYER; m.renderingGroupId = 3; });+  });++  // X/Y/Z labels at the triad tips. Moved here from the world-origin helper+  // per UX feedback: labels on the in-scene gizmo clutter the model; the+  // corner triad already lives in its own viewport where labels never+  // interfere with the chip. Always on top within that viewport so they're+  // always readable against whatever's rendered behind them.+  const mkCornerLabel = (text, pos) => {+    const plane = BABYLON.MeshBuilder.CreatePlane(`corner-lbl-${text}`, { size: 0.4 }, scene);+    plane.billboardMode = BABYLON.Mesh.BILLBOARDMODE_ALL;+    plane.position.copyFrom(pos);+    plane.layerMask = CORNER_LAYER;+    plane.renderingGroupId = 3;+    plane.isPickable = false;+    const dt = new BABYLON.DynamicTexture(`dt-corner-${text}`, { width: 64, height: 64 }, scene, true);+    dt.hasAlpha = true;+    const ctx = dt.getContext();+    ctx.clearRect(0, 0, 64, 64);+    ctx.font = 'bold 52px Inter, sans-serif';+    ctx.fillStyle = '#e6edf3';+    ctx.textAlign = 'center'; ctx.textBaseline = 'middle';+    ctx.fillText(text, 32, 34);+    dt.update();+    const mat = new BABYLON.PBRMaterial(`corner-lbl-mat-${text}`, scene);+    mat.unlit = true;+    mat.albedoTexture = dt;+    mat.albedoTexture.hasAlpha = true;+    mat.transparencyMode = BABYLON.PBRMaterial.PBRMATERIAL_ALPHABLEND;+    plane.material = mat;+    return plane;+  };+  mkCornerLabel('X', new BABYLON.Vector3(1.15, 0, 0));+  mkCornerLabel('Y', new BABYLON.Vector3(0, 1.15, 0));+  mkCornerLabel('Z', new BABYLON.Vector3(0, 0, 1.15));++  // Render both cameras each frame.+  scene.activeCameras = [camera, cornerCam];++  // Mirror alpha/beta so the triad rotates in sync with the main camera.+  scene.onBeforeRenderObservable.add(() => {+    cornerCam.alpha = camera.alpha;+    cornerCam.beta  = camera.beta;+  });+}++// ── §7 ViewCube — tween, don't snap. Z-up preset angles. ────────────++// ── §7 ViewCube — tween, don't snap. Z-up preset angles. ────────────+//+// ADOM/CNC CONVENTION (non-negotiable across every Adom 3D viewer):+//   +X = east  (operator's right as they face the machine)+//   +Y = north (away from the operator, toward the back of the machine)+//   +Z = up    (out of the table surface, toward the ceiling)+//+// View presets below are tuned so TOP shows (+X right, +Y up, +Z toward+// viewer) — i.e. exactly the mental map a CNC operator has of the+// machine bed. Every lateral view assumes the operator is standing at -Y+// facing +Y; FRONT is what the operator sees when walking up to the+// machine.+//+// If you change any of these angles, first read the §NEW "Adom/CNC+// coordinate convention" section in the 3d-viewer-design skill — these+// numbers are coupled to that spec.+// Babylon's ArcRotateCamera + upVector=(0,0,1) places the camera at+//   pos = (r·sin β·cos α, −r·sin β·sin α, r·cos β)+// i.e. **alpha's sign is reversed vs standard spherical**. That sign+// flip is the reason an earlier iteration of this table showed +X on+// the wrong side at Front / Back — a "-π/2" I'd written down meaning+// "camera at -Y" was actually landing the camera at +Y. The table+// below is derived empirically (see the eval dump in the 0.4.1 commit).+const VIEW_PRESETS = {+  // Top — camera at +Z, screen-right = +X, screen-up = +Y.+  top:    { alpha:  Math.PI/2, beta: 0.01 },+  // Bottom — camera at -Z, mirrors Top (screen-up becomes -Y).+  bottom: { alpha:  Math.PI/2, beta: Math.PI - 0.01 },+  // Front — operator's natural view: camera at -Y, looking +Y.+  // screen-right = +X, screen-up = +Z.+  front:  { alpha:  Math.PI/2, beta: Math.PI/2 },+  // Back — camera at +Y, looking -Y. screen-right = -X, screen-up = +Z.+  back:   { alpha: -Math.PI/2, beta: Math.PI/2 },+  // Right — camera at +X, looking -X. screen-right = -Y, screen-up = +Z.+  right:  { alpha:  0,         beta: Math.PI/2 },+  // Left — camera at -X, looking +X. screen-right = +Y, screen-up = +Z.+  left:   { alpha: -Math.PI,   beta: Math.PI/2 },+  // Iso — camera in (+X, -Y, +Z) octant, i.e. operator's-right-and-in-front,+  // slightly above. This is the Adom/CNC canonical iso: the operator+  // stands at -Y, so the iso "home" view is from their own side of the+  // machine, not from behind it. (−π/4 here would land in the +Y/back+  // octant — that's the Blender/VRay default but wrong for CNC.)+  iso:    { alpha:  Math.PI/4, beta: Math.PI/3 },+};+function setView(preset) {+  if (preset === 'fit') { fitCameraToScene(); return; }+  const p = VIEW_PRESETS[preset];+  if (!p) return;+  const ease = new BABYLON.CubicEase();+  ease.setEasingMode(BABYLON.EasingFunction.EASINGMODE_EASEINOUT);+  BABYLON.Animation.CreateAndStartAnimation(+    'viewAlpha', camera, 'alpha', 60, 18, camera.alpha, p.alpha,+    BABYLON.Animation.ANIMATIONLOOPMODE_CONSTANT, ease);+  BABYLON.Animation.CreateAndStartAnimation(+    'viewBeta',  camera, 'beta',  60, 18, camera.beta,  p.beta,+    BABYLON.Animation.ANIMATIONLOOPMODE_CONSTANT, ease);+  document.querySelectorAll('#viewcube button').forEach(b =>+    b.classList.toggle('active', b.dataset.view === preset));+}+document.querySelectorAll('#viewcube button').forEach(b =>+  b.addEventListener('click', () => setView(b.dataset.view)));+window.setView = setView;++window.addEventListener('keydown', e => {+  if (e.target && ['INPUT','TEXTAREA','SELECT'].includes(e.target.tagName)) return;+  // §8e: A → world axes, Shift+A → mesh-local axes+  if ((e.key === 'a' || e.key === 'A') && !e.ctrlKey && !e.metaKey && !e.altKey) {+    e.preventDefault();+    if (e.shiftKey) toggleAxes('mesh-local');+    else            toggleAxes('world');+    return;+  }+  // Skip view-preset keys when any modifier is held.+  if (e.shiftKey || e.ctrlKey || e.altKey || e.metaKey) return;+  const m = { t:'top', f:'front', r:'right', i:'iso', b:'back', l:'left' };+  if (m[e.key]) setView(m[e.key]);+});++function toggleAxes(target, explicit) {+  if (target === 'world')+    setWorldAxes(explicit == null ? !axesState.world : !!explicit);+  else if (target === 'mesh-local')+    setMeshLocalAxes(explicit == null ? !axesState.meshLocal : !!explicit);+}+window.toggleAxes = toggleAxes;++// ── §5b Material fixups on any GLB load ────────────────────────────+// §5a mandates PBRMaterial as the default for every new material, and+// §5b says incoming GLB materials should be detected + converted on+// load. Scoped to JUST the freshly-loaded meshes (via the `beforeIds`+// set passed from loadGlb) — otherwise the iteration would also rewrite+// the AxesViewer's internal cylinders, the shadow ground, pivot sphere,+// and corner-triad labels, which we already built correctly as PBR ++// don't want touched.+function fixupMaterials(beforeIds) {+  scene.meshes.forEach(mesh => {+    if (beforeIds && beforeIds.has(mesh.uniqueId)) return;  // pre-existing scenery, leave alone+    const mat = mesh.material;+    if (!mat) return;+    const cls = mat.getClassName();+    if (cls === 'PBRMaterial') {+      if (!envFallback) mat.environmentBRDFTexture = scene.environmentBRDFTexture;+    } else if (cls === 'StandardMaterial') {+      // Copy key StandardMaterial fields into a fresh PBRMaterial. This+      // preserves color + textures and gives the mesh consistent HDRI+      // reflections once the env texture is wired up.+      const pbr = new BABYLON.PBRMaterial(mat.name + '_pbr', scene);+      pbr.albedoColor = (mat.diffuseColor || BABYLON.Color3.White()).clone();+      if (mat.diffuseTexture) pbr.albedoTexture = mat.diffuseTexture;+      if (mat.emissiveColor) pbr.emissiveColor = mat.emissiveColor.clone();+      if (mat.alpha != null) pbr.alpha = mat.alpha;+      // shininess 0–128 → roughness 1–0. Flat Standard materials become+      // matte PBR; shiny ones become glossy. Rough heuristic, per §5b.+      const shin = (mat.specularPower != null) ? mat.specularPower : 64;+      pbr.roughness = Math.max(0.05, Math.min(1.0, 1 - shin / 128));+      pbr.metallic = 0;+      if (!envFallback) pbr.environmentBRDFTexture = scene.environmentBRDFTexture;+      mesh.material = pbr;+    }+  });+}++// ── §6d / fit — frame model to ~70% of viewport ─────────────────────+function fitCameraToScene() {+  const all = scene.meshes.filter(m =>+    m.name !== '__root__' && !m.name.startsWith('pivot-flash') &&+    m.isVisible && m.getTotalVertices && m.getTotalVertices() > 0);+  if (!all.length) return;+  let min = new BABYLON.Vector3( Infinity,  Infinity,  Infinity);+  let max = new BABYLON.Vector3(-Infinity, -Infinity, -Infinity);+  all.forEach(m => {+    const bb = m.getBoundingInfo().boundingBox;+    min = BABYLON.Vector3.Minimize(min, bb.minimumWorld);+    max = BABYLON.Vector3.Maximize(max, bb.maximumWorld);+  });+  const center = BABYLON.Vector3.Center(min, max);+  const diag = max.subtract(min).length();+  camera.setTarget(center);+  // §6d: initial radius = bounding sphere radius × 1.3. Half the+  // diagonal is the bounding-sphere radius; × 2.6 there lands the+  // camera far enough at FOV ~34° to frame ~70% of the viewport.+  const boundingRadius = Math.max(diag / 2, 1);+  camera.radius = boundingRadius * 2.6;+  // §8a axis-helper length + §6c pivot-sphere size scale off this.+  sceneExtentMm = Math.max(diag, 1);+  // §6b adaptive precision — scale wheel/pan/pinch inversely with scene+  // size so a small chip (R=10mm) and a full assembly (R=200mm) both+  // feel identical at the mouse. Canonical viewer pattern from+  // gallia/viewer/viewer/js/adom-3d-viewer.min.js.+  camera.wheelPrecision     = 100  / boundingRadius;+  camera.panningSensibility = 5000 / boundingRadius;+  camera.pinchPrecision     = 500  / boundingRadius;+  // Rebuild world axes + shadow ground at the new extent if already built.+  if (worldAxes) buildWorldAxes();+  if (shadowGround) resizeShadowGround();+}++// ── GLB load with meters→mm scale + Y-up→Z-up ──────────────────────+async function loadGlb() {+  // Parent all newly loaded meshes under a wrapper that:+  //   (a) scales by 1000 (glTF spec is 1 unit = 1 meter; we want mm like the rest of the Adom stack)+  //   (b) rotates to Z-up (Y-up → Z-up is `rotation.x = -π/2`; canonical viewer's applyGlbZUpTransform)+  const wrapper = new BABYLON.TransformNode('glb-wrapper', scene);+  wrapper.scaling = new BABYLON.Vector3(1000, 1000, 1000);+  // Do NOT pre-rotate. Babylon's glTF loader handles the Y-up→right-handed+  // flip itself when scene.useRightHandedSystem is true; we then orient+  // via the camera's upVector = (0,0,1). A manual rotation.x = ±π/2 here+  // puts the chip on-edge. The canonical viewer's applyGlbZUpTransform is+  // data-dependent (reads axis metadata from the source); for KiCad-exported+  // STEP→GLB via OCCT the identity works because OCCT writes Z-up meshes+  // that Babylon's loader lands in our Z-up scene correctly.++  const beforeIds = new Set(scene.meshes.map(m => m.uniqueId));+  await BABYLON.SceneLoader.AppendAsync('', 'model.glb', scene);+  scene.meshes.forEach(m => {+    if (beforeIds.has(m.uniqueId)) return;+    if (!m.parent) m.parent = wrapper;+  });+  // Force-update world matrices so fit-camera sees the transformed bounds.+  scene.meshes.forEach(m => m.computeWorldMatrix && m.computeWorldMatrix(true));+  fixupMaterials(beforeIds);+  addChipShadowCasters(beforeIds);+  fitCameraToScene();+  // §8b — attach a local-axis helper to every NEW root node so the+  // user can flip on "Show mesh origins" and instantly see each mesh's+  // local coordinate frame. Uses the GLB wrapper as the root so the+  // axes pick up our meters→mm scale + any future rotation.+  addMeshLocalAxes(wrapper);+}++// ── HUD metadata ────────────────────────────────────────────────────+async function init() {+  const meta = await (await fetch('meta')).json();+  const c = meta.conversion || {};+  document.getElementById('meta-source').textContent = shortLabel(meta.source || '');+  document.getElementById('meta-glb').textContent = shortLabel(meta.glbPath || '');+  document.getElementById('d-source').textContent = meta.source || '—';+  document.getElementById('d-glb').textContent = meta.glbPath || '—';+  document.getElementById('d-size').textContent = fmtBytes(c.sizeBytes);+  document.getElementById('d-duration').textContent = c.durationMs != null ? c.durationMs + ' ms' : '—';+  document.getElementById('d-meshes').textContent = c.meshes ?? '—';+  document.getElementById('d-nodes').textContent = c.nodes ?? '—';+  document.getElementById('p-meshes').textContent = c.meshes ?? '—';+  document.getElementById('p-materials').textContent = (c.materials && c.materials.length) ?? 0;+  document.getElementById('p-size').textContent = fmtBytes(c.sizeBytes);+  document.getElementById('p-duration').textContent = c.durationMs != null ? c.durationMs + 'ms' : '—';++  classifyPill('pill-meshes', (c.meshes || 0) > 0);+  classifyPill('pill-materials', (c.materials && c.materials.length) > 0);+  classifyPill('pill-size', (c.sizeBytes || 0) > 1000);+  classifyPill('pill-duration', (c.durationMs || 0) < 30000);++  const matList = document.getElementById('materials-list');+  matList.innerHTML = '';+  for (const m of (c.materials || [])) {+    const r = document.createElement('div'); r.className = 'mat-row';+    const sw = document.createElement('div'); sw.className = 'swatch';+    const bc = m.baseColor || [0.5, 0.5, 0.5, 1];+    sw.style.background = `rgb(${(bc[0]*255|0)}, ${(bc[1]*255|0)}, ${(bc[2]*255|0)})`;+    const info = document.createElement('div'); info.style.flex = '1';+    info.innerHTML = `<div class="mat-name">${m.name || '(unnamed)'}</div>` ++      `<div class="mat-details">rgb(${bc.slice(0,3).map(v=>v.toFixed(3)).join(', ')})` ++      (m.metal != null ? ` · metal=${m.metal}` : '') + `</div>`;+    r.appendChild(sw); r.appendChild(info);+    matList.appendChild(r);+  }+  if ((c.materials || []).length === 0) {+    matList.innerHTML = '<div style="color:var(--yellow)">⚠ no materials — the STEP may lack color assignments</div>';+  }++  // §8c corner triad — built once, always on.+  buildCornerTriad();++  await loadGlb();+  // §8a world-origin helper — default ON, built AFTER fit so length+  // scales to the loaded model's bounding sphere (15% of scene extent).+  buildWorldAxes();++  setView('iso');+}++function fmtBytes(n) { if (!n) return '—'; if (n < 1024) return n+' B'; if (n<1048576) return (n/1024).toFixed(1)+' KB'; return (n/1048576).toFixed(2)+' MB'; }+function classifyPill(id, ok) { const el = document.getElementById(id); if (!el) return; el.classList.remove('ok','warn','bad'); el.classList.add(ok ? 'ok' : 'bad'); }+function shortLabel(s) { return s.length > 48 ? '…' + s.slice(-45) : s; }+function toggleDrawer() { document.getElementById('drawer').classList.toggle('open'); document.getElementById('drawer-toggle').classList.toggle('active'); }+window.toggleDrawer = toggleDrawer;++// ── §11 bg preset toolbar ──────────────────────────────────────────+const BG_PRESETS = {+  // §3b: top brightened from #21262d to #3e4a5c per the 2026-04-24 field-feedback pass;+  // anything darker regresses black-chip / black-solder-mask visibility.+  studio: 'radial-gradient(ellipse 60% 40% at 50% 100%, rgba(0,184,177,0.04), transparent 70%), linear-gradient(180deg, #3e4a5c 0%, #0d1117 80%)',+  dark:   '#0d1117',+  light:  '#e6edf3',+};+function setBgPreset(name) {+  const css = BG_PRESETS[name] || BG_PRESETS.studio;+  document.getElementById('canvas-wrap').style.background = css;+  try { localStorage.setItem('step2glb-bg', name); } catch (_) {}+}+window.setBgPreset = setBgPreset;+(function restoreBg() {+  try {+    const saved = localStorage.getItem('step2glb-bg');+    if (saved && BG_PRESETS[saved]) {+      document.getElementById('bg-preset').value = saved;+      setBgPreset(saved);+    }+  } catch (_) {}+})();++// ── §9a window.Adom3DViewer global ─────────────────────────────────+window.Adom3DViewer = {+  get scene() { return scene; },+  get camera() { return camera; },+  setCamera({ alpha, beta, radius, targetX, targetY, targetZ }) {+    if (alpha  != null) camera.alpha  = alpha;+    if (beta   != null) camera.beta   = beta;+    if (radius != null) camera.radius = radius;+    if (targetX != null || targetY != null || targetZ != null) {+      camera.setTarget(new BABYLON.Vector3(+        targetX != null ? targetX : camera.target.x,+        targetY != null ? targetY : camera.target.y,+        targetZ != null ? targetZ : camera.target.z,+      ));+    }+  },+  setBottomLight,+  setBgPreset,+  viewPreset: setView,+  toggleAxes,+  recenterOrbit({ x, y, z }) {+    const ease = new BABYLON.CubicEase();+    ease.setEasingMode(BABYLON.EasingFunction.EASINGMODE_EASEINOUT);+    BABYLON.Animation.CreateAndStartAnimation(+      'orbitRecenterMsg', camera, 'target', 60, 12,+      camera.target.clone(), new BABYLON.Vector3(x, y, z),+      BABYLON.Animation.ANIMATIONLOOPMODE_CONSTANT, ease);+    flashPivotMarker(new BABYLON.Vector3(x, y, z));+  },+  dumpMeshes() {+    return scene.meshes.filter(m => m.getTotalVertices && m.getTotalVertices() > 0)+      .map(m => ({ name: m.name, vertices: m.getTotalVertices(), material: m.material && m.material.name }));+  },+  BABYLON,+};++// ── §9b postMessage protocol ───────────────────────────────────────+window.addEventListener('message', (e) => {+  const d = e && e.data; if (!d || typeof d !== 'object') return;+  switch (d.type) {+    case 'set_camera':       window.Adom3DViewer.setCamera(d); break;+    case 'set_bottom_light': setBottomLight(!!d.enabled); break;+    case 'recenter_orbit':   window.Adom3DViewer.recenterOrbit(d); break;+    case 'view_preset':      setView(d.preset); break;+    case 'toggle_axes':      toggleAxes(d.target, d.enabled); break;  // §8e / §10b+    case 'tweak_scene':+      if (d.envIntensity != null) scene.environmentIntensity = d.envIntensity;+      if (d.hemiIntensity != null) hemi.intensity = d.hemiIntensity;+      break;+    case 'set_bg_preset':    setBgPreset(d.preset); break;+  }+});++engine.runRenderLoop(() => scene.render());++// ── Resize handling — grid intrinsic-sizing bug fix, as in chipfit ─+(function () {+  const app = document.querySelector('.app');+  let pending = false;+  const sync = () => {+    const h = (window.visualViewport && window.visualViewport.height) || window.innerHeight;+    const w = (window.visualViewport && window.visualViewport.width)  || window.innerWidth;+    app.style.height = h + 'px'; app.style.maxHeight = h + 'px';+    app.style.width  = w + 'px'; app.style.maxWidth  = w + 'px';+  };+  const schedule = () => {+    if (pending) return; pending = true;+    requestAnimationFrame(() => { pending = false; sync(); engine.resize(); requestAnimationFrame(() => engine.resize()); });+  };+  sync();+  window.addEventListener('resize', schedule);+  if (window.visualViewport) {+    window.visualViewport.addEventListener('resize', schedule);+    window.visualViewport.addEventListener('scroll', schedule);+  }+  new ResizeObserver(schedule).observe(document.getElementById('canvas-wrap'));+  let lw = window.innerWidth, lh = window.innerHeight;+  setInterval(() => {+    if (window.innerWidth !== lw || window.innerHeight !== lh) { lw = window.innerWidth; lh = window.innerHeight; schedule(); }+  }, 1000);+})();++init();+</script>+</body>+</html>
src/ui.rsadded+65
@@ -0,0 +1,65 @@+//! Shared CLI output helpers — OK: / ERROR: / Hint: + note! macro.+//! Mirrors the pattern from adom-cli-design SKILL + video-post's implementation:+//! ANSI colors only when stdout/stderr is a TTY; piped output is plain ASCII+//! so `cmd > log` never embeds raw escape sequences.+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()) }++pub fn strip_ansi(s: &str) -> String {+    let mut out = String::with_capacity(s.len());+    let mut chars = s.chars().peekable();+    while let Some(c) = chars.next() {+        if c == '\x1b' && chars.peek() == Some(&'[') {+            chars.next();+            for c2 in chars.by_ref() { if c2.is_ascii_alphabetic() { break; } }+            continue;+        }+        out.push(c);+    }+    out+}++pub fn ok(msg: impl std::fmt::Display) {+    let s = msg.to_string();+    if stdout_is_tty() { println!("{GREEN}{BOLD}OK:{RESET} {s}"); }+    else               { println!("OK: {}", strip_ansi(&s)); }+}+pub fn err(msg: impl std::fmt::Display) {+    let s = msg.to_string();+    if stderr_is_tty() { eprintln!("{RED}{BOLD}ERROR:{RESET} {s}"); }+    else               { eprintln!("ERROR: {}", strip_ansi(&s)); }+}+pub fn hint(msg: impl std::fmt::Display) {+    let s = msg.to_string();+    if stderr_is_tty() { eprintln!("{DIM}Hint:{RESET} {s}"); }+    else               { eprintln!("Hint: {}", strip_ansi(&s)); }+}+pub fn warn(msg: impl std::fmt::Display) {+    let s = msg.to_string();+    if stderr_is_tty() { eprintln!("{YELLOW}{BOLD}WARN:{RESET} {s}"); }+    else               { eprintln!("WARN: {}", strip_ansi(&s)); }+}++/// Internal progress macro — prints to stdout with ANSI gating.+#[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)); }+    }};+}
tests/ralph.shadded+85
@@ -0,0 +1,85 @@+#!/usr/bin/env bash+# step2glb ralph-loop test+# Pass criteria per package (all four must hold):+#   - ok: true+#   - meshes >= 1+#   - materials.length >= 1   (the "not all white" criterion — guards against regressing to cadquery default export)+#   - sizeBytes > 1000        (non-trivial geometry)+# Overall: 5/5 packages pass.++set -u++TESTS=(+  "Package_TO_SOT_SMD/SOT-89-3"+  "Package_TO_SOT_SMD/TO-252-3_TabPin2"+  "Package_QFP/LQFP-100_14x14mm_P0.5mm"+  "Package_SO/SOIC-8_3.9x4.9mm_P1.27mm"+  "Connector_USB/USB_C_Receptacle_Amphenol_12401610E4-2A"+)++pass=0; fail=0+mkdir -p /tmp/step2glb-test+RESULTS=/tmp/step2glb-test/ralph-results.jsonl+> "$RESULTS"++for lib in "${TESTS[@]}"; do+  leaf="${lib##*/}"+  out="/tmp/step2glb-test/${leaf}.glb"+  echo "── $lib ──"+  # Capture both stdout (JSON on success) and stderr (JSON on failure).+  rm -f "$out"+  stdout=$(step2glb from-library "$lib" --out "$out" 2>/tmp/step2glb-test/${leaf}.stderr | tail -1)+  rc=$?++  # Evaluate pass criteria+  python3 - "$lib" "$stdout" "$rc" >> "$RESULTS" <<'PY'+import json, sys+lib = sys.argv[1]+stdout = sys.argv[2]+rc = int(sys.argv[3])++reasons = []+try:+    r = json.loads(stdout)+except Exception as e:+    r = {}+    reasons.append(f"non-JSON stdout: {e}")++ok = bool(r.get("ok"))+meshes = r.get("meshes", 0)+mats = len(r.get("materials") or [])+size = r.get("sizeBytes", 0)++if rc != 0: reasons.append(f"rc={rc}")+if not ok:  reasons.append("ok=false")+if meshes < 1: reasons.append(f"meshes={meshes}")+if mats < 1:   reasons.append(f"materials={mats}")+if size < 1000: reasons.append(f"sizeBytes={size}")++passed = len(reasons) == 0+row = {"lib": lib, "pass": passed, "meshes": meshes, "materials": mats, "sizeBytes": size, "durationMs": r.get("durationMs")}+if not passed: row["reasons"] = reasons+print(json.dumps(row))+PY+  tail -1 "$RESULTS"+done++echo+echo "=== summary ==="+python3 - "$RESULTS" <<'PY'+import json, sys+rows = [json.loads(l) for l in open(sys.argv[1])]+p = sum(1 for r in rows if r["pass"])+f = len(rows) - p+hdr = f"{'#':>2} {'pkg':<50} {'meshes':>6} {'mats':>4} {'size':>10} {'dur':>8}  status"+print(hdr); print('-'*len(hdr))+for i, r in enumerate(rows, 1):+    status = "✓" if r["pass"] else "✗ " + ",".join(r.get("reasons", []))+    sz = r.get("sizeBytes", 0)+    size_s = f"{sz/1024:.1f}KB" if sz >= 1024 else f"{sz}B"+    dur = r.get("durationMs")+    print(f"{i:>2} {r['lib']:<50} {r.get('meshes', '?'):>6} {r.get('materials', '?'):>4} {size_s:>10} {str(dur)+'ms':>8}  {status}")+print('-'*len(hdr))+print(f"pass: {p}   fail: {f}   total: {len(rows)}")+sys.exit(0 if f == 0 else 1)+PY
uninstall.shadded+12
@@ -0,0 +1,12 @@+#!/usr/bin/env bash+# adompkg uninstall hook for adom-step2glb.+set -euo pipefail++SKILL_DIR="$HOME/.claude/skills/adom-step2glb"++if [ -d "$SKILL_DIR" ]; then+  rm -rf "$SKILL_DIR"+  echo "OK: removed adom-step2glb skill -> $SKILL_DIR"+else+  echo "OK: nothing to uninstall"+fi
wiki/body.mdadded+128
@@ -0,0 +1,128 @@+> **Renamed:** this app was formerly `step2glb`. The binary is now **`adom-step2glb`** (a `step2glb` alias is kept for back-compat). Same color-preserving STEP→GLB conversion.++# step2glb++Thin CLI for converting STEP CAD files to GLB with **colors preserved** from the source file.++The CLI is ~3 MB and ships no CAD runtime. Conversion happens on the shared **service-step2glb** container via OCCT's XCAF pipeline (`STEPCAFControl_Reader` → `BRepMesh_IncrementalMesh` → `RWGltf_CafWriter`), which preserves per-face colors that `cadquery.save('GLTF')`, `assimp`, and `kicad-cli pcb export glb` all lose.++## Quick use++```bash+# Local file+step2glb convert foo.step -o foo.glb++# KiCad library part (service-kicad fetches the STEP for you)+step2glb from-library Package_TO_SOT_SMD/SOT-89-3+step2glb from-library Package_QFP/LQFP-100_14x14mm_P0.5mm++# Convert + open a Hydrogen webview with a materials HUD+step2glb preview Package_QFP/LQFP-100_14x14mm_P0.5mm+```++Every command prints an `OK:` / `ERROR:` summary line (with a `Hint:` on errors), followed by a JSON companion line so AI callers can parse the result programmatically.++## Identity headers (v0.9.0+)++Every POST to service-step2glb requires two headers:++| Header | Purpose | Example |+|---|---|---|+| `X-Client` | Who is calling (tool + user) | `adom-tsci/john-dev` |+| `X-Job-Name` | Descriptive slug for this job | `convert-usbc-connector-type-c-31-m-12-to-glb` |++The CLI sends both automatically. If you're calling the service directly, include them or you'll get a 400 with instructions.++**Building a good X-Job-Name:** `<endpoint>-<what>-<part-or-file>-<context>`. Use lowercase, hyphens, no spaces. Include the MPN or filename so the job is identifiable in `/health` stats. Examples:+- `convert-usbc-connector-type-c-31-m-12-to-glb`+- `thumbnail-iso-vl53l8cx-for-chip-fetcher-library`+- `features-extract-bme688-pin-detection`+- `batch-thumbnails-6orient-3size-rp2040`++## Abuse protection++- **Content dedup**: Submitting the same STEP file that's already queued/processing returns the existing job ID with `"deduplicated": true` instead of spawning a new job.+- **Per-client cap**: Max 8 active jobs per client. Returns 429 if exceeded.+- **Per-file cap**: Max 2 active jobs for the same content hash.++## Async job queue (files >30MB)++Large files are converted asynchronously:+1. POST `/convert` → returns `{"job_id": "...", "poll": "/jobs/<id>"}`+2. Poll `GET /jobs/<id>` until `status` is `complete` or `failed`+3. Fetch `GET /jobs/<id>/result` for the GLB bytes++Workers run at `nice 15` so the health check and SSH always stay responsive. Max 4 concurrent workers; excess jobs queue.++## Worker health stats++`GET /health` returns live worker pool stats:++```json+{+  "workers": {+    "max_workers": 4,+    "active_workers": 2,+    "jobs_queued": 1,+    "jobs_processing": 2,+    "jobs_complete": 15,+    "jobs_failed": 3,+    "active_by_client": {+      "adom-tsci/john-dev": 2,+      "chip-fetcher/ray-main": 1+    }+  }+}+```++When `active_workers == max_workers` and `jobs_queued` is climbing, that's the signal to scale to additional containers.++## Install++Paste this into Claude Code:++> Install the latest step2glb CLI from the Adom Wiki (app/step2glb). It sends the required X-Client and X-Job-Name headers automatically.++Or manually:+```bash+curl -fsSL https://wiki-ufypy5dpx93o.adom.cloud/static/apps/step2glb/step2glb -o /tmp/step2glb \+  && chmod +x /tmp/step2glb \+  && sudo install -m 0755 /tmp/step2glb /usr/local/bin/step2glb \+  && step2glb install \+  && step2glb health+```++`step2glb install` fetches the latest SKILL.md from the wiki and installs bash completions. No venv, no pip, no OCCT locally.++## Environment variables++| Variable | Purpose | Default |+|---|---|---|+| `STEP2GLB_SERVICE_API` | Override service URL | shared Adom container |+| `STEP2GLB_CLIENT` | Override X-Client identity | `step2glb-cli/<version>` |++## HTTP API (service side)++| Method | Path | Notes |+|---|---|---|+| GET | `/health` | version + uptime + worker stats + active_by_client |+| POST | `/convert` | body=STEP bytes → GLB bytes (sync <30MB, async >30MB) |+| POST | `/convert?upAxis=y` | same, with up-axis rotation baked into the GLB |+| GET | `/convert?library=L&name=N` | one-call: service-kicad fetch + convert |+| POST | `/features` | STEP → geometric features JSON |+| POST | `/step-meta` | STEP → full B-rep metadata |+| POST | `/create-chip` | chip_3d JSON → parametric STEP |+| POST | `/create-footprint` | pads[] JSON → copper footprint STEP |+| POST | `/bake` | STEP + bake.json → signed .chipsmith.step |+| POST | `/thumbnail` | STEP/GLB → PNG |+| POST | `/thumbnail-batch` | STEP → N orientations × M sizes |+| GET | `/jobs/<id>` | poll async job status |+| GET | `/jobs/<id>/result` | fetch completed GLB |++All POST endpoints require `X-Client` and `X-Job-Name` headers. Response headers include `X-Meshes`, `X-Nodes`, `X-Materials`, `X-Size-Bytes`, `X-Duration-Ms`, and `X-Materials-Json`.++## Architecture++- **Client (this app)** — `adom-inc/step2glb`, public repo, 3 MB Rust binary, Tier B auto-discovery.+- **Service** — `adom-inc/service-step2glb`, private repo, default-light container (`step2glb-gmdoncpxdwx0.adom.cloud:8782`) with OCCT + Python + cadquery-ocp (~1.1 GB on-disk). Watchdog: cron every 2 min + external service-watcher with SSH auto-remediation.+- **Upstream data** — `service-kicad` (4.7 GB of STEP/WRL/symbols/footprints) supplies library parts for `from-library`.
wiki/page.jsonadded+38
@@ -0,0 +1,38 @@+{+  "type": "app",+  "slug": "adom-step2glb",+  "title": "adom-step2glb \u2014 STEP \u2192 GLB converter (color-preserving)",+  "brief": "Thin Rust CLI that converts STEP (.step/.stp) CAD files to GLB with per-face colors preserved, via the shared service-step2glb container (OCCT XCAF). Includes a Hydrogen webview preview so humans can verify conversions visually.",+  "metadata": {+    "version": "1.0.0",+    "releases": {+      "adom_docker": {+        "asset_name": "adom-step2glb",+        "binary_url": "https://wiki-ufypy5dpx93o.adom.cloud/static/apps/adom-step2glb/adom-step2glb",+        "install_hint": "curl -fsSL https://wiki-ufypy5dpx93o.adom.cloud/static/apps/adom-step2glb/adom-step2glb -o /tmp/adom-step2glb && chmod +x /tmp/adom-step2glb && sudo install -m 0755 /tmp/adom-step2glb /usr/local/bin/adom-step2glb && adom-step2glb install && adom-step2glb health"+      }+    },+    "discovery_triggers": [+      "adom-step2glb",+      "step to glb",+      "step2glb",+      "convert step",+      "convert .step",+      "cad to glb",+      "kicad step to glb",+      "step file to glb",+      "occt gltf",+      "step preview",+      "chip 3d preview",+      "component 3d viewer",+      "regenerate chip glb",+      "white glb",+      "all-white glb",+      "missing materials glb",+      "3d model color not working",+      "step file no colors",+      "fix 3d component colors"+    ],+    "discovery_pitch": "Convert a STEP (.step/.stp) file to a GLB with colors + materials preserved, via the shared service-step2glb container (OCCT XCAF pipeline). Local install is ~3 MB, no Python, no CAD runtime needed."+  }+}\ No newline at end of file