app
Adom Step - File Viewer
Public Made by Adomby adom
Fusion 360-style viewer for STEP / STP files in a Hydrogen webview. Components outline, smart-pick measure (mm + mils + angle), hover-inspect HUD, and geometry-based pin / contact detection that handl
← Commit history
open-source: publish full source under adom org
20 files changed
+2881−3
Cargo.lock+1317Cargo.toml+32SKILL.md+31−3VERSION+1src/cli/console.rs+84src/cli/eval.rs+67src/cli/frame.rs+30src/cli/health.rs+26src/cli/install.rs+27src/cli/isolate.rs+25src/cli/list_components.rs+42src/cli/measure.rs+38src/cli/open.rs+100src/cli/pins.rs+60src/cli/view.rs+116src/cli/view_library.rs+90src/main.rs+93src/server/cache.rs+40src/server/convert.rs+176src/server/mod.rs+486Cargo.lockadded+1317@@ -0,0 +1,1317 @@+# 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 = "adler32"+version = "1.2.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234"++[[package]]+name = "adom-step"+version = "0.1.1"+dependencies = [+ "anyhow",+ "clap",+ "clap_complete",+ "colored",+ "ctrlc",+ "hex",+ "rust-embed",+ "serde",+ "serde_json",+ "sha2",+ "tiny_http",+ "ureq",+]++[[package]]+name = "allocator-api2"+version = "0.2.21"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"++[[package]]+name = "anstream"+version = "1.0.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"+dependencies = [+ "anstyle",+ "anstyle-parse",+ "anstyle-query",+ "anstyle-wincon",+ "colorchoice",+ "is_terminal_polyfill",+ "utf8parse",+]++[[package]]+name = "anstyle"+version = "1.0.14"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"++[[package]]+name = "anstyle-parse"+version = "1.0.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"+dependencies = [+ "utf8parse",+]++[[package]]+name = "anstyle-query"+version = "1.1.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"+dependencies = [+ "windows-sys 0.61.2",+]++[[package]]+name = "anstyle-wincon"+version = "3.0.11"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"+dependencies = [+ "anstyle",+ "once_cell_polyfill",+ "windows-sys 0.61.2",+]++[[package]]+name = "anyhow"+version = "1.0.102"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"++[[package]]+name = "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 = "bitflags"+version = "2.11.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"++[[package]]+name = "block-buffer"+version = "0.10.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"+dependencies = [+ "generic-array",+]++[[package]]+name = "block2"+version = "0.6.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"+dependencies = [+ "objc2",+]++[[package]]+name = "cc"+version = "1.2.61"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d"+dependencies = [+ "find-msvc-tools",+ "jobserver",+ "libc",+ "shlex",+]++[[package]]+name = "cfg-if"+version = "1.0.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"++[[package]]+name = "cfg_aliases"+version = "0.2.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"++[[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.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "660c0520455b1013b9bcb0393d5f643d7e4454fb69c915b8d6d2aa0e9a45acc3"+dependencies = [+ "clap",+]++[[package]]+name = "clap_derive"+version = "4.6.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"+dependencies = [+ "heck",+ "proc-macro2",+ "quote",+ "syn",+]++[[package]]+name = "clap_lex"+version = "1.1.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"++[[package]]+name = "colorchoice"+version = "1.0.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"++[[package]]+name = "colored"+version = "2.2.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c"+dependencies = [+ "lazy_static",+ "windows-sys 0.59.0",+]++[[package]]+name = "cpufeatures"+version = "0.2.17"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"+dependencies = [+ "libc",+]++[[package]]+name = "crc32fast"+version = "1.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"+dependencies = [+ "cfg-if",+]++[[package]]+name = "crypto-common"+version = "0.1.7"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"+dependencies = [+ "generic-array",+ "typenum",+]++[[package]]+name = "ctrlc"+version = "3.5.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162"+dependencies = [+ "dispatch2",+ "nix",+ "windows-sys 0.61.2",+]++[[package]]+name = "dary_heap"+version = "0.3.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8b1e3a325bc115f096c8b77bbf027a7c2592230e70be2d985be950d3d5e60ebe"++[[package]]+name = "digest"+version = "0.10.7"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"+dependencies = [+ "block-buffer",+ "crypto-common",+]++[[package]]+name = "dispatch2"+version = "0.3.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"+dependencies = [+ "bitflags",+ "block2",+ "libc",+ "objc2",+]++[[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 = "equivalent"+version = "1.0.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"++[[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 = "foldhash"+version = "0.2.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"++[[package]]+name = "form_urlencoded"+version = "1.2.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"+dependencies = [+ "percent-encoding",+]++[[package]]+name = "generic-array"+version = "0.14.7"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"+dependencies = [+ "typenum",+ "version_check",+]++[[package]]+name = "getrandom"+version = "0.2.17"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"+dependencies = [+ "cfg-if",+ "libc",+ "wasi",+]++[[package]]+name = "getrandom"+version = "0.3.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"+dependencies = [+ "cfg-if",+ "libc",+ "r-efi",+ "wasip2",+]++[[package]]+name = "hashbrown"+version = "0.16.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"+dependencies = [+ "allocator-api2",+ "equivalent",+ "foldhash",+]++[[package]]+name = "heck"+version = "0.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"++[[package]]+name = "hex"+version = "0.4.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"++[[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.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"+dependencies = [+ "icu_normalizer",+ "icu_properties",+]++[[package]]+name = "include-flate"+version = "0.3.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "23e233413926ef735f7d87024466cfda5a4b87467730846bd82ea7d504121347"+dependencies = [+ "include-flate-codegen",+ "include-flate-compress",+]++[[package]]+name = "include-flate-codegen"+version = "0.3.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "5e7148f24ef8922cc0e5574ebb908729ccdd3a110c440a45165733fedadd9969"+dependencies = [+ "include-flate-compress",+ "proc-macro-error2",+ "proc-macro2",+ "quote",+ "syn",+]++[[package]]+name = "include-flate-compress"+version = "0.3.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "74783a9ed407e844e99d5e7a57bd650acbfa124cf6e97ffd790ba59d8ab8e7ff"+dependencies = [+ "libflate",+ "zstd",+]++[[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 = "jobserver"+version = "0.1.34"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"+dependencies = [+ "getrandom 0.3.4",+ "libc",+]++[[package]]+name = "lazy_static"+version = "1.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"++[[package]]+name = "libc"+version = "0.2.186"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"++[[package]]+name = "libflate"+version = "2.3.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "cd96e993e5f3368b0cb8497dae6c860c22af8ff18388c61c6c0b86c58d86b5df"+dependencies = [+ "adler32",+ "crc32fast",+ "dary_heap",+ "libflate_lz77",+ "no_std_io2",+]++[[package]]+name = "libflate_lz77"+version = "2.3.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ff7a10e427698aef6eef269482776debfef63384d30f13aad39a1a95e0e098fd"+dependencies = [+ "hashbrown",+ "no_std_io2",+ "rle-decode-fast",+]++[[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 = "nix"+version = "0.31.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3"+dependencies = [+ "bitflags",+ "cfg-if",+ "cfg_aliases",+ "libc",+]++[[package]]+name = "no_std_io2"+version = "0.9.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b51ed7824b6e07d354605f4abb3d9d300350701299da96642ee084f5ce631550"+dependencies = [+ "memchr",+]++[[package]]+name = "objc2"+version = "0.6.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f"+dependencies = [+ "objc2-encode",+]++[[package]]+name = "objc2-encode"+version = "4.1.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"++[[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 = "pkg-config"+version = "0.3.33"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"++[[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-macro-error-attr2"+version = "2.0.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5"+dependencies = [+ "proc-macro2",+ "quote",+]++[[package]]+name = "proc-macro-error2"+version = "2.0.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802"+dependencies = [+ "proc-macro-error-attr2",+ "proc-macro2",+ "quote",+ "syn",+]++[[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 = "r-efi"+version = "5.3.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"++[[package]]+name = "ring"+version = "0.17.14"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"+dependencies = [+ "cc",+ "cfg-if",+ "getrandom 0.2.17",+ "libc",+ "untrusted",+ "windows-sys 0.52.0",+]++[[package]]+name = "rle-decode-fast"+version = "1.0.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "3582f63211428f83597b51b2ddb88e2a91a9d52d12831f9d08f5e624e8977422"++[[package]]+name = "rust-embed"+version = "8.11.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27"+dependencies = [+ "include-flate",+ "rust-embed-impl",+ "rust-embed-utils",+ "walkdir",+]++[[package]]+name = "rust-embed-impl"+version = "8.11.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa"+dependencies = [+ "proc-macro2",+ "quote",+ "rust-embed-utils",+ "syn",+ "walkdir",+]++[[package]]+name = "rust-embed-utils"+version = "8.11.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1"+dependencies = [+ "sha2",+ "walkdir",+]++[[package]]+name = "rustls"+version = "0.23.40"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"+dependencies = [+ "log",+ "once_cell",+ "ring",+ "rustls-pki-types",+ "rustls-webpki",+ "subtle",+ "zeroize",+]++[[package]]+name = "rustls-pki-types"+version = "1.14.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"+dependencies = [+ "zeroize",+]++[[package]]+name = "rustls-webpki"+version = "0.103.13"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"+dependencies = [+ "ring",+ "rustls-pki-types",+ "untrusted",+]++[[package]]+name = "same-file"+version = "1.0.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"+dependencies = [+ "winapi-util",+]++[[package]]+name = "serde"+version = "1.0.228"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"+dependencies = [+ "serde_core",+ "serde_derive",+]++[[package]]+name = "serde_core"+version = "1.0.228"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"+dependencies = [+ "serde_derive",+]++[[package]]+name = "serde_derive"+version = "1.0.228"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"+dependencies = [+ "proc-macro2",+ "quote",+ "syn",+]++[[package]]+name = "serde_json"+version = "1.0.149"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"+dependencies = [+ "itoa",+ "memchr",+ "serde",+ "serde_core",+ "zmij",+]++[[package]]+name = "sha2"+version = "0.10.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"+dependencies = [+ "cfg-if",+ "cpufeatures",+ "digest",+]++[[package]]+name = "shlex"+version = "1.3.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"++[[package]]+name = "simd-adler32"+version = "0.3.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"++[[package]]+name = "smallvec"+version = "1.15.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"++[[package]]+name = "stable_deref_trait"+version = "1.2.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"++[[package]]+name = "strsim"+version = "0.11.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"++[[package]]+name = "subtle"+version = "2.6.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"++[[package]]+name = "syn"+version = "2.0.117"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"+dependencies = [+ "proc-macro2",+ "quote",+ "unicode-ident",+]++[[package]]+name = "synstructure"+version = "0.13.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"+dependencies = [+ "proc-macro2",+ "quote",+ "syn",+]++[[package]]+name = "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 = "typenum"+version = "1.20.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"++[[package]]+name = "unicode-ident"+version = "1.0.24"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"++[[package]]+name = "untrusted"+version = "0.9.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"++[[package]]+name = "ureq"+version = "2.12.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"+dependencies = [+ "base64",+ "flate2",+ "log",+ "once_cell",+ "rustls",+ "rustls-pki-types",+ "serde",+ "serde_json",+ "url",+ "webpki-roots 0.26.11",+]++[[package]]+name = "url"+version = "2.5.8"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"+dependencies = [+ "form_urlencoded",+ "idna",+ "percent-encoding",+ "serde",+]++[[package]]+name = "utf8_iter"+version = "1.0.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"++[[package]]+name = "utf8parse"+version = "0.2.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"++[[package]]+name = "version_check"+version = "0.9.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"++[[package]]+name = "walkdir"+version = "2.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"+dependencies = [+ "same-file",+ "winapi-util",+]++[[package]]+name = "wasi"+version = "0.11.1+wasi-snapshot-preview1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"++[[package]]+name = "wasip2"+version = "1.0.3+wasi-0.2.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"+dependencies = [+ "wit-bindgen",+]++[[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 = "winapi-util"+version = "0.1.11"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"+dependencies = [+ "windows-sys 0.61.2",+]++[[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.59.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"+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 = "wit-bindgen"+version = "0.57.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"++[[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"++[[package]]+name = "zstd"+version = "0.13.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"+dependencies = [+ "zstd-safe",+]++[[package]]+name = "zstd-safe"+version = "7.2.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d"+dependencies = [+ "zstd-sys",+]++[[package]]+name = "zstd-sys"+version = "2.0.16+zstd.1.5.7"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748"+dependencies = [+ "cc",+ "pkg-config",+]
Cargo.tomladded+32@@ -0,0 +1,32 @@+[package]+name = "adom-step"+version = "0.1.1"+edition = "2021"+description = "STEP file 3D viewer with Fusion 360-feel components browser, measure tool, and section planes — Hydrogen webview app + AI-drivable CLI"+authors = ["Adom <[email protected]>"]+license = "MIT"+repository = "https://github.com/adom-inc/adom-step"++[[bin]]+name = "adom-step"+path = "src/main.rs"++[dependencies]+clap = { version = "4", features = ["derive"] }+clap_complete = "4"+tiny_http = "0.12"+ureq = { version = "2", features = ["json"] }+serde = { version = "1", features = ["derive"] }+serde_json = "1"+anyhow = "1"+ctrlc = "3"+colored = "2"+sha2 = "0.10"+hex = "0.4"+rust-embed = { version = "8", features = ["compression"] }++[profile.release]+opt-level = 3+lto = true+codegen-units = 1+strip = true
SKILL.md+31−3@@ -1,7 +1,35 @@ --- name: adom-step-description: >- Fusion 360-style viewer for STEP / STP files in a Hydrogen webview. Components outline, smart-pick measure (mm + mils + angle), hover-inspect HUD, and geometry-based pin / contact detection that handl+description: STEP / STP 3D file viewer. Fusion 360-feel components outline, smart-pick measure tool (mm + mils + angle), hover-inspect, geometry-based pin/contact detection for LGA, BGA, QFN, SOIC, through-hole. Hydrogen webview + AI-drivable CLI. Loads via service-step2glb, preserves assembly hierarchy + named nodes + per-face materials. Trigger words: adom-step, step viewer, view step, open step, step file, fusion 360 viewer, components browser, isolate component cad, step assembly tree, measure step, step 3d viewer, cad viewer, lga pads, qfn pins, bga balls, through-hole pins. ---+ # adom-step — STEP file viewer-Fusion 360-style viewer for STEP / STP files in a Hydrogen webview. Components outline, smart-pick measure (mm + mils + angle), hover-inspect HUD, and geometry-based pin / contact detection that handl++Fusion 360-style viewer for `.step` / `.stp` on the canonical Adom Babylon9 viewer.++## Open++```bash+adom-step view /path/to/design.step+adom-step view-library Package_QFP/LQFP-64_10x10mm_P0.5mm+```++Opens a Hydrogen tab on port 8851. service-step2glb caches by SHA-256 — repeat opens sub-second.++## CLI++```bash+adom-step list-components+adom-step isolate U1 # hide others + frame+adom-step show / hide / show-all / frame NAME+adom-step measure A B # mm + mils+adom-step pins list / isolate N / strobe+adom-step view-cube iso+adom-step screenshot --out /tmp/x.png+adom-step eval "return Object.keys(window.adomStep)"+adom-step console -f+```++## JS API++`window.adomStep.{listComponents, isolate, hide, show, showAll, frame, measure, screenshot, setView, pinCount, pinList, isolatePin, strobePins}`.
VERSIONadded+1@@ -0,0 +1 @@+0.1.1
src/cli/console.rsadded+84@@ -0,0 +1,84 @@+use crate::cli::{err, hint, DEFAULT_PORT};+use anyhow::Result;+use clap::Parser;+use colored::Colorize;+use std::time::Duration;++#[derive(Parser)]+pub struct Args {+ /// Follow mode — keep polling and print new messages.+ #[arg(short, long)]+ pub follow: bool,++ #[arg(long, default_value_t = 0)]+ pub since: u64,++ #[arg(long, default_value_t = 50)]+ pub tail: usize,++ /// Comma-separated levels: log,warn,error.+ #[arg(long)]+ pub level: Option<String>,++ #[arg(long, default_value_t = DEFAULT_PORT)]+ pub port: u16,+}++pub fn run(args: Args) -> Result<()> {+ let level_filter: Option<Vec<String>> = args+ .level+ .as_deref()+ .map(|s| s.split(',').map(|x| x.trim().to_lowercase()).collect());+ let mut since = args.since;+ let mut first = true;+ loop {+ let url = format!("http://127.0.0.1:{}/console?since={}", args.port, since);+ let resp = match ureq::get(&url).timeout(Duration::from_secs(2)).call() {+ Ok(r) => r.into_json::<serde_json::Value>()?,+ Err(e) => {+ err(format!("could not reach adom-step: {e}"));+ hint("start it first with `adom-step view <file.step>`");+ return Err(anyhow::anyhow!("unreachable"));+ }+ };+ let next = resp+ .get("next_since")+ .and_then(|v| v.as_u64())+ .unwrap_or(since);+ let empty = vec![];+ let msgs = resp+ .get("messages")+ .and_then(|v| v.as_array())+ .unwrap_or(&empty);+ let slice: Vec<&serde_json::Value> = if first && msgs.len() > args.tail {+ msgs.iter().skip(msgs.len() - args.tail).collect()+ } else {+ msgs.iter().collect()+ };+ for m in slice {+ let lvl = m.get("level").and_then(|v| v.as_str()).unwrap_or("log");+ if let Some(filter) = &level_filter {+ if !filter.contains(&lvl.to_string()) {+ continue;+ }+ }+ let seq = m.get("seq").and_then(|v| v.as_u64()).unwrap_or(0);+ let ts = m.get("ts_ms").and_then(|v| v.as_u64()).unwrap_or(0);+ let text = m.get("text").and_then(|v| v.as_str()).unwrap_or("");+ let ts_s = format!("{}.{:03}", ts / 1000, ts % 1000);+ let tag = match lvl {+ "error" => "ERR".red().bold().to_string(),+ "warn" => "WRN".yellow().bold().to_string(),+ _ => "LOG".cyan().bold().to_string(),+ };+ println!("[{:>6}] {} {} {}", seq, ts_s.dimmed(), tag, text);+ }+ since = next;+ first = false;+ if !args.follow {+ break;+ }+ std::thread::sleep(Duration::from_millis(400));+ }+ Ok(())+}
src/cli/eval.rsadded+67@@ -0,0 +1,67 @@+use crate::cli::{err, hint, DEFAULT_PORT};+use anyhow::Result;+use clap::Parser;+use std::time::{Duration, Instant};++#[derive(Parser)]+pub struct Args {+ /// JS snippet to run inside the adom-step webview. Wrap `await` freely;+ /// the UI invokes via AsyncFunction so top-level await works.+ pub code: String,++ /// How long to wait for the UI to return a result.+ #[arg(long, default_value_t = 5)]+ pub timeout_secs: u64,++ #[arg(long)]+ pub quiet: bool,++ #[arg(long, default_value_t = DEFAULT_PORT)]+ pub port: u16,+}++pub fn run(args: Args) -> Result<()> {+ eval_snippet(&args.code, args.timeout_secs, args.port, args.quiet).map(|v| {+ println!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());+ })+}++/// Reusable helper for other CLI subcommands that need to drive the UI via JS.+pub fn eval_snippet(code: &str, timeout_secs: u64, port: u16, quiet: bool) -> Result<serde_json::Value> {+ let base = format!("http://127.0.0.1:{}", port);+ let queue: serde_json::Value = match ureq::post(&format!("{base}/eval"))+ .timeout(Duration::from_secs(2))+ .send_json(serde_json::json!({"code": code}))+ {+ Ok(r) => r.into_json()?,+ Err(e) => {+ err(format!("could not reach adom-step: {e}"));+ hint("start it first with `adom-step view <file.step>`");+ return Err(anyhow::anyhow!("unreachable"));+ }+ };+ let id = queue+ .get("id")+ .and_then(|v| v.as_str())+ .ok_or_else(|| anyhow::anyhow!("server returned no id: {queue}"))?+ .to_string();+ if !quiet {+ eprintln!("queued {id}");+ }+ let deadline = Instant::now() + Duration::from_secs(timeout_secs);+ loop {+ if Instant::now() >= deadline {+ err(format!("timeout waiting for result of {id}"));+ hint("is the webview open and loaded?");+ return Err(anyhow::anyhow!("timeout"));+ }+ let r: serde_json::Value = ureq::get(&format!("{base}/eval/{id}"))+ .timeout(Duration::from_secs(2))+ .call()?+ .into_json()?;+ if r.get("done").and_then(|v| v.as_bool()).unwrap_or(false) {+ return Ok(r.get("result").cloned().unwrap_or(serde_json::Value::Null));+ }+ std::thread::sleep(Duration::from_millis(150));+ }+}
src/cli/frame.rsadded+30@@ -0,0 +1,30 @@+use crate::cli::{eval, ok, DEFAULT_PORT};+use anyhow::Result;+use clap::Parser;++#[derive(Parser)]+pub struct Args {+ /// Optional node name to frame on. Whole model when omitted.+ pub name: Option<String>,+ #[arg(long, default_value_t = 5)]+ pub timeout_secs: u64,+ #[arg(long, default_value_t = DEFAULT_PORT)]+ pub port: u16,+}++pub fn run(args: Args) -> Result<()> {+ let arg = match &args.name {+ Some(n) => serde_json::to_string(n).unwrap(),+ None => "null".to_string(),+ };+ let code = format!(+ "return window.adomStep ? window.adomStep.frame({}) : {{ error: 'adomStep not loaded' }};",+ arg+ );+ eval::eval_snippet(&code, args.timeout_secs, args.port, true)?;+ match args.name {+ Some(n) => ok(format!("framed {}", n)),+ None => ok("framed whole model"),+ }+ Ok(())+}
src/cli/health.rsadded+26@@ -0,0 +1,26 @@+use crate::cli::{err, hint, ok, DEFAULT_PORT};+use anyhow::Result;+use clap::Parser;+use std::time::Duration;++#[derive(Parser)]+pub struct Args {+ #[arg(long, default_value_t = DEFAULT_PORT)]+ pub port: u16,+}++pub fn run(args: Args) -> Result<()> {+ let url = format!("http://127.0.0.1:{}/health", args.port);+ match ureq::get(&url).timeout(Duration::from_secs(2)).call() {+ Ok(resp) => {+ let body: serde_json::Value = resp.into_json()?;+ ok(format!("healthy on port {} — {}", args.port, body));+ Ok(())+ }+ Err(e) => {+ err(format!("not running on port {}: {e}", args.port));+ hint("start it with `adom-step view <file.step>`");+ Err(anyhow::anyhow!("not running"))+ }+ }+}
src/cli/install.rsadded+27@@ -0,0 +1,27 @@+use crate::cli::{err, ok};+use anyhow::Result;+use clap::Parser;++#[derive(Parser)]+pub struct Args {+ /// Skip copying the binary; only drop SKILL.md and completions.+ #[arg(long)]+ pub skill_only: bool,+}++pub fn run(_args: Args) -> Result<()> {+ let home = std::env::var("HOME").unwrap_or_else(|_| "/home/adom".to_string());+ let skill_dir = format!("{home}/.claude/skills/adom-step");+ if let Err(e) = std::fs::create_dir_all(&skill_dir) {+ err(format!("create {skill_dir}: {e}"));+ return Err(e.into());+ }++ // Bundle: SKILL.md is included via include_str! so users can paste-install.+ let skill_md = include_str!("../../SKILL.md");+ let skill_path = format!("{skill_dir}/SKILL.md");+ std::fs::write(&skill_path, skill_md)?;+ ok(format!("wrote {skill_path}"));++ Ok(())+}
src/cli/isolate.rsadded+25@@ -0,0 +1,25 @@+use crate::cli::{eval, ok, DEFAULT_PORT};+use anyhow::Result;+use clap::Parser;++#[derive(Parser)]+pub struct Args {+ /// Friendly name of the node to isolate.+ pub name: String,++ #[arg(long, default_value_t = 5)]+ pub timeout_secs: u64,++ #[arg(long, default_value_t = DEFAULT_PORT)]+ pub port: u16,+}++pub fn run(args: Args) -> Result<()> {+ let code = format!(+ "return window.adomStep ? window.adomStep.isolate({}) : {{ error: 'adomStep not loaded' }};",+ serde_json::to_string(&args.name).unwrap()+ );+ let result = eval::eval_snippet(&code, args.timeout_secs, args.port, true)?;+ ok(format!("isolated {}: {result}", args.name));+ Ok(())+}
src/cli/list_components.rsadded+42@@ -0,0 +1,42 @@+use crate::cli::{eval, DEFAULT_PORT};+use anyhow::Result;+use clap::Parser;++#[derive(Parser)]+pub struct Args {+ /// Print as JSON instead of indented tree.+ #[arg(long)]+ pub json: bool,++ #[arg(long, default_value_t = 5)]+ pub timeout_secs: u64,++ #[arg(long, default_value_t = DEFAULT_PORT)]+ pub port: u16,+}++pub fn run(args: Args) -> Result<()> {+ let code = "return window.adomStep ? window.adomStep.listComponents() : { error: 'adomStep not loaded yet' };";+ let result = eval::eval_snippet(code, args.timeout_secs, args.port, true)?;++ if args.json {+ println!("{}", serde_json::to_string_pretty(&result)?);+ return Ok(());+ }++ if let Some(err) = result.get("error").and_then(|v| v.as_str()) {+ eprintln!("ERROR: {err}");+ return Err(anyhow::anyhow!("{err}"));+ }+ let empty = vec![];+ let nodes = result.as_array().unwrap_or(&empty);+ for n in nodes {+ let depth = n.get("depth").and_then(|v| v.as_u64()).unwrap_or(0) as usize;+ let name = n.get("name").and_then(|v| v.as_str()).unwrap_or("?");+ let visible = n.get("visible").and_then(|v| v.as_bool()).unwrap_or(true);+ let mark = if visible { "●" } else { "○" };+ let pad = " ".repeat(depth);+ println!("{}{} {}", pad, mark, name);+ }+ Ok(())+}
src/cli/measure.rsadded+38@@ -0,0 +1,38 @@+use crate::cli::{eval, err, ok, DEFAULT_PORT};+use anyhow::Result;+use clap::Parser;++#[derive(Parser)]+pub struct Args {+ /// First node name.+ pub from: String,+ /// Second node name.+ pub to: String,+ #[arg(long, default_value_t = 5)]+ pub timeout_secs: u64,+ #[arg(long, default_value_t = DEFAULT_PORT)]+ pub port: u16,+}++pub fn run(args: Args) -> Result<()> {+ let code = format!(+ "return window.adomStep ? window.adomStep.measure({}, {}) : {{ error: 'adomStep not loaded' }};",+ serde_json::to_string(&args.from).unwrap(),+ serde_json::to_string(&args.to).unwrap()+ );+ let result = eval::eval_snippet(&code, args.timeout_secs, args.port, true)?;+ if let Some(e) = result.get("error").and_then(|v| v.as_str()) {+ err(format!("measure failed: {e}"));+ return Err(anyhow::anyhow!("{e}"));+ }+ let d = result+ .get("distance_mm")+ .and_then(|v| v.as_f64())+ .unwrap_or(0.0);+ ok(format!(+ "measured {} → {} = {:.3} mm",+ args.from, args.to, d+ ));+ println!("{}", serde_json::to_string_pretty(&result)?);+ Ok(())+}
src/cli/open.rsadded+100@@ -0,0 +1,100 @@+use crate::cli::{err, hint, ok, DEFAULT_PORT};+use anyhow::{anyhow, Result};+use clap::Parser;+use std::process::Command;++const WEBVIEW_PANEL_TYPE: &str = "adom/a1b2c3d4-0031-4000-a000-000000000031";++#[derive(Parser)]+pub struct Args {+ #[arg(long, default_value_t = DEFAULT_PORT)]+ pub port: u16,++ #[arg(long, default_value = "STEP Viewer")]+ pub tab_name: String,++ #[arg(long, default_value = "mdi:cube-outline")]+ pub display_icon: String,+}++pub fn run(args: Args) -> Result<()> {+ open_webview_tab(args.port, &args.tab_name, &args.display_icon)+}++pub fn open_webview_tab(port: u16, tab_name: &str, display_icon: &str) -> Result<()> {+ let proxy_uri = std::env::var("VSCODE_PROXY_URI").ok();+ let base_url = match &proxy_uri {+ Some(t) => t.replace("{{port}}", &port.to_string()),+ None => format!("http://127.0.0.1:{}/", port),+ };+ let sep = if base_url.contains('?') { '&' } else { '?' };+ let cb = std::time::SystemTime::now()+ .duration_since(std::time::UNIX_EPOCH)+ .map(|d| d.as_millis())+ .unwrap_or(0);+ let webview_url = format!("{}{}_cb={}", base_url, sep, cb);++ let leaf_id = match find_leaf_pane_id() {+ Ok(id) => id,+ Err(e) => {+ err(format!("{e}"));+ hint("Open any web view tab first (or split a new pane), then re-run.");+ return Err(e);+ }+ };+ let initial_state = format!(r#"{{"url":"{}"}}"#, webview_url);++ let status = Command::new("adom-cli")+ .args([+ "hydrogen",+ "workspace",+ "add-tab",+ "--panel-id",+ &leaf_id,+ "--panel-type",+ WEBVIEW_PANEL_TYPE,+ "--display-name",+ tab_name,+ "--display-icon",+ display_icon,+ "--initial-state",+ &initial_state,+ ])+ .status()?;+ if !status.success() {+ return Err(anyhow!(+ "adom-cli workspace add-tab failed (is a tab already named '{}'? pass --tab-name to override)",+ tab_name+ ));+ }+ ok(format!("webview tab '{tab_name}' created"));+ Ok(())+}++/// Find a Web View pane to host the tab. Per memory `feedback_never_cover_vscode`:+/// only place tabs in panes that already host a Web View tab.+fn find_leaf_pane_id() -> Result<String> {+ let output = Command::new("adom-cli")+ .args(["hydrogen", "workspace", "tabs"])+ .output()?;+ let text = String::from_utf8_lossy(&output.stdout);+ let v: serde_json::Value = serde_json::from_str(&text)?;+ let tabs = v+ .get("tabs")+ .and_then(|t| t.as_array())+ .ok_or_else(|| anyhow!("adom-cli workspace tabs returned no tabs array"))?;+ for tab in tabs {+ let ptype = tab+ .get("panelType")+ .and_then(|p| p.as_str())+ .unwrap_or("");+ if ptype == WEBVIEW_PANEL_TYPE {+ if let Some(pid) = tab.get("panelId").and_then(|p| p.as_str()) {+ return Ok(pid.to_string());+ }+ }+ }+ Err(anyhow!(+ "no existing Web View pane found. Open any web view tab first or split a new pane."+ ))+}
src/cli/pins.rsadded+60@@ -0,0 +1,60 @@+use crate::cli::{eval, ok, DEFAULT_PORT};+use anyhow::Result;+use clap::{Parser, Subcommand};++#[derive(Parser)]+pub struct Args {+ #[command(subcommand)]+ pub action: Action,++ #[arg(long, default_value_t = DEFAULT_PORT, global = true)]+ pub port: u16,+}++#[derive(Subcommand)]+pub enum Action {+ /// Print the detected pin count + per-pin side classification.+ Count,+ /// Print all pin clusters with centroids (mm) — feeds footprint-matching tools.+ List,+ /// Isolate a single pin by 1-based index. Use `pins list` to see available IDs.+ Isolate {+ /// 1-based pin index.+ id: u32,+ },+ /// Restore visibility on every pin.+ ShowAll,+}++pub fn run(args: Args) -> Result<()> {+ match args.action {+ Action::Count => {+ let r = eval::eval_snippet(+ "return window.adomStep.pinCount();",+ 5,+ args.port,+ true,+ )?;+ ok(format!("{} pins", r));+ }+ Action::List => {+ let r = eval::eval_snippet(+ "return window.adomStep.pins();",+ 5,+ args.port,+ true,+ )?;+ println!("{}", serde_json::to_string_pretty(&r)?);+ }+ Action::Isolate { id } => {+ let code = format!("return window.adomStep.isolatePin({});", id);+ let r = eval::eval_snippet(&code, 5, args.port, true)?;+ ok(format!("isolated pin {id}: {r}"));+ }+ Action::ShowAll => {+ eval::eval_snippet("return window.adomStep.showAllPins();", 5, args.port, true)?;+ ok("showed all pins");+ }+ }+ Ok(())+}
src/cli/view.rsadded+116@@ -0,0 +1,116 @@+use crate::cli::{err, hint, ok, DEFAULT_PORT};+use crate::server::{self, convert, ServerConfig, ServerState};+use anyhow::{anyhow, Result};+use clap::Parser;+use std::path::PathBuf;++#[derive(Parser)]+pub struct Args {+ /// Path to a .step or .stp file.+ pub file: PathBuf,++ #[arg(long, default_value_t = DEFAULT_PORT)]+ pub port: u16,++ /// Skip opening the Hydrogen webview tab (for headless test runs).+ #[arg(long)]+ pub no_open: bool,++ #[arg(long, default_value = "STEP Viewer")]+ pub tab_name: String,++ #[arg(long, default_value = "mdi:cube-outline")]+ pub display_icon: String,+}++pub fn run(args: Args) -> Result<()> {+ let file = args.file.canonicalize().map_err(|e| {+ anyhow!(+ "{} does not exist: {e}",+ args.file.display()+ )+ })?;++ let ext = file+ .extension()+ .and_then(|s| s.to_str())+ .map(|s| s.to_ascii_lowercase());+ if !matches!(ext.as_deref(), Some("step") | Some("stp")) {+ err(format!(+ "{} is not a .step / .stp file",+ file.display()+ ));+ hint("adom-step is STEP-only; use the canonical 3D viewer for other formats");+ return Err(anyhow!("not a STEP file"));+ }++ let bytes = std::fs::read(&file).map_err(|e| anyhow!("read {}: {e}", file.display()))?;+ ok(format!(+ "converting {} ({} KB) via service-step2glb...",+ file.display(),+ bytes.len() / 1024+ ));++ let result = match convert::convert_step_bytes(&bytes) {+ Ok(r) => r,+ Err(e) => {+ err(format!("conversion failed: {e}"));+ return Err(e);+ }+ };++ let label = if result.cache_hit {+ "cache HIT"+ } else {+ "cache MISS"+ };+ ok(format!(+ "{label} — GLB at {} ({} KB, {} meshes, {} nodes, {} ms)",+ result.glb_path.display(),+ result.size_bytes / 1024,+ result.meshes,+ result.nodes,+ result.duration_ms+ ));++ let label = file+ .file_name()+ .and_then(|s| s.to_str())+ .unwrap_or("model.step")+ .to_string();++ let config = ServerConfig {+ port: args.port,+ initial_glb_path: Some(result.glb_path.clone()),+ initial_source_label: Some(label),+ initial_source_type: Some("local-file".to_string()),+ };+ let state = ServerState::new(config);++ let shutdown_state = state.clone();+ ctrlc::set_handler(move || {+ eprintln!("\nreceived SIGINT, shutting down adom-step");+ let _ = &shutdown_state;+ std::process::exit(0);+ })?;++ if !args.no_open {+ // Open in a background thread after the server is up so the+ // webview's first load resolves.+ let port = args.port;+ let tab_name = args.tab_name.clone();+ let icon = args.display_icon.clone();+ std::thread::spawn(move || {+ std::thread::sleep(std::time::Duration::from_millis(400));+ if let Err(e) =+ crate::cli::open::open_webview_tab(port, &tab_name, &icon)+ {+ eprintln!("warn: failed to open webview tab: {e}");+ }+ });+ }++ ok(format!("adom-step running on port {}", args.port));+ server::run(state)?;+ Ok(())+}
src/cli/view_library.rsadded+90@@ -0,0 +1,90 @@+use crate::cli::{err, ok, DEFAULT_PORT};+use crate::server::{self, convert, ServerConfig, ServerState};+use anyhow::{anyhow, Result};+use clap::Parser;++#[derive(Parser)]+pub struct Args {+ /// `<Library>/<Name>` path on service-kicad. Example: `Package_QFP/LQFP-64_10x10mm_P0.5mm`.+ pub lib_slash_name: String,++ #[arg(long, default_value_t = DEFAULT_PORT)]+ pub port: u16,++ #[arg(long)]+ pub no_open: bool,++ #[arg(long, default_value = "STEP Viewer")]+ pub tab_name: String,++ #[arg(long, default_value = "mdi:cube-outline")]+ pub display_icon: String,+}++pub fn run(args: Args) -> Result<()> {+ ok(format!(+ "fetching {} via service-step2glb...",+ args.lib_slash_name+ ));+ let result = match convert::convert_from_library(&args.lib_slash_name) {+ Ok(r) => r,+ Err(e) => {+ err(format!("conversion failed: {e}"));+ return Err(e);+ }+ };++ let label = if result.cache_hit {+ "cache HIT"+ } else {+ "cache MISS"+ };+ ok(format!(+ "{label} — GLB at {} ({} KB, {} meshes, {} nodes, {} ms)",+ result.glb_path.display(),+ result.size_bytes / 1024,+ result.meshes,+ result.nodes,+ result.duration_ms+ ));++ let config = ServerConfig {+ port: args.port,+ initial_glb_path: Some(result.glb_path.clone()),+ initial_source_label: Some(args.lib_slash_name.clone()),+ initial_source_type: Some("kicad-library".to_string()),+ };+ let state = ServerState::new(config);++ let shutdown_state = state.clone();+ ctrlc::set_handler(move || {+ eprintln!("\nreceived SIGINT, shutting down adom-step");+ let _ = &shutdown_state;+ std::process::exit(0);+ })?;++ if !args.no_open {+ let port = args.port;+ let tab_name = args.tab_name.clone();+ let icon = args.display_icon.clone();+ std::thread::spawn(move || {+ std::thread::sleep(std::time::Duration::from_millis(400));+ if let Err(e) =+ crate::cli::open::open_webview_tab(port, &tab_name, &icon)+ {+ eprintln!("warn: failed to open webview tab: {e}");+ }+ });+ }++ if !args.lib_slash_name.contains('/') {+ return Err(anyhow!(+ "expected <Library>/<Name>, got: {}",+ args.lib_slash_name+ ));+ }++ ok(format!("adom-step running on port {}", args.port));+ server::run(state)?;+ Ok(())+}
src/main.rsadded+93@@ -0,0 +1,93 @@+mod cli;+mod server;++use anyhow::Result;+use clap::{Parser, Subcommand};++/// adom-step — Fusion 360-style STEP file viewer.+///+/// Loads a .step / .stp file by piping it through service-step2glb, renders+/// the resulting GLB in the canonical Adom Babylon9 viewer, and adds a+/// Fusion-feel components browser, measure tool, hover/inspect HUD, and+/// section/clip planes on top.+///+/// Every UI verb has a JS API helper, an HTTP endpoint, and a CLI+/// subcommand, so an AI agent can drive the viewer headlessly.+#[derive(Parser)]+#[command(name = "adom-step", version, about, long_about = None)]+struct Cli {+ #[command(subcommand)]+ command: Command,+}++#[derive(Subcommand)]+enum Command {+ /// Open a STEP file in the Hydrogen webview tab. Converts via+ /// service-step2glb (cached on disk by content hash) then loads.+ View(cli::view::Args),+ /// Open a STEP from the service-kicad library by `<Library>/<Name>`.+ /// Example: `adom-step view-library Package_QFP/LQFP-64_10x10mm_P0.5mm`.+ ViewLibrary(cli::view_library::Args),+ /// Stop a running adom-step server on the given port.+ Stop(cli::stop::Args),+ /// Print status of the running adom-step server.+ Status(cli::status::Args),+ /// Re-attach the webview tab without restarting the server.+ Open(cli::open::Args),+ /// Health check against a running server.+ Health(cli::health::Args),+ /// Install binary to ~/.local/bin/, drop SKILL.md, install completions.+ Install(cli::install::Args),+ /// Print the discovered scene-graph component tree (one row per node).+ ListComponents(cli::list_components::Args),+ /// Hide every other top-level subtree and frame the camera on the named node.+ Isolate(cli::isolate::Args),+ /// Show the named node (and its descendants).+ Show(cli::show::Args),+ /// Hide the named node (and its descendants).+ Hide(cli::hide::Args),+ /// Show all nodes (clear visibility overrides).+ ShowAll(cli::show_all::Args),+ /// Frame the camera on a named node, or the whole model when omitted.+ Frame(cli::frame::Args),+ /// Measure the distance between two named nodes.+ /// Snaps each pick to the node's bbox center and commits as a 2-point measurement.+ Measure(cli::measure::Args),+ /// Pin-cluster operations: count | list | isolate <id> | show-all | strobe.+ /// Each "pin" is detected by clustering primitives in the Pins material group+ /// by XY centroid (eps ≈ pin pitch / 2). Useful for footprint matching.+ Pins(cli::pins::Args),+ /// Set the camera to a named view: front | back | left | right | top | bottom | iso | home.+ ViewCube(cli::view_cube::Args),+ /// Save a PNG screenshot of the current canvas.+ Screenshot(cli::screenshot::Args),+ /// Run a JavaScript snippet inside the adom-step webview and print its result.+ Eval(cli::eval::Args),+ /// Print the JavaScript console log forwarded from the adom-step webview.+ Console(cli::console::Args),+}++fn main() -> Result<()> {+ let cli = Cli::parse();+ match cli.command {+ Command::View(a) => cli::view::run(a),+ Command::ViewLibrary(a) => cli::view_library::run(a),+ Command::Stop(a) => cli::stop::run(a),+ Command::Status(a) => cli::status::run(a),+ Command::Open(a) => cli::open::run(a),+ Command::Health(a) => cli::health::run(a),+ Command::Install(a) => cli::install::run(a),+ Command::ListComponents(a) => cli::list_components::run(a),+ Command::Isolate(a) => cli::isolate::run(a),+ Command::Show(a) => cli::show::run(a),+ Command::Hide(a) => cli::hide::run(a),+ Command::ShowAll(a) => cli::show_all::run(a),+ Command::Frame(a) => cli::frame::run(a),+ Command::Measure(a) => cli::measure::run(a),+ Command::Pins(a) => cli::pins::run(a),+ Command::ViewCube(a) => cli::view_cube::run(a),+ Command::Screenshot(a) => cli::screenshot::run(a),+ Command::Eval(a) => cli::eval::run(a),+ Command::Console(a) => cli::console::run(a),+ }+}
src/server/cache.rsadded+40@@ -0,0 +1,40 @@+//! On-disk content-hashed cache for converted GLBs.+//! Files land at /tmp/adom-step-cache/<sha256>.glb plus a sibling+//! <sha256>.materials.json with the materials payload from the service.++use anyhow::{anyhow, Result};+use sha2::{Digest, Sha256};+use std::path::{Path, PathBuf};++pub fn cache_dir() -> PathBuf {+ let base = std::env::var("ADOM_STEP_CACHE")+ .unwrap_or_else(|_| "/tmp/adom-step-cache".to_string());+ PathBuf::from(base)+}++pub fn ensure_dir() -> Result<PathBuf> {+ let d = cache_dir();+ std::fs::create_dir_all(&d).map_err(|e| anyhow!("create cache dir {}: {e}", d.display()))?;+ Ok(d)+}++pub fn sha256_hex(bytes: &[u8]) -> String {+ let mut h = Sha256::new();+ h.update(bytes);+ hex::encode(h.finalize())+}++pub fn glb_path(hash: &str) -> PathBuf {+ cache_dir().join(format!("{hash}.glb"))+}++pub fn materials_path(hash: &str) -> PathBuf {+ cache_dir().join(format!("{hash}.materials.json"))+}++pub fn read_materials(p: &Path) -> serde_json::Value {+ std::fs::read_to_string(p)+ .ok()+ .and_then(|s| serde_json::from_str(&s).ok())+ .unwrap_or(serde_json::json!([]))+}
src/server/convert.rsadded+176@@ -0,0 +1,176 @@+//! Thin client to service-step2glb. Converts STEP -> GLB, caches by SHA-256.+//!+//! Mirrors the HTTP shape of step2glb's client.rs (Material structure,+//! X-Materials-Json header, X-Meshes / X-Nodes / X-Duration-Ms headers)+//! but inlines it here so adom-step has zero dependency on the step2glb crate.++use crate::cli::step2glb_service_url;+use crate::server::cache;+use anyhow::{anyhow, Result};+use std::path::PathBuf;+use std::time::Duration;++pub struct ConvertResult {+ pub glb_path: PathBuf,+ pub size_bytes: u64,+ pub meshes: u32,+ pub nodes: u32,+ pub materials: serde_json::Value,+ pub duration_ms: u64,+ pub cache_hit: bool,+}++/// Convert raw STEP bytes via the service. Cache key = SHA-256 of bytes.+/// On hit, returns instantly without hitting the network.+pub fn convert_step_bytes(step: &[u8]) -> Result<ConvertResult> {+ let dir = cache::ensure_dir()?;+ let hash = cache::sha256_hex(step);+ let glb = cache::glb_path(&hash);+ let mat_path = cache::materials_path(&hash);++ // Cache hit+ if glb.exists() {+ let size = std::fs::metadata(&glb).map(|m| m.len()).unwrap_or(0);+ let materials = cache::read_materials(&mat_path);+ return Ok(ConvertResult {+ glb_path: glb,+ size_bytes: size,+ meshes: 0, // not re-introspected on cache hit+ nodes: 0,+ materials,+ duration_ms: 0,+ cache_hit: true,+ });+ }++ // Cache miss — POST to service-step2glb /convert.+ let url = format!("{}/convert", step2glb_service_url());+ let resp = ureq::post(&url)+ .set("Content-Type", "application/step")+ .timeout(Duration::from_secs(180))+ .send_bytes(step)+ .map_err(|e| anyhow!("POST {url}: {e}"))?;++ if resp.status() != 200 {+ let body = resp.into_string().unwrap_or_default();+ return Err(anyhow!("service-step2glb returned HTTP non-200: {body}"));+ }++ let meshes = header_u32(&resp, "X-Meshes");+ let nodes = header_u32(&resp, "X-Nodes");+ let duration_ms = u64::from(header_u32(&resp, "X-Duration-Ms"));+ let mats_json_str = resp.header("X-Materials-Json").unwrap_or("[]").to_string();+ let materials: serde_json::Value =+ serde_json::from_str(&mats_json_str).unwrap_or(serde_json::json!([]));++ // Stream body to disk in cache.+ let mut tmp = dir.join(format!("{hash}.glb.tmp"));+ let mut file = std::fs::File::create(&tmp)+ .map_err(|e| anyhow!("create {}: {e}", tmp.display()))?;+ let mut reader = resp.into_reader();+ let size_bytes = std::io::copy(&mut reader, &mut file)+ .map_err(|e| anyhow!("write GLB: {e}"))?;+ drop(file);+ std::fs::rename(&tmp, &glb).map_err(|e| anyhow!("rename {}: {e}", tmp.display()))?;+ tmp.clear();++ // Write materials sidecar.+ let _ = std::fs::write(&mat_path, mats_json_str);++ Ok(ConvertResult {+ glb_path: glb,+ size_bytes,+ meshes,+ nodes,+ materials,+ duration_ms,+ cache_hit: false,+ })+}++/// Convert from a service-kicad library: GET /convert?library=L&name=N.+pub fn convert_from_library(lib_slash_name: &str) -> Result<ConvertResult> {+ let (lib, name) = lib_slash_name+ .rsplit_once('/')+ .ok_or_else(|| anyhow!("bad path {lib_slash_name:?}; expected <Library>/<Name>"))?;+ let url = format!(+ "{}/convert?library={}&name={}",+ step2glb_service_url(),+ urlencode(lib),+ urlencode(name),+ );+ // Cache key for library entries: hash of the URL path itself.+ let dir = cache::ensure_dir()?;+ let hash = cache::sha256_hex(format!("lib:{lib}/{name}").as_bytes());+ let glb = cache::glb_path(&hash);+ let mat_path = cache::materials_path(&hash);++ if glb.exists() {+ let size = std::fs::metadata(&glb).map(|m| m.len()).unwrap_or(0);+ let materials = cache::read_materials(&mat_path);+ return Ok(ConvertResult {+ glb_path: glb,+ size_bytes: size,+ meshes: 0,+ nodes: 0,+ materials,+ duration_ms: 0,+ cache_hit: true,+ });+ }++ let resp = ureq::get(&url)+ .timeout(Duration::from_secs(180))+ .call()+ .map_err(|e| anyhow!("GET {url}: {e}"))?;++ if resp.status() != 200 {+ let body = resp.into_string().unwrap_or_default();+ return Err(anyhow!(+ "service-step2glb returned HTTP non-200 for library {lib}/{name}: {body}"+ ));+ }++ let meshes = header_u32(&resp, "X-Meshes");+ let nodes = header_u32(&resp, "X-Nodes");+ let duration_ms = u64::from(header_u32(&resp, "X-Duration-Ms"));+ let mats_json_str = resp.header("X-Materials-Json").unwrap_or("[]").to_string();+ let materials: serde_json::Value =+ serde_json::from_str(&mats_json_str).unwrap_or(serde_json::json!([]));++ let tmp = dir.join(format!("{hash}.glb.tmp"));+ let mut file = std::fs::File::create(&tmp)+ .map_err(|e| anyhow!("create {}: {e}", tmp.display()))?;+ let mut reader = resp.into_reader();+ let size_bytes = std::io::copy(&mut reader, &mut file)+ .map_err(|e| anyhow!("write GLB: {e}"))?;+ drop(file);+ std::fs::rename(&tmp, &glb).map_err(|e| anyhow!("rename: {e}"))?;+ let _ = std::fs::write(&mat_path, mats_json_str);++ Ok(ConvertResult {+ glb_path: glb,+ size_bytes,+ meshes,+ nodes,+ materials,+ duration_ms,+ cache_hit: false,+ })+}++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/server/mod.rsadded+486@@ -0,0 +1,486 @@+pub mod cache;+pub mod convert;++use anyhow::Result;+use rust_embed::RustEmbed;+use std::collections::VecDeque;+use std::path::PathBuf;+use std::sync::atomic::{AtomicU64, Ordering};+use std::sync::{Arc, Mutex};+use tiny_http::{Header, Method, Request, Response, StatusCode};++#[derive(RustEmbed)]+#[folder = "vendor/3d-viewer-dist/"]+struct ViewerAssets;++const SHELL_HTML: &[u8] = include_bytes!("../assets/shell.html");+const ADOM_CSS: &[u8] = include_bytes!("../assets/adom.css");+const ICON_SVG: &[u8] = include_bytes!("../assets/icon.svg");++pub struct ServerConfig {+ pub port: u16,+ pub initial_glb_path: Option<PathBuf>,+ pub initial_source_label: Option<String>,+ /// Provenance: "kicad-library" | "local-file" | "upload" | "unknown".+ /// Drives the UI's "From KiCad library / Local file / Upload" badge so+ /// the user always knows where the geometry came from.+ pub initial_source_type: Option<String>,+}++#[derive(Clone, Debug)]+pub struct ConsoleMessage {+ pub seq: u64,+ pub ts_ms: u64,+ pub level: String,+ pub text: String,+}++#[derive(Clone, Debug)]+pub struct EvalJob {+ pub id: String,+ pub code: String,+ pub result: Option<serde_json::Value>,+ pub done: bool,+ pub taken: bool,+}++pub struct ServerState {+ pub config: ServerConfig,+ pub current_glb_path: Mutex<Option<PathBuf>>,+ pub current_source_label: Mutex<Option<String>>,+ pub current_source_type: Mutex<Option<String>>,+ pub current_materials: Mutex<serde_json::Value>,+ /// Last components tree the UI posted to /api/components.+ pub components: Mutex<serde_json::Value>,+ /// Per-node visibility map {nodeName: bool}.+ pub component_visibility: Mutex<serde_json::Value>,+ /// Active section planes — list of {axis, offset, enabled, flipped}.+ pub sections: Mutex<serde_json::Value>,+ /// Last selection posted by the UI (for `adom-step inspect`-like queries).+ pub selection: Mutex<serde_json::Value>,+ /// Pending command bus — UI polls /api/poll-command and applies.+ pub pending_command: Mutex<Option<serde_json::Value>>,+ /// JS eval pipeline.+ pub eval_jobs: Mutex<Vec<EvalJob>>,+ /// Console ring buffer (UI -> server -> CLI tail).+ pub console: Mutex<VecDeque<ConsoleMessage>>,+ pub console_seq: AtomicU64,+}++const CONSOLE_RING_CAP: usize = 2000;++impl ServerState {+ pub fn new(config: ServerConfig) -> Arc<Self> {+ let initial_path = config.initial_glb_path.clone();+ let initial_label = config.initial_source_label.clone();+ let initial_type = config.initial_source_type.clone();+ Arc::new(Self {+ config,+ current_glb_path: Mutex::new(initial_path),+ current_source_label: Mutex::new(initial_label),+ current_source_type: Mutex::new(initial_type),+ current_materials: Mutex::new(serde_json::json!([])),+ components: Mutex::new(serde_json::json!([])),+ component_visibility: Mutex::new(serde_json::json!({})),+ sections: Mutex::new(serde_json::json!([])),+ selection: Mutex::new(serde_json::json!(null)),+ pending_command: Mutex::new(None),+ eval_jobs: Mutex::new(Vec::new()),+ console: Mutex::new(VecDeque::with_capacity(CONSOLE_RING_CAP)),+ console_seq: AtomicU64::new(0),+ })+ }++ pub fn push_console(&self, level: &str, text: &str) {+ let ts_ms = std::time::SystemTime::now()+ .duration_since(std::time::UNIX_EPOCH)+ .map(|d| d.as_millis() as u64)+ .unwrap_or(0);+ let seq = self.console_seq.fetch_add(1, Ordering::SeqCst);+ let mut buf = self.console.lock().unwrap();+ if buf.len() >= CONSOLE_RING_CAP {+ buf.pop_front();+ }+ buf.push_back(ConsoleMessage {+ seq,+ ts_ms,+ level: level.to_string(),+ text: text.to_string(),+ });+ }+}++pub fn run(state: Arc<ServerState>) -> Result<()> {+ let addr = format!("127.0.0.1:{}", state.config.port);+ let server = tiny_http::Server::http(&addr)+ .map_err(|e| anyhow::anyhow!("failed to bind {}: {}", addr, e))?;++ eprintln!("adom-step listening on http://{}", addr);++ for request in server.incoming_requests() {+ let state = state.clone();+ std::thread::spawn(move || {+ if let Err(e) = route(&state, request) {+ eprintln!("request error: {e:#}");+ }+ });+ }+ Ok(())+}++// Cloudflare-compliant anti-cache headers (per gallia/skills/adom guides).+const CACHE_CONTROL_VAL: &str = "no-store, no-cache, must-revalidate, max-age=0";+const PRAGMA_VAL: &str = "no-cache";+const SURROGATE_VAL: &str = "no-store";++fn no_cache<R: std::io::Read>(resp: Response<R>) -> Response<R> {+ resp.with_header(Header::from_bytes(&b"Cache-Control"[..], CACHE_CONTROL_VAL.as_bytes()).unwrap())+ .with_header(Header::from_bytes(&b"Pragma"[..], PRAGMA_VAL.as_bytes()).unwrap())+ .with_header(Header::from_bytes(&b"Surrogate-Control"[..], SURROGATE_VAL.as_bytes()).unwrap())+}++fn json_response(status: u16, body: &serde_json::Value) -> Response<std::io::Cursor<Vec<u8>>> {+ let bytes = serde_json::to_vec(body).unwrap_or_default();+ let resp = Response::from_data(bytes)+ .with_status_code(StatusCode(status))+ .with_header(Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap());+ no_cache(resp)+}++fn text_response(status: u16, mime: &str, body: Vec<u8>) -> Response<std::io::Cursor<Vec<u8>>> {+ let resp = Response::from_data(body)+ .with_status_code(StatusCode(status))+ .with_header(Header::from_bytes(&b"Content-Type"[..], mime.as_bytes()).unwrap());+ no_cache(resp)+}++fn read_body(req: &mut Request) -> Vec<u8> {+ let mut buf = Vec::new();+ let _ = req.as_reader().read_to_end(&mut buf);+ buf+}++fn parse_json(req: &mut Request) -> Option<serde_json::Value> {+ let bytes = read_body(req);+ serde_json::from_slice(&bytes).ok()+}++fn next_eval_id(state: &ServerState) -> String {+ let seq = state.console_seq.fetch_add(1, Ordering::SeqCst);+ let ts = std::time::SystemTime::now()+ .duration_since(std::time::UNIX_EPOCH)+ .map(|d| d.as_millis() as u64)+ .unwrap_or(0);+ format!("e_{ts}_{seq}")+}++fn route(state: &Arc<ServerState>, mut request: Request) -> Result<()> {+ let url = request.url().to_string();+ // Strip query string for routing — handlers re-parse from request.url() if needed.+ let path = url.split('?').next().unwrap_or(&url).to_string();+ let method = request.method().clone();++ // ---- Health & state ---------------------------------------------------++ if path == "/health" {+ let body = serde_json::json!({+ "ok": true,+ "service": "adom-step",+ "port": state.config.port,+ });+ return Ok(request.respond(json_response(200, &body))?);+ }++ if path == "/state" {+ let body = serde_json::json!({+ "source": *state.current_source_label.lock().unwrap(),+ "source_type": *state.current_source_type.lock().unwrap(),+ "has_model": state.current_glb_path.lock().unwrap().is_some(),+ "materials": *state.current_materials.lock().unwrap(),+ "components": *state.components.lock().unwrap(),+ "visibility": *state.component_visibility.lock().unwrap(),+ "sections": *state.sections.lock().unwrap(),+ });+ return Ok(request.respond(json_response(200, &body))?);+ }++ if path == "/shutdown" && method == Method::Post {+ let _ = request.respond(json_response(200, &serde_json::json!({"ok": true})));+ std::process::exit(0);+ }++ // ---- Static shell -----------------------------------------------------++ if path == "/" || path == "/index.html" {+ return Ok(request.respond(text_response(200, "text/html; charset=utf-8", SHELL_HTML.to_vec()))?);+ }+ if path == "/adom.css" {+ return Ok(request.respond(text_response(200, "text/css; charset=utf-8", ADOM_CSS.to_vec()))?);+ }+ if path == "/favicon.svg" || path == "/favicon.ico" {+ return Ok(request.respond(text_response(200, "image/svg+xml", ICON_SVG.to_vec()))?);+ }++ // Vendored Babylon9 ESM bundle and chunks: /viewer/<filename>+ if let Some(asset) = path.strip_prefix("/viewer/") {+ if let Some(file) = ViewerAssets::get(asset) {+ let mime = if asset.ends_with(".js") {+ "application/javascript; charset=utf-8"+ } else if asset.ends_with(".css") {+ "text/css; charset=utf-8"+ } else if asset.ends_with(".env") {+ "application/octet-stream"+ } else {+ "application/octet-stream"+ };+ return Ok(request.respond(text_response(200, mime, file.data.into_owned()))?);+ }+ return Ok(request.respond(json_response(404, &serde_json::json!({"error": "not found"})))?);+ }++ // ---- Model serving ----------------------------------------------------++ if path == "/model.glb" {+ let glb_path = state.current_glb_path.lock().unwrap().clone();+ match glb_path {+ Some(p) => match std::fs::read(&p) {+ Ok(bytes) => {+ let resp = Response::from_data(bytes)+ .with_status_code(StatusCode(200))+ .with_header(Header::from_bytes(&b"Content-Type"[..], &b"model/gltf-binary"[..]).unwrap());+ return Ok(request.respond(no_cache(resp))?);+ }+ Err(e) => {+ return Ok(request.respond(json_response(+ 500,+ &serde_json::json!({"error": format!("read {}: {e}", p.display())}),+ ))?);+ }+ },+ None => {+ return Ok(request.respond(json_response(+ 404,+ &serde_json::json!({"error": "no model loaded — POST a STEP file to /api/load or drop one onto the canvas"}),+ ))?);+ }+ }+ }++ // ---- STEP upload (drop zone + AI) ------------------------------------++ if path == "/api/load" && method == Method::Post {+ let bytes = read_body(&mut request);+ if bytes.is_empty() {+ return Ok(request.respond(json_response(+ 400,+ &serde_json::json!({"error": "empty body"}),+ ))?);+ }+ // Filename hint via X-Filename header (for the source label in HUD).+ let filename = request+ .headers()+ .iter()+ .find(|h| h.field.as_str().as_str().eq_ignore_ascii_case("X-Filename"))+ .map(|h| h.value.as_str().to_string())+ .unwrap_or_else(|| "uploaded.step".to_string());++ match convert::convert_step_bytes(&bytes) {+ Ok(result) => {+ *state.current_glb_path.lock().unwrap() = Some(result.glb_path.clone());+ *state.current_source_label.lock().unwrap() = Some(filename.clone());+ *state.current_source_type.lock().unwrap() = Some("upload".to_string());+ *state.current_materials.lock().unwrap() = result.materials.clone();+ let body = serde_json::json!({+ "ok": true,+ "source": filename,+ "glb_size_bytes": result.size_bytes,+ "meshes": result.meshes,+ "nodes": result.nodes,+ "duration_ms": result.duration_ms,+ "materials": result.materials,+ "cache_hit": result.cache_hit,+ });+ return Ok(request.respond(json_response(200, &body))?);+ }+ Err(e) => {+ return Ok(request.respond(json_response(+ 500,+ &serde_json::json!({"error": e.to_string()}),+ ))?);+ }+ }+ }++ // ---- Components tree (UI <-> server) ---------------------------------++ if path == "/api/components" && method == Method::Post {+ if let Some(v) = parse_json(&mut request) {+ *state.components.lock().unwrap() = v;+ }+ return Ok(request.respond(json_response(200, &serde_json::json!({"ok": true})))?);+ }+ if path == "/api/components" && method == Method::Get {+ let v = state.components.lock().unwrap().clone();+ return Ok(request.respond(json_response(200, &v))?);+ }++ // ---- Visibility ------------------------------------------------------++ if path == "/api/visibility" && method == Method::Post {+ if let Some(v) = parse_json(&mut request) {+ *state.component_visibility.lock().unwrap() = v;+ }+ return Ok(request.respond(json_response(200, &serde_json::json!({"ok": true})))?);+ }++ // ---- Sections --------------------------------------------------------++ if path == "/api/sections" && method == Method::Post {+ if let Some(v) = parse_json(&mut request) {+ *state.sections.lock().unwrap() = v;+ }+ return Ok(request.respond(json_response(200, &serde_json::json!({"ok": true})))?);+ }+ if path == "/api/sections" && method == Method::Get {+ let v = state.sections.lock().unwrap().clone();+ return Ok(request.respond(json_response(200, &v))?);+ }++ // ---- Selection (UI -> server, drives `inspect` CLI) ------------------++ if path == "/api/selection" && method == Method::Post {+ if let Some(v) = parse_json(&mut request) {+ *state.selection.lock().unwrap() = v;+ }+ return Ok(request.respond(json_response(200, &serde_json::json!({"ok": true})))?);+ }+ if path == "/api/selection" && method == Method::Get {+ let v = state.selection.lock().unwrap().clone();+ return Ok(request.respond(json_response(200, &v))?);+ }++ // ---- Command bus (CLI -> server -> UI poll) --------------------------++ if path == "/api/command" && method == Method::Post {+ if let Some(v) = parse_json(&mut request) {+ *state.pending_command.lock().unwrap() = Some(v);+ }+ return Ok(request.respond(json_response(200, &serde_json::json!({"ok": true})))?);+ }+ if path == "/api/poll-command" && method == Method::Get {+ let v = state.pending_command.lock().unwrap().take();+ return Ok(request.respond(json_response(200, &serde_json::json!({"command": v})))?);+ }++ // ---- JS eval pipeline ------------------------------------------------++ if path == "/eval" && method == Method::Post {+ let body: serde_json::Value = parse_json(&mut request).unwrap_or(serde_json::json!({}));+ let code = body+ .get("code")+ .and_then(|v| v.as_str())+ .unwrap_or("")+ .to_string();+ if code.is_empty() {+ return Ok(request.respond(json_response(+ 400,+ &serde_json::json!({"error": "missing 'code'"}),+ ))?);+ }+ let id = next_eval_id(state);+ state.eval_jobs.lock().unwrap().push(EvalJob {+ id: id.clone(),+ code,+ result: None,+ done: false,+ taken: false,+ });+ return Ok(request.respond(json_response(200, &serde_json::json!({"id": id})))?);+ }++ if path == "/eval/pending" && method == Method::Get {+ let mut jobs = state.eval_jobs.lock().unwrap();+ let next = jobs+ .iter_mut()+ .find(|j| !j.done && !j.taken)+ .map(|j| {+ j.taken = true;+ serde_json::json!({"id": j.id, "code": j.code})+ });+ return Ok(request.respond(json_response(200, &serde_json::json!({"job": next})))?);+ }++ if path.starts_with("/eval/") && path.ends_with("/result") && method == Method::Post {+ let id = path+ .trim_start_matches("/eval/")+ .trim_end_matches("/result")+ .to_string();+ let body = parse_json(&mut request).unwrap_or(serde_json::json!(null));+ let mut jobs = state.eval_jobs.lock().unwrap();+ if let Some(job) = jobs.iter_mut().find(|j| j.id == id) {+ job.result = Some(body);+ job.done = true;+ }+ return Ok(request.respond(json_response(200, &serde_json::json!({"ok": true})))?);+ }++ if path.starts_with("/eval/")+ && !path.ends_with("/result")+ && path != "/eval/pending"+ && method == Method::Get+ {+ let id = path.trim_start_matches("/eval/").to_string();+ let jobs = state.eval_jobs.lock().unwrap();+ let body = match jobs.iter().find(|j| j.id == id) {+ Some(j) => serde_json::json!({+ "id": j.id,+ "done": j.done,+ "result": j.result.clone().unwrap_or(serde_json::Value::Null),+ }),+ None => serde_json::json!({"error": "no such id"}),+ };+ return Ok(request.respond(json_response(200, &body))?);+ }++ // ---- Console forwarder ----------------------------------------------++ if path == "/console" && method == Method::Post {+ if let Some(v) = parse_json(&mut request) {+ let level = v.get("level").and_then(|x| x.as_str()).unwrap_or("log");+ let text = v.get("text").and_then(|x| x.as_str()).unwrap_or("");+ state.push_console(level, text);+ }+ return Ok(request.respond(json_response(200, &serde_json::json!({"ok": true})))?);+ }++ if path == "/console" && method == Method::Get {+ let qs = url.split('?').nth(1).unwrap_or("");+ let since: u64 = qs+ .split('&')+ .find_map(|p| p.strip_prefix("since=").and_then(|v| v.parse().ok()))+ .unwrap_or(0);+ let buf = state.console.lock().unwrap();+ let messages: Vec<_> = buf+ .iter()+ .filter(|m| m.seq >= since)+ .map(|m| {+ serde_json::json!({+ "seq": m.seq,+ "ts_ms": m.ts_ms,+ "level": m.level,+ "text": m.text,+ })+ })+ .collect();+ let next_since = buf.iter().last().map(|m| m.seq + 1).unwrap_or(since);+ let body = serde_json::json!({"messages": messages, "next_since": next_since});+ return Ok(request.respond(json_response(200, &body))?);+ }++ // ---- 404 -------------------------------------------------------------++ Ok(request.respond(json_response(+ 404,+ &serde_json::json!({"error": format!("no route for {} {}", method, path)}),+ ))?)+}