main
Colby Knox 0.2.57 source (not yet released, needs signing): schematic editing (sch_place_part, sch_connect, sch_edit, annotate, do… 93b3032 10h ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738
//! Outputs and project ops: compile/ERC, OutJobs (gerber/BOM), STEP export via Barrett's
//! AdomBridge extension, the DRC modal dance, and the -R script lanes.
use crate::support::{self, s, s_or, timeout_of};
use crate::{ab, agent, dialogs, instance, lifecycle, shot};
use serde_json::{json, Value};
use std::time::Duration;

fn ensure_project(args: &Value) -> (String, Value) {
    let path = s(args, "projectPath").or_else(|| s(args, "filePath")).unwrap_or_else(support::demo_project);
    let r = agent::call("open", &[("path", path.clone())], None, timeout_of(args, 60));
    (path, r)
}

pub fn compile(args: &Value) -> Value {
    if let Some(g) = support::need_licensed() {
        return g;
    }
    let t0 = support::now();
    let (path, r) = ensure_project(args);
    if r.get("success").and_then(|b| b.as_bool()) != Some(true) {
        return r;
    }
    let c = agent::call("compile",
        &[("objectKind", s_or(args, "objectKind", "Project")), ("limit", s_or(args, "limit", "200"))],
        None, timeout_of(args, 120));
    if c.get("success").and_then(|b| b.as_bool()) != Some(true) {
        return c;
    }
    let data = c.get("data").cloned().unwrap_or(json!({}));
    let msgs = data.get("messages").and_then(|m| m.as_array()).cloned().unwrap_or_default();
    let mut by_class: std::collections::BTreeMap<String, u64> = std::collections::BTreeMap::new();
    for m in &msgs {
        let k = m.get("class").and_then(|c| c.as_str()).unwrap_or("?").to_string();
        *by_class.entry(k).or_default() += 1;
    }
    json!({"success": true, "project": path, "messageCount": data.get("messageCount"),
        "byClass": by_class, "messages": msgs, "elapsedSec": support::elapsed_round(t0),
        "windowEtiquette": "background",
        "_hint": "messages[] is Altium's Messages panel after Compile (ERC + connectivity). Zero messages = clean."})
}

pub fn run_outjob(args: &Value) -> Value {
    if let Some(g) = support::need_licensed() {
        return g;
    }
    let t0 = support::now();
    let outjob = s(args, "outjobPath").or_else(|| s(args, "filePath")).unwrap_or_else(support::demo_outjob_fab);
    // Refuse a missing OutJob BY NAME. The agent answered ok:false with no error text, so the caller
    // saw success:false and nothing else (found by the verb battery, 2026-09-21).
    if !std::path::Path::new(&outjob).is_file() {
        return json!({"success": false, "errorCode": "outjob_not_found", "outjobPath": outjob,
            "error": format!("no OutJob file at {outjob}"),
            "_hint": "Pass outjobPath (an absolute path to a .OutJob on THIS machine) and projectPath. With no outjobPath the verb runs Altium's bundled demo OutJob, which OPENS what it generates on the user's screen (a spreadsheet, a PDF): fine for 'show me', wrong for unattended work."});
    }
    let medium = s_or(args, "medium", "Folder Structure");
    let proj = s(args, "projectPath")
        .or_else(|| if outjob.starts_with(support::EXAMPLES_DIR) { Some(support::demo_project()) } else { None });
    if let Some(p) = &proj {
        let r = support::agent_open(p, 60);
        if r.get("success").and_then(|b| b.as_bool()) != Some(true) {
            return r;
        }
    }
    // Altium stops an OutJob on a board with health-check findings (shelved or modified polygons, ...) with a
    // modal the agent cannot answer while it waits: a watcher answers it (Generate Outputs, or Cancel with
    // healthCheck:"stop") and the reply says what it found (measured 2026-10-21 on a board after a change order)
    let stop = s_or(args, "healthCheck", "continue") == "stop";
    let done = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
    let seen: std::sync::Arc<std::sync::Mutex<Vec<Value>>> = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
    let watcher = {
        let (done, seen) = (done.clone(), seen.clone());
        std::thread::spawn(move || {
            while !done.load(std::sync::atomic::Ordering::Relaxed) {
                instance::invalidate_snapshot();
                if let Some(w) = instance::altium_windows().into_iter().find(|w| w.get("title").and_then(|t| t.as_str()).map(|t| t.to_lowercase().contains("health check")).unwrap_or(false)) {
                    let h = w.get("hwnd").cloned().unwrap_or(Value::Null);
                    let tree = ab::call("desktop_ui_tree", json!({"hwnd": h, "maxDepth": 12, "maxNodes": 200, "roleFilter": ["text"]}), &[], 20);
                    let mut texts = Vec::new();
                    fn walk(n: &Value, out: &mut Vec<String>) {
                        if let Some(s) = n.get("name").and_then(|v| v.as_str()) { if !s.trim().is_empty() { out.push(s.trim().to_string()); } }
                        for c in n.get("children").and_then(|c| c.as_array()).into_iter().flatten() { walk(c, out); }
                    }
                    let root = tree.get("root").cloned().or_else(|| tree.get("output").and_then(|o| o.as_str()).and_then(|t| serde_json::from_str::<Value>(t).ok()).and_then(|v| v.get("root").cloned())).unwrap_or(Value::Null);
                    walk(&root, &mut texts);
                    let button = if stop { "Cancel" } else { "Generate Outputs" };
                    let c = ab::call("desktop_ui_click", json!({"hwnd": h, "contains": button, "role": "button"}), &[], 20);
                    seen.lock().unwrap().push(json!({"dialog": w.get("title"), "text": texts, "answered": button, "clicked": c.get("success")}));
                    std::thread::sleep(Duration::from_secs(2));
                }
                std::thread::sleep(Duration::from_secs(1));
            }
        })
    };
    let o = agent::call("outjob",
        &[("path", outjob.clone()), ("medium", medium.clone()), ("objectKind", s_or(args, "objectKind", "OutputBatch"))],
        None, timeout_of(args, 300));
    done.store(true, std::sync::atomic::Ordering::Relaxed);
    let _ = watcher.join();
    let health: Vec<Value> = seen.lock().unwrap().clone();
    let mut dl = dialogs::scan_scoped(true);
    shot::attach_dialog_shots(&mut dl);
    // what the OutJob wrote under "Project Outputs for <project>"
    let base = std::path::Path::new(proj.as_deref().unwrap_or(&outjob));
    let stem = base.file_stem().map(|q| q.to_string_lossy().to_string()).unwrap_or_default();
    let pdir = base.parent().map(|p| p.join(format!("Project Outputs for {stem}")));
    let (out_dir, files) = match pdir {
        Some(d) if d.exists() => {
            let mut files = Vec::new();
            let mut stack = vec![d.clone()];
            while let Some(q) = stack.pop() {
                if let Ok(rd) = std::fs::read_dir(&q) {
                    for e in rd.flatten() {
                        let p = e.path();
                        if p.is_dir() {
                            stack.push(p);
                        } else if let Ok(rel) = p.strip_prefix(&d) {
                            files.push(rel.to_string_lossy().to_string());
                        }
                    }
                }
            }
            files.sort();
            files.truncate(60);
            (Some(d.to_string_lossy().to_string()), files)
        }
        _ => (None, Vec::new()),
    };
    let ok = o.get("success").and_then(|b| b.as_bool()).unwrap_or(false) && dl.is_empty();
    json!({"success": ok, "outjob": outjob, "medium": medium, "agent": o.get("data"),
        "outputDir": out_dir, "files": files, "dialogs": dl, "healthCheck": health,
        "elapsedSec": support::elapsed_round(t0), "windowEtiquette": "background",
        "_hint": "files[] is what the OutJob wrote under 'Project Outputs for <project>'. A dialog means a medium needed a decision (printer/PDF path) — read dialogs[].shot."})
}

pub fn export_gerber(args: &Value) -> Value {
    let mut a = args.clone();
    if a.get("outjobPath").is_none() {
        a["outjobPath"] = json!(support::demo_outjob_fab());
    }
    if a.get("medium").is_none() {
        a["medium"] = json!("Folder Structure");
    }
    let mut r = run_outjob(&a);
    r["surface"] = json!("gerber");
    r
}

pub fn export_bom(args: &Value) -> Value {
    let mut a = args.clone();
    if a.get("outjobPath").is_none() {
        a["outjobPath"] = json!(support::demo_outjob_asm());
    }
    if a.get("medium").is_none() {
        a["medium"] = json!("Folder Structure");
    }
    let mut r = run_outjob(&a);
    r["surface"] = json!("bom");
    r
}

// ---- the AdomBridge .NET extension (Barrett's) — parameterized processes -----

/// SPACE-FREE: Altium's -R parser splits on whitespace.
const EXT_REQ_DIR: &str = r"C:\Users\Public\AdomAltium";

pub const EXT_STATUS_NOTE: &str = "MEASURED 2026-08-29 on AD 26.9.1: the AdomBridge server never loads into the RUNNING instance — `X2 -RAdomBridge:*` helpers wait on Altium's startup mutex ~65-90 s and then host the server themselves, and RunProcess('AdomBridge:Ping') from inside the agent is silently ignored (unknown process). It also coincided with a 'Could not start Vault Explorer' warning at startup, so it was unregistered again. Keep the verbs; they fail loudly until the extension is rebuilt/verified for AD 26.";

fn extension_installed() -> bool {
    let base = std::path::Path::new(r"C:\ProgramData\Altium");
    let Ok(rd) = std::fs::read_dir(base) else { return false };
    for e in rd.flatten() {
        let name = e.file_name().to_string_lossy().to_string();
        if name.starts_with("Altium Designer")
            && e.path().join("Extensions").join("AdomBridge").join("AdomBridge.dll").exists()
        {
            return true;
        }
    }
    false
}

/// Extension directory shipped beside the exe (release packs `extension/` next to it).
fn ext_dir() -> std::path::PathBuf {
    std::env::current_exe()
        .ok()
        .and_then(|p| p.parent().map(|d| d.join("extension")))
        .unwrap_or_else(|| std::path::PathBuf::from("extension"))
}

/// X2.EXE -RAdomBridge:<Process>(Req=<file>) with the parameters in a request file at a
/// space-free path. The extension writes its result file; only the filesystem is proof.
fn ext_dispatch(process: &str, req_lines: &[String], result_file: &str, timeout: Duration) -> Value {
    if !extension_installed() {
        return json!({"success": false, "errorCode": "extension_not_installed",
            "_hint": "altium_install_extension (Altium closed), restart Altium, retry."});
    }
    let exe = match lifecycle::x2_exe_pub() {
        Some(e) => e,
        None => return support::not_installed(),
    };
    let _ = std::fs::create_dir_all(EXT_REQ_DIR);
    let req = format!("{EXT_REQ_DIR}\\{}_req.txt", process.to_lowercase());
    let res = format!("{EXT_REQ_DIR}\\{result_file}");
    let _ = std::fs::remove_file(&res);
    if let Err(e) = std::fs::write(&req, format!("{}\n", req_lines.join("\n"))) {
        return json!({"success": false, "error": format!("cannot write request file: {e}")});
    }
    let mut child = match lifecycle::spawn_helper_pub(&exe, &format!("-RAdomBridge:{process}(Req={req})")) {
        Ok(c) => c,
        Err(e) => return json!({"success": false, "error": format!("could not launch X2 helper: {e}")}),
    };
    let t0 = std::time::Instant::now();
    while t0.elapsed() < timeout {
        if std::path::Path::new(&res).exists() {
            std::thread::sleep(Duration::from_millis(300));
            let body = std::fs::read_to_string(&res).unwrap_or_default();
            return json!({"success": true, "result": body, "elapsedSec": (t0.elapsed().as_secs_f64() * 10.0).round() / 10.0});
        }
        std::thread::sleep(Duration::from_millis(500));
    }
    let _ = child.kill();
    let _ = child.wait();
    json!({"success": false, "errorCode": "extension_timeout",
        "elapsedSec": (t0.elapsed().as_secs_f64() * 10.0).round() / 10.0,
        "_hint": format!("No result file. {EXT_STATUS_NOTE}")})
}

/// Headless STEP export. Default lane `outjob`: Altium's own ExportSTEP output, run from a throwaway
/// project in %TEMP% that points at the board (measured on AD 26.9.1, 2026-10-09: no dialog, no click,
/// the file is written in ~5 s). Lane `extension` is Barrett's AdomBridge:ExportStep, kept for when the
/// .NET extension loads again.
pub fn export_step(args: &Value) -> Value {
    match s_or(args, "lane", "outjob").as_str() {
        "extension" => export_step_extension(args),
        _ => export_step_outjob(args),
    }
}

/// ComponentSuffixType in Altium's ExportSTEPView record, measured 2026-10-09 by exporting the same board
/// four times: the component (PRODUCT) name is always the DESIGNATOR, the type only picks the suffix.
/// 0 = none ("J1"), 1 = board name ("J1MpinTest"), 2 = `suffix` ("J1_SFX"), 3 = random id ("J1_ID_LENSEEVK").
fn step_suffix_type(name: &str) -> Option<u8> {
    match name.to_ascii_lowercase().as_str() {
        "none" | "0" => Some(0),
        "board" | "1" => Some(1),
        "custom" | "2" => Some(2),
        "id" | "3" => Some(3),
        _ => None,
    }
}

fn step_outjob_text(args: &Value) -> Result<String, String> {
    let b = |k: &str, d: bool| args.get(k).and_then(|v| v.as_bool()).unwrap_or(d);
    let tf = |v: bool| if v { "True" } else { "False" };
    let holes = b("holes", true);
    let suffix_kind = s_or(args, "suffixType", "none");
    let suffix_type = step_suffix_type(&suffix_kind).ok_or_else(|| format!("suffixType must be none, board, custom or id (got {suffix_kind:?})"))?;
    let suffix = s(args, "suffix").unwrap_or_default();
    if suffix_type == 2 && suffix.is_empty() {
        return Err("suffixType custom needs a non-empty suffix".into());
    }
    if suffix.contains('|') || suffix.contains('\n') || suffix.contains('\r') {
        return Err("suffix may not contain '|' or a line break".into());
    }
    let n = |k: &str, d: u64| args.get(k).and_then(|v| v.as_u64()).unwrap_or(d);
    // The record Altium's own Assembly.OutJob template ships, with pad holes on by default.
    let item = format!(
        "Record=ExportSTEPView|ExportComponentOptions={}|ExportModelsOption={}|ExportHolesOption=0|CanSelectPrimitives=False|IncludeMechanicalPadHoles={}|IncludeElectricalPadHoles={}|IncludeFreePadHoles={}|ExportFoldedBoard={}|ExportFoldedBoardRate=100|ComponentSuffixType={}|ComponentSuffix={}|ExportCopperOption={}|ExportCopperLayer=1|ExportPadAndViaBarrelsOnly=False|IgnoreBoardCopperLayerColors=False|ExportAsSinglePart={}",
        n("componentsOption", 0), n("modelsOption", 2), tf(holes), tf(holes), tf(holes), tf(b("folded", false)),
        suffix_type, if suffix.is_empty() { " ".to_string() } else { suffix }, n("copperOption", 0), tf(b("singlePart", false)));
    let lines = [
        "[OutputJobFile]", "Version=1.0", "Caption=", "Description=Adom altium_export_step", "",
        "[OutputGroup1]", "Name=", "Description=", "TargetOutputMedium=STEP", "VariantName=[No Variations]", "VariantScope=0",
        "CurrentConfigurationName=", "TargetPrinter=",
        "PrinterOptions=Record=PrinterOptions|Copies=1|Duplex=1|TrueTypeOptions=3|Collate=1|PrintJobKind=1|PrintWhat=1",
        "OutputMedium1=STEP", "OutputMedium1_Type=GeneratedFiles",
        "OutputType1=ExportSTEP", "OutputName1=STEP", "OutputCategory1=Export", "OutputDocumentPath1=", "OutputVariantName1=",
        "OutputEnabled1=1", "OutputEnabled1_OutputMedium1=1", "OutputDefault1=0",
        "Configuration1_Name1=OutputConfigurationParameter1",
    ];
    let mut out = lines.join("\r\n");
    out.push_str(&format!("\r\nConfiguration1_Item1={item}\r\n\r\n[PublishSettings]\r\n\r\n[GeneratedFilesSettings]\r\nRelativeOutputPath1=\r\nOpenOutputs1=0\r\nAddToProject1=0\r\nTimestampFolder1=0\r\nUseOutputName1=0\r\nEnableReload1=0\r\n"));
    Ok(out)
}

/// PRODUCT names in a STEP file, so the caller sees how parts are named without opening it.
fn step_product_names(path: &std::path::Path) -> Vec<String> {
    let text = std::fs::read(path).map(|b| String::from_utf8_lossy(&b).to_string()).unwrap_or_default();
    let mut names: Vec<String> = Vec::new();
    for part in text.split("PRODUCT('").skip(1) {
        if let Some(end) = part.find('\'') {
            let n = part[..end].to_string();
            if !n.is_empty() && !n.starts_with("Open CASCADE") && !names.contains(&n) {
                names.push(n);
            }
        }
        if names.len() >= 200 {
            break;
        }
    }
    names
}

/// Split a STEP entity's argument list at its top-level commas (outside strings and nested lists).
fn step_args(body: &str) -> Vec<&str> {
    let (mut out, mut depth, mut quoted, mut start) = (Vec::new(), 0i32, false, 0usize);
    for (i, c) in body.char_indices() {
        match c {
            '\'' => quoted = !quoted,
            '(' if !quoted => depth += 1,
            ')' if !quoted => depth -= 1,
            ',' if !quoted && depth == 0 => {
                out.push(body[start..i].trim());
                start = i + 1;
            }
            _ => {}
        }
    }
    out.push(body[start..].trim());
    out
}

/// Name each component occurrence of the board assembly after its component (the designator).
/// Altium writes every NEXT_ASSEMBLY_USAGE_OCCURRENCE with an empty name and puts the designator on
/// the PRODUCT, while KiCad names the occurrence ("MP1") and the product after the model. Readers that
/// look for parts by occurrence name (step2glb's molecule MP1-MP4 anchoring, measured 2026-10-20: 0
/// markers before, anchored after) then find nothing in an Altium STEP. Returns the text and how many
/// occurrences it named; occurrences that already have a name and nested model occurrences are kept.
fn name_component_occurrences(text: &str) -> (String, usize) {
    // (byte range of the entity's argument list, id, type, args)
    let mut ents: Vec<(usize, usize, String, String, String)> = Vec::new();
    let bytes = text.as_bytes();
    let mut i = 0usize;
    while i < bytes.len() {
        let at_line_start = i == 0 || bytes[i - 1] == b'\n';
        if at_line_start && bytes[i] == b'#' {
            let Some(eq) = text[i..].find('=').map(|e| i + e) else { break };
            let id = text[i + 1..eq].trim().to_string();
            let Some(open) = text[eq..].find('(').map(|o| eq + o) else { break };
            let ty = text[eq + 1..open].trim().to_string();
            let (mut depth, mut quoted, mut close) = (0i32, false, None);
            for (k, c) in text[open..].char_indices() {
                match c {
                    '\'' => quoted = !quoted,
                    '(' if !quoted => depth += 1,
                    ')' if !quoted => {
                        depth -= 1;
                        if depth == 0 {
                            close = Some(open + k);
                            break;
                        }
                    }
                    _ => {}
                }
            }
            let Some(close) = close else { break };
            if id.chars().all(|c| c.is_ascii_digit()) && !ty.is_empty() {
                ents.push((open + 1, close, id, ty, text[open + 1..close].to_string()));
            }
            i = close + 1;
        } else {
            i += 1;
        }
    }
    let unquote = |a: &str| a.strip_prefix('\'').and_then(|x| x.strip_suffix('\'')).map(|x| x.replace("''", "'"));
    let reference = |a: &str| a.strip_prefix('#').map(|x| x.to_string());
    let mut product_name = std::collections::HashMap::new();
    let mut formation_product = std::collections::HashMap::new();
    let mut definition_formation = std::collections::HashMap::new();
    for (_, _, id, ty, body) in &ents {
        let a = step_args(body);
        match ty.as_str() {
            "PRODUCT" => if let Some(n) = a.first().and_then(|x| unquote(x)) { product_name.insert(id.clone(), n); },
            t if t.starts_with("PRODUCT_DEFINITION_FORMATION") => if let Some(p) = a.get(2).and_then(|x| reference(x)) { formation_product.insert(id.clone(), p); },
            "PRODUCT_DEFINITION" => if let Some(f) = a.get(2).and_then(|x| reference(x)) { definition_formation.insert(id.clone(), f); },
            _ => {}
        }
    }
    let name_of = |pd: &str| definition_formation.get(pd).and_then(|f| formation_product.get(f)).and_then(|p| product_name.get(p)).cloned();
    let usages: Vec<_> = ents.iter().filter(|e| e.3 == "NEXT_ASSEMBLY_USAGE_OCCURRENCE").collect();
    let children: std::collections::HashSet<String> = usages.iter().filter_map(|e| step_args(&e.4).get(4).and_then(|x| reference(x))).collect();
    let mut edits: Vec<(usize, usize, String)> = Vec::new();
    for (start, end, _, _, body) in usages {
        let a = step_args(body);
        if a.len() < 5 || a[1] != "''" {
            continue;
        }
        let (Some(parent), Some(child)) = (reference(a[3]), reference(a[4])) else { continue };
        // Only the board assembly's own components: the root is the definition that is no one's child.
        if children.contains(&parent) {
            continue;
        }
        let Some(name) = name_of(&child).filter(|n| !n.is_empty()) else { continue };
        let mut rebuilt: Vec<String> = a.iter().map(|x| x.to_string()).collect();
        rebuilt[1] = format!("'{}'", name.replace('\'', "''"));
        edits.push((*start, *end, rebuilt.join(",")));
    }
    let named = edits.len();
    let mut out = text.to_string();
    edits.sort_by(|x, y| y.0.cmp(&x.0));
    for (s, e, body) in edits {
        out.replace_range(s..e, &body);
    }
    (out, named)
}

/// Apply `name_component_occurrences` to an exported STEP in place (unless `nameOccurrences` is false).
fn name_step_occurrences(path: &str, args: &Value) -> Value {
    if args.get("nameOccurrences").and_then(|b| b.as_bool()) == Some(false) {
        return json!({"skipped": "nameOccurrences false"});
    }
    let Ok(bytes) = std::fs::read(path) else { return json!({"error": "could not read the STEP"}) };
    let text = String::from_utf8_lossy(&bytes).to_string();
    let (named_text, n) = name_component_occurrences(&text);
    if n > 0 {
        if let Err(e) = std::fs::write(path, named_text) {
            return json!({"error": e.to_string()});
        }
    }
    json!({"named": n})
}

fn export_step_outjob(args: &Value) -> Value {
    if let Some(g) = support::need_licensed() {
        return g;
    }
    let t0 = support::now();
    let pcb = s(args, "filePath").unwrap_or_else(support::demo_pcb);
    if !std::path::Path::new(&pcb).is_file() {
        return json!({"success": false, "errorCode": "pcb_not_found", "filePath": pcb,
            "_hint": "filePath must be an absolute path to a .PcbDoc on THIS machine (send it with ab's send_files first)."});
    }
    let out = s(args, "output").unwrap_or_else(|| {
        let stem = pcb.rsplit_once('.').map(|(a, _)| a.to_string()).unwrap_or_else(|| pcb.clone());
        format!("{stem}.step")
    });
    let outjob = match step_outjob_text(args) {
        Ok(t) => t,
        Err(e) => return json!({"success": false, "errorCode": "bad_args", "error": e}),
    };
    // A throwaway project: an OutJob only runs for the project it belongs to (an OutJob outside the
    // project wrote nothing, measured), and the user's own project is never touched.
    let tmp = std::env::temp_dir().join("adom-altium-step").join(format!("{}", (support::now() * 1000.0) as u64));
    let proj = tmp.join("AdomStepExport.PrjPcb");
    let oj = tmp.join("AdomStep.OutJob");
    let prj = format!("[Design]\r\nVersion=1.0\r\nHierarchyMode=0\r\n\r\n[Document1]\r\nDocumentPath={}\r\nAnnotationEnabled=1\r\nDocumentUniqueId=\r\n\r\n[Document2]\r\nDocumentPath=AdomStep.OutJob\r\nAnnotationEnabled=1\r\nDocumentUniqueId=\r\n",
        pcb.replace('/', "\\"));
    if let Err(e) = std::fs::create_dir_all(&tmp).and_then(|_| std::fs::write(&oj, outjob)).and_then(|_| std::fs::write(&proj, prj)) {
        return json!({"success": false, "errorCode": "temp_write_failed", "error": e.to_string()});
    }
    let proj_s = proj.to_string_lossy().to_string();
    let r = run_outjob(&json!({"outjobPath": oj.to_string_lossy(), "medium": "STEP", "projectPath": proj_s, "timeout": timeout_of(args, 300).as_secs()}));
    let gen_dir = tmp.join("Project Outputs for AdomStepExport").join("ExportSTEP");
    let produced = std::fs::read_dir(&gen_dir).ok().and_then(|rd| {
        rd.flatten().map(|e| e.path()).find(|p| p.extension().map(|x| x.eq_ignore_ascii_case("step") || x.eq_ignore_ascii_case("stp")).unwrap_or(false))
    });
    // Close the throwaway project whatever happened, so it does not sit in the user's Projects panel.
    let _ = agent::call("close_document", &[("path", oj.to_string_lossy().to_string())], None, Duration::from_secs(30));
    let _ = agent::call("close_document", &[("path", proj_s.clone())], None, Duration::from_secs(30));
    let Some(src) = produced else {
        return json!({"success": false, "errorCode": "step_not_written", "filePath": pcb, "outjob": r,
            "elapsedSec": support::elapsed_round(t0), "tempDir": tmp.to_string_lossy(),
            "_hint": "Altium ran the STEP output but wrote no .step. Read outjob.dialogs[] (a modal needs a decision) and altium_check_dialogs; a board that is open with unsaved changes exports its SAVED state, so altium_save first."});
    };
    if let Some(parent) = std::path::Path::new(&out).parent() {
        let _ = std::fs::create_dir_all(parent);
    }
    if let Err(e) = std::fs::copy(&src, &out) {
        return json!({"success": false, "errorCode": "copy_failed", "error": e.to_string(), "produced": src.to_string_lossy(), "tempDir": tmp.to_string_lossy()});
    }
    let occurrences = name_step_occurrences(&out, args);
    let bytes = std::fs::metadata(&out).map(|m| m.len()).unwrap_or(0);
    let products = step_product_names(std::path::Path::new(&out));
    let _ = std::fs::remove_dir_all(&tmp);
    json!({"success": bytes > 0, "lane": "outjob", "filePath": pcb, "output": out, "outputExists": bytes > 0, "outputBytes": bytes,
        "products": products, "occurrences": occurrences, "suffixType": s_or(args, "suffixType", "none"), "elapsedSec": support::elapsed_round(t0),
        "windowEtiquette": "background",
        "_hint": "products[] are the STEP part names: Altium names each component by its DESIGNATOR (suffixType only appends none/board/custom/id), and a 3D model keeps the name inside its own STEP. Each component occurrence is also named after its designator (occurrences.named), as KiCad's STEP does, so step2glb --molecule finds MP1-MP4; nameOccurrences:false keeps Altium's empty names. Pull the file with ab's pull_file."})
}

/// Headless STEP with real holes/copper/models — Barrett's AdomBridge:ExportStep.
fn export_step_extension(args: &Value) -> Value {
    if let Some(g) = support::need_licensed() {
        return g;
    }
    let pcb = s(args, "filePath").unwrap_or_else(support::demo_pcb);
    let out = s(args, "output").unwrap_or_else(|| {
        let stem = pcb.rsplit_once('.').map(|(a, _)| a.to_string()).unwrap_or_else(|| pcb.clone());
        format!("{stem}.step")
    });
    let lines = vec![
        format!("PcbDoc={pcb}"),
        format!("Out={out}"),
        format!("Holes={}", s_or(args, "holes", "All")),
        format!("PadHoles={}", if args.get("padHoles").and_then(|b| b.as_bool()).unwrap_or(true) { "true" } else { "false" }),
        format!("Copper={}", s_or(args, "copper", "None")),
        format!("Models={}", s_or(args, "models", "Step")),
        format!("Folded={}", if args.get("folded").and_then(|b| b.as_bool()).unwrap_or(false) { "true" } else { "false" }),
    ];
    let mut r = ext_dispatch("ExportStep", &lines, "adombridge_exportstep.txt", timeout_of(args, 300));
    let exists = std::path::Path::new(&out).exists();
    r["output"] = json!(out);
    r["outputExists"] = json!(exists);
    r["outputBytes"] = json!(if exists { std::fs::metadata(&out).map(|m| m.len()).unwrap_or(0) } else { 0 });
    if r.get("success").and_then(|b| b.as_bool()) == Some(true) && !exists {
        r["success"] = json!(false);
        r["errorCode"] = json!("step_not_written");
    }
    if exists {
        r["occurrences"] = name_step_occurrences(&out, args);
    }
    if r.get("_hint").is_none() {
        r["_hint"] = json!("Copper=All bakes traces as solids (30x bigger file); Holes=All gives real drilled geometry.");
    }
    r
}

pub fn import_kicad(args: &Value) -> Value {
    if let Some(g) = support::need_licensed() {
        return g;
    }
    let file = match s(args, "filePath") {
        Some(f) if !f.is_empty() => f,
        _ => return json!({"success": false, "error": "filePath (.kicad_sym / .kicad_sch / .kicad_pcb) is required"}),
    };
    let lines = vec![format!("Mode={}", s_or(args, "mode", "Import")), format!("File={file}")];
    let mut r = ext_dispatch("ImportKiCad", &lines, "adombridge_importkicad.txt", timeout_of(args, 300));
    if r.get("_hint").is_none() {
        r["_hint"] = json!("Output lands in 'Imported <stem>' beside the source. Footprints are NOT imported by Altium's KiCad importer — link one with altium_set_params {footprint}.");
    }
    r
}

pub fn install_extension(args: &Value) -> Value {
    let installer = ext_dir().join("Install-AdomBridge.ps1");
    if !installer.exists() {
        return json!({"success": false, "error": format!("installer missing at {}", installer.display())});
    }
    let remove = args.get("remove").and_then(|b| b.as_bool()).unwrap_or(false);
    if !instance::instances().is_empty() && !remove {
        return json!({"success": false, "errorCode": "altium_running",
            "error": "Altium must be CLOSED to install/update the extension (it holds AdomBridge.dll open).",
            "_hint": "altium_close, then altium_install_extension, then altium_launch. Put extension/REMOVE-AdomBridge.cmd on the Desktop first — a failed extension load stops Altium starting."});
    }
    let mut cmd = std::process::Command::new("powershell");
    cmd.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-File"])
        .arg(&installer)
        .arg("-Source")
        .arg(ext_dir().join("AdomBridge"));
    if remove {
        cmd.arg("-Remove");
    }
    #[cfg(windows)]
    {
        use std::os::windows::process::CommandExt;
        cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
    }
    match cmd.output() {
        Ok(p) => {
            let stdout = String::from_utf8_lossy(&p.stdout).to_string();
            let stderr = String::from_utf8_lossy(&p.stderr).to_string();
            let ok = p.status.success() && !stderr.to_lowercase().contains("denied");
            json!({"success": ok, "removed": remove,
                "output": stdout.chars().rev().take(1200).collect::<String>().chars().rev().collect::<String>(),
                "stderr": stderr.chars().rev().take(400).collect::<String>().chars().rev().collect::<String>(),
                "_hint": "Start Altium — extensions load at startup. Then altium_export_step / altium_import_kicad."})
        }
        Err(e) => json!({"success": false, "error": format!("powershell failed to start: {e}")}),
    }
}

pub fn extension_ping(_args: &Value) -> Value {
    let mut r = ext_dispatch("Ping", &["Msg=hello".to_string()], "adombridge_ping.txt", Duration::from_secs(30));
    r["installedOnDisk"] = json!(extension_installed());
    r
}

// ---- DRC: the modal dance ----------------------------------------------------

/// PCB:DesignRuleCheck opens a MODAL — the agent blocks inside RunProcess until it closes.
/// The bridge drives the dialog's 'Run Design Rule Check' via ab UIA in the background, then
/// reads the violations off the board through the agent.
pub fn run_drc(args: &Value) -> Value {
    if let Some(g) = support::need_licensed() {
        return g;
    }
    let t0 = support::now();
    let path = s(args, "filePath").unwrap_or_else(support::demo_pcb);
    let r = support::agent_open(&path, 60);
    if r.get("success").and_then(|b| b.as_bool()) != Some(true) {
        return r;
    }
    // fire the process WITHOUT waiting for the agent (the modal freezes the loop)
    agent::call("run_process", &[("process", "PCB:DesignRuleCheck".into()), ("params", "".into())], None, Duration::from_secs(6));
    let mut dlg: Option<Value> = None;
    for _ in 0..30 {
        instance::invalidate_snapshot();
        let hit = instance::altium_windows().into_iter().find(|w| {
            w.get("kind").and_then(|k| k.as_str()) != Some("main")
                && w.get("title").and_then(|t| t.as_str()).map(|t| t.to_lowercase().contains("rule")).unwrap_or(false)
        });
        if hit.is_some() {
            dlg = hit;
            break;
        }
        std::thread::sleep(Duration::from_millis(500));
    }
    let mut steps = vec![json!({"dialog": dlg.as_ref().and_then(|d| d.get("title")).cloned(),
                                "hwnd": dlg.as_ref().and_then(|d| d.get("hwnd")).cloned()})];
    let dlg = match dlg {
        Some(d) => d,
        None => return json!({"success": false, "errorCode": "drc_dialog_not_found", "steps": steps,
            "_hint": "PCB:DesignRuleCheck did not open its dialog — is a PCB the focused document? altium_window_info."}),
    };
    let hwnd = dlg.get("hwnd").and_then(|h| h.as_i64()).unwrap_or(0);
    let click = ab::call("desktop_ui_click", json!({"hwnd": hwnd, "contains": "Run Design Rule Check", "role": "button"}), &[], 30);
    steps.push(json!({"uiClick": "Run Design Rule Check", "result": click}));
    // wait for the agent to come back (the modal closed) and DRC to finish. When pours are out of date
    // Altium asks first ("Run DRC / Cancel / Repour Polygons and Run DRC", measured 2026-10-16): repour
    // then run, the honest answer (the repour runs in the dialog, outside the script, so scoped rules apply)
    let wait = timeout_of(args, 180);
    let t1 = std::time::Instant::now();
    let mut repour_answered = false;
    while t1.elapsed() < wait {
        if agent::is_responsive() {
            break;
        }
        if !repour_answered {
            instance::invalidate_snapshot();
            if let Some(w) = instance::altium_windows().into_iter().find(|w| w.get("kind").and_then(|k| k.as_str()) != Some("main")
                && w.get("title").and_then(|t| t.as_str()).map(|t| t.to_lowercase().contains("design rule check")).unwrap_or(false)) {
                let h = w.get("hwnd").and_then(|h| h.as_i64()).unwrap_or(0);
                let c = ab::call("desktop_ui_click", json!({"hwnd": h, "contains": "Repour Polygons and Run DRC", "role": "button"}), &[], 30);
                if c.get("success").and_then(|v| v.as_bool()) == Some(true) {
                    repour_answered = true;
                    steps.push(json!({"uiClick": "Repour Polygons and Run DRC", "result": c}));
                }
            }
        }
        std::thread::sleep(Duration::from_millis(500));
    }
    // the DRC report (HTML) opens as a new document and takes focus — put the board back first
    support::agent_open(&path, 60);
    let v = agent::call("violations", &[("limit", s_or(args, "limit", "200"))], None, Duration::from_secs(60));
    let data = v.get("data").cloned().unwrap_or(json!({}));
    steps.push(json!({"violations": {"success": v.get("success"),
        "error": data.get("error").or_else(|| v.get("error")), "ms": v.get("roundtripMs")}}));
    let mut dl = dialogs::scan_scoped(true);
    shot::attach_dialog_shots(&mut dl);
    let ok = v.get("success").and_then(|b| b.as_bool()).unwrap_or(false) && dl.is_empty();
    json!({"success": ok, "board": path, "violationCount": data.get("violationCount"),
        "violations": data.get("violations"), "steps": steps, "dialogs": dl,
        "elapsedSec": support::elapsed_round(t0), "windowEtiquette": "background",
        "_hint": "violations[] are the DRC markers on the board (rule, description, location). A DRC report document may also have opened — altium_state shows it."})
}

/// One-shot: run ProcName in a .PrjScr through the -R lane (agent must NOT be resident).
pub fn run_script(args: &Value) -> Value {
    if let Some(g) = support::need_licensed() {
        return g;
    }
    let prj = match s(args, "prjscr") {
        Some(p) if !p.is_empty() => p,
        _ => return json!({"success": false, "error": "prjscr (absolute path to a .PrjScr on this machine) is required"}),
    };
    let proc = s_or(args, "proc", "Main");
    lifecycle::run_script_lane_in(&proc, Some(&prj), timeout_of(args, 60))
}

#[cfg(test)]
mod step_outjob_tests {
    use super::*;

    #[test]
    fn suffix_types_follow_the_measured_mapping() {
        for (k, v) in [("none", 0), ("board", 1), ("custom", 2), ("id", 3), ("ID", 3), ("2", 2)] {
            assert_eq!(step_suffix_type(k), Some(v), "{k}");
        }
        assert_eq!(step_suffix_type("comment"), None);
    }

    #[test]
    fn board_components_are_named_after_their_designators() {
        let step = "ISO-10303-21;\nDATA;\n\
#1 = PRODUCT('Buck','Buck','',(#9));\n#2 = PRODUCT_DEFINITION_FORMATION('','',#1);\n#3 = PRODUCT_DEFINITION('design','',#2,#8);\n\
#11 = PRODUCT('MP1','MP1','',(#9));\n#12 = PRODUCT_DEFINITION_FORMATION('','',#11);\n#13 = PRODUCT_DEFINITION('design','',#12,#8);\n\
#21 = PRODUCT('O''Hare','x','',(#9));\n#22 = PRODUCT_DEFINITION_FORMATION_WITH_SPECIFIED_SOURCE('','',#21,.NOT_KNOWN.);\n#23 = PRODUCT_DEFINITION('design','',#22,#8);\n\
#31 = PRODUCT('15354622432','15354622432','',(#9));\n#32 = PRODUCT_DEFINITION_FORMATION('','',#31);\n#33 = PRODUCT_DEFINITION('design','',#32,#8);\n\
#41 = NEXT_ASSEMBLY_USAGE_OCCURRENCE('1','','',#3,#13,$);\n#42 = NEXT_ASSEMBLY_USAGE_OCCURRENCE('2','',\n  '',#3,#23,$);\n\
#43 = NEXT_ASSEMBLY_USAGE_OCCURRENCE('3','','',#13,#33,$);\n#44 = NEXT_ASSEMBLY_USAGE_OCCURRENCE('4','Kept','',#3,#33,$);\nENDSEC;\n";
        let (out, n) = name_component_occurrences(step);
        assert_eq!(n, 2);
        assert!(out.contains("#41 = NEXT_ASSEMBLY_USAGE_OCCURRENCE('1','MP1','',#3,#13,$);"));
        assert!(out.contains("NEXT_ASSEMBLY_USAGE_OCCURRENCE('2','O''Hare','',#3,#23,$);"));
        // a model inside a component, and an occurrence that already has a name, stay as they were
        assert!(out.contains("#43 = NEXT_ASSEMBLY_USAGE_OCCURRENCE('3','','',#13,#33,$);"));
        assert!(out.contains("('4','Kept','',#3,#33,$)"));
        assert_eq!(name_component_occurrences(&out).1, 0, "a second pass names nothing");
    }

    /// ALTIUM_STEP=<an Altium board STEP> cargo test -- --ignored real_altium_step
    #[test]
    #[ignore]
    fn real_altium_step() {
        let p = std::env::var("ALTIUM_STEP").expect("ALTIUM_STEP");
        let text = String::from_utf8_lossy(&std::fs::read(&p).unwrap()).to_string();
        let (out, n) = name_component_occurrences(&text);
        println!("named {n}");
        for mp in ["MP1", "MP2", "MP3", "MP4"] {
            assert!(out.contains(&format!("','{mp}','',#")), "{mp}");
        }
        if let Ok(w) = std::env::var("ALTIUM_STEP_OUT") {
            std::fs::write(w, out).unwrap();
        }
    }

    #[test]
    fn default_outjob_exports_holes_without_copper() {
        let t = step_outjob_text(&json!({})).unwrap();
        assert!(t.contains("OutputType1=ExportSTEP"));
        assert!(t.contains("OutputMedium1=STEP\r\nOutputMedium1_Type=GeneratedFiles"));
        assert!(t.contains("IncludeElectricalPadHoles=True"));
        assert!(t.contains("ComponentSuffixType=0|ComponentSuffix= |"));
        assert!(t.contains("ExportCopperOption=0"));
        assert!(t.contains("ExportAsSinglePart=False"));
        assert!(!t.contains("\n\n[OutputGroup1]") || t.contains("\r\n"));
    }

    #[test]
    fn custom_suffix_is_written_and_validated() {
        let t = step_outjob_text(&json!({"suffixType": "custom", "suffix": "_A", "holes": false})).unwrap();
        assert!(t.contains("ComponentSuffixType=2|ComponentSuffix=_A|"));
        assert!(t.contains("IncludeElectricalPadHoles=False"));
        assert!(step_outjob_text(&json!({"suffixType": "custom"})).is_err());
        assert!(step_outjob_text(&json!({"suffixType": "custom", "suffix": "a|b"})).is_err());
        assert!(step_outjob_text(&json!({"suffixType": "designator"})).is_err());
    }
}