app
Adom Step2GLB - The Adom STEP Engine
Public Made by Adomby adom
The Adom STEP engine: fully manage STEP files. Convert to color-preserving GLB, render thumbnails and vector outlines, laser-etch MPNs, create and edit STEP geometry, extract features, and run molecule mode. One name for the thin Rust CLI and the shared OCCT service. Formerly 'step2glb' + 'service-step2glb'.
← Commit history
Publish 1.0.0
29 files changed
+3816
.gitignore+3Cargo.lock+856Cargo.toml+24SKILL.md+103VERSION+1adom-step2glbadom-step2glb-billboard.pngbuild.sh+19docs/hero.pngdocs/screenshots/01-iso-lqfp100.pngdocs/screenshots/02-materials-drawer.pngdocs/screenshots/03-bottom-transparent-ground.pngdocs/screenshots/04-iso-sot-89-3.pnginstall.sh+18package.json+11src/cli.rs+526src/client.rs+341src/install.rs+57src/main.rs+26src/playground.html+216src/playground.rs+116src/preview.rs+153src/tab.rs+56src/ui.html+962src/ui.rs+65tests/ralph.sh+85uninstall.sh+12wiki/body.md+128wiki/page.json+38.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."+ }+}