app
Altium - the Altium Bridge
Public Made by Adomby adom
Adom Bridge for Altium Designer: a resident DelphiScript agent gives the AI eval, the 1,100-command process launcher, all 8 EDA surfaces with screenshots, compile/ERC, DRC, OutJob outputs, native SchLib/PcbLib authoring with STEP 3D bodies, and dialog handling by PID.
← Commit history
0.2.47 source (not yet released, needs signing): AI Flow Altium lane verbs. Live board edits (routing_state, route_net, move_parts, place_vias, remove_items, ripup_nets), pours with per-pour connect style, solid joints, cutouts, island/neck options and a repour outside the script engine (scoped rules apply only there), set_design_rules (clearance, width, via, hole, ring, hole-to-hole, edge, polygon connect), build_board, build_schematic on Altium's own sheet template inside its project (compiled and checked pin for pin against the netlist), project_nets, export_manufacturing (the project's own OutJob: Gerber, NC drill, pick and place, BOM), silk_state + apply_silkscreen, select_net, zoom_to, add_rule; native passive symbols from the KiCad drawing; route_net skips vias in same-net through-hole pads; run_drc answers the repour prompt; agent start closes old script projects; verb timeouts match the docs (lint); version stamps agree at 0.2.47.
21 files changed
+3782−34
SKILL.md+1−1bridge-rs/src/lifecycle.rs+6bridge-rs/src/main.rs+3−1bridge-rs/src/manufacturing.rs+177bridge-rs/src/outputs.rs+16−1bridge-rs/src/routing.rs+1223bridge-rs/src/verbs.rs+119install.sh+19−1package.json+4−1scripts/release.py+24skills/altium-bridge-plan/SKILL.md+1−1skills/altium-kicad-import/SKILL.md+5−5skills/altium-libraries/SKILL.md+2−2skills/altium-part-import/SKILL.md+2−2skills/altium-wiki-import/SKILL.md+3−3src/BRIDGE_VERSION+1−1src/agent/AdomAgent.pas.tmpl+1966−8src/bridge.json+104−6tools/kicad2altium.py+8tools/kicad_v10_to_v9.py+8tools/symbol_modes.py+90−1SKILL.md+1−1@@ -63,7 +63,7 @@ Altium is a licensed host app. The bridge never installs it silently; it detects | `altium_add_body` | 3D body on a footprint — STEP model or extruded box | `libraryPath`, `footprintName`, `step` \| `bwMil,bhMil,hMil` | | `altium_install_library` / `altium_compile_intlib` / `altium_extract_intlib` | Installed Libraries, .LibPkg → .IntLib, .IntLib → sources | `libraryPath` / `libpkgPath` / `intlibPath` | | `altium_list_installed_libraries` / `altium_uninstall_library` | read Installed Libraries / remove entries (from 0.2.45; files never touched, any open document for the library is closed first, nothing matching answers `notInstalled:true`). Close Altium with a plain `altium_close` afterwards: a discard close can bring the entry back | (none) / `libraryName` (file stem: SchLib AND PcbLib) or `libraryPath`, `dryRun` |-| `altium_build_footprint` / `altium_build_symbol_modes` | author a footprint from a `tools/kicad2altium.py` spec (via pads, roundrect, mech layers) / ONE symbol with N display modes | `libraryPath`, `spec` |+| `altium_build_footprint` / `altium_build_symbol_modes` | author a footprint from a `altium-kicad2altium` spec (via pads, roundrect, mech layers) / ONE symbol with N display modes | `libraryPath`, `spec` | | `altium_add_models` / `altium_symbol_mode` | N footprint models + params on a symbol / switch display mode + frame | `symbolName`, `models[]`, `params` / `mode` | | `altium_add_symbol_art` / `altium_symbol_prims` (from 0.2.15) | draw the chip outline adom-symbol baked into the KiCad symbol onto a SchLib component (replaces earlier art unless `clear:false`) / read-only primitive counts, body rectangle, polyline bbox, models | `libraryPath`, `symbolName`, `kicadSym`, `clear`, `mode:"lines"\|"polyline"` / `models:true` | | `altium_set_models` (from 0.2.15) | replace every footprint model on a symbol with ONE PCBLIB model (`set_params` footprint and `add_models` append) | `libraryPath`, `symbolName`, `footprint` |
bridge-rs/src/lifecycle.rs+6@@ -290,6 +290,8 @@ pub fn x2_exe_pub() -> Option<String> { x2_exe() } +pub fn wait_helper_pub(child: &mut std::process::Child, cap: Duration) -> bool { wait_helper(child, cap) }+ pub fn spawn_helper_pub(exe: &str, arg: &str) -> Result<std::process::Child, String> { spawn_helper(exe, arg) }@@ -510,6 +512,10 @@ pub fn start(mut wait: Duration) -> Value { } let mut out = json!({"success": true, "startedInSec": t0.elapsed().as_secs_f64(), "generation": resident_generation(), "install": inst});+ // earlier generations' script projects close (each start opens a new one in Altium's+ // Projects panel; ten were stacked there by 2026-10-15)+ let c = agent::call("close_old_agents", &[], None, Duration::from_secs(30));+ out["oldAgentProjectsClosed"] = c.get("data").and_then(|d| d.get("closed")).cloned().unwrap_or(Value::Null); if retried { out["resentBootstrap"] = json!(true); out["_hint"] = json!("The first RunScript hand-off was swallowed (Altium was busy); the bridge sent it again and the agent came up. Nothing for the caller to do.");
bridge-rs/src/main.rs+3−1@@ -12,10 +12,12 @@ mod instance; mod library; mod license; mod lifecycle;+mod manufacturing; mod misc; mod outputs; mod progress; mod review;+mod routing; mod shot; mod support; mod surfaces;@@ -29,7 +31,7 @@ use tiny_http::{Header, Method, Response, Server}; /// Attached to every failed reply (see the /command handler). const REPORT_ISSUES: &str = "If this looks like an altium-bridge BUG (not a refusal you expected), report it: check for a duplicate with `adom-wiki issue list adom/altium-bridge`, then `adom-wiki issue create adom/altium-bridge --title \"<verb>: <one-line symptom>\" --category bug-report --body \"<what you ran, what you expected, what happened, the VERBATIM response JSON (and errorShot if present), bridgeVersion, the Altium version from altium_status, the OS>\"`. A relay, routing, toast or lifecycle problem belongs on adom/adom-bridge instead. Reports from real machines are how this bridge gets correct: three different Altium builds behaved three different ways in one weekend."; -pub const BRIDGE_VERSION: &str = "0.2.45";+pub const BRIDGE_VERSION: &str = "0.2.47"; fn json_response(status: u16, body: &serde_json::Value) -> Response<std::io::Cursor<Vec<u8>>> { let data = serde_json::to_vec(body).unwrap_or_else(|_| b"{}".to_vec());
bridge-rs/src/manufacturing.rsadded+177@@ -0,0 +1,177 @@+//! A project's manufacturing set (Gerbers, NC drill, pick and place, BOM) from ONE OutJob the bridge+//! writes for that project, composed from Altium's own OutJob templates on this machine (so the output+//! settings are the installed Altium's), every output on one GeneratedFiles medium that opens nothing.++use serde_json::{json, Value};++use crate::outputs;++const TEMPLATES: &str = r"C:\Users\Public\Documents\Altium\AD26\OutputJobs";++/// INI as Altium writes it: [sections] of key=value lines, in order.+fn parse_ini(text: &str) -> Vec<(String, Vec<(String, String)>)> {+ let mut out: Vec<(String, Vec<(String, String)>)> = Vec::new();+ for line in text.lines() {+ let l = line.trim_end_matches('\r');+ if l.starts_with('[') && l.ends_with(']') {+ out.push((l[1..l.len() - 1].to_string(), Vec::new()));+ } else if let (Some(sec), Some((k, v))) = (out.last_mut(), l.split_once('=')) {+ sec.1.push((k.to_string(), v.to_string()));+ }+ }+ out+}++/// One output of a template's OutputGroup1 (by its OutputType), its keys renumbered to `to`.+fn take_output(ini: &[(String, Vec<(String, String)>)], kind: &str, to: usize) -> Option<Vec<(String, String)>> {+ let g = &ini.iter().find(|(n, _)| n == "OutputGroup1")?.1;+ let n = g.iter().find(|(k, v)| k.starts_with("OutputType") && v == kind)?.0["OutputType".len()..].to_string();+ let mut out = Vec::new();+ for (k, v) in g {+ // OutputXxxN, OutputXxxN_..., ConfigurationN_..., PageOptionsN, PcbPrintPreferencesN+ let renamed = ["OutputType", "OutputName", "OutputCategory", "OutputDocumentPath", "OutputVariantName", "OutputEnabled", "OutputDefault", "PageOptions", "PcbPrintPreferences", "Configuration"]+ .iter().find_map(|p| {+ let rest = k.strip_prefix(p)?;+ let num: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();+ let tail = &rest[num.len()..];+ // OutputEnabledN_OutputMediumM: only the medium flag is rewritten below+ (num == n && (tail.is_empty() || tail.starts_with('_'))).then(|| format!("{p}{to}{tail}"))+ });+ if let Some(r) = renamed {+ if r.starts_with(&format!("OutputEnabled{to}_OutputMedium")) { continue; }+ out.push((r, v.clone()));+ }+ }+ Some(out)+}++fn set(kv: &mut Vec<(String, String)>, k: &str, v: &str) {+ match kv.iter_mut().find(|(a, _)| a == k) { Some(e) => e.1 = v.to_string(), None => kv.push((k.to_string(), v.to_string())) }+}++/// a `|`-separated record with one field replaced or added+fn field(rec: &str, k: &str, v: &str) -> String {+ let mut parts: Vec<String> = rec.split('|').map(String::from).collect();+ match parts.iter_mut().find(|p| p.split_once('=').map(|(a, _)| a == k).unwrap_or(false)) {+ Some(p) => *p = format!("{k}={v}"),+ None => parts.push(format!("{k}={v}")),+ }+ parts.join("|")+}++/// the project's manufacturing OutJob path (beside the project, named after it)+pub fn outjob_path(prj: &str) -> String {+ let pdir = prj.rsplit_once(['\\', '/']).map(|(d, _)| d.to_string()).unwrap_or_default();+ let stem = prj.rsplit(['\\', '/']).next().unwrap_or(prj).rsplit_once('.').map(|(s, _)| s.to_string()).unwrap_or_default();+ format!(r"{pdir}\{stem}.Manufacturing.OutJob")+}++/// Write the project's manufacturing OutJob (only when its text changes: an open document rewritten on+/// disk makes Altium ask to reload it). Answers its path.+pub fn write_outjob(prj: &str, pcb: &str, bom_columns: Option<&Vec<Value>>) -> Result<String, Value> {+ let (prj, pcb) = (prj.to_string(), pcb.to_string());+ let read = |f: &str| std::fs::read_to_string(format!(r"{TEMPLATES}\{f}")).map(|t| parse_ini(&t));+ let (fab, asm) = match (read("Fabrication.OutJob"), read("Assembly.OutJob")) {+ (Ok(f), Ok(a)) => (f, a),+ (f, a) => return Err(json!({"success": false, "errorCode": "no_templates", "error": format!("Altium's OutJob templates are not at {TEMPLATES}: {:?} {:?}", f.err(), a.err())})),+ };+ let pcb_name = pcb.rsplit(['\\', '/']).next().unwrap_or(&pcb).to_string();+ let pdir = prj.rsplit_once(['\\', '/']).map(|(d, _)| d.to_string()).unwrap_or_default();+ let stem = prj.rsplit(['\\', '/']).next().unwrap_or(&prj).rsplit_once('.').map(|(s, _)| s.to_string()).unwrap_or_default();+ let mut outputs: Vec<Vec<(String, String)>> = Vec::new();+ let mut missing = Vec::new();+ for (src, kind) in [(&fab, "Gerber"), (&fab, "NC Drill"), (&asm, "Pick Place"), (&asm, "BOM_PartType")] {+ match take_output(src, kind, outputs.len() + 1) { Some(o) => outputs.push(o), None => missing.push(kind) }+ }+ if !missing.is_empty() { return Err(json!({"success": false, "errorCode": "template_output_missing", "error": format!("the templates have no {missing:?} output")})); }+ for (i, o) in outputs.iter_mut().enumerate() {+ let n = i + 1;+ let kind = o.iter().find(|(k, _)| k == &format!("OutputType{n}")).map(|(_, v)| v.clone()).unwrap_or_default();+ // the board for the PCB outputs; the BOM is the compiled project's+ set(o, &format!("OutputDocumentPath{n}"), if kind == "BOM_PartType" { "" } else { &pcb_name });+ set(o, &format!("OutputEnabled{n}"), "1");+ set(o, &format!("OutputEnabled{n}_OutputMedium1"), &n.to_string());+ if kind == "Pick Place" {+ // a CSV in millimetres, what an assembly house loads+ if let Some(e) = o.iter_mut().find(|(k, v)| k.starts_with(&format!("Configuration{n}_Item")) && v.contains("Record=PickPlaceView")) {+ e.1 = field(&field(&field(&e.1, "GenerateCSVFormat", "True"), "GenerateTextFormat", "False"), "Units", "Metric");+ }+ }+ if kind == "BOM_PartType" {+ // BatchMode is the BOM's file format: 0 csv, 1 txt, 2 xlsx, 3 html, 4 xml, 6 pdf; 5 (the+ // examples' value) wrote no file here (measured AD 26.11.1, 2026-10-15)+ let cols = bom_columns.map(|a| a.iter().filter_map(|x| x.as_str()).map(|c| format!("{c}=100")).collect::<Vec<_>>().join("|"))+ .unwrap_or_else(|| "Comment=100|Description=100|Designator=100|Footprint=100|Quantity=100|MPN=100|Manufacturer=100|Mouser Part #=100|DigiKey Part #=100|LCSC Part #=100".into());+ o.retain(|(k, _)| !k.starts_with(&format!("Configuration{n}_")));+ for (j, (name, item)) in [("General", "OpenExported=False|AddToProject=False|ForceFit=False|NotFitted=False|Database=False|IncludePCBData=False|ShowExportOptions=False|TemplateFilename=|BatchMode=0|FormWidth=1185|FormHeight=665|SupplierProdQty=1|SupplierAutoQty=False|SupplierUseCachedPricing=False|SupplierCurrency=<none>".to_string()),+ ("GroupOrder", "Comment=True|Footprint=True".to_string()),+ ("SortOrder", "Designator=Up|Comment=Up|Footprint=Up".to_string()),+ ("VisibleOrder", cols)].into_iter().enumerate() {+ o.push((format!("Configuration{n}_Name{}", j + 1), name.to_string()));+ o.push((format!("Configuration{n}_Item{}", j + 1), item));+ }+ }+ }+ let base = format!("Project Outputs for {stem}");+ let mut t = String::from("[OutputJobFile]\r\nVersion=1.0\r\nCaption=Manufacturing\r\nDescription=Gerbers, NC drill, pick and place and BOM (written by the Altium bridge)\r\n\r\n[OutputGroup1]\r\nName=Manufacturing\r\nDescription=\r\nTargetOutputMedium=Manufacturing\r\nVariantName=[No Variations]\r\nVariantScope=0\r\nCurrentConfigurationName=\r\nOutputMedium1=Manufacturing\r\nOutputMedium1_Type=GeneratedFiles\r\n");+ for o in &outputs { for (k, v) in o { t.push_str(&format!("{k}={v}\r\n")); } }+ t.push_str(&format!("\r\n[PublishSettings]\r\nOutputFilePath1={pdir}\\{base}\\\r\nReleaseManaged1=1\r\nOutputBasePath1={base}\r\nOutputPathMedia1=\r\nOutputPathMediaValue1=\r\nOutputPathOutputer1=[Output Type]\r\nOutputPathOutputerPrefix1=\r\nOutputPathOutputerValue1=\r\nOutputFileName1=\r\nOutputFileNameMulti1=\r\nUseOutputNameForMulti1=1\r\nOutputFileNameSpecial1=\r\nOpenOutput1=0\r\n"));+ t.push_str(&format!("\r\n[GeneratedFilesSettings]\r\nRelativeOutputPath1={pdir}\\{base}\\\r\nOpenOutputs1=0\r\nAddToProject1=0\r\nTimestampFolder1=0\r\nUseOutputName1=0\r\nOpenODBOutput1=0\r\nOpenGerberOutput1=0\r\nOpenNCDrillOutput1=0\r\nOpenIPCOutput1=0\r\nEnableReload1=0\r\n"));+ let outjob = outjob_path(&prj);+ if std::fs::read_to_string(&outjob).ok().as_deref() != Some(t.as_str()) {+ if let Err(e) = std::fs::write(&outjob, &t) { return Err(json!({"success": false, "errorCode": "write_failed", "error": format!("could not write {outjob}: {e}")})); }+ }+ Ok(outjob)+}++/// altium_export_manufacturing {projectPath, pcbPath, bomColumns?, timeout?}+pub fn export_manufacturing(args: &Value) -> Value {+ let st = |k: &str| args.get(k).and_then(|v| v.as_str()).unwrap_or("").to_string();+ let (prj, pcb) = (st("projectPath"), st("pcbPath"));+ if prj.is_empty() || pcb.is_empty() {+ return json!({"success": false, "errorCode": "bad_args", "error": "projectPath (.PrjPcb) and pcbPath (.PcbDoc) are required",+ "_hint": "altium_build_schematic writes the project; the board must be one of its documents."});+ }+ let outjob = match write_outjob(&prj, &pcb, args.get("bomColumns").and_then(|v| v.as_array())) { Ok(p) => p, Err(e) => return e };+ // an OutJob generates for the project it belongs to: one that is not in the project runs for Free+ // Documents and writes nothing (measured 2026-10-15)+ let oj_name = outjob.rsplit(['\\', '/']).next().unwrap_or(&outjob).to_lowercase();+ let listed = std::fs::read_to_string(&prj).map(|t| t.lines().any(|l| l.to_lowercase().starts_with("documentpath=") && l.to_lowercase().ends_with(&oj_name))).unwrap_or(false);+ if !listed {+ return json!({"success": false, "errorCode": "outjob_not_in_project", "outjob": outjob,+ "error": format!("{outjob} is not a document of {prj}: an OutJob outside the project writes nothing"),+ "_hint": "Projects from altium_build_schematic list it. Otherwise add it in Altium (Project > Add Existing to Project) and run this again."});+ }+ // an OutJob already open as a FREE document stays one (its outputs went to 'Project Outputs for Free+ // Documents', and a free OutJob cannot build the project's BOM, measured 2026-10-15): close it, so+ // run_outjob opens it again inside its project+ crate::agent::call("close_document", &[("path", outjob.clone())], None, std::time::Duration::from_secs(60));+ // a fresh set: files from an earlier run (another BOM format, a removed layer) never pass as this one's+ if args.get("clean").and_then(|v| v.as_bool()) != Some(false) {+ let pdir = prj.rsplit_once(['\\', '/']).map(|(d, _)| d.to_string()).unwrap_or_default();+ let stem = prj.rsplit(['\\', '/']).next().unwrap_or(&prj).rsplit_once('.').map(|(s, _)| s.to_string()).unwrap_or_default();+ let _ = std::fs::remove_dir_all(format!(r"{pdir}\Project Outputs for {stem}"));+ }+ let mut a = json!({"outjobPath": outjob, "projectPath": prj, "medium": "Manufacturing"});+ if let Some(v) = args.get("timeout") { a["timeout"] = v.clone(); }+ let mut r = outputs::run_outjob(&a);+ r["outjob"] = json!(outjob);+ r["_hint"] = json!("files[] are the manufacturing set under outputDir (Gerber, NC Drill, Pick Place, Bill of Materials folders); pull them with ab's pull_file and check them against the board before handing them over.");+ r+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn renumbers_one_output() {+ let ini = parse_ini("[OutputGroup1]\nOutputType1=Gerber\nOutputName1=Gerber\nOutputEnabled1=1\nOutputEnabled1_OutputMedium3=1\nConfiguration1_Item1=a=b\nOutputType2=NC Drill\nOutputName2=NC\nConfiguration2_Item1=Record=DrillView\n");+ let o = take_output(&ini, "NC Drill", 1).unwrap();+ assert!(o.contains(&("OutputType1".into(), "NC Drill".into())));+ assert!(o.contains(&("Configuration1_Item1".into(), "Record=DrillView".into())));+ assert!(!o.iter().any(|(k, _)| k.contains("Medium")));+ assert_eq!(field("A=1|Units=Imperial", "Units", "Metric"), "A=1|Units=Metric");+ assert_eq!(field("A=1", "B", "2"), "A=1|B=2");+ }+}
bridge-rs/src/outputs.rs+16−1@@ -459,13 +459,28 @@ pub fn run_drc(args: &Value) -> Value { 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+ // 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
bridge-rs/src/routing.rsadded+1223@@ -0,0 +1,1223 @@+//! The live-board lane AI Flow drives: read the whole board as data (`routing_state`), with a+//! revision so every edit can refuse a board that changed behind the caller's back.+//!+//! The agent dumps RAW internal coordinates (10000 per mil) so nothing is rounded inside Altium;+//! this module converts to mm and hashes the dump. Layer ids are Altium's (1 = Top Layer,+//! 32 = Bottom Layer, 74 = Multi Layer).++use serde_json::{json, Value};+use std::time::Duration;++use crate::agent;++/// Internal units per mm: 10000 per mil, 1 mil = 0.0254 mm.+const PER_MM: f64 = 10000.0 / 0.0254;++pub const TOP: i64 = 1;+pub const BOTTOM: i64 = 32;++fn mm(v: &Value) -> Value {+ let raw = v.as_f64().unwrap_or(0.0);+ // 0.1 um resolution: exact enough for any board, and stable when hashed.+ json!(((raw / PER_MM) * 10000.0).round() / 10000.0)+}++fn deg(v: &Value) -> Value {+ json!(v.as_f64().unwrap_or(0.0) / 1000.0)+}++/// Altium's copper layer id as the KiCad name AI Flow speaks.+pub fn layer_name(id: i64) -> String {+ match id {+ TOP => "F.Cu".into(),+ BOTTOM => "B.Cu".into(),+ 74 => "*.Cu".into(),+ n if (2..=31).contains(&n) => format!("In{}.Cu", n - 1),+ n => format!("L{n}"),+ }+}++/// FNV-1a 64: stable across builds (std's hasher is not), plenty for change detection.+fn fnv(s: &str) -> String {+ let mut h: u64 = 0xcbf29ce484222325;+ for b in s.as_bytes() {+ h ^= *b as u64;+ h = h.wrapping_mul(0x100000001b3);+ }+ format!("{h:016x}")+}++/// The revision covers copper, parts and outline: anything an edit could have changed. Order+/// is the iterator's, which is stable for an unchanged board.+pub fn revision(d: &Value) -> String {+ let keys = ["tracks", "arcs", "vias", "comps", "polys", "outline"];+ let canon: Vec<String> = keys.iter().map(|k| d.get(*k).map(|v| v.to_string()).unwrap_or_default()).collect();+ fnv(&canon.join("|"))+}++fn arr<'a>(d: &'a Value, k: &str) -> &'a [Value] {+ d.get(k).and_then(|v| v.as_array()).map(|v| v.as_slice()).unwrap_or(&[])+}++fn s(v: &Value) -> String {+ v.as_str().unwrap_or("").to_string()+}++fn i(v: &Value) -> i64 {+ v.as_i64().unwrap_or(0)+}++/// Altium's pad shape id (TShape) as a name.+fn pad_shape(id: i64) -> &'static str {+ match id { 1 => "round", 2 => "rect", 3 => "octagonal", 4 => "circle", 7 => "roundrect", 8 => "rotated-rect", _ => "other" }+}++// -- connectivity from the copper itself -------------------------------------------------------+//+// Altium's own ratsnest leaves out parts outside the board shape (parked parts before placement read 4+// lines on a 38-part board, measured 2026-10-15), so the count AI Flow judges is computed here: per net,+// pads, tracks, vias and pours are joined where their copper touches on a shared layer, and every group+// beyond the first is one connection still to make.++fn dist_seg(p: (f64, f64), a: (f64, f64), b: (f64, f64)) -> f64 {+ let (dx, dy) = (b.0 - a.0, b.1 - a.1);+ let l2 = dx * dx + dy * dy;+ let t = if l2 < 1e-12 { 0.0 } else { (((p.0 - a.0) * dx + (p.1 - a.1) * dy) / l2).clamp(0.0, 1.0) };+ ((p.0 - a.0 - t * dx).powi(2) + (p.1 - a.1 - t * dy).powi(2)).sqrt()+}++fn seg_seg(a: (f64, f64), b: (f64, f64), c: (f64, f64), d: (f64, f64)) -> f64 {+ let cross = |o: (f64, f64), p: (f64, f64), q: (f64, f64)| (p.0 - o.0) * (q.1 - o.1) - (p.1 - o.1) * (q.0 - o.0);+ let (d1, d2, d3, d4) = (cross(c, d, a), cross(c, d, b), cross(a, b, c), cross(a, b, d));+ if ((d1 > 0.0) != (d2 > 0.0)) && ((d3 > 0.0) != (d4 > 0.0)) { return 0.0; }+ dist_seg(a, c, d).min(dist_seg(b, c, d)).min(dist_seg(c, a, b)).min(dist_seg(d, a, b))+}++fn in_poly(p: (f64, f64), poly: &[(f64, f64)]) -> bool {+ let mut inside = false;+ let n = poly.len();+ for k in 0..n {+ let (a, b) = (poly[k], poly[(k + n - 1) % n]);+ if (a.1 > p.1) != (b.1 > p.1) && p.0 < (b.0 - a.0) * (p.1 - a.1) / (b.1 - a.1) + a.0 { inside = !inside; }+ }+ inside+}++#[derive(Clone)]+enum Cu { Pad { c: (f64, f64), half: f64, layers: Vec<String> }, Seg { a: (f64, f64), b: (f64, f64), half: f64, layer: String }, Via { c: (f64, f64), r: f64 }, Pour { poly: Vec<(f64, f64)>, layer: String } }++fn on(cu: &Cu, layer: &str) -> bool {+ match cu {+ Cu::Pad { layers, .. } => layers.iter().any(|l| l == layer || l == "*.Cu"),+ Cu::Seg { layer: l, .. } | Cu::Pour { layer: l, .. } => l == layer,+ Cu::Via { .. } => true,+ }+}++fn touch(x: &Cu, y: &Cu) -> bool {+ let shared = ["F.Cu", "B.Cu"].iter().any(|l| on(x, l) && on(y, l)) || matches!(x, Cu::Pad { layers, .. } if layers.iter().any(|l| l == "*.Cu")) && matches!(y, Cu::Pad { layers, .. } if layers.iter().any(|l| l == "*.Cu"))+ || (1..31).any(|k| { let l = format!("In{k}.Cu"); on(x, &l) && on(y, &l) });+ if !shared { return false; }+ let eps = 1e-4;+ match (x, y) {+ (Cu::Pad { c: a, half: ha, .. }, Cu::Pad { c: b, half: hb, .. }) => ((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt() <= ha + hb + eps,+ (Cu::Pad { c, half, .. }, Cu::Seg { a, b, half: w, .. }) | (Cu::Seg { a, b, half: w, .. }, Cu::Pad { c, half, .. }) => dist_seg(*c, *a, *b) <= half + w + eps,+ (Cu::Pad { c, half, .. }, Cu::Via { c: v, r }) | (Cu::Via { c: v, r }, Cu::Pad { c, half, .. }) => ((c.0 - v.0).powi(2) + (c.1 - v.1).powi(2)).sqrt() <= half + r + eps,+ (Cu::Seg { a, b, half: w1, .. }, Cu::Seg { a: c, b: d, half: w2, .. }) => seg_seg(*a, *b, *c, *d) <= w1 + w2 + eps,+ (Cu::Seg { a, b, half: w, .. }, Cu::Via { c, r }) | (Cu::Via { c, r }, Cu::Seg { a, b, half: w, .. }) => dist_seg(*c, *a, *b) <= w + r + eps,+ (Cu::Via { c: a, r: ra }, Cu::Via { c: b, r: rb }) => ((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt() <= ra + rb + eps,+ (Cu::Pour { poly, .. }, other) | (other, Cu::Pour { poly, .. }) => match other {+ Cu::Pad { c, .. } | Cu::Via { c, .. } => in_poly(*c, poly),+ Cu::Seg { a, b, .. } => in_poly(*a, poly) || in_poly(*b, poly),+ Cu::Pour { poly: q, .. } => q.iter().any(|p| in_poly(*p, poly)) || poly.iter().any(|p| in_poly(*p, q)),+ },+ }+}++/// Connections still to make, from the copper: per net, (groups of touching copper) - 1. Also which+/// nets are split, for the hint.+pub fn unrouted(st: &Value) -> (usize, Vec<Value>) {+ let mut by_net: std::collections::BTreeMap<String, Vec<Cu>> = std::collections::BTreeMap::new();+ let pt = |v: &Value| (v["x"].as_f64().unwrap_or(0.0), v["y"].as_f64().unwrap_or(0.0));+ for p in st["pads"].as_array().cloned().unwrap_or_default() {+ let net = p["net"].as_str().unwrap_or("");+ if net.is_empty() { continue; }+ let layer = p["layer"].as_str().unwrap_or("F.Cu").to_string();+ let half = p["sizeX"].as_f64().unwrap_or(0.0).min(p["sizeY"].as_f64().unwrap_or(0.0)) / 2.0;+ by_net.entry(net.into()).or_default().push(Cu::Pad { c: (p["x"].as_f64().unwrap_or(0.0), p["y"].as_f64().unwrap_or(0.0)), half, layers: vec![layer] });+ }+ for t in st["tracks"].as_array().cloned().unwrap_or_default() {+ let net = t["net"].as_str().unwrap_or("");+ let layer = t["layer"].as_str().unwrap_or("");+ if net.is_empty() || !(layer == "F.Cu" || layer == "B.Cu" || layer.starts_with("In")) { continue; }+ by_net.entry(net.into()).or_default().push(Cu::Seg { a: pt(&t["start"]), b: pt(&t["end"]), half: t["width"].as_f64().unwrap_or(0.0) / 2.0, layer: layer.into() });+ }+ for v in st["vias"].as_array().cloned().unwrap_or_default() {+ let net = v["net"].as_str().unwrap_or("");+ if net.is_empty() { continue; }+ by_net.entry(net.into()).or_default().push(Cu::Via { c: pt(&v["position"]), r: v["size"].as_f64().unwrap_or(0.0) / 2.0 });+ }+ for g in st["polygons"].as_array().cloned().unwrap_or_default() {+ let net = g["net"].as_str().unwrap_or("");+ if net.is_empty() { continue; }+ let poly: Vec<(f64, f64)> = g["points"].as_array().cloned().unwrap_or_default().iter().map(|p| (p[0].as_f64().unwrap_or(0.0), p[1].as_f64().unwrap_or(0.0))).collect();+ if poly.len() >= 3 { by_net.entry(net.into()).or_default().push(Cu::Pour { poly, layer: g["layer"].as_str().unwrap_or("").into() }); }+ }+ let mut total = 0;+ let mut split = Vec::new();+ for (net, items) in by_net {+ let n = items.len();+ let mut parent: Vec<usize> = (0..n).collect();+ fn find(p: &mut Vec<usize>, x: usize) -> usize { let mut r = x; while p[r] != r { r = p[r]; } let mut y = x; while p[y] != r { let nx = p[y]; p[y] = r; y = nx; } r }+ for i in 0..n { for j in (i + 1)..n { if touch(&items[i], &items[j]) { let (a, b) = (find(&mut parent, i), find(&mut parent, j)); if a != b { parent[a] = b; } } } }+ // a group that holds no pad (a stray track or a pour on its own) still counts: copper that+ // reaches nothing is a connection not made+ let roots: std::collections::BTreeSet<usize> = (0..n).map(|i| find(&mut parent, i)).collect();+ if roots.len() > 1 { total += roots.len() - 1; split.push(json!({"net": net, "groups": roots.len()})); }+ }+ (total, split)+}++/// Convert the agent's compact dump into the mm-based state AI Flow reads.+pub fn shape(d: &Value) -> Value {+ let tracks: Vec<Value> = arr(d, "tracks").iter().map(|t| json!({+ "start": {"x": mm(&t[0]), "y": mm(&t[1])}, "end": {"x": mm(&t[2]), "y": mm(&t[3])},+ "width": mm(&t[4]), "layer": layer_name(i(&t[5])), "layerId": t[5], "net": t[6]})).collect();+ let arcs: Vec<Value> = arr(d, "arcs").iter().map(|a| json!({+ "center": {"x": mm(&a[0]), "y": mm(&a[1])}, "radius": mm(&a[2]),+ "startAngle": deg(&a[3]), "endAngle": deg(&a[4]), "width": mm(&a[5]),+ "layer": layer_name(i(&a[6])), "layerId": a[6], "net": a[7]})).collect();+ let vias: Vec<Value> = arr(d, "vias").iter().map(|v| json!({+ "position": {"x": mm(&v[0]), "y": mm(&v[1])}, "size": mm(&v[2]), "drill": mm(&v[3]),+ "layers": [layer_name(i(&v[4])), layer_name(i(&v[5]))], "net": v[6]})).collect();+ let pads: Vec<Value> = arr(d, "pads").iter().map(|p| json!({+ "ref": p[0], "name": p[1], "x": mm(&p[2]), "y": mm(&p[3]),+ "sizeX": mm(&p[4]), "sizeY": mm(&p[5]), "rotation": deg(&p[6]),+ "layer": layer_name(i(&p[7])), "layerId": p[7], "drill": mm(&p[8]), "net": p[9], "shape": pad_shape(i(&p[10]))})).collect();+ let comps: Vec<Value> = arr(d, "comps").iter().map(|c| json!({+ "ref": c[0], "x": mm(&c[1]), "y": mm(&c[2]), "rotation": deg(&c[3]),+ "side": if i(&c[4]) == BOTTOM { "bottom" } else { "top" }, "pattern": c[5], "bodies": c.get(6).cloned().unwrap_or(json!(null)),+ "has3d": c.get(6).and_then(|b| b.as_i64()).map(|b| b > 0)})).collect();+ let polys: Vec<Value> = arr(d, "polys").iter().map(|g| json!({+ "net": g[0], "layer": layer_name(i(&g[1])), "layerId": g[1],+ "points": g[2].as_array().map(|ps| ps.iter().map(|p| json!([mm(&p[0]), mm(&p[1])])).collect::<Vec<_>>()).unwrap_or_default()})).collect();+ let outline: Vec<Value> = arr(d, "outline").iter().map(|p| json!([mm(&p[0]), mm(&p[1])])).collect();+ // the outline as edges: a vertex of kind 1 bends the edge from it to the next into an arc+ let raw_out = arr(d, "outline");+ let edges: Vec<Value> = (0..raw_out.len()).map(|k| {+ let (p, q) = (&raw_out[k], &raw_out[(k + 1) % raw_out.len()]);+ if i(&p.get(2).cloned().unwrap_or(json!(0))) == 1 {+ json!({"kind": "arc", "start": [mm(&p[0]), mm(&p[1])], "end": [mm(&q[0]), mm(&q[1])], "center": [mm(&p[3]), mm(&p[4])], "radius": mm(&p[5]), "angle1": deg(&p[6]), "angle2": deg(&p[7])})+ } else {+ json!({"kind": "line", "start": [mm(&p[0]), mm(&p[1])], "end": [mm(&q[0]), mm(&q[1])]})+ }+ }).collect();+ let mut nets: Vec<String> = pads.iter().map(|p| s(&p["net"])).filter(|n| !n.is_empty()).collect();+ nets.sort();+ nets.dedup();+ let mut out = json!({+ "units": "mm", "file": d.get("file").cloned().unwrap_or(Value::Null),+ "boardId": d.get("file").cloned().unwrap_or(Value::Null), "revision": revision(d),+ "counts": {"tracks": tracks.len(), "arcs": arcs.len(), "vias": vias.len(), "pads": pads.len(),+ "components": comps.len(), "polygons": polys.len(), "nets": nets.len()},+ "unconnected": d.get("unrouted").cloned().unwrap_or(json!(0)),+ "nets": nets, "tracks": tracks, "arcs": arcs, "vias": vias, "pads": pads,+ "components": comps, "polygons": polys, "outline": outline, "outlineEdges": edges,+ });+ // the count AI Flow judges is the copper's own; Altium's ratsnest count rides along+ let (n, split) = unrouted(&out);+ out["altiumRatsnest"] = out["unconnected"].clone();+ out["unconnected"] = json!(n);+ out["splitNets"] = json!(split);+ out+}++/// altium_routing_state {expectPath?}+pub fn routing_state(args: &Value) -> Value {+ let mut r = agent::call("routing_state", &[], None, Duration::from_secs(180));+ let data = match r.get("data") {+ Some(d) if d.get("ok").and_then(|v| v.as_bool()) == Some(true) => d.clone(),+ Some(d) => {+ let err = d.get("error").and_then(|e| e.as_str()).unwrap_or("");+ if err.contains("no current PCB board") {+ r["_hint"] = json!("Open the board first (altium_show_2d_board {filePath}), then retry.");+ }+ return r;+ }+ None => return r,+ };+ let st = shape(&data);+ if let Some(want) = args.get("expectPath").and_then(|v| v.as_str()) {+ let have = st["file"].as_str().unwrap_or("");+ if !have.eq_ignore_ascii_case(want) {+ return json!({"success": false, "errorCode": "wrong_board",+ "error": format!("the current board is {have}, not {want}"),+ "_hint": "altium_show_2d_board {filePath} makes the board you want current, then retry."});+ }+ }+ let mut out = json!({"success": true});+ for (k, v) in st.as_object().unwrap() {+ out[k] = v.clone();+ }+ out["roundtripMs"] = r.get("roundtripMs").cloned().unwrap_or(Value::Null);+ out["_hint"] = json!("mm, KiCad layer names (F.Cu/B.Cu/In<n>.Cu, *.Cu = multi-layer pad). Pass revision as expectedRevision to the edit verbs; unconnected is Altium's ratsnest count.");+ out+}++fn raw(mm: f64) -> i64 {+ (mm * PER_MM).round() as i64+}++/// KiCad layer name (or an Altium layer id as a number) to Altium's layer id.+pub fn layer_id(v: &Value) -> Option<i64> {+ if let Some(n) = v.as_i64() {+ return Some(n);+ }+ let name = v.as_str()?;+ match name {+ "F.Cu" | "Top" | "TopLayer" => Some(TOP),+ "B.Cu" | "Bottom" | "BottomLayer" => Some(BOTTOM),+ _ => name.strip_prefix("In").and_then(|r| r.strip_suffix(".Cu")).and_then(|n| n.parse::<i64>().ok()).map(|n| n + 1),+ }+}++fn err(code: &str, msg: String, hint: &str) -> Value {+ json!({"success": false, "errorCode": code, "error": msg, "_hint": hint})+}++/// Read the live board and check the caller's guard: the right board and the revision it last+/// read. A board changed behind the caller's back is refused, never edited blind.+fn guarded(args: &Value) -> Result<Value, Value> {+ let st = routing_state(&json!({"expectPath": args.get("expectPath").cloned().unwrap_or(Value::Null)}));+ if st.get("success").and_then(|v| v.as_bool()) != Some(true) {+ return Err(st);+ }+ if let Some(id) = args.get("boardId").and_then(|v| v.as_str()) {+ if !st["boardId"].as_str().unwrap_or("").eq_ignore_ascii_case(id) {+ return Err(err("board_mismatch", format!("the current board is {}, not {id}", st["boardId"]), "altium_show_2d_board {filePath} the board you mean, then read altium_routing_state again."));+ }+ }+ if let Some(rev) = args.get("expectedRevision").and_then(|v| v.as_str()) {+ if st["revision"].as_str() != Some(rev) {+ return Err(err("stale_board", format!("the board changed since revision {rev} (now {})", st["revision"]),+ "Read altium_routing_state again and plan against the board as it is now."));+ }+ }+ Ok(st)+}++/// Send one batch to the agent's edit_board and read the board back.+fn apply(ops: &[String], before: &Value, what: &str) -> Value {+ if ops.is_empty() {+ return err("nothing_to_do", format!("{what}: no edits in the request"), "Pass at least one item.");+ }+ let payload = ops.join("\n");+ let r = agent::call("edit_board", &[], Some(&payload), Duration::from_secs(300));+ let d = match r.get("data") {+ Some(d) if d.get("ok").and_then(|v| v.as_bool()) == Some(true) => d.clone(),+ _ => return r,+ };+ let after = routing_state(&json!({}));+ let failed = d.get("failed").and_then(|v| v.as_i64()).unwrap_or(0);+ let mut out = json!({+ "success": failed == 0, "applied": d["applied"], "failed": failed, "results": d["results"],+ "revisionBefore": before["revision"], "revision": after["revision"], "boardId": after["boardId"],+ "unconnected": after["unconnected"],+ "countsBefore": before["counts"], "counts": after["counts"],+ });+ if failed > 0 {+ out["errorCode"] = json!("partial_edit");+ out["_hint"] = json!("results[] answers each edit in order; the ones marked err: did not land. The rest did (one undo step in Altium). Re-plan from the new revision.");+ } else {+ out["_hint"] = json!("Landed as one undo step. Pass revision as expectedRevision to the next edit.");+ }+ out["state"] = after;+ out+}++fn pad_xy(state: &Value, key: &str) -> Option<(f64, f64, String)> {+ let (r, n) = key.split_once('.')?;+ state["pads"].as_array()?.iter().find(|p| p["ref"].as_str() == Some(r) && p["name"].as_str() == Some(n))+ .map(|p| (p["x"].as_f64().unwrap_or(0.0), p["y"].as_f64().unwrap_or(0.0), p["layer"].as_str().unwrap_or("").to_string()))+}++/// A waypoint: [x,y], {x,y,layer?} or {pad:"U1.3"}. Returns (x, y, layer-or-None).+fn waypoint(state: &Value, w: &Value) -> Result<(f64, f64, Option<i64>), String> {+ if let Some(a) = w.as_array() {+ return Ok((a.first().and_then(|v| v.as_f64()).ok_or("bad point")?, a.get(1).and_then(|v| v.as_f64()).ok_or("bad point")?, None));+ }+ if let Some(p) = w.get("pad").and_then(|v| v.as_str()) {+ let (x, y, _) = pad_xy(state, p).ok_or(format!("no pad {p} on the board"))?;+ return Ok((x, y, w.get("layer").and_then(layer_id)));+ }+ let x = w.get("x").and_then(|v| v.as_f64()).ok_or("waypoint needs x")?;+ let y = w.get("y").and_then(|v| v.as_f64()).ok_or("waypoint needs y")?;+ Ok((x, y, w.get("layer").and_then(layer_id)))+}++/// a drilled, all-layer pad of `net` under (x, y)+fn tht_pad_at(state: &Value, x: f64, y: f64, net: &str) -> bool {+ state["pads"].as_array().map(|ps| ps.iter().any(|p| {+ let f = |k: &str| p.get(k).and_then(|v| v.as_f64()).unwrap_or(0.0);+ f("drill") > 0.0 && p["net"].as_str() == Some(net)+ && (f("x") - x).abs() <= f("sizeX").max(f("sizeY")) / 2.0 && (f("y") - y).abs() <= f("sizeX").max(f("sizeY")) / 2.0+ })).unwrap_or(false)+}++/// altium_route_net {net, width, layer, viaSize, viaDrill, paths:[[waypoint...]], expectedRevision?}+/// One track per segment; a waypoint that names a different layer drops a via at that point.+pub fn route_net(args: &Value) -> Value {+ let st = match guarded(args) { Ok(s) => s, Err(e) => return e };+ let net = args.get("net").and_then(|v| v.as_str()).unwrap_or("");+ if net.is_empty() || !st["nets"].as_array().map(|n| n.iter().any(|x| x.as_str() == Some(net))).unwrap_or(false) {+ return err("unknown_net", format!("net {net:?} is not on the board"), "Use a net name from altium_routing_state nets[].");+ }+ let width = args.get("width").and_then(|v| v.as_f64()).unwrap_or(0.25);+ let mut layer = args.get("layer").and_then(layer_id).unwrap_or(TOP);+ let via_size = args.get("viaSize").or(args.get("viaDiameter")).and_then(|v| v.as_f64()).unwrap_or(0.6);+ let via_drill = args.get("viaDrill").and_then(|v| v.as_f64()).unwrap_or(0.3);+ let paths: Vec<Value> = match args.get("paths").and_then(|v| v.as_array()) {+ Some(p) => p.clone(),+ None => match args.get("points") { Some(p) => vec![p.clone()], None => vec![] },+ };+ let mut ops = Vec::new();+ for path in &paths {+ let pts = match path.as_array() { Some(p) => p, None => return err("bad_args", "each path is an array of waypoints".into(), "") };+ let mut prev: Option<(f64, f64)> = None;+ for w in pts {+ let (x, y, lay) = match waypoint(&st, w) { Ok(v) => v, Err(e) => return err("bad_args", e, "Waypoints are [x,y], {x,y,layer} or {pad:\"U1.3\"}.") };+ // Fusion's convention, which AI Flow plans for: the segment ARRIVING at a waypoint is on+ // the current layer; a waypoint naming another layer drops a via there and the path+ // LEAVES on that layer.+ if let Some((px, py)) = prev {+ if (px - x).abs() > 1e-6 || (py - y).abs() > 1e-6 {+ ops.push(format!("T|{}|{}|{}|{}|{}|{}|{}", raw(px), raw(py), raw(x), raw(y), raw(width), layer, net));+ }+ }+ if let Some(l) = lay {+ if l != layer {+ // a layer change inside a through-hole pad of this net needs no via: the pad's barrel+ // joins the layers, and a via there is a hole in a hole (measured 2026-10-16: vias on+ // every machine pin and contact, Altium's DRC 'Pad/Via Touching Holes')+ if !tht_pad_at(&st, x, y, net) {+ let v = format!("V|{}|{}|{}|{}|{}|{}|{}", raw(x), raw(y), raw(via_size), raw(via_drill), TOP, BOTTOM, net);+ if !ops.contains(&v) {+ ops.push(v); // several branches may name the same transition: one physical via+ }+ }+ layer = l;+ }+ }+ prev = Some((x, y));+ }+ }+ apply(&ops, &st, "route_net")+}++/// altium_move_parts {parts:[{ref,x,y,rotation}], expectedRevision?}+pub fn move_parts(args: &Value) -> Value {+ let st = match guarded(args) { Ok(s) => s, Err(e) => return e };+ let mut ops = Vec::new();+ for p in args.get("parts").and_then(|v| v.as_array()).cloned().unwrap_or_default() {+ let r = p.get("ref").and_then(|v| v.as_str()).unwrap_or("");+ let (Some(x), Some(y)) = (p.get("x").and_then(|v| v.as_f64()), p.get("y").and_then(|v| v.as_f64())) else {+ return err("bad_args", format!("part {r}: x and y are required (mm)"), "");+ };+ let rot = p.get("rotation").and_then(|v| v.as_f64()).unwrap_or(0.0);+ ops.push(format!("M|{r}|{}|{}|{}", raw(x), raw(y), (rot * 1000.0).round() as i64));+ }+ let mut out = apply(&ops, &st, "move_parts");+ // Readback: every part must sit where it was asked to, to 0.001 mm and the exact rotation.+ if let Some(after) = out.get("state").cloned() {+ let mut off = Vec::new();+ for p in args["parts"].as_array().cloned().unwrap_or_default() {+ let r = p["ref"].as_str().unwrap_or("");+ let got = after["components"].as_array().and_then(|c| c.iter().find(|c| c["ref"].as_str() == Some(r)).cloned());+ match got {+ Some(c) if (c["x"].as_f64().unwrap_or(0.0) - p["x"].as_f64().unwrap_or(0.0)).abs() <= 0.001+ && (c["y"].as_f64().unwrap_or(0.0) - p["y"].as_f64().unwrap_or(0.0)).abs() <= 0.001+ && ((c["rotation"].as_f64().unwrap_or(0.0) - p.get("rotation").and_then(|v| v.as_f64()).unwrap_or(0.0)).rem_euclid(360.0)).min(+ (p.get("rotation").and_then(|v| v.as_f64()).unwrap_or(0.0) - c["rotation"].as_f64().unwrap_or(0.0)).rem_euclid(360.0)) < 0.01 => {}+ other => off.push(json!({"ref": r, "asked": p, "got": other})),+ }+ }+ if !off.is_empty() {+ out["success"] = json!(false);+ out["errorCode"] = json!("readback_mismatch");+ out["mismatches"] = json!(off);+ }+ }+ out+}++/// altium_place_vias {vias:[{net,x,y,size,drill}], expectedRevision?}+pub fn place_vias(args: &Value) -> Value {+ let st = match guarded(args) { Ok(s) => s, Err(e) => return e };+ let mut ops = Vec::new();+ for v in args.get("vias").and_then(|v| v.as_array()).cloned().unwrap_or_default() {+ let (Some(x), Some(y)) = (v.get("x").and_then(|v| v.as_f64()), v.get("y").and_then(|v| v.as_f64())) else {+ return err("bad_args", "each via needs x and y (mm)".into(), "");+ };+ let size = v.get("size").or(v.get("diameter")).and_then(|v| v.as_f64()).unwrap_or(0.6);+ let drill = v.get("drill").and_then(|v| v.as_f64()).unwrap_or(0.3);+ let net = v.get("net").and_then(|v| v.as_str()).unwrap_or("");+ ops.push(format!("V|{}|{}|{}|{}|{}|{}|{}", raw(x), raw(y), raw(size), raw(drill), TOP, BOTTOM, net));+ }+ apply(&ops, &st, "place_vias")+}++/// altium_remove_items {tracks:[{start{x,y},end{x,y},layer}], vias:[{x,y}|{position{x,y}}], expectedRevision?}+/// Accepts the exact objects altium_routing_state returned.+pub fn remove_items(args: &Value) -> Value {+ let st = match guarded(args) { Ok(s) => s, Err(e) => return e };+ let mut ops = Vec::new();+ for t in args.get("tracks").and_then(|v| v.as_array()).cloned().unwrap_or_default() {+ let f = |k: &str, a: &str| t[k][a].as_f64().map(raw);+ let (Some(x1), Some(y1), Some(x2), Some(y2)) = (f("start", "x"), f("start", "y"), f("end", "x"), f("end", "y")) else {+ return err("bad_args", "a track needs start{x,y} and end{x,y}".into(), "Pass tracks as altium_routing_state returned them.");+ };+ ops.push(format!("D|T|{x1}|{y1}|{x2}|{y2}|{}", t.get("layerId").and_then(|v| v.as_i64()).or(t.get("layer").and_then(layer_id)).unwrap_or(TOP)));+ }+ for v in args.get("vias").and_then(|v| v.as_array()).cloned().unwrap_or_default() {+ let p = if v.get("position").is_some() { &v["position"] } else { &v };+ let (Some(x), Some(y)) = (p["x"].as_f64(), p["y"].as_f64()) else { return err("bad_args", "a via needs x,y".into(), "") };+ ops.push(format!("D|V|{}|{}", raw(x), raw(y)));+ }+ apply(&ops, &st, "remove_items")+}++/// altium_ripup_nets {nets:[...], expectedRevision?}: every free track, arc and via on each net.+pub fn ripup_nets(args: &Value) -> Value {+ let st = match guarded(args) { Ok(s) => s, Err(e) => return e };+ let ops: Vec<String> = args.get("nets").and_then(|v| v.as_array()).cloned().unwrap_or_default()+ .iter().filter_map(|n| n.as_str()).map(|n| format!("N|{n}")).collect();+ apply(&ops, &st, "ripup_nets")+}++fn shoelace_mm2(pts: &[Value]) -> f64 {+ let n = pts.len();+ if n < 3 {+ return 0.0;+ }+ let mut a = 0.0;+ for k in 0..n {+ let (x1, y1) = (pts[k][0].as_f64().unwrap_or(0.0), pts[k][1].as_f64().unwrap_or(0.0));+ let (x2, y2) = (pts[(k + 1) % n][0].as_f64().unwrap_or(0.0), pts[(k + 1) % n][1].as_f64().unwrap_or(0.0));+ a += x1 * y2 - x2 * y1;+ }+ (a / 2.0).abs() / (PER_MM * PER_MM)+}++/// The agent's pour dump as zones with their real poured copper area.+pub fn shape_pours(d: &Value) -> Value {+ let mut by_layer = serde_json::Map::new();+ let mut zones = Vec::new();+ let mut unfilled = Vec::new();+ for g in arr(d, "polys") {+ let layer = layer_name(i(&g[1]));+ let mut area = 0.0;+ let mut islands = 0;+ let pts_mm = |c: &Value| -> Value { json!(c.as_array().cloned().unwrap_or_default().iter().map(|p| json!([mm(&p[0]), mm(&p[1])])).collect::<Vec<_>>()) };+ let fills: Vec<Value> = g[4].as_array().cloned().unwrap_or_default().iter().map(|f| json!({+ "outline": pts_mm(&f[0]), "holes": f[1].as_array().cloned().unwrap_or_default().iter().map(|h| pts_mm(h)).collect::<Vec<_>>()})).collect();+ for f in g[4].as_array().cloned().unwrap_or_default() {+ let main = shoelace_mm2(f[0].as_array().map(|v| v.as_slice()).unwrap_or(&[]));+ let holes: f64 = f[1].as_array().cloned().unwrap_or_default().iter()+ .map(|h| shoelace_mm2(h.as_array().map(|v| v.as_slice()).unwrap_or(&[]))).sum();+ area += main - holes;+ islands += 1;+ }+ let area = (area * 1000.0).round() / 1000.0;+ *by_layer.entry(layer.clone()).or_insert(json!(0.0)) = json!(by_layer.get(&layer).and_then(|v| v.as_f64()).unwrap_or(0.0) + area);+ let outline: Vec<Value> = g[3].as_array().cloned().unwrap_or_default().iter().map(|p| json!([mm(&p[0]), mm(&p[1])])).collect();+ let z = json!({"net": g[0], "name": g[5], "layer": layer, "layerId": g[1], "pourIndex": g[2], "outline": outline,+ "filledMm2": area, "islands": islands, "filled": area > 0.0, "fills": fills});+ if area <= 0.0 {+ unfilled.push(z.clone());+ }+ zones.push(z);+ }+ json!({"zones": zones, "filledAreaByLayerMm2": by_layer, "unfilled": unfilled})+}++/// altium_pour_state {}: every pour with the copper it actually poured.+pub fn pour_state(_args: &Value) -> Value {+ let r = agent::call("pour_state", &[], None, Duration::from_secs(180));+ match r.get("data") {+ Some(d) if d.get("ok").and_then(|v| v.as_bool()) == Some(true) => {+ let mut out = shape_pours(d);+ out["success"] = json!(true);+ out["file"] = d["file"].clone();+ out["_hint"] = json!("filledMm2 is the copper Altium poured (regions minus holes), not the outline area. A zone with filled:false poured nothing: an island removed as dead copper, or no connection to its net.");+ out+ }+ _ => r,+ }+}++/// a name that survives the agent's '|'/';' framing and an Altium query string+fn safe_name(s: &str) -> String {+ s.chars().filter(|c| !matches!(c, '|' | ';' | '\'' | '\n' | '\r')).collect::<String>().trim().to_string()+}++/// a connect style as the agent's one letter: direct (solid), relief (thermal), none+fn connect_code(s: &str) -> Option<&'static str> {+ match s.to_ascii_lowercase().as_str() {+ "direct" | "solid" | "full" => Some("D"),+ "relief" | "thermal" | "thermal_reliefs" | "spokes" => Some("R"),+ "none" | "no" | "no_connect" => Some("N"),+ _ => None,+ }+}++/// altium_apply_pours {pours:[{net, layer, points:[[x,y]...], order?, name?, connect?, reliefWidth?, reliefGap?, reliefEntries?,+/// removeDead?, removeSmallIslands?, minNeck?, solidPads?:[[x1,y1,x2,y2]...]}],+/// cutouts?:[{layer, points, name?}], expectedRevision?}+pub fn apply_pours(args: &Value) -> Value {+ let st = match guarded(args) { Ok(s) => s, Err(e) => return e };+ let mut ops = Vec::new();+ let mut pads_state: Option<Value> = None;+ let mut joint_notes: Vec<Value> = Vec::new();+ // cutouts land first so every pour in this call is poured clear of them+ for (i, c) in args.get("cutouts").and_then(|v| v.as_array()).cloned().unwrap_or_default().iter().enumerate() {+ let layer = c.get("layer").and_then(layer_id).unwrap_or(TOP);+ let pts = c.get("points").and_then(|v| v.as_array()).cloned().unwrap_or_default();+ if pts.len() < 3 {+ return err("bad_args", format!("cutout {i}: points needs 3 or more [x,y]"), "");+ }+ let p: Vec<String> = pts.iter().map(|q| format!("{},{}", raw(q[0].as_f64().unwrap_or(0.0)), raw(q[1].as_f64().unwrap_or(0.0)))).collect();+ let name = safe_name(c.get("name").and_then(|v| v.as_str()).unwrap_or(""));+ let name = if name.is_empty() { format!("Adom keepout {i}") } else if name.starts_with("Adom") { name } else { format!("Adom {name}") };+ ops.push(format!("C|{layer}|{}|{name}", p.join(";")));+ }+ for g in args.get("pours").and_then(|v| v.as_array()).cloned().unwrap_or_default() {+ let net = g.get("net").and_then(|v| v.as_str()).unwrap_or("");+ let layer = g.get("layer").and_then(layer_id).unwrap_or(TOP);+ let pts = g.get("points").and_then(|v| v.as_array()).cloned().unwrap_or_default();+ if pts.len() < 3 {+ return err("bad_args", format!("pour on {net}: points needs 3 or more [x,y]"), "");+ }+ let order = g.get("order").or(g.get("pourIndex")).and_then(|v| v.as_i64()).unwrap_or(0);+ let p: Vec<String> = pts.iter().map(|q| format!("{},{}", raw(q[0].as_f64().unwrap_or(0.0)), raw(q[1].as_f64().unwrap_or(0.0)))).collect();+ let dead = if g.get("removeDead").and_then(|v| v.as_bool()) == Some(false) { "0" } else { "1" };+ let name = safe_name(g.get("name").and_then(|v| v.as_str()).unwrap_or(""));+ let connect = match g.get("connect").and_then(|v| v.as_str()) {+ None => "",+ Some(c) => match connect_code(c) {+ Some(code) => code,+ None => return err("bad_args", format!("pour on {net}: connect \"{c}\" is not direct, relief or none"), "connect: \"direct\" (solid joints), \"relief\" (thermal spokes), \"none\"."),+ },+ };+ let mm = |k: &str| g.get(k).and_then(|v| v.as_f64()).map(raw).unwrap_or(0);+ let entries = g.get("reliefEntries").and_then(|v| v.as_i64()).unwrap_or(0);+ let small = if g.get("removeSmallIslands").and_then(|v| v.as_bool()) == Some(true) { "1" } else { "0" };+ let mut joints = Vec::new();+ for r in g.get("solidPads").and_then(|v| v.as_array()).cloned().unwrap_or_default() {+ let n: Vec<f64> = r.as_array().map(|a| a.iter().filter_map(|v| v.as_f64()).collect()).unwrap_or_default();+ if n.len() != 4 { return err("bad_args", format!("pour on {net}: solidPads entries are [x1, y1, x2, y2] mm"), ""); }+ // Altium's region test wants an object COMPLETELY inside, and a joint box is often smaller+ // than its pad: one region per pad of this net the box touches, the pad's own outline+ let bx = [n[0].min(n[2]), n[1].min(n[3]), n[0].max(n[2]), n[1].max(n[3])];+ let st = pads_state.get_or_insert_with(|| routing_state(&json!({})));+ let mut hit = false;+ for p in st["pads"].as_array().cloned().unwrap_or_default() {+ if p["net"].as_str() != Some(net) { continue; }+ let on = p["layerId"].as_i64() == Some(layer) || p["layer"].as_str() == Some("*.Cu");+ if !on { continue; }+ let f = |k: &str| p.get(k).and_then(|v| v.as_f64()).unwrap_or(0.0);+ let rot = f("rotation").rem_euclid(180.0);+ let (hx, hy) = if (rot - 90.0).abs() < 0.01 { (f("sizeY") / 2.0, f("sizeX") / 2.0) }+ else if rot < 0.01 || (rot - 180.0).abs() < 0.01 { (f("sizeX") / 2.0, f("sizeY") / 2.0) }+ else { let m = f("sizeX").hypot(f("sizeY")) / 2.0; (m, m) };+ let pb = [f("x") - hx, f("y") - hy, f("x") + hx, f("y") + hy];+ if pb[0] <= bx[2] && pb[2] >= bx[0] && pb[1] <= bx[3] && pb[3] >= bx[1] {+ hit = true;+ joints.push(format!("{},{},{},{}", raw(pb[0]), raw(pb[1]), raw(pb[2]), raw(pb[3])));+ }+ }+ if !hit { joint_notes.push(json!(format!("pour on {net}: no {net} pad touches solidPads box {bx:?}; nothing to join there"))); }+ }+ ops.push(format!("P|{net}|{layer}|{order}|{}|{dead}|{name}|{connect}|{}|{}|{entries}|{small}|{}|{}", p.join(";"), mm("reliefWidth"), mm("reliefGap"), mm("minNeck"), joints.join(";")));+ }+ if ops.is_empty() {+ return err("nothing_to_do", "apply_pours: no pours or cutouts in the request".into(), "Pass pours:[{net, layer, points}] and/or cutouts:[{layer, points}].");+ }+ let gap_ignored = args.get("pours").and_then(|v| v.as_array()).map(|a| a.iter().any(|g| g.get("reliefGap").is_some())).unwrap_or(false);+ let r = agent::call("apply_pours", &[], Some(&ops.join("\n")), Duration::from_secs(600));+ let d = match r.get("data") {+ Some(d) if d.get("ok").and_then(|v| v.as_bool()) == Some(true) => d.clone(),+ _ => return r,+ };+ // a pour poured inside the agent ignored its own scoped connect rule: pour again outside+ let scoped = ops.iter().any(|o| o.starts_with('P') && (o.split('|').nth(7).map(|c| !c.is_empty()).unwrap_or(false) || o.split('|').nth(13).map(|c| !c.is_empty()).unwrap_or(false)));+ let outside = if scoped { repour_outside_script() } else { json!(null) };+ let after = routing_state(&json!({}));+ let pours = pour_state(&json!({}));+ let failed = d.get("failed").and_then(|v| v.as_i64()).unwrap_or(0);+ let mut notes = joint_notes;+ if gap_ignored { notes.push(json!("reliefGap is not set by script on this Altium build (the gap is per pad type there); the relief keeps Altium's 10 mil (0.254 mm) air gap")); }+ json!({"success": failed == 0, "applied": d["applied"], "failed": failed, "results": d["results"], "repouredOutsideScript": outside,+ "revisionBefore": st["revision"], "revision": after["revision"], "unconnected": after["unconnected"],+ "zones": pours["zones"], "filledAreaByLayerMm2": pours["filledAreaByLayerMm2"], "unfilled": pours["unfilled"],+ "notes": notes,+ "_hint": "Each pour was added and poured (solid, dead copper removed); results[i] is ok:<polygon name>. A pour with connect gets its own PolygonConnect rule (AdomPC <name>, scoped IsNamedPolygon) that outranks the board's; without it the board's Polygon Connect Style applies. Cutouts keep every pour out of their area on their layer. Clearance comes from the board's Clearance rule."})+}++/// altium_remove_pours {pours?:"all" | [{net, layer?} | {name}], cutouts?:"all" | [{layer}], expectedRevision?}+pub fn remove_pours(args: &Value) -> Value {+ let st = match guarded(args) { Ok(s) => s, Err(e) => return e };+ let mut lines: Vec<String> = match args.get("pours") {+ Some(Value::String(s)) if s == "all" => vec!["*".into()],+ Some(Value::Array(a)) => a.iter().map(|g| match g.get("name").and_then(|v| v.as_str()) {+ Some(n) => format!("={}|0", safe_name(n)),+ None => format!("{}|{}", g.get("net").and_then(|v| v.as_str()).unwrap_or(""), g.get("layer").and_then(layer_id).unwrap_or(0)),+ }).collect(),+ None => vec![],+ _ => return err("bad_args", "pours is \"all\" or [{net, layer?} | {name}]".into(), ""),+ };+ match args.get("cutouts") {+ Some(Value::String(s)) if s == "all" => lines.push("C|0".into()),+ Some(Value::Array(a)) => lines.extend(a.iter().map(|c| format!("C|{}", c.get("layer").and_then(layer_id).unwrap_or(0)))),+ None => {}+ _ => return err("bad_args", "cutouts is \"all\" or [{layer}]".into(), ""),+ }+ if lines.is_empty() {+ return err("bad_args", "nothing to remove: pass pours and/or cutouts".into(), "pours:\"all\" | [{net, layer?}], cutouts:\"all\" | [{layer}]");+ }+ let r = agent::call("remove_pours", &[], Some(&lines.join("\n")), Duration::from_secs(300));+ let d = match r.get("data") { Some(d) if d.get("ok").and_then(|v| v.as_bool()) == Some(true) => d.clone(), _ => return r };+ let after = routing_state(&json!({}));+ json!({"success": true, "removed": d["removed"], "cutoutsRemoved": d["cutoutsRemoved"], "rulesRemoved": d["rulesRemoved"], "revisionBefore": st["revision"], "revision": after["revision"], "counts": after["counts"],+ "_hint": "Tracks and vias are untouched; altium_ripup_nets clears those."})+}++/// Repour All with NO script running. Altium evaluates a rule's custom query through its scripting+/// system, so while the agent loop is resident every scoped rule (InNamedPolygon, InNet, a net-class+/// clearance...) matches nothing and a repour falls back to the board's catch-all rules: the pours+/// came out with the default relief spokes whatever their own connect rule said (measured AD 26.11.1,+/// 2026-10-15: Test Queries read 0 objects with the agent up, 106 with it stopped). So: stop the+/// agent, run the repour from a helper (-R), start the agent again.+fn repour_outside_script() -> Value {+ let Some(exe) = crate::lifecycle::x2_exe_pub() else { return json!({"success": false, "error": "X2.exe not found"}) };+ let stop = crate::lifecycle::stop();+ if stop.get("success").and_then(|v| v.as_bool()) != Some(true) { return json!({"success": false, "stage": "agent_stop", "stop": stop}); }+ let t0 = std::time::Instant::now();+ let ran = match crate::lifecycle::spawn_helper_pub(&exe, "-RPCB:ChangeObject(ContextObject=Polygon|Action=RepourAllPolygons)") {+ Ok(mut c) => crate::lifecycle::wait_helper_pub(&mut c, Duration::from_secs(120)),+ Err(_) => false,+ };+ // the agent's start is queued behind the repour on Altium's main thread, so it answers after it+ let start = crate::lifecycle::start(Duration::from_secs(90));+ json!({"success": ran && start.get("success").and_then(|v| v.as_bool()) == Some(true), "helperExited": ran,+ "seconds": (t0.elapsed().as_secs_f64() * 10.0).round() / 10.0, "agentStart": start.get("success").cloned().unwrap_or(json!(false))})+}++/// altium_repour {inScript?}: repour every polygon on the board (Altium's Repour All). By default+/// outside the script engine so scoped rules apply; inScript:true keeps the old in-agent repour.+pub fn repour(args: &Value) -> Value {+ let r = if args.get("inScript").and_then(|v| v.as_bool()) == Some(true) {+ agent::call("run_process", &[("process", "PCB:ChangeObject".into()), ("params", "ContextObject=Polygon|Action=RepourAllPolygons".into())], None, Duration::from_secs(600))+ } else {+ json!({"data": repour_outside_script()})+ };+ let pours = pour_state(&json!({}));+ let mut out = json!({"success": pours.get("success").cloned().unwrap_or(json!(false)), "run": r.get("data").cloned().unwrap_or(r.clone())});+ for k in ["zones", "filledAreaByLayerMm2", "unfilled"] {+ out[k] = pours[k].clone();+ }+ out["_hint"] = json!("Repoured every polygon outside the script engine (the agent stops for it and starts again), so scoped rules (each pour's connect style, net-class clearances) apply. zones[] is the copper after the repour.");+ out+}++/// altium_routing_drc {full?}: violations with locations + the unrouted count. full:true runs+/// Altium's batch DRC on the CURRENT board first (otherwise online DRC's live set is read).+pub fn routing_drc(args: &Value) -> Value {+ let mut batch = Value::Null;+ if args.get("full").and_then(|v| v.as_bool()) == Some(true) {+ let st = routing_state(&json!({}));+ let path = st["file"].as_str().unwrap_or("").to_string();+ if path.is_empty() {+ return st;+ }+ batch = crate::outputs::run_drc(&json!({"filePath": path}));+ }+ let r = agent::call("routing_drc", &[], None, Duration::from_secs(180));+ let d = match r.get("data") { Some(d) if d.get("ok").and_then(|v| v.as_bool()) == Some(true) => d.clone(), _ => return r };+ let viol: Vec<Value> = arr(&d, "violations").iter().map(|v| json!({+ "rule": v[0], "description": v[1],+ "at": {"x": json!(((v[2].as_f64().unwrap_or(0.0) + v[4].as_f64().unwrap_or(0.0)) / 2.0 / PER_MM * 1000.0).round() / 1000.0),+ "y": json!(((v[3].as_f64().unwrap_or(0.0) + v[5].as_f64().unwrap_or(0.0)) / 2.0 / PER_MM * 1000.0).round() / 1000.0)}})).collect();+ let errors = d["violationCount"].as_i64().unwrap_or(0);+ let unconnected = d["unrouted"].as_i64().unwrap_or(0);+ let mut out = json!({"success": true, "file": d["file"], "unconnected": unconnected, "errors": errors,+ "clean": errors == 0 && unconnected == 0, "violations": viol,+ "_hint": if args.get("full").and_then(|v| v.as_bool()) == Some(true) { "Batch DRC ran on this board first, so violations[] is the full set." } else { "Read from online DRC's live set; pass full:true to run the batch DRC first (it covers rules online DRC skips)." }});+ if !batch.is_null() {+ out["batch"] = json!({"success": batch["success"], "violationCount": batch["violationCount"], "errorCode": batch["errorCode"]});+ }+ out+}++/// Outline vertices in the agent's form (`x,y,kind,cx,cy,r,a1,a2`, RAW units) for a rectangle, with+/// rounded corners when `r > 0`: each corner is an arc from its vertex to the next, counterclockwise.+pub fn rect_outline(x0: f64, y0: f64, w: f64, h: f64, r: f64) -> Vec<String> {+ let (x1, y1) = (x0 + w, y0 + h);+ if r <= 0.0 {+ return [(x0, y0), (x1, y0), (x1, y1), (x0, y1)].iter().map(|(x, y)| format!("{},{},0", raw(*x), raw(*y))).collect();+ }+ let arc = |x: f64, y: f64, cx: f64, cy: f64, a1: f64, a2: f64| format!("{},{},1,{},{},{},{},{}", raw(x), raw(y), raw(cx), raw(cy), raw(r), (a1 * 1000.0) as i64, (a2 * 1000.0) as i64);+ let line = |x: f64, y: f64| format!("{},{},0", raw(x), raw(y));+ vec![+ line(x0 + r, y0), arc(x1 - r, y0, x1 - r, y0 + r, 270.0, 360.0),+ line(x1, y0 + r), arc(x1, y1 - r, x1 - r, y1 - r, 0.0, 90.0),+ line(x1 - r, y1), arc(x0 + r, y1, x0 + r, y1 - r, 90.0, 180.0),+ line(x0, y1 - r), arc(x0, y0 + r, x0 + r, y0 + r, 180.0, 270.0),+ ]+}++/// The outline argument: {rect:[x0,y0,w,h], cornerRadius} or {points:[[x,y]...]} (mm, Altium frame).+fn outline_spec(o: &Value) -> Result<String, Value> {+ if let Some(r) = o.get("rect").and_then(|v| v.as_array()) {+ let n: Vec<f64> = r.iter().filter_map(|v| v.as_f64()).collect();+ if n.len() != 4 || n[2] <= 0.0 || n[3] <= 0.0 { return Err(err("bad_args", "outline.rect is [x0, y0, w, h] in mm with w, h > 0".into(), "")); }+ let cr = o.get("cornerRadius").and_then(|v| v.as_f64()).unwrap_or(0.0);+ if cr * 2.0 >= n[2].min(n[3]) { return Err(err("bad_args", "cornerRadius must be under half the shorter side".into(), "")); }+ return Ok(rect_outline(n[0], n[1], n[2], n[3], cr).join(";"));+ }+ if let Some(ps) = o.get("points").and_then(|v| v.as_array()) {+ if ps.len() < 3 { return Err(err("bad_args", "outline.points needs 3 or more [x,y]".into(), "")); }+ return Ok(ps.iter().map(|p| format!("{},{},0", raw(p[0].as_f64().unwrap_or(0.0)), raw(p[1].as_f64().unwrap_or(0.0)))).collect::<Vec<_>>().join(";"));+ }+ Err(err("bad_args", "outline is {rect:[x0,y0,w,h], cornerRadius?} or {points:[[x,y]...]}".into(), ""))+}++/// altium_build_board {pcbPath, libraryPath, outline, parts:[{ref, footprint, x, y, rotation, comment}], nets:{net:["R1.1",...]}}+pub fn build_board(args: &Value) -> Value {+ let pcb = args.get("pcbPath").and_then(|v| v.as_str()).unwrap_or("");+ let lib = args.get("libraryPath").and_then(|v| v.as_str()).unwrap_or("");+ if pcb.is_empty() || lib.is_empty() { return err("bad_args", "pcbPath (the .PcbDoc to write) and libraryPath (the .PcbLib with every footprint) are required".into(), ""); }+ let outline = match args.get("outline") { Some(o) => match outline_spec(o) { Ok(s) => s, Err(e) => return e }, None => String::new() };+ let mut ops = Vec::new();+ if !outline.is_empty() { ops.push(format!("O|{outline}")); }+ let parts = args.get("parts").and_then(|v| v.as_array()).cloned().unwrap_or_default();+ if parts.is_empty() { return err("bad_args", "parts is empty".into(), "Pass parts:[{ref, footprint, x, y, rotation}] (mm, Altium frame)."); }+ for p in &parts {+ let r = p.get("ref").and_then(|v| v.as_str()).unwrap_or("");+ let fp = p.get("footprint").and_then(|v| v.as_str()).unwrap_or("");+ if r.is_empty() || fp.is_empty() { return err("bad_args", "every part needs ref and footprint".into(), ""); }+ ops.push(format!("C|{r}|{fp}|{}|{}|{}|{}", raw(p.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0)), raw(p.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0)),+ (p.get("rotation").and_then(|v| v.as_f64()).unwrap_or(0.0) * 1000.0).round() as i64, p.get("comment").and_then(|v| v.as_str()).unwrap_or("").replace('|', "/")));+ }+ let mut want: Vec<(String, String, String)> = Vec::new();+ for (net, pins) in args.get("nets").and_then(|v| v.as_object()).cloned().unwrap_or_default() {+ for pin in pins.as_array().cloned().unwrap_or_default() {+ if let Some((r, pad)) = pin.as_str().and_then(|s| s.split_once('.')) {+ ops.push(format!("N|{r}|{pad}|{net}"));+ want.push((r.to_string(), pad.to_string(), net.clone()));+ }+ }+ }+ // the library must be the current document for the agent to read its footprints+ let open = crate::support::agent_open(lib, 90);+ if open.get("success").and_then(|v| v.as_bool()) != Some(true) { return open; }+ let r = agent::call("build_board", &[("pcb", pcb.to_string())], Some(&ops.join("\n")), Duration::from_secs(900));+ let d = match r.get("data") { Some(d) if d.get("ok").and_then(|v| v.as_bool()) == Some(true) => d.clone(), _ => return r };+ // read back: every part on the board, every listed pad on its net+ let st = routing_state(&json!({}));+ let mut wrong = Vec::new();+ for (rf, pad, net) in &want {+ let got = st["pads"].as_array().and_then(|ps| ps.iter().find(|p| p["ref"].as_str() == Some(rf) && p["name"].as_str() == Some(pad)).cloned());+ match got {+ Some(p) if p["net"].as_str() == Some(net) => {}+ other => wrong.push(json!({"pin": format!("{rf}.{pad}"), "want": net, "got": other.map(|p| p["net"].clone()).unwrap_or(json!("no such pad"))})),+ }+ }+ let placed_refs: Vec<String> = st["components"].as_array().cloned().unwrap_or_default().iter().filter_map(|c| c["ref"].as_str().map(str::to_string)).collect();+ let missing: Vec<&str> = parts.iter().filter_map(|p| p["ref"].as_str()).filter(|r| !placed_refs.iter().any(|x| x == r)).collect();+ let clean = wrong.is_empty() && missing.is_empty();+ json!({"success": clean, "errorCode": if clean { Value::Null } else { json!("readback_mismatch") }, "pcb": pcb, "placed": d["placed"], "padsWired": d["padsWired"],+ "outlineVertices": d["outlineVertices"], "missingParts": missing, "wrongPins": wrong, "revision": st["revision"], "unconnected": st["unconnected"],+ "counts": st["counts"],+ "_hint": if clean { "Saved and read back: every part is on the board and every listed pin on its net. altium_routing_state reads it; unconnected is the ratsnest to route." } else { "Some pins did not land on their net (wrongPins) or parts are missing: a footprint's pad names must match the netlist's pin numbers." }})+}++/// altium_set_outline {outline: {rect, cornerRadius} | {points}, expectedRevision?}+pub fn set_outline(args: &Value) -> Value {+ let st = match guarded(args) { Ok(s) => s, Err(e) => return e };+ let spec = match args.get("outline") { Some(o) => match outline_spec(o) { Ok(s) => s, Err(e) => return e }, None => return err("bad_args", "outline is required".into(), "") };+ let r = agent::call("set_outline", &[], Some(&format!("O|{spec}")), Duration::from_secs(120));+ let d = match r.get("data") { Some(d) if d.get("ok").and_then(|v| v.as_bool()) == Some(true) => d.clone(), _ => return r };+ let after = routing_state(&json!({}));+ json!({"success": true, "vertices": d["vertices"], "revisionBefore": st["revision"], "revision": after["revision"], "outlineEdges": after["outlineEdges"],+ "_hint": "Copper keeps its edge clearance from the new outline: repour (altium_repour) when a pour followed the old one."})+}++/// altium_set_design_rules {clearance, trackWidth:{min,preferred,max}, via:{diameter:{min,preferred,max}, drill:{min,preferred,max}},+/// hole:{min,max}, annularRing, holeToHole, edgeClearance} (mm). Each rule kind is written by its own agent call,+/// so a property one Altium build lacks fails alone; the reply says which kinds landed.+pub fn set_design_rules(args: &Value) -> Value {+ let mm_of = |v: Option<&Value>| v.and_then(|x| x.as_f64());+ let g = |o: &str, k: &str| mm_of(args.get(o).and_then(|x| x.get(k)));+ let mut plan: Vec<(&str, Vec<(&str, String)>)> = Vec::new();+ if let Some(c) = mm_of(args.get("clearance")) { plan.push(("clearance", vec![("a", raw(c).to_string())])); }+ if let Some(min) = g("trackWidth", "min") {+ let pref = g("trackWidth", "preferred").unwrap_or(min.max(0.25));+ let max = g("trackWidth", "max").unwrap_or(5.0);+ plan.push(("width", vec![("a", raw(min).to_string()), ("b", raw(pref).to_string()), ("c", raw(max).to_string())]));+ }+ if let Some(v) = args.get("via") {+ let d = |k: &str, j: &str, def: f64| v.get(k).and_then(|x| x.get(j)).and_then(|x| x.as_f64()).unwrap_or(def);+ let (dmin, dmax) = (d("diameter", "min", 0.45), d("diameter", "max", 1.0));+ let (hmin, hmax) = (d("drill", "min", 0.2), d("drill", "max", 0.6));+ plan.push(("via", vec![("a", raw(dmin).to_string()), ("b", raw(d("diameter", "preferred", dmin.max(0.6))).to_string()), ("c", raw(dmax).to_string()),+ ("d", raw(hmin).to_string()), ("e", raw(d("drill", "preferred", hmin.max(0.3))).to_string()), ("f", raw(hmax).to_string())]));+ }+ if let Some(min) = g("hole", "min") { plan.push(("hole", vec![("a", raw(min).to_string()), ("c", raw(g("hole", "max").unwrap_or(6.3)).to_string())])); }+ if let Some(r) = mm_of(args.get("annularRing")) { plan.push(("ring", vec![("a", raw(r).to_string())])); }+ if let Some(h) = mm_of(args.get("holeToHole")) { plan.push(("holetohole", vec![("a", raw(h).to_string())])); }+ if let Some(e) = mm_of(args.get("edgeClearance")) { plan.push(("edge", vec![("a", raw(e).to_string())])); }+ if let Some(pc) = args.get("polygonConnect") {+ let style = pc.get("style").and_then(|v| v.as_str()).or(pc.as_str()).unwrap_or("");+ let a = match connect_code(style) { Some("D") => "1", Some("N") => "2", Some(_) => "0",+ None => return err("bad_args", format!("polygonConnect style \"{style}\" is not direct, relief or none"), "polygonConnect: \"direct\" or {style:\"relief\", reliefWidth: mm, reliefEntries: 2|4}") };+ let w = pc.get("reliefWidth").and_then(|v| v.as_f64()).map(raw).unwrap_or(0);+ let e = pc.get("reliefEntries").and_then(|v| v.as_i64()).unwrap_or(0);+ plan.push(("polyconnect", vec![("a", a.to_string()), ("b", w.to_string()), ("c", e.to_string())]));+ }+ if plan.is_empty() { return err("bad_args", "no rule values given".into(), "Pass any of clearance, trackWidth{min,preferred,max}, via{diameter{...}, drill{...}}, hole{min,max}, annularRing, holeToHole, edgeClearance (mm), polygonConnect (direct|relief|none or {style, reliefWidth, reliefEntries})."); }+ let mut landed = Vec::new();+ let mut failed = Vec::new();+ let mut first_shot: Option<Value> = None;+ for (kind, vals) in plan {+ let mut params: Vec<(&str, String)> = vec![("kind", kind.to_string())];+ params.extend(vals);+ let r = agent::call("set_rule", ¶ms, None, Duration::from_secs(60));+ match r.get("data") {+ Some(d) if d.get("ok").and_then(|v| v.as_bool()) == Some(true) => landed.push(json!({"kind": kind, "rulesWritten": d["rulesWritten"]})),+ _ => {+ if let Some(shot) = r.get("errorShot") { first_shot.get_or_insert(shot.clone()); }+ failed.push(json!({"kind": kind, "errorCode": r.get("errorCode").cloned().unwrap_or(Value::Null), "error": r.get("error").cloned().or_else(|| r.get("data").and_then(|d| d.get("error")).cloned()).unwrap_or(Value::Null)}));+ // a script error recovers the agent and can drop the current board: stop, do not cascade+ if r.get("errorCode").and_then(|c| c.as_str()) == Some("agent_script_error") { break; }+ }+ }+ }+ let rules = agent::call("read_rules", &[], None, Duration::from_secs(60));+ json!({"success": failed.is_empty(), "errorCode": if failed.is_empty() { Value::Null } else { json!("partial_rules") }, "landed": landed, "failed": failed,+ "rules": rules.get("data").and_then(|d| d.get("rules")).cloned().unwrap_or(Value::Null),+ "errorShot": first_shot.unwrap_or(Value::Null),+ "_hint": "Every rule of each kind now carries the fab's values (scopes kept). failed[] names any kind this Altium build refused; the rest landed."})+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn converts_raw_units_and_hashes() {+ let d = json!({"ok": true, "file": "C:/x.PcbDoc",+ "tracks": [[0, 0, 393700, 0, 78740, 1, "GND"]],+ "arcs": [], "vias": [[100000, 200000, 236220, 118110, 1, 32, "GND"]],+ "pads": [["U1", "1", 0, 0, 39370, 39370, 90000, 1, 0, "GND"]],+ "comps": [["U1", 0, 0, 90000, 32, "SOT-23"]], "polys": [], "outline": [[0, 0], [393700, 0]], "unrouted": 3});+ let st = shape(&d);+ assert_eq!(st["tracks"][0]["end"]["x"], json!(1.0));+ assert_eq!(st["tracks"][0]["width"], json!(0.2));+ assert_eq!(st["vias"][0]["layers"], json!(["F.Cu", "B.Cu"]));+ assert_eq!(st["components"][0]["side"], json!("bottom"));+ assert_eq!(st["components"][0]["rotation"], json!(90.0));+ assert_eq!(st["altiumRatsnest"], json!(3));+ let r1 = revision(&d);+ let mut d2 = d.clone();+ d2["tracks"][0][2] = json!(393701);+ assert_ne!(r1, revision(&d2));+ assert_eq!(r1, revision(&d));+ }++ #[test]+ fn unrouted_counts_groups_of_touching_copper() {+ let st = json!({"pads": [+ {"net": "A", "x": 0.0, "y": 0.0, "sizeX": 1.0, "sizeY": 1.0, "layer": "F.Cu"},+ {"net": "A", "x": 10.0, "y": 0.0, "sizeX": 1.0, "sizeY": 1.0, "layer": "F.Cu"},+ {"net": "A", "x": 20.0, "y": 0.0, "sizeX": 1.0, "sizeY": 1.0, "layer": "F.Cu"}],+ "tracks": [{"net": "A", "start": {"x": 0.0, "y": 0.0}, "end": {"x": 10.0, "y": 0.0}, "width": 0.3, "layer": "F.Cu"}],+ "vias": [], "polygons": []});+ assert_eq!(unrouted(&st).0, 1);+ let mut st2 = st.clone();+ st2["tracks"].as_array_mut().unwrap().push(json!({"net": "A", "start": {"x": 10.0, "y": 0.0}, "end": {"x": 20.0, "y": 0.0}, "width": 0.3, "layer": "B.Cu"}));+ assert_eq!(unrouted(&st2).0, 2, "a track on the wrong layer reaches neither F.Cu pad: it is its own island");+ st2["tracks"][1]["layer"] = json!("F.Cu");+ assert_eq!(unrouted(&st2).0, 0);+ }++ #[test]+ fn a_rounded_rectangle_is_eight_vertices_with_four_arcs() {+ let v = rect_outline(0.0, 0.0, 28.0, 20.0, 1.0);+ assert_eq!(v.len(), 8);+ assert_eq!(v.iter().filter(|s| s.split(',').nth(2) == Some("1")).count(), 4);+ assert_eq!(rect_outline(0.0, 0.0, 28.0, 20.0, 0.0).len(), 4);+ }++ #[test]+ fn layer_names_round_trip() {+ assert_eq!(layer_id(&json!("F.Cu")), Some(1));+ assert_eq!(layer_id(&json!("B.Cu")), Some(32));+ assert_eq!(layer_id(&json!("In1.Cu")), Some(2));+ assert_eq!(layer_name(layer_id(&json!("In3.Cu")).unwrap()), "In3.Cu");+ assert_eq!(raw(1.0), 393701);+ }+}++/// altium_zoom_to {rect?:[x1,y1,x2,y2] mm | ref?: "U1" | refs?: [..] | net?: "SW", margin?: mm}: frame the view.+pub fn zoom_to(args: &Value) -> Value {+ let margin = args.get("margin").and_then(|v| v.as_f64()).unwrap_or(1.0);+ let mut b: Option<[f64; 4]> = None;+ let mut grow = |x: f64, y: f64, r: f64| {+ let e = b.get_or_insert([x - r, y - r, x + r, y + r]);+ e[0] = e[0].min(x - r); e[1] = e[1].min(y - r); e[2] = e[2].max(x + r); e[3] = e[3].max(y + r);+ };+ if let Some(r) = args.get("rect").and_then(|v| v.as_array()) {+ let n: Vec<f64> = r.iter().filter_map(|v| v.as_f64()).collect();+ if n.len() != 4 { return err("bad_args", "rect is [x1, y1, x2, y2] in mm".into(), ""); }+ grow(n[0], n[1], 0.0); grow(n[2], n[3], 0.0);+ } else {+ let refs: Vec<String> = match (args.get("ref").and_then(|v| v.as_str()), args.get("refs").and_then(|v| v.as_array())) {+ (Some(r), _) => vec![r.to_string()],+ (None, Some(a)) => a.iter().filter_map(|v| v.as_str().map(String::from)).collect(),+ _ => vec![],+ };+ let net = args.get("net").and_then(|v| v.as_str());+ if refs.is_empty() && net.is_none() {+ return err("bad_args", "pass rect, ref, refs or net".into(), "e.g. {\"ref\":\"U1\"} or {\"net\":\"SW\"} or {\"rect\":[10,5,20,15]}");+ }+ let st = routing_state(&json!({}));+ if st.get("success").and_then(|v| v.as_bool()) != Some(true) { return st; }+ let f = |v: &Value, k: &str| v.get(k).and_then(|x| x.as_f64()).unwrap_or(0.0);+ for p in st["pads"].as_array().cloned().unwrap_or_default() {+ let by_ref = p["ref"].as_str().map(|r| refs.iter().any(|x| x == r)).unwrap_or(false);+ let by_net = net.is_some() && p["net"].as_str() == net;+ if by_ref || by_net { grow(f(&p, "x"), f(&p, "y"), f(&p, "sizeX").max(f(&p, "sizeY")) / 2.0); }+ }+ if net.is_some() {+ for t in st["tracks"].as_array().cloned().unwrap_or_default() {+ if t["net"].as_str() != net { continue; }+ let w = f(&t, "width") / 2.0;+ grow(f(&t["start"], "x"), f(&t["start"], "y"), w); grow(f(&t["end"], "x"), f(&t["end"], "y"), w);+ }+ for v in st["vias"].as_array().cloned().unwrap_or_default() {+ if v["net"].as_str() == net { grow(f(&v["position"], "x"), f(&v["position"], "y"), f(&v, "size") / 2.0); }+ }+ }+ }+ let Some(r) = b else { return err("not_found", "nothing on the board matched".into(), "Check the ref or net name against altium_routing_state."); };+ let rect = [r[0] - margin, r[1] - margin, r[2] + margin, r[3] + margin];+ let c = agent::call("zoom_rect", &[("l", raw(rect[0]).to_string()), ("b", raw(rect[1]).to_string()), ("r", raw(rect[2]).to_string()), ("t", raw(rect[3]).to_string())], None, Duration::from_secs(30));+ match c.get("data") {+ Some(d) if d.get("ok").and_then(|v| v.as_bool()) == Some(true) => json!({"success": true, "rect": rect, "_hint": "The view is framed on rect (mm, Altium's y-up frame). desktop_screenshot_window shows it."}),+ _ => c,+ }+}++/// altium_add_rule {kind: clearance|width, name, scope1, scope2?, gap? | min?/preferred?/max?, comment?}:+/// one scoped rule, mm. scope is Altium's query language: InNet('SW'), InNetClass('Power'), All.+pub fn add_rule(args: &Value) -> Value {+ let st = |k: &str| args.get(k).and_then(|v| v.as_str()).unwrap_or("").to_string();+ let kind = st("kind");+ let name = safe_name(&st("name"));+ let scope1 = st("scope1");+ if args.get("remove").and_then(|v| v.as_bool()) == Some(true) {+ if name.is_empty() || !matches!(kind.as_str(), "clearance" | "width") { return err("bad_args", "remove needs kind and name".into(), ""); }+ let r = agent::call("add_rule", &[("kind", kind.clone()), ("name", name.clone()), ("remove", "1".into())], None, Duration::from_secs(60));+ return match r.get("data") { Some(d) if d.get("ok").and_then(|v| v.as_bool()) == Some(true) => json!({"success": true, "name": d["name"], "removed": d["removed"]}), _ => r };+ }+ if name.is_empty() || scope1.is_empty() { return err("bad_args", "name and scope1 are required".into(), "e.g. {kind:\"clearance\", name:\"SW 0.3\", scope1:\"InNet('SW')\", scope2:\"All\", gap:0.3}"); }+ if scope1.contains('\n') || st("scope2").contains('\n') { return err("bad_args", "a scope is one line".into(), ""); }+ let scope2 = if st("scope2").is_empty() { "All".to_string() } else { st("scope2") };+ let mm = |k: &str| args.get(k).and_then(|v| v.as_f64());+ let mut p: Vec<(&str, String)> = vec![("kind", kind.clone()), ("name", name.clone()), ("scope1", scope1), ("scope2", scope2), ("comment", st("comment"))];+ match kind.as_str() {+ "clearance" => { let Some(g) = mm("gap") else { return err("bad_args", "clearance needs gap (mm)".into(), "") }; p.push(("a", raw(g).to_string())); }+ "width" => {+ let Some(min) = mm("min") else { return err("bad_args", "width needs min (mm)".into(), "") };+ p.push(("a", raw(min).to_string())); p.push(("b", raw(mm("preferred").unwrap_or(min)).to_string())); p.push(("c", raw(mm("max").unwrap_or(min.max(5.0))).to_string()));+ }+ _ => return err("bad_args", format!("kind \"{kind}\" is not clearance or width"), ""),+ }+ let r = agent::call("add_rule", &p, None, Duration::from_secs(60));+ match r.get("data") {+ Some(d) if d.get("ok").and_then(|v| v.as_bool()) == Some(true) => json!({"success": true, "name": d["name"], "priority": d["priority"],+ "_hint": "The rule is first among its kind. Check its query in Altium's rules editor (Test Queries) when unsure: a bad scope matches nothing and DRC passes silently."}),+ _ => r,+ }+}++fn agent_ok(r: &Value) -> Option<Value> {+ match r.get("data") { Some(d) if d.get("ok").and_then(|v| v.as_bool()) == Some(true) => Some(d.clone()), _ => None }+}++/// a document's path as the project file lists it: relative when it sits under the project's folder+fn rel_to(dir: &str, path: &str) -> String {+ let d = dir.trim_end_matches(['\\', '/']).replace('/', "\\");+ let p = path.replace('/', "\\");+ if p.to_lowercase().starts_with(&(d.to_lowercase() + "\\")) { p[d.len() + 1..].to_string() } else { p }+}++/// altium_build_schematic {schPath, libraryPath (.SchLib), parts:[{ref, symbol, block?, comment?}], nets:{net:[ref.pin]},+/// projectPath?, pcbPath?, pcbLibPath?, title?, number?, revision?, drawnBy?}+pub fn build_schematic(args: &Value) -> Value {+ let st = |k: &str| args.get(k).and_then(|v| v.as_str()).unwrap_or("").to_string();+ let (sch, lib) = (st("schPath"), st("libraryPath"));+ if sch.is_empty() || lib.is_empty() { return err("bad_args", "schPath and libraryPath (.SchLib) are required".into(), ""); }+ let parts = args.get("parts").and_then(|v| v.as_array()).cloned().unwrap_or_default();+ if parts.is_empty() { return err("bad_args", "parts is empty".into(), "parts:[{ref, symbol, block?, comment?}]"); }+ let nets = args.get("nets").and_then(|v| v.as_object()).cloned().unwrap_or_default();+ let clean = |s: &str| s.chars().filter(|c| !matches!(c, '|' | '\n' | '\r')).collect::<String>();+ let mut ops = Vec::new();+ let mut refs = std::collections::BTreeSet::new();+ for p in &parts {+ let r = clean(p.get("ref").and_then(|v| v.as_str()).unwrap_or(""));+ let sym = clean(p.get("symbol").and_then(|v| v.as_str()).unwrap_or(""));+ if r.is_empty() || sym.is_empty() { return err("bad_args", "every part needs ref and symbol (its LibReference in the SchLib)".into(), ""); }+ ops.push(format!("C|{r}|{sym}|{}|{}|{}", clean(p.get("block").and_then(|v| v.as_str()).unwrap_or("")), clean(p.get("comment").and_then(|v| v.as_str()).unwrap_or("")),+ clean(p.get("footprint").and_then(|v| v.as_str()).unwrap_or(""))));+ refs.insert(r);+ }+ // the nets as the sheet should compile: only pins of parts on it+ let mut want: std::collections::BTreeMap<String, std::collections::BTreeSet<String>> = Default::default();+ for (n, pins) in &nets {+ for pin in pins.as_array().cloned().unwrap_or_default() {+ let Some(pin) = pin.as_str() else { continue };+ let Some((r, num)) = pin.split_once('.') else { continue };+ if !refs.contains(r) { continue; }+ ops.push(format!("P|{r}|{num}|{}", clean(n)));+ want.entry(n.clone()).or_default().insert(pin.to_string());+ }+ }+ // a sheet drawn again replaces the old one: close it first (an open document cannot be overwritten)+ agent::call("close_document", &[("path", sch.clone())], None, Duration::from_secs(60));+ // the sheet starts as one of Altium's own landscape templates (title block included), sized for the parts+ let (tpl, tw, th) = match parts.len() { 0..=12 => ("ANSI B Landscape", 17000, 11000), 13..=40 => ("ANSI C Landscape", 22000, 17000), _ => ("ANSI D Landscape", 34000, 22000) };+ let tpl_path = format!(r"C:\Users\Public\Documents\Altium\AD26\Templates\{tpl}.SchDot");+ if let Err(e) = std::fs::copy(&tpl_path, &sch) { return err("template_missing", format!("could not start the sheet from {tpl_path}: {e}"), "Altium's sheet templates live under Public Documents\\Altium\\AD26\\Templates."); }+ // the project: the sheet, the board, the libraries and the manufacturing OutJob, as Altium lists them+ let dir = sch.rsplit_once(['\\', '/']).map(|(d, _)| d.to_string()).unwrap_or_default();+ let stem = sch.rsplit_once(['\\', '/']).map(|(_, f)| f).unwrap_or(&sch).rsplit_once('.').map(|(s, _)| s.to_string()).unwrap_or_default();+ let prj = if st("projectPath").is_empty() { format!("{dir}\\{stem}.PrjPcb") } else { st("projectPath") };+ let pdir = prj.rsplit_once(['\\', '/']).map(|(d, _)| d.to_string()).unwrap_or_default();+ // the manufacturing OutJob belongs to the project from the start (an OutJob outside it writes nothing)+ let outjob = if st("pcbPath").is_empty() { String::new() } else {+ crate::manufacturing::write_outjob(&prj, &st("pcbPath"), None).unwrap_or_default()+ };+ let docs: Vec<String> = [sch.clone(), st("pcbPath"), lib.clone(), st("pcbLibPath"), outjob].into_iter().filter(|d| !d.is_empty()).collect();+ let mut text = format!("[Design]\r\nVersion=1.0\r\nHierarchyMode=0\r\nOutputPath=Project Outputs for {stem}\r\nLogFolderPath=Project Logs for {stem}\r\n");+ for (i, d) in docs.iter().enumerate() { text.push_str(&format!("\r\n[Document{}]\r\nDocumentPath={}\r\n", i + 1, rel_to(&pdir, d))); }+ // written only when it changes: rewriting an OPEN project's file makes Altium ask to reload it+ // (a modal that blocked the next verb, measured 2026-10-15)+ if std::fs::read_to_string(&prj).ok().as_deref() != Some(text.as_str()) {+ // an open project closes first, so the new file opens clean instead of asking to reload+ if std::path::Path::new(&prj).exists() { agent::call("close_document", &[("path", prj.clone())], None, Duration::from_secs(60)); }+ if let Err(e) = std::fs::write(&prj, &text) { return err("write_failed", format!("could not write {prj}: {e}"), ""); }+ }+ // the project open first, so the sheet the agent opens is the project's+ agent::call("open", &[("path", prj.clone())], None, Duration::from_secs(120));+ let o = agent::call("open", &[("path", lib.clone())], None, Duration::from_secs(120));+ if agent_ok(&o).is_none() { return o; }+ let mut p: Vec<(&str, String)> = vec![("sch", sch.clone()), ("pcblib", st("pcbLibPath")), ("tplW", tw.to_string()), ("tplH", th.to_string())];+ for k in ["title", "number", "revision", "drawnBy"] { if !st(k).is_empty() { p.push((k, st(k))); } }+ let b = agent::call("build_schematic", &p, Some(&ops.join("\n")), Duration::from_secs(900));+ let Some(bd) = agent_ok(&b) else { return b };+ let c = agent::call("compile", &[("objectKind", "Project".into())], None, Duration::from_secs(300));+ let n = agent::call("project_nets", &[], None, Duration::from_secs(120));+ let Some(nd) = agent_ok(&n) else { return json!({"success": false, "errorCode": "netlist_unread", "sch": sch, "project": prj, "build": bd, "compile": c.get("data").cloned().unwrap_or(c.clone()), "netlist": n}) };+ let mut got: std::collections::BTreeMap<String, std::collections::BTreeSet<String>> = Default::default();+ for (net, pins) in nd["nets"].as_object().cloned().unwrap_or_default() {+ got.insert(net, pins.as_array().cloned().unwrap_or_default().iter().filter_map(|v| v.as_str().map(String::from)).collect());+ }+ let mut mismatches = Vec::new();+ for (net, w) in &want {+ let g = got.get(net).cloned().unwrap_or_default();+ if &g != w { mismatches.push(json!({"net": net, "missing": w.difference(&g).collect::<Vec<_>>(), "extra": g.difference(w).collect::<Vec<_>>()})); }+ }+ for (net, g) in &got {+ if !want.contains_key(net) && g.len() > 1 { mismatches.push(json!({"net": net, "unexpected": g})); }+ }+ let compile_errors: Vec<Value> = c.get("data").and_then(|d| d.get("messages")).and_then(|m| m.as_array()).cloned().unwrap_or_default()+ .into_iter().filter(|m| m["class"].as_str().map(|k| k.to_lowercase().contains("error")).unwrap_or(false)).collect();+ json!({"success": mismatches.is_empty(), "sch": sch, "project": prj, "placed": bd["placed"], "wires": bd["wires"], "labels": bd["labels"],+ "pinsWithoutNet": bd["pinsWithoutNet"], "sheetMil": bd["sheetMil"], "nets": want.len(), "compiledNets": got.len(),+ "mismatches": mismatches, "compileErrors": compile_errors,+ "_hint": "The sheet is drawn and saved inside its project, compiled, and its netlist compared pin by pin with nets (pins of parts on the sheet only). mismatches[] names any net that differs. Parts without a symbol (board-only pads) stay off the sheet."})+}++/// altium_select_net {net?, zoom?}: select one net as a whole (and nothing else); no net clears.+pub fn select_net(args: &Value) -> Value {+ let net = args.get("net").and_then(|v| v.as_str()).unwrap_or("").to_string();+ let r = agent::call("select_net", &[("net", net.clone())], None, Duration::from_secs(120));+ let Some(d) = agent_ok(&r) else { return r };+ let mut out = json!({"success": true, "net": net, "selected": d["selected"], "byKind": d["byKind"]});+ if !net.is_empty() && args.get("zoom").and_then(|v| v.as_bool()) == Some(true) {+ out["zoom"] = zoom_to(&json!({"net": net, "margin": args.get("margin").cloned().unwrap_or(json!(1.0))}))["rect"].clone();+ }+ if !net.is_empty() && d["selected"].as_u64() == Some(0) { out["_hint"] = json!("Nothing on the board is on that net: check the name against routing_state nets[]."); }+ out+}++fn mmv(v: &Value) -> f64 { (v.as_f64().unwrap_or(0.0) / PER_MM * 10000.0).round() / 10000.0 }+fn boxv(v: &Value) -> Value { json!(v.as_array().map(|a| a.iter().map(mmv).collect::<Vec<_>>()).unwrap_or_default()) }+fn textv(t: &Value) -> Value {+ json!({"text": t[0], "x": mmv(&t[1]), "y": mmv(&t[2]), "size": mmv(&t[3]), "rotation": t[4], "shown": t[5], "box": boxv(&t[6])})+}++/// altium_silk_state {}: every part's body, designator and comment text, and the free overlay texts and lines, mm.+pub fn silk_state(_args: &Value) -> Value {+ let r = agent::call("silk_state", &[], None, Duration::from_secs(180));+ let Some(d) = agent_ok(&r) else { return r };+ let (top, bottom) = (d["top"].as_i64().unwrap_or(-1), d["bottom"].as_i64().unwrap_or(-2));+ let side = |l: &Value| if l.as_i64() == Some(bottom) { "bottom" } else if l.as_i64() == Some(top) { "top" } else { "other" };+ let comps: Vec<Value> = d["comps"].as_array().cloned().unwrap_or_default().iter().map(|c| json!({+ "ref": c[0], "pattern": c[1], "x": mmv(&c[2]), "y": mmv(&c[3]), "rotation": c[4], "side": if c[5].as_bool() == Some(true) { "bottom" } else { "top" },+ "body": boxv(&c[6]), "name": textv(&c[7]), "comment": textv(&c[8])})).collect();+ let texts: Vec<Value> = d["texts"].as_array().cloned().unwrap_or_default().iter().map(|t| json!({+ "side": side(&t[0]), "text": t[1], "x": mmv(&t[2]), "y": mmv(&t[3]), "size": mmv(&t[4]), "rotation": t[5], "mirror": t[6], "box": boxv(&t[7]),+ "raw": {"layer": t[0], "x": t[2], "y": t[3]}})).collect();+ let lines: Vec<Value> = d["lines"].as_array().cloned().unwrap_or_default().iter().map(|l| json!({+ "side": side(&l[0]), "x1": mmv(&l[1]), "y1": mmv(&l[2]), "x2": mmv(&l[3]), "y2": mmv(&l[4]), "width": mmv(&l[5]),+ "raw": {"layer": l[0], "x1": l[1], "y1": l[2], "x2": l[3], "y2": l[4]}})).collect();+ json!({"success": true, "components": comps, "texts": texts, "lines": lines,+ "_hint": "mm, Altium's frame (y up). A text's x,y is its own location (lower left of the text before rotation); box is what Altium measures. texts/lines are the free overlay items, not a part's own."})+}++/// altium_apply_silkscreen {names?:[{ref,x,y,size,rotation?,show?}], comments?:[...], texts?:[{text,x,y,size,rotation?,side?}],+/// lines?:[{x1,y1,x2,y2,width?,side?}], remove?:{texts:[raw from silk_state], lines:[raw]}}+pub fn apply_silkscreen(args: &Value) -> Value {+ let f = |v: &Value, k: &str| v.get(k).and_then(|x| x.as_f64()).unwrap_or(0.0);+ let clean = |s: &str| s.chars().filter(|c| !matches!(c, '|' | '\n' | '\r')).collect::<String>();+ let side = |v: &Value| if v.get("side").and_then(|x| x.as_str()) == Some("bottom") { "B" } else { "T" };+ let mut ops = Vec::new();+ let rm = args.get("remove").cloned().unwrap_or(json!({}));+ for t in rm["texts"].as_array().cloned().unwrap_or_default() {+ ops.push(format!("DT|{}|{}|{}|{}", t["layer"], clean(t["text"].as_str().unwrap_or("")), t["x"], t["y"]));+ }+ for l in rm["lines"].as_array().cloned().unwrap_or_default() {+ ops.push(format!("DL|{}|{}|{}|{}|{}", l["layer"], l["x1"], l["y1"], l["x2"], l["y2"]));+ }+ for (key, k) in [("names", "N"), ("comments", "V")] {+ for n in args.get(key).and_then(|v| v.as_array()).cloned().unwrap_or_default() {+ let show = if n.get("show").and_then(|v| v.as_bool()) == Some(false) { "0" } else { "1" };+ ops.push(format!("{k}|{}|{}|{}|{}|{}|{show}", clean(n["ref"].as_str().unwrap_or("")), raw(f(&n, "x")), raw(f(&n, "y")), raw(f(&n, "size").max(0.3)), f(&n, "rotation").round() as i64));+ }+ }+ for t in args.get("texts").and_then(|v| v.as_array()).cloned().unwrap_or_default() {+ ops.push(format!("T|{}|{}|{}|{}|{}|{}", side(&t), clean(t["text"].as_str().unwrap_or("")), raw(f(&t, "x")), raw(f(&t, "y")), raw(f(&t, "size").max(0.3)), f(&t, "rotation").round() as i64));+ }+ for l in args.get("lines").and_then(|v| v.as_array()).cloned().unwrap_or_default() {+ let w = if f(&l, "width") > 0.0 { f(&l, "width") } else { 0.15 };+ ops.push(format!("L|{}|{}|{}|{}|{}|{}", side(&l), raw(f(&l, "x1")), raw(f(&l, "y1")), raw(f(&l, "x2")), raw(f(&l, "y2")), raw(w)));+ }+ if ops.is_empty() { return err("nothing_to_do", "no names, comments, texts, lines or removals".into(), ""); }+ let r = agent::call("apply_silk", &[], Some(&ops.join("\n")), Duration::from_secs(600));+ let Some(d) = agent_ok(&r) else { return r };+ let after = silk_state(&json!({}));+ json!({"success": d["failed"].as_i64() == Some(0), "applied": d["applied"], "failed": d["failed"], "results": d["results"],+ "after": after, "_hint": "after is silk_state once it landed: check each label's box (Altium's own measure) against pads, vias, bodies and the other labels."})+}
bridge-rs/src/verbs.rs+119@@ -21,6 +21,8 @@ pub const KNOWN: &[&str] = &[ "cross_check", "review_board", "list_designators", "component_data", "component_pins", "list_nets", "net_connections", "layers", "copper_layers", "read_rules", "set_layer_visibility", "footprint_primitives", "schematic_data",+ // the live-board lane AI Flow drives+ "routing_state", "route_net", "move_parts", "place_vias", "remove_items", "ripup_nets", "apply_pours", "pour_state", "remove_pours", "repour", "routing_drc", "build_board", "set_outline", "set_design_rules", "zoom_to", "add_rule", "build_schematic", "export_manufacturing", "select_net", "silk_state", "apply_silkscreen", // surfaces "show_project", "show_schematic", "show_2d_board", "show_3d_board", "show_library", "show_symbol", "show_footprint", "show_3d_chip", "list_library", "demo",@@ -394,6 +396,102 @@ fn describe() -> Value { json!({"data": "{layerId, name, displayed}", "roundtripMs": "int"}), 60, "Then altium_show_2d_board {filePath} for a frame of the result. Restore what you hid: visibility is view state the user keeps seeing.", &["layers", "layers_in_use", "show_2d_board"], &["Needs a .PcbDoc current.", "Only pass ids that altium_layers returned: the id is not range-checked before the layer object is read."], json!({"layerId": 1, "displayed": false})),+ verb_doc("routing_state", "The WHOLE current board as data, in mm: tracks, arcs, vias, pads (with owner and net), components (ref, x, y, rotation, side), polygon pours, the outline, the unrouted (ratsnest) count, and a revision hash.",+ json!({"expectPath": "string (optional) - refuse with wrong_board unless this .PcbDoc is the current board"}),+ json!({"revision": "string - pass as expectedRevision to the edit verbs", "boardId": "string (the .PcbDoc path)", "unconnected": "int", "counts": "{tracks, arcs, vias, pads, components, polygons, nets}", "tracks": "[{start{x,y}, end{x,y}, width, layer, net}]", "vias": "[{position{x,y}, size, drill, layers[2], net}]", "pads": "[{ref, name, x, y, sizeX, sizeY, rotation, layer, drill, net}]", "components": "[{ref, x, y, rotation, side, pattern}]", "polygons": "[{net, layer, points[[x,y]]}]", "outline": "[[x,y]]"}), 180,+ "Layers use KiCad names (F.Cu, B.Cu, In<n>.Cu; *.Cu = a multi-layer pad) with Altium's layerId alongside. Coordinates are Altium's own frame (y up), in mm. This is the read AI Flow's Altium lane calls before and after every edit.",+ &["read_board", "net_connections", "footprint_primitives"], &["Needs a .PcbDoc current (altium_show_2d_board {filePath}).", "Footprint-internal tracks and arcs are left out; only free copper is listed.", "unconnected counts Altium's ratsnest lines: re-read after an edit for a fresh count."], json!({"expectPath": "C:/designs/buck/buck.PcbDoc"})),+ verb_doc("route_net", "Route one net as tracks, segment by segment; a waypoint on another layer drops a via there. One undo step, read back.",+ json!({"net": "string (required)", "width": "mm (default 0.25)", "layer": "F.Cu|B.Cu|In<n>.Cu or an Altium layer id (default F.Cu)", "viaSize": "mm (default 0.6)", "viaDrill": "mm (default 0.3)", "paths": "[[waypoint,...]] - a waypoint is [x,y], {x,y,layer} or {pad:\"U1.3\"}", "expectedRevision": "string (optional) - the revision you last read; refused with stale_board if the board changed", "boardId": "string (optional) - refused with board_mismatch unless this is the current board", "expectPath": "string (optional)"}),+ json!({"revision": "string - the board after the edit", "applied": "int", "failed": "int", "results": "[ok|err:...] per segment/via", "unconnected": "int", "counts": "{tracks, vias, ...}", "state": "the full altium_routing_state after"}), 300,+ "Coordinates are mm in Altium's frame, the same as altium_routing_state. There is no clearance check here yet: run your own DRC (AI Flow's gate) or altium_run_drc after.",+ &["routing_state", "place_vias", "remove_items", "ripup_nets"], &["Needs the board current.", "A net name must already exist on the board (nets come from the schematic / pads)."], json!({"net": "VOUT", "width": 0.5, "paths": [[{"pad": "L1.2"}, [120.0, 90.0], {"pad": "C6.1"}]]})),+ verb_doc("move_parts", "Move and rotate placed components; each part is read back to 0.001 mm and the exact rotation. One undo step.",+ json!({"parts": "[{ref, x, y, rotation}] - mm and degrees, Altium's frame", "expectedRevision": "string (optional) - the revision you last read; refused with stale_board if the board changed", "boardId": "string (optional) - refused with board_mismatch unless this is the current board", "expectPath": "string (optional)"}),+ json!({"revision": "string", "results": "[ok|err:...]", "mismatches": "[{ref, asked, got}] when a part did not land where asked (errorCode readback_mismatch)"}), 300,+ "Rotation is absolute, not a delta.", &["routing_state", "route_net"], &["Moving to the other side of the board is not supported yet."], json!({"parts": [{"ref": "U1", "x": 112.0, "y": 92.0, "rotation": 90}]})),+ verb_doc("place_vias", "Place vias (through, top to bottom). One undo step, read back.",+ json!({"vias": "[{net, x, y, size, drill}] - mm", "expectedRevision": "string (optional) - the revision you last read; refused with stale_board if the board changed", "boardId": "string (optional) - refused with board_mismatch unless this is the current board", "expectPath": "string (optional)"}),+ json!({"revision": "string", "results": "[ok|err:...]", "counts": "{vias, ...}"}), 300, "Through vias, top to bottom, on the named net. Heatsink vias under a pad: place them here, then pour or route to them.", &["route_net", "routing_state"], &[], json!({"vias": [{"net": "GND", "x": 110.0, "y": 90.0, "size": 0.6, "drill": 0.3}]})),+ verb_doc("remove_items", "Delete specific free tracks and vias, given exactly as altium_routing_state returned them. One undo step.",+ json!({"tracks": "[{start{x,y}, end{x,y}, layer}]", "vias": "[{position{x,y}}] or [{x,y}]", "expectedRevision": "string (optional) - the revision you last read; refused with stale_board if the board changed", "boardId": "string (optional) - refused with board_mismatch unless this is the current board", "expectPath": "string (optional)"}),+ json!({"revision": "string", "results": "[ok|err:not found]"}), 300, "Undo a bad segment or via without touching the rest of the net; altium_ripup_nets clears a whole net.", &["ripup_nets", "routing_state"], &["Matching is exact to the internal unit: pass the coordinates you read, not rounded ones."], json!({"vias": [{"x": 110.0, "y": 90.0}]})),+ verb_doc("ripup_nets", "Delete every free track, arc and via on each named net. One undo step.",+ json!({"nets": "[string]", "expectedRevision": "string (optional) - the revision you last read; refused with stale_board if the board changed", "boardId": "string (optional) - refused with board_mismatch unless this is the current board", "expectPath": "string (optional)"}),+ json!({"revision": "string", "results": "[ok:<deleted count>]"}), 300, "Pours are not touched.", &["remove_items", "route_net"], &[], json!({"nets": ["SW", "VOUT"]})),+ verb_doc("apply_pours", "Add solid polygon pours from outlines and pour them, each with its own connect style (solid joints or thermal spokes), plus pour keepouts (polygon cutouts); answers each pour's real poured copper area. One undo step.",+ json!({"pours": "[{net, layer (F.Cu|B.Cu|In<n>.Cu), points:[[x,y]...] mm, order?: pour order (1 = poured first), name?: polygon name, connect?: direct|relief|none, reliefWidth?: mm spoke width, reliefGap?: mm air gap (not settable by script on AD 26: Altium keeps 10 mil and notes[] says so), reliefEntries?: 2|4, removeDead?: bool (default true), removeSmallIslands?: bool (default false: connected copper of any size stays, as in KiCad; true = Altium's own 2500 sq mil island filter), minNeck?: mm - copper narrower than this is removed (KiCad's zone min thickness), solidPads?: [[x1,y1,x2,y2]] mm - pads completely inside each rect join THIS pour solid whatever its connect (KiCad's small solid joint zone over a pad; Altium pours nothing for a same-net polygon inside another)}]",+ "cutouts": "(optional) [{layer, points:[[x,y]...] mm, name?}] - areas every pour on that layer stays out of (KiCad's pour keepout); landed before the pours",+ "expectedRevision": "string (optional) - refused with stale_board if the board changed since you read it"}),+ json!({"results": "[ok:<polygon name>|err:...]", "zones": "[{net, layer, outline, filledMm2, islands, filled}]", "filledAreaByLayerMm2": "{F.Cu: mm2, ...}", "unfilled": "[zone] that poured nothing", "revision": "string"}), 600,+ "connect writes a PolygonConnect rule 'AdomPC <name>' scoped IsNamedPolygon('<name>') that outranks the board's own, so a power patch can join its pads solid while the ground pour keeps spokes. Without connect the board's Polygon Connect Style applies (Altium's default is 4 relief spokes of 10 mil, a current neck). Clearance comes from the board's Clearance rule. Dead copper is removed unless removeDead is false.",+ &["pour_state", "repour", "remove_pours", "routing_state", "set_design_rules"], &["Needs the board current.", "The outline need not repeat its first point.", "A name already on the board gets a ' 2' suffix: read the real name from results."],+ json!({"pours": [{"net": "GND", "layer": "B.Cu", "points": [[0, 0], [28, 0], [28, 20], [0, 20]], "connect": "relief", "reliefWidth": 0.35, "reliefGap": 0.25}, {"net": "SW", "layer": "F.Cu", "points": [[10, 5], [16, 5], [16, 9], [10, 9]], "connect": "direct", "order": 1}], "cutouts": [{"layer": "F.Cu", "points": [[20, 2], [22, 2], [22, 4], [20, 4]], "name": "Kelvin tap"}]})),+ verb_doc("pour_state", "Every polygon pour with the copper it actually poured: filled area per pour and per layer, and the pours that poured nothing.",+ json!({}), json!({"zones": "[{net, name, layer, pourIndex, outline, filledMm2, islands, filled}]", "filledAreaByLayerMm2": "{layer: mm2}", "unfilled": "[zone]"}), 180,+ "filledMm2 is regions minus holes, computed from Altium's own poured geometry.", &["apply_pours", "repour"], &["Needs the board current.", "A shelved or unpoured polygon reads filledMm2 0."], json!({})),+ verb_doc("remove_pours", "Delete polygon pours by net (and layer), or all of them, and the pour keepouts (cutouts) apply_pours made. Tracks and vias are untouched.",+ json!({"pours": "(optional) \"all\" or [{net, layer?} | {name} - one pour by the name apply_pours answered]", "cutouts": "(optional) \"all\" or [{layer}] - only the cutouts apply_pours landed (named Adom...)", "expectedRevision": "string (optional) - refused with stale_board if the board changed since you read it"}), json!({"removed": "int", "cutoutsRemoved": "int", "rulesRemoved": "int - AdomPC connect rules whose pour is gone", "revision": "string"}), 300,+ "Use before re-pouring a changed plan; the connect rules of removed pours go with them. altium_ripup_nets clears copper.", &["apply_pours", "ripup_nets"], &[], json!({"pours": [{"net": "GND"}]})),+ verb_doc("repour", "Repour every polygon on the board (Altium's Repour All) and answer the copper after. Runs outside the script engine so scoped rules apply.",+ json!({"inScript": "bool (default false) - repour from inside the agent instead (scoped rules then match nothing)"}), json!({"zones": "[...]", "filledAreaByLayerMm2": "{...}", "unfilled": "[...]", "run": "{helperExited, seconds, agentStart}"}), 600,+ "Run after moving parts or copper that pours flow around. Altium evaluates rule queries through its scripting system, so a repour from inside the resident agent ignores every scoped rule (a pour's own connect style, net-class clearances); this verb stops the agent, repours, and starts it again (a few seconds). apply_pours does the same on its own when it wrote connect rules.",+ &["pour_state", "apply_pours"], &["Needs the board current.", "The agent restarts: a verb sent meanwhile waits for it."], json!({})),+ verb_doc("routing_drc", "Design-rule violations on the current board WITH locations, plus the unrouted (ratsnest) count, as one clean/not-clean answer.",+ json!({"full": "bool (default false) - run Altium's batch DRC on this board first"}),+ json!({"clean": "bool - no violations and nothing unrouted", "errors": "int", "unconnected": "int", "violations": "[{rule, description, at{x,y} mm}]"}), 300,+ "Without full, this reads online DRC's live violations (fast). full:true runs the batch check through Altium's DRC dialog first.",+ &["routing_state", "run_drc"], &["Needs the board current.", "violations[] is capped at 500; errors is the real count."], json!({"full": true})),+ verb_doc("build_board", "Build a NEW board from a parts list and a netlist: outline (rounded corners allowed), every footprint from one PcbLib placed by its own origin, pad nets assigned, saved; then read back pin by pin.",+ json!({"pcbPath": "string (required) - the .PcbDoc to write", "libraryPath": "string (required) - the .PcbLib holding every footprint", "outline": "{rect:[x0,y0,w,h], cornerRadius?} or {points:[[x,y]...]} (mm)", "parts": "[{ref, footprint, x, y, rotation, comment?}] (mm, degrees; x,y is the footprint's own origin)", "nets": "{net: [\"R1.1\", \"U1.3\", ...]}"}),+ json!({"placed": "int", "padsWired": "int", "missingParts": "[ref]", "wrongPins": "[{pin, want, got}]", "revision": "string", "unconnected": "int - the ratsnest to route", "counts": "{...}"}), 900,+ "The board AI Flow's Altium project builds from its netlist. Footprint pad names must match the netlist's pin numbers (R1.1 = pad 1 of R1). Coordinates are Altium's frame (y up).",+ &["routing_state", "set_outline", "install_library"], &["The library must open in this Altium; a footprint missing from it refuses the whole build (footprint_not_found).", "A new board each call: pcbPath is overwritten."], json!({"pcbPath": "C:/designs/buck/buck.PcbDoc", "libraryPath": "C:/designs/buck/Buck.PcbLib", "outline": {"rect": [0, 0, 28, 20], "cornerRadius": 1}, "parts": [{"ref": "U1", "footprint": "SOT-23-6", "x": 12, "y": 10, "rotation": 0}], "nets": {"GND": ["U1.1"]}})),+ verb_doc("set_outline", "Reshape the current board's outline (a rectangle with rounded corners, or a polygon). One undo step.",+ json!({"outline": "{rect:[x0,y0,w,h], cornerRadius?} or {points:[[x,y]...]} (mm)", "expectedRevision": "string (optional) - refused with stale_board if the board changed"}),+ json!({"vertices": "int", "revision": "string", "outlineEdges": "[{kind: line|arc, ...}]"}), 120,+ "Rounding corners: pass the same rectangle with cornerRadius (1 mm is a common default).", &["build_board", "routing_state", "repour"], &["Needs the board current."], json!({"outline": {"rect": [0, 0, 28, 20], "cornerRadius": 1}})),+ verb_doc("set_design_rules", "Write a fab's design rules into the current board: clearance, track width, routing via style, hole size, annular ring, hole-to-hole and board-edge clearance (mm).",+ json!({"clearance": "mm", "trackWidth": "{min, preferred?, max?} mm", "via": "{diameter:{min, preferred?, max?}, drill:{min, preferred?, max?}} mm", "hole": "{min, max?} mm", "annularRing": "mm", "holeToHole": "mm", "edgeClearance": "mm", "polygonConnect": "the board's catch-all polygon connect style: direct|relief|none, or {style, reliefWidth: mm, reliefEntries: 2|4} (per-pour rules from apply_pours keep theirs)"}),+ json!({"landed": "[{kind, rulesWritten}]", "failed": "[{kind, error}]", "rules": "the board's rules after"}), 300,+ "AI Flow's `rules use` calls this with the fab pack's numbers (adom/pcb-design-rules). Each kind is written separately, so one this Altium build lacks fails alone.",+ &["read_rules", "rules_by_kind", "build_board"], &["Needs the board current.", "Every existing rule of a kind gets the values; rule scopes are kept."], json!({"clearance": 0.15, "trackWidth": {"min": 0.15}, "via": {"diameter": {"min": 0.45}, "drill": {"min": 0.2}}, "hole": {"min": 0.2}, "edgeClearance": 0.3})),+ verb_doc("add_rule", "Add one SCOPED design rule (a net's clearance or width, a net class's, any Altium query) that set_design_rules' board-wide values cannot express.",+ json!({"kind": "clearance|width", "name": "string - the rule is named 'Adom <name>'; the same name replaces it", "scope1": "Altium query, e.g. InNet('SW') or InNetClass('Power')", "scope2": "query (default All)", "gap": "mm (clearance)", "min": "mm (width)", "preferred": "mm (width, optional)", "max": "mm (width, optional)", "comment": "optional", "remove": "bool - delete the Adom rule of this kind and name instead"}),+ json!({"name": "string", "priority": "int (1 = first of its kind)"}), 60,+ "AI Flow's net clearances (a switch node kept farther from everything) land here. A scope with a typo matches nothing and DRC then passes silently: check it in the rules editor (Test Queries).",+ &["set_design_rules", "read_rules", "routing_drc"], &["Needs the board current.", "One rule per call."], json!({"kind": "clearance", "name": "SW node", "scope1": "InNet('SW')", "scope2": "All", "gap": 0.3})),+ verb_doc("build_schematic", "Draw a design's schematic from its netlist with the project library's symbols, inside a new project (.PrjPcb with the board and libraries), compile it, and check its netlist pin by pin.",+ json!({"schPath": "string - the .SchDoc to write", "libraryPath": "string - the project .SchLib", "parts": "[{ref, symbol: LibReference in the SchLib, block?: column name (input, core, output...), comment?: value, footprint?: its footprint in pcbLibPath (the part's PCB model link)}]", "nets": "{net: [\"U1.3\", ...]} - pins of parts not on the sheet are ignored", "projectPath": "(optional) .PrjPcb, default beside the sheet", "pcbPath": "(optional) the board, listed in the project", "pcbLibPath": "(optional)", "title": "optional", "number": "optional", "revision": "optional", "drawnBy": "optional"}),+ json!({"sch": "path", "project": "path", "placed": "int", "wires": "int", "labels": "int", "pinsWithoutNet": "ref.pin;...", "mismatches": "[{net, missing, extra}]", "compileErrors": "[..]"}), 900,+ "One column per block, each part with a wire stub and a net label on every connected pin; designator above, value below; title block filled. The compiled netlist is the proof: success means it matches the given nets.",+ &["schematic_data", "compile", "build_board"], &["The SchLib must hold every symbol (altium_import_part).", "Parts are placed unrotated."],+ json!({"schPath": "C:\\work\\Buck.SchDoc", "libraryPath": "C:\\work\\library\\Buck.SchLib", "parts": [{"ref": "U1", "symbol": "TPS54202DDCR", "block": "core", "comment": "TPS54202"}], "nets": {"VIN": ["U1.3"]}})),+ verb_doc("export_manufacturing", "A project's manufacturing set in one call: Gerbers, NC drill, pick and place (CSV, mm) and the BOM, from an OutJob the bridge writes beside the project (composed from this Altium's own OutJob templates, every output on one medium that opens nothing).",+ json!({"projectPath": "the .PrjPcb", "pcbPath": "its .PcbDoc", "bomColumns": "(optional) [column or parameter name] - default Comment, Description, Designator, Footprint, Quantity, MPN, Manufacturer, Mouser/DigiKey/LCSC Part #", "clean": "bool (default true) - empty the output folder first", "timeout": "seconds, default 300"}),+ json!({"outjob": "path", "outputDir": "path", "files": "[relative paths]", "dialogs": "[..]", "elapsedSec": "float"}), 600,+ "Pull files[] with pull_file and check them against the board (layers, drill hits, every placed part in the pick and place and the BOM) before handing them over. AI Flow's `altium export` does both.",+ &["run_outjob", "build_schematic", "routing_state"], &["The board must be a document of the project.", "The BOM is the compiled project's (schematic parts); board-only pads are not in it."],+ json!({"projectPath": "C:\\work\\Buck.PrjPcb", "pcbPath": "C:\\work\\Buck.PcbDoc"})),+ verb_doc("select_net", "Select one net as a whole on the current board (tracks, arcs, vias, pads, pours) and nothing else, optionally framing it; no net clears the selection.",+ json!({"net": "string (optional) - empty or absent clears", "zoom": "bool - frame the net (altium_zoom_to)", "margin": "mm, default 1"}),+ json!({"net": "string", "selected": "int", "byKind": "{tracks, arcs, vias, pads, pours, other}", "zoom": "[l, b, r, t] mm when zoom"}), 120,+ "AI Flow's net walkthrough lights each loaded net in turn. Altium draws a selection in its highlight colour.",+ &["zoom_to", "routing_state", "list_nets"], &["Needs the board current.", "Pour regions are counted once, as their polygon."], json!({"net": "SW", "zoom": true})),+ verb_doc("silk_state", "The silkscreen as it stands: every part's body box, its designator and comment (text, location, size, rotation, shown, Altium's measured box), and the free overlay texts and lines on both faces.",+ json!({}), json!({"components": "[{ref, pattern, x, y, rotation, side, body:[l,b,r,t], name:{text,x,y,size,rotation,shown,box}, comment:{...}}]", "texts": "[{side, text, x, y, size, rotation, mirror, box, raw}]", "lines": "[{side, x1, y1, x2, y2, width, raw}]"}), 180,+ "The input to a silkscreen plan (AI Flow's `altium silkscreen`), and its check after landing: compare each box with the pads and vias from routing_state.",+ &["apply_silkscreen", "routing_state"], &["Needs the board current.", "raw is the exact handle apply_silkscreen's remove takes."], json!({})),+ verb_doc("apply_silkscreen", "Land a silkscreen plan in one undo step: designators and comments moved, sized and turned (or hidden), free overlay texts and lines added (bottom texts mirrored), earlier ones removed; answers silk_state after.",+ json!({"names": "[{ref, x, y, size, rotation?, show?}] - the designator's own location, mm", "comments": "[same] - the value", "texts": "[{text, x, y, size, rotation?, side?: top|bottom}]", "lines": "[{x1, y1, x2, y2, width?: mm (0.15), side?}]", "remove": "{texts:[raw], lines:[raw]} - silk_state raw handles of earlier free items"}),+ json!({"applied": "int", "failed": "int", "results": "[ok|err:...]", "after": "silk_state"}), 600,+ "A text's location is its lower left before rotation (for 90 degrees: the box's lower right). Stroke width is a seventh of the height.",+ &["silk_state", "routing_drc"], &["Needs the board current.", "Removal matches the raw handle exactly: pass what silk_state answered."], json!({"names": [{"ref": "U1", "x": 12.0, "y": 14.2, "size": 0.8}], "lines": [{"x1": 1, "y1": 1, "x2": 5, "y2": 1}]})),+ verb_doc("zoom_to", "Frame the PCB view on a rectangle, a part (or several) or a whole net, with a margin.",+ json!({"rect": "(optional) [x1, y1, x2, y2] mm", "ref": "(optional) a designator, e.g. U1", "refs": "(optional) [designator]", "net": "(optional) a net: its pads, tracks and vias", "margin": "mm (default 1)"}),+ json!({"rect": "[left, bottom, right, top] mm framed"}), 60,+ "For a close look at a joint or a camera shot of one part or net; altium_run_command PCB:Zoom Action=All frames the whole board.",+ &["routing_state", "run_command", "animate_view"], &["Needs the board current.", "Coordinates are Altium's (y up), as routing_state reports them."], json!({"net": "SW", "margin": 0.5})), verb_doc("footprint_primitives", "Geometry dump of one PLACED component: its tracks, arcs, pads and texts in mils with layer ids; other primitive kinds are counted.", json!({"designator": "string (required) - a component on the current board, e.g. U1"}), json!({"data": "{designator, pattern, count, otherPrimitives, primitives: [{kind: track|arc|pad|text, layerId, ...}]}", "roundtripMs": "int"}), 120,@@ -1141,6 +1239,27 @@ pub fn dispatch(command: &str, args: &Value, _forwarded: &[(String, String)]) -> "save" => surfaces::save(args), "board_shape" => surfaces::board_shape(args), "pcb_report" => surfaces::pcb_report(args),+ "routing_state" => { if let Some(g) = need_licensed() { return g; } crate::routing::routing_state(args) }+ "route_net" => { if let Some(g) = need_licensed() { return g; } crate::routing::route_net(args) }+ "move_parts" => { if let Some(g) = need_licensed() { return g; } crate::routing::move_parts(args) }+ "place_vias" => { if let Some(g) = need_licensed() { return g; } crate::routing::place_vias(args) }+ "remove_items" => { if let Some(g) = need_licensed() { return g; } crate::routing::remove_items(args) }+ "ripup_nets" => { if let Some(g) = need_licensed() { return g; } crate::routing::ripup_nets(args) }+ "apply_pours" => { if let Some(g) = need_licensed() { return g; } crate::routing::apply_pours(args) }+ "pour_state" => { if let Some(g) = need_licensed() { return g; } crate::routing::pour_state(args) }+ "remove_pours" => { if let Some(g) = need_licensed() { return g; } crate::routing::remove_pours(args) }+ "repour" => { if let Some(g) = need_licensed() { return g; } crate::routing::repour(args) }+ "routing_drc" => { if let Some(g) = need_licensed() { return g; } crate::routing::routing_drc(args) }+ "build_board" => { if let Some(g) = need_licensed() { return g; } crate::routing::build_board(args) }+ "set_outline" => { if let Some(g) = need_licensed() { return g; } crate::routing::set_outline(args) }+ "set_design_rules" => { if let Some(g) = need_licensed() { return g; } crate::routing::set_design_rules(args) }+ "zoom_to" => crate::routing::zoom_to(args),+ "select_net" => crate::routing::select_net(args),+ "silk_state" => crate::routing::silk_state(args),+ "apply_silkscreen" => { if let Some(g) = need_licensed() { return g; } crate::routing::apply_silkscreen(args) }+ "export_manufacturing" => { if let Some(g) = need_licensed() { return g; } crate::manufacturing::export_manufacturing(args) }+ "build_schematic" => { if let Some(g) = need_licensed() { return g; } crate::routing::build_schematic(args) }+ "add_rule" => { if let Some(g) = need_licensed() { return g; } crate::routing::add_rule(args) } // ---- outputs / project ---- "compile" | "run_erc" => outputs::compile(args), "run_drc" => outputs::run_drc(args),
install.sh+19−1@@ -42,4 +42,22 @@ cat > "$BINDIR/altium-dashboard" <<EOF exec python3 "$APPDIR/dashboard/altium_dashboard.py" "\$@" EOF chmod +x "$BINDIR/altium-dashboard"-echo "OK: altium-bridge user skills installed + altium-dashboard"++# The KiCad -> Altium converters the skills (altium-wiki-import, altium-part-import, altium-kicad-import)+# and AI Flow's Altium library step run in the container. They were only in the source repo, so an AI+# with just this package could not follow its own skills (found 2026-10-15). Install them as commands.+mkdir -p "$APPDIR/tools"+for t in kicad2altium symbol_modes kicad_v10_to_v9; do+ cp -f "$HERE/tools/$t.py" "$APPDIR/tools/$t.py"+done+mk() { # <command> <tool>+ cat > "$BINDIR/$1" <<EOF2+#!/usr/bin/env bash+exec python3 "$APPDIR/tools/$2.py" "\$@"+EOF2+ chmod +x "$BINDIR/$1"+}+mk altium-kicad2altium kicad2altium+mk altium-symbol-modes symbol_modes+mk altium-kicad-downgrade kicad_v10_to_v9+echo "OK: altium-bridge user skills installed + altium-dashboard + altium-kicad2altium, altium-symbol-modes, altium-kicad-downgrade"
package.json+4−1@@ -1,6 +1,6 @@ { "slug": "altium-bridge",- "version": "0.2.46",+ "version": "0.2.47", "type": "app", "description": "Skills for your container so your AI knows how to drive the Altium Designer bridge (altium_* verbs). The bridge runtime itself is the release zip; Adom Bridge loads that.", "tags": [@@ -44,6 +44,9 @@ "skills/altium-part-import/SKILL.md", "skills/altium-uia/SKILL.md", "skills/altium-wiki-import/SKILL.md",+ "tools/kicad2altium.py",+ "tools/kicad_v10_to_v9.py",+ "tools/symbol_modes.py", "uninstall.sh" ], "discovery_triggers": [
scripts/release.py+24@@ -99,6 +99,27 @@ def lint_verbs(): "bridge.json does not match bridge-rs/src/verbs.rs\n" f" declared but NOT dispatchable: {phantom}\n" f" dispatchable but NOT declared: {unreachable}")+ # ab cuts a verb at bridge.json's timeouts (60 s unless listed), whatever its describe says: on+ # 2026-10-15, 73 verbs documented 90-900 s were cut at 60 (a schematic build answered timed_out+ # while it was still drawing). Every documented timeout over the default must be listed.+ t = json.loads((SRC / "bridge.json").read_text())["timeouts"]+ short = []+ for m in re.finditer(r'verb_doc(?:_long)?\("([a-z_0-9]+)",', rs):+ depth, cur, args, j = 1, "", [], m.end()+ while j < len(rs) and depth > 0:+ ch = rs[j]+ depth += ch in "([{"+ depth -= ch in ")]}"+ if depth == 1 and ch == ",":+ args.append(cur.strip()); cur = ""+ elif depth >= 1:+ cur += ch+ j += 1+ nums = [a for a in args if re.fullmatch(r"\d+", a)]+ if nums and int(nums[0]) > t["verbs"].get(m.group(1), t["default"]):+ short.append(f"{m.group(1)} documents {nums[0]} s, ab allows {t['verbs'].get(m.group(1), t['default'])}")+ if short:+ raise SystemExit("bridge.json timeouts are shorter than the verbs' documented timeouts:\n " + "\n ".join(short)) return len(declared) @@ -127,6 +148,9 @@ ENGINE_HALTING = [ r"\beProcessFreePrimitives\b", # (IPCB_Board.GroupIterator_Create also halts, but the same call is # correct on a component/footprint and a regex cannot see the receiver: review those by hand) r"\bRunProcessWithParameters\b", r"\.SuperClass\s*:=", r"\bCreateOleObject\b",+ r"\.MinimumRing\b", # IPCB_MinimumAnnularRing has no MinimumRing on AD 26.11.1 (measured 2026-10-15)+ r"\.Priority\s*:=", # writing a rule's Priority halts with an empty Error box (AD 26.11.1, 2026-10-15);+ # a newly added rule already lands at priority 1 ]
skills/altium-bridge-plan/SKILL.md+1−1@@ -312,7 +312,7 @@ Frame-check EVERY generated look/variant, not a sample — the shipped symbol ha ## 6k. LAN7800 creative import (2026-08-30) — variants, display modes, footprint models, board proof -New verbs (all agent-lane, all measured): `altium_build_footprint {libraryPath, spec}` (spec from `tools/kicad2altium.py`: pads incl. duplicate-designator through-hole vias, roundrect %, tracks/arcs/filled discs on named mech layers), `altium_build_symbol_modes {libraryPath, spec}` (ONE component, N display modes from `tools/symbol_modes.py`), `altium_symbol_mode {mode}` (editor-driven: `Sch:ToggleComponentDisplayMode Action=Mode0/Next` — `Comp.DisplayMode` changes data only), `altium_add_models {models[], params, comment, description}` (N PCBLIB implementations = Altium footprint variants; the user picks the current footprint on the placed part), `altium_normalize_bodies`, `altium_test_board {footprintName, wMm, hMm, assigns, stubs}`, `altium_board_shape {x0Mm,y0Mm,wMm,hMm}` (process `PCB:PlaceBoardOutline Mode=BOARDOUTLINE_FROM_SEL_PRIMS` on 4 temp selected tracks — `BoardOutline.PointCount/Segments` is a silent no-op, `.Clear` halts), `altium_swap_footprint {footprintName, libraryPath, assigns}` (remove + per-primitive re-place at the same x/y, nets re-assigned by designator — tracks untouched, so wiring survives), `altium_pcb_report` gained `padsBboxMil`, `nets`, `tracks`.+New verbs (all agent-lane, all measured): `altium_build_footprint {libraryPath, spec}` (spec from `altium-kicad2altium`: pads incl. duplicate-designator through-hole vias, roundrect %, tracks/arcs/filled discs on named mech layers), `altium_build_symbol_modes {libraryPath, spec}` (ONE component, N display modes from `altium-symbol-modes`), `altium_symbol_mode {mode}` (editor-driven: `Sch:ToggleComponentDisplayMode Action=Mode0/Next` — `Comp.DisplayMode` changes data only), `altium_add_models {models[], params, comment, description}` (N PCBLIB implementations = Altium footprint variants; the user picks the current footprint on the placed part), `altium_normalize_bodies`, `altium_test_board {footprintName, wMm, hMm, assigns, stubs}`, `altium_board_shape {x0Mm,y0Mm,wMm,hMm}` (process `PCB:PlaceBoardOutline Mode=BOARDOUTLINE_FROM_SEL_PRIMS` on 4 temp selected tracks — `BoardOutline.PointCount/Segments` is a silent no-op, `.Clear` halts), `altium_swap_footprint {footprintName, libraryPath, assigns}` (remove + per-primitive re-place at the same x/y, nets re-assigned by designator — tracks untouched, so wiring survives), `altium_pcb_report` gained `padsBboxMil`, `nets`, `tracks`. **Dialog pre-emption (John: a dismissed dialog is a defect).** The DelphiScript debugger breaks on ANY raised exception even inside Try/Except — a raise IS an Error modal. `ReadAll` now probes with `FileOpen` (returns -1, never raises) before `LoadFromFile`. A halted command leaves `PCBServer.PreProcess` open = "a command is currently active" → save draws the save-a-copy Warning: `TxClear` (unbalanced `PostProcess`, harmless) runs at agent start, before every save, and after every recover. Undeclared identifiers hit this session: `BottomXSize`/`Mid*` pad sizes, `IPCB_BoardOutline.Clear`, `eProcessFreePrimitives`, `IPCB_Board.GroupIterator_Create`.
skills/altium-kicad-import/SKILL.md+5−5@@ -4,8 +4,8 @@ description: >- Import KiCad parts into Altium Designer correctly, especially KiCad 10. Altium 26's own importer supports KiCad through 9 only — a raw v10 import silently drops property visibility (hidden fields arrive visible, piled on the symbol) and imports text in the- wrong font. Two lanes: downgrade v10→v9 with tools/kicad_v10_to_v9.py then use Altium's- importer, or convert natively with tools/kicad2altium.py + altium_build_footprint /+ wrong font. Two lanes: downgrade v10→v9 with altium-kicad-downgrade then use Altium's+ importer, or convert natively with altium-kicad2altium + altium_build_footprint / altium_build_symbol_modes (no importer at all). Carries barrett-land/kicad10-to-altium's measured findings into the master altium-bridge skillpack. Trigger words — kicad to altium, kicad 10 altium import, overlapping fields after import, hidden fields visible, imported@@ -37,7 +37,7 @@ has no equivalent; pin names survive). ## Lane 1 — downgrade, then Altium's importer ```bash-python3 tools/kicad_v10_to_v9.py input_v10.kicad_sym output_v9.kicad_sym # container side+altium-kicad-downgrade input_v10.kicad_sym output_v9.kicad_sym # container side # send output to the box, then drive File → Import Wizard (or the AdomBridge extension where it loads) ``` After import, normalise text to Altium's defaults (imported text arrives in KiCad's font):@@ -49,8 +49,8 @@ The bridge builds the part natively from the KiCad sources — this is how the C LAN7800 on the adom/altium-bridge page were made: ```bash-python3 tools/kicad2altium.py part.kicad_mod fp.spec "<Name>" "<desc>" # footprint spec-python3 tools/symbol_modes.py part.kicad_sym sym.spec <LIBREF> "<desc>" # symbol (N display modes)+altium-kicad2altium part.kicad_mod fp.spec "<Name>" "<desc>" # footprint spec+altium-symbol-modes part.kicad_sym sym.spec <LIBREF> "<desc>" # symbol (N display modes) altium_build_footprint '{"libraryPath":"...PcbLib","spec":"C:/.../fp.spec"}' altium_build_symbol_modes '{"libraryPath":"...SchLib","spec":"C:/.../sym.spec"}' altium_add_body '{"libraryPath":"...PcbLib","footprintName":"...","step":"C:/.../part.step"}'
skills/altium-libraries/SKILL.md+2−2@@ -23,7 +23,7 @@ How library authoring actually works on this bridge, with the traps already paid Altium's alternate-symbol mechanism is DISPLAY MODES (Normal + Alternate 1..N-1), and modes live on the primitives: every pin/rectangle/line carries `OwnerPartId` (1) and `OwnerPartDisplayMode` (its mode). `altium_build_symbol_modes {libraryPath, spec}` builds the-whole set from a `tools/symbol_modes.py` spec — the generator sizes bodies from MEASURED+whole set from a `altium-symbol-modes` spec — the generator sizes bodies from MEASURED pin-name lengths (55 mil/char) so names never collide, makes "std" the CCW datasheet wrap (distinct from 2-column), and draws die-outline modes as centred squares, skipped when the free middle is under 300 mil. **The editor paints its OWN mode**: `Comp.DisplayMode` changes@@ -67,7 +67,7 @@ included. Save afterwards. ## Footprints: the spec lane -`altium_build_footprint {libraryPath, spec}` consumes a `tools/kicad2altium.py` spec:+`altium_build_footprint {libraryPath, spec}` consumes a `altium-kicad2altium` spec: - **Via pads**: through-hole pads sharing a designator (16 × "49") all map to that pin — the heatsink-via convention; real copper + drill, carried to the board and the drill file. - **Rounded-rect pads**: `eRoundedRectangular` + `SetState_StackCRPctOnLayer` (KiCad ratio ×100).
skills/altium-part-import/SKILL.md+2−2@@ -59,7 +59,7 @@ adom-bridge --ai-thread "<t>" altium_read_symbol '{"libraryPath":"...","symbolNa ```bash # clone the symbol (all display modes carried), rename to the new value altium_clone_symbol '{"libraryPath":"<lib>.SchLib","from":"R_10k_0603_1%","to":"R_1k_0402_1%"}'-# parametric footprint: a spec is 2 pads for an 0402 (tools/kicad2altium.py emits these, or write by hand)+# parametric footprint: a spec is 2 pads for an 0402 (altium-kicad2altium emits these, or write by hand) altium_build_footprint '{"libraryPath":"<lib>.PcbLib","spec":"C:/.../r0402.spec"}' altium_add_body '{"libraryPath":"<lib>.PcbLib","footprintName":"R0402","bwMil":39,"bhMil":20,"hMil":16}' # vendor identity + footprint link@@ -78,7 +78,7 @@ altium_install_library '{"libraryPath":"<lib>"}' ``` From a **wiki part page** (kicad_sym/kicad_mod/step files), the one-call lane: ```bash-python3 tools/kicad2altium.py part.kicad_mod fp.spec "<Footprint>" "<desc>" # container side+altium-kicad2altium part.kicad_mod fp.spec "<Footprint>" "<desc>" # container side altium_import_part '{"schLib":"...","pcbLib":"...","footprintSpec":"C:/.../fp.spec","step":"C:/.../part.step","params":{...},"description":"..."}' ```
skills/altium-wiki-import/SKILL.md+3−3@@ -45,9 +45,9 @@ adom-wiki library export <ref> --format kicad -o lib.zip # merged .kicad_sym # symbol_modes.py will fuse every part's pins into a single symbol. # Each part's (property "Footprint" "Lib:FP") names its .pretty/<FP>.kicad_mod. -# 3. convert (container side; both tools ship in the bridge repo's tools/)-python3 tools/kicad2altium.py <part>.kicad_mod fp.spec "<FootprintName>" "<desc>"-python3 tools/symbol_modes.py <part>.kicad_sym sym.spec "<SymbolName>" "<desc>"+# 3. convert (container side: `adom-wiki pkg install adom/altium-bridge` installs both as commands)+altium-kicad2altium <part>.kicad_mod fp.spec "<FootprintName>" "<desc>"+altium-symbol-modes <part>.kicad_sym sym.spec "<SymbolName>" "<desc>" # 4. vendor identity — ONE parts-search call, never a loop (shared daily quota) adom-parts-search search "<MPN>" # -> Mouser / DigiKey / LCSC part numbers
src/BRIDGE_VERSION+1−1@@ -1 +1 @@-0.2.46+0.2.47
src/agent/AdomAgent.pas.tmpl+1966−8@@ -733,11 +733,22 @@ Begin Result := '{' + JRaw('ok', 'false') + ',' + JStr('error', 'OutJob not found: ' + Path) + '}'; Exit; End;- ResetParameters;- AddStringParameter('ObjectKind', 'Document');- AddStringParameter('FileName', Path);- RunProcess('WorkspaceManager:OpenObject');- Doc := Client.GetDocumentByPath(Path);+ { the OutJob's own document kind: ObjectKind 'Document' is the generic word Altium answers with+ "File type not recognised" (see CmdOpen) }+ Doc := Nil;+ Try+ Doc := Client.OpenDocument('OUTPUTJOB', Path);+ Except+ Doc := Nil;+ End;+ If Doc = Nil Then+ Begin+ ResetParameters;+ AddStringParameter('ObjectKind', 'OUTPUTJOB');+ AddStringParameter('FileName', Path);+ RunProcess('WorkspaceManager:OpenObject');+ Doc := Client.GetDocumentByPath(Path);+ End; If Doc <> Nil Then Client.ShowDocument(Doc); ResetParameters; AddStringParameter('Action', 'Run');@@ -2808,7 +2819,11 @@ Var Rect : ISch_Rectangle; Pin : ISch_Pin; Ln : ISch_Line;+ Ar : ISch_Arc; Spec : TStringList;+ Old : ISch_Component;+ OIt : ISch_Iterator;+ NOld : Integer; I, NModes, NPins, NLines, Md : Integer; L, Kind, LibRef, Desc, Des, LibPath, SpecPath, LibState, Orient : String; Begin@@ -2878,9 +2893,29 @@ Begin Else Pin.Orientation := eRotate270; Pin.Location := Point(MilsToCoord(AdomInt(FieldP(L, 5))), MilsToCoord(AdomInt(FieldP(L, 6)))); Pin.Electrical := eElectricPassive;+ { a small part's pins carry no visible name or number, as a library resistor's do }+ If FieldP(L, 9) = 'hide' Then+ Begin+ Pin.ShowName := False;+ Pin.ShowDesignator := False;+ End; Comp.AddSchObject(Pin); NPins := NPins + 1; End+ Else If Kind = 'ARC' Then+ Begin+ { ARC|mode|cx|cy|r|startDeg|endDeg, counterclockwise }+ Md := AdomInt(FieldP(L, 2));+ Ar := SchServer.SchObjectFactory(eArc, eCreate_Default);+ Ar.OwnerPartId := 1;+ Ar.OwnerPartDisplayMode := Md;+ Ar.Location := Point(MilsToCoord(AdomInt(FieldP(L, 3))), MilsToCoord(AdomInt(FieldP(L, 4))));+ Ar.Radius := MilsToCoord(AdomInt(FieldP(L, 5)));+ Ar.StartAngle := AdomInt(FieldP(L, 6));+ Ar.EndAngle := AdomInt(FieldP(L, 7));+ Comp.AddSchObject(Ar);+ NLines := NLines + 1;+ End Else If Kind = 'LINE' Then Begin Md := AdomInt(FieldP(L, 2));@@ -2895,6 +2930,23 @@ Begin End; Spec.Free; Comp.DisplayModeCount := NModes;+ { a part imported again REPLACES its symbol: a second component with the same LibReference+ left the old one first in the library, so every reader kept drawing the old pins+ (measured 2026-10-15: a fixed 2-pin capacitor still placed with 1 pin) }+ NOld := 0;+ Repeat+ Old := Nil;+ OIt := SLib.SchLibIterator_Create;+ OIt.AddFilter_ObjectSet(MkSet(eSchComponent));+ Old := OIt.FirstSchObject;+ While Old <> Nil Do+ Begin+ If (Old <> Comp) And (UpperCase(Old.LibReference) = UpperCase(LibRef)) Then Break;+ Old := OIt.NextSchObject;+ End;+ SLib.SchIterator_Destroy(OIt);+ If Old <> Nil Then Begin SLib.RemoveSchComponent(Old); NOld := NOld + 1; End;+ Until (Old = Nil) Or (NOld > 20); SLib.AddSchComponent(Comp); SchServer.RobotManager.SendMessage(SLib.I_ObjectAddress, c_BroadCast, SCHM_PrimitiveRegistration, Comp.I_ObjectAddress); SLib.CurrentSchComponent := Comp;@@ -2927,7 +2979,7 @@ Begin Md := StrToIntDef(Param(Lines, 'mode'), 0); Comp.DisplayMode := Md; SLib.GraphicallyInvalidate;- Result := '{' + JRaw('ok','true') + ',' + JRaw('mode', IntToStr(Comp.DisplayMode)) + ',' ++ Result := '{' + JRaw('ok','true') + ',' + JRaw('mode', IntToStr(Comp.DisplayMode)) + ',' + JRaw('replaced', IntToStr(NOld)) + ',' + JRaw('displayModeCount', IntToStr(Comp.DisplayModeCount)) + ',' + JStr('libref', Comp.LibReference) + '}'; End; @@ -3261,9 +3313,12 @@ Begin Net := FindNet(Board, NetName); If Net = Nil Then Begin+ { a net that is only added (not registered) lives in memory: the save drops it and+ Altium builds no ratsnest for it (measured 2026-10-15: reopened board, 0 nets) } Net := PCBServer.PCBObjectFactory(eNetObject, eNoDimension, eCreate_Default); Net.Name := NetName; Board.AddPCBObject(Net);+ PCBServer.SendMessageToRobots(Board.I_ObjectAddress, c_Broadcast, PCBM_BoardRegisteration, Net.I_ObjectAddress); End; GIt := Inst.GroupIterator_Create; GIt.AddFilter_ObjectSet(MkSet(ePadObject));@@ -3271,7 +3326,13 @@ Begin Pad := GIt.FirstPCBObject; While Pad <> Nil Do Begin- If Pad.Name = Des Then Pad.Net := Net;+ If Pad.Name = Des Then+ Begin+ Pad.BeginModify;+ Pad.Net := Net;+ Pad.EndModify;+ Net.AddPCBObject(Pad);+ End; Pad := GIt.NextPCBObject; End; Inst.GroupIterator_Destroy(GIt);@@ -4494,7 +4555,9 @@ Begin Begin If PS <> '' Then PS := PS + ','; PS := PS + '{' + JStr('number', Pin.Designator) + ',' + JStr('name', Pin.Name) + ',' +- JRaw('electrical', IntToStr(Pin.Electrical)) + '}';+ JRaw('electrical', IntToStr(Pin.Electrical)) + ',' ++ JRaw('xMil', IntToStr(Round(CoordToMils(Pin.Location.X)))) + ',' + JRaw('yMil', IntToStr(Round(CoordToMils(Pin.Location.Y)))) + ',' ++ JRaw('orientation', IntToStr(Pin.Orientation)) + ',' + JRaw('lengthMil', IntToStr(Round(CoordToMils(Pin.PinLength)))) + '}'; Pin := PIt.NextSchObject; End; Comp.SchIterator_Destroy(PIt);@@ -4951,6 +5014,1884 @@ Begin Result := '{' + JRaw('ok','true') + ',' + JStr('board', Board.FileName) + ',' + JStr('sections', Sects) + S + '}'; End; +{ routing_state: the whole live board as data for AI Flow's Altium lane. Every coordinate is the+ RAW internal unit (an integer; 10000 per mil), so nothing is rounded and no decimal is+ formatted in the user's locale. Rotations and angles are in thousandths of a degree. The+ bridge converts to mm and hashes the dump into a revision. Arrays, not objects, keep a board+ with thousands of tracks small:+ tracks [x1,y1,x2,y2,width,layer,"net"]+ arcs [cx,cy,radius,startMilliDeg,endMilliDeg,width,layer,"net"]+ vias [x,y,size,hole,lowLayer,highLayer,"net"]+ pads ["comp","name",x,y,xSize,ySize,rotMilliDeg,layer,hole,"net",shape]+ comps ["designator",x,y,rotMilliDeg,layer,"pattern",bodies]+ polys ["net",layer,[[x,y],...]]+ outline [[x,y,kind,cx,cy,radius,angle1MilliDeg,angle2MilliDeg],...] kind 1 = an arc to the next vertex+ unrouted the ratsnest connection count (connection objects on the board) }+Function JoinList(L : TStringList) : String;+Var+ I : Integer;+ S : String;+Begin+ S := '';+ For I := 0 To L.Count - 1 Do+ Begin+ If I > 0 Then S := S + ',';+ S := S + L[I];+ End;+ Result := '[' + S + ']';+End;++Function NetOf(Prim : IPCB_Primitive) : String;+Begin+ Result := '""';+ If Prim.Net <> Nil Then Result := '"' + JsonEsc(Prim.Net.Name) + '"';+End;++Function CmdRoutingState(Lines : TStringList) : String;+Var+ Board : IPCB_Board;+ BIt : IPCB_BoardIterator;+ Prim : IPCB_Primitive;+ Trk : IPCB_Track;+ Arc : IPCB_Arc;+ Via : IPCB_Via;+ Pad : IPCB_Pad;+ Comp : IPCB_Component;+ Poly : IPCB_Polygon;+ BO : IPCB_BoardOutline;+ Seg : TPolySegment;+ LT, LA, LV, LP, LC, LG, LO : TStringList;+ Own, Pts : String;+ K, Unrouted : Integer;+Begin+ Board := PCBServer.GetCurrentPCBBoard;+ If Board = Nil Then Begin Result := '{' + JRaw('ok','false') + ',' + JStr('error','no current PCB board (open a .PcbDoc first)') + '}'; Exit; End;+ LT := TStringList.Create; LA := TStringList.Create; LV := TStringList.Create; LP := TStringList.Create;+ LC := TStringList.Create; LG := TStringList.Create; LO := TStringList.Create;+ Unrouted := 0;+ BIt := Board.BoardIterator_Create;+ BIt.AddFilter_ObjectSet(MkSet(eTrackObject, eArcObject, eViaObject, ePadObject, eComponentObject, ePolyObject, eConnectionObject));+ BIt.AddFilter_LayerSet(AllLayers);+ BIt.AddFilter_Method(eProcessAll);+ Prim := BIt.FirstPCBObject;+ While Prim <> Nil Do+ Begin+ If Prim.ObjectId = eTrackObject Then+ Begin+ Trk := Prim;+ If Not Trk.InComponent Then+ LT.Add('[' + IntToStr(Trk.x1) + ',' + IntToStr(Trk.y1) + ',' + IntToStr(Trk.x2) + ',' + IntToStr(Trk.y2) + ',' ++ IntToStr(Trk.Width) + ',' + IntToStr(Trk.Layer) + ',' + NetOf(Trk) + ']');+ End+ Else If Prim.ObjectId = eArcObject Then+ Begin+ Arc := Prim;+ If Not Arc.InComponent Then+ LA.Add('[' + IntToStr(Arc.XCenter) + ',' + IntToStr(Arc.YCenter) + ',' + IntToStr(Arc.Radius) + ',' ++ IntToStr(Round(Arc.StartAngle * 1000)) + ',' + IntToStr(Round(Arc.EndAngle * 1000)) + ',' ++ IntToStr(Arc.LineWidth) + ',' + IntToStr(Arc.Layer) + ',' + NetOf(Arc) + ']');+ End+ Else If Prim.ObjectId = eViaObject Then+ Begin+ Via := Prim;+ LV.Add('[' + IntToStr(Via.x) + ',' + IntToStr(Via.y) + ',' + IntToStr(Via.Size) + ',' + IntToStr(Via.HoleSize) + ',' ++ IntToStr(Via.LowLayer) + ',' + IntToStr(Via.HighLayer) + ',' + NetOf(Via) + ']');+ End+ Else If Prim.ObjectId = ePadObject Then+ Begin+ Pad := Prim;+ Own := '';+ If Pad.Component <> Nil Then Own := Pad.Component.Name.Text;+ LP.Add('["' + JsonEsc(Own) + '","' + JsonEsc(Pad.Name) + '",' + IntToStr(Pad.x) + ',' + IntToStr(Pad.y) + ',' ++ IntToStr(Pad.TopXSize) + ',' + IntToStr(Pad.TopYSize) + ',' + IntToStr(Round(Pad.Rotation * 1000)) + ',' ++ IntToStr(Pad.Layer) + ',' + IntToStr(Pad.HoleSize) + ',' + NetOf(Pad) + ',' + IntToStr(Pad.TopShape) + ']');+ End+ Else If Prim.ObjectId = eComponentObject Then+ Begin+ Comp := Prim;+ LC.Add('["' + JsonEsc(Comp.Name.Text) + '",' + IntToStr(Comp.x) + ',' + IntToStr(Comp.y) + ',' ++ IntToStr(Round(Comp.Rotation * 1000)) + ',' + IntToStr(Comp.Layer) + ',"' + JsonEsc(Comp.Pattern) + '",' ++ IntToStr(GroupCount(Comp, eComponentBodyObject)) + ']');+ End+ Else If Prim.ObjectId = ePolyObject Then+ Begin+ Poly := Prim;+ Pts := '';+ For K := 0 To Poly.PointCount - 1 Do+ Begin+ Seg := Poly.Segments[K];+ If K > 0 Then Pts := Pts + ',';+ Pts := Pts + '[' + IntToStr(Seg.vx) + ',' + IntToStr(Seg.vy) + ']';+ End;+ LG.Add('[' + NetOf(Poly) + ',' + IntToStr(Poly.Layer) + ',[' + Pts + ']]');+ End+ Else If Prim.ObjectId = eConnectionObject Then+ Unrouted := Unrouted + 1;+ Prim := BIt.NextPCBObject;+ End;+ Board.BoardIterator_Destroy(BIt);+ BO := Board.BoardOutline;+ If BO <> Nil Then+ For K := 0 To BO.PointCount - 1 Do+ Begin+ Seg := BO.Segments[K];+ If Seg.Kind = ePolySegmentArc Then+ LO.Add('[' + IntToStr(Seg.vx) + ',' + IntToStr(Seg.vy) + ',1,' + IntToStr(Seg.cx) + ',' + IntToStr(Seg.cy) + ',' ++ IntToStr(Seg.Radius) + ',' + IntToStr(Round(Seg.Angle1 * 1000)) + ',' + IntToStr(Round(Seg.Angle2 * 1000)) + ']')+ Else+ LO.Add('[' + IntToStr(Seg.vx) + ',' + IntToStr(Seg.vy) + ',0]');+ End;+ Result := '{' + JRaw('ok','true') + ',' + JStr('file', Board.FileName) + ',' + JRaw('coordPerMil','10000') + ',' ++ JRaw('tracks', JoinList(LT)) + ',' + JRaw('arcs', JoinList(LA)) + ',' + JRaw('vias', JoinList(LV)) + ',' ++ JRaw('pads', JoinList(LP)) + ',' + JRaw('comps', JoinList(LC)) + ',' + JRaw('polys', JoinList(LG)) + ',' ++ JRaw('outline', JoinList(LO)) + ',' + JRaw('unrouted', IntToStr(Unrouted)) + '}';+ LT.Free; LA.Free; LV.Free; LP.Free; LC.Free; LG.Free; LO.Free;+End;++{ edit_board: a batch of copper and placement edits in ONE PreProcess/PostProcess (one undo+ step), for AI Flow's Altium lane. The payload is one op per line, '|'-separated, coordinates+ in RAW internal units (10000 per mil), rotation in thousandths of a degree:+ T|x1|y1|x2|y2|width|layer|net add a track+ V|x|y|size|hole|lowLayer|highLayer|net add a via+ M|ref|x|y|rotMilliDeg move + rotate a placed component+ D|T|x1|y1|x2|y2|layer delete the free track with these ends (either direction)+ D|V|x|y delete the via at x,y+ N|net rip up: delete every free track, arc and via on the net+ Each op answers in results[] in order: "ok", "ok:<n>" (deleted count) or "err:<why>". A+ failed op does not stop the batch; the bridge reads the board back and reports what landed. }+Function FindTrack(Board : IPCB_Board; X1, Y1, X2, Y2, Lay : Integer) : IPCB_Track;+Var+ BIt : IPCB_BoardIterator;+ Trk : IPCB_Track;+Begin+ Result := Nil;+ BIt := Board.BoardIterator_Create;+ BIt.AddFilter_ObjectSet(MkSet(eTrackObject));+ BIt.AddFilter_LayerSet(AllLayers);+ BIt.AddFilter_Method(eProcessAll);+ Trk := BIt.FirstPCBObject;+ While Trk <> Nil Do+ Begin+ If (Not Trk.InComponent) And (Trk.Layer = Lay) And+ (((Trk.x1 = X1) And (Trk.y1 = Y1) And (Trk.x2 = X2) And (Trk.y2 = Y2)) Or+ ((Trk.x1 = X2) And (Trk.y1 = Y2) And (Trk.x2 = X1) And (Trk.y2 = Y1))) Then+ Begin+ Result := Trk; Break;+ End;+ Trk := BIt.NextPCBObject;+ End;+ Board.BoardIterator_Destroy(BIt);+End;++Function FindViaAt(Board : IPCB_Board; X, Y : Integer) : IPCB_Via;+Var+ BIt : IPCB_BoardIterator;+ Via : IPCB_Via;+Begin+ Result := Nil;+ BIt := Board.BoardIterator_Create;+ BIt.AddFilter_ObjectSet(MkSet(eViaObject));+ BIt.AddFilter_LayerSet(AllLayers);+ BIt.AddFilter_Method(eProcessAll);+ Via := BIt.FirstPCBObject;+ While Via <> Nil Do+ Begin+ If (Via.x = X) And (Via.y = Y) Then Begin Result := Via; Break; End;+ Via := BIt.NextPCBObject;+ End;+ Board.BoardIterator_Destroy(BIt);+End;++{ The first free track, arc or via on the net, or Nil. }+Function FirstOnNet(Board : IPCB_Board; NetName : String) : IPCB_Primitive;+Var+ BIt : IPCB_BoardIterator;+ Prim : IPCB_Primitive;+Begin+ Result := Nil;+ BIt := Board.BoardIterator_Create;+ BIt.AddFilter_ObjectSet(MkSet(eTrackObject, eArcObject, eViaObject));+ BIt.AddFilter_LayerSet(AllLayers);+ BIt.AddFilter_Method(eProcessAll);+ Prim := BIt.FirstPCBObject;+ While Prim <> Nil Do+ Begin+ If (Not Prim.InComponent) And (Prim.Net <> Nil) Then+ If Prim.Net.Name = NetName Then Begin Result := Prim; Break; End;+ Prim := BIt.NextPCBObject;+ End;+ Board.BoardIterator_Destroy(BIt);+End;++Procedure RegisterPrim(Board : IPCB_Board; Prim : IPCB_Primitive);+Begin+ Board.AddPCBObject(Prim);+ PCBServer.SendMessageToRobots(Board.I_ObjectAddress, c_Broadcast, PCBM_BoardRegisteration, Prim.I_ObjectAddress);+End;++Function CmdEditBoard(Lines : TStringList; Payload : String) : String;+Var+ Board : IPCB_Board;+ Ops : TStringList;+ Op, Kind, Res, Results : String;+ Trk : IPCB_Track;+ Via : IPCB_Via;+ Comp : IPCB_Component;+ Prim : IPCB_Primitive;+ Net : IPCB_Net;+ I, N, Okay, Failed : Integer;+Begin+ Board := PCBServer.GetCurrentPCBBoard;+ If Board = Nil Then Begin Result := '{' + JRaw('ok','false') + ',' + JStr('error','no current PCB board (open a .PcbDoc first)') + '}'; Exit; End;+ Ops := TStringList.Create;+ Ops.Text := Payload;+ Results := ''; Okay := 0; Failed := 0;+ PCBServer.PreProcess;+ For I := 0 To Ops.Count - 1 Do+ Begin+ Op := Trim(Ops[I]);+ If Op = '' Then Continue;+ Kind := FieldP(Op, 1);+ Res := 'ok';+ gInputError := '';+ If Kind = 'T' Then+ Begin+ Net := FindNet(Board, FieldP(Op, 8));+ If (Net = Nil) And (FieldP(Op, 8) <> '') Then Res := 'err:no net ' + FieldP(Op, 8)+ Else+ Begin+ Trk := PCBServer.PCBObjectFactory(eTrackObject, eNoDimension, eCreate_Default);+ Trk.Layer := AdomInt(FieldP(Op, 7));+ Trk.X1 := AdomInt(FieldP(Op, 2)); Trk.Y1 := AdomInt(FieldP(Op, 3));+ Trk.X2 := AdomInt(FieldP(Op, 4)); Trk.Y2 := AdomInt(FieldP(Op, 5));+ Trk.Width := AdomInt(FieldP(Op, 6));+ If Net <> Nil Then Trk.Net := Net;+ If gInputError <> '' Then Res := 'err:bad number ' + gInputError Else RegisterPrim(Board, Trk);+ End;+ End+ Else If Kind = 'V' Then+ Begin+ Net := FindNet(Board, FieldP(Op, 8));+ If (Net = Nil) And (FieldP(Op, 8) <> '') Then Res := 'err:no net ' + FieldP(Op, 8)+ Else+ Begin+ Via := PCBServer.PCBObjectFactory(eViaObject, eNoDimension, eCreate_Default);+ Via.x := AdomInt(FieldP(Op, 2)); Via.y := AdomInt(FieldP(Op, 3));+ Via.Size := AdomInt(FieldP(Op, 4)); Via.HoleSize := AdomInt(FieldP(Op, 5));+ Via.LowLayer := AdomInt(FieldP(Op, 6)); Via.HighLayer := AdomInt(FieldP(Op, 7));+ If Net <> Nil Then Via.Net := Net;+ If gInputError <> '' Then Res := 'err:bad number ' + gInputError Else RegisterPrim(Board, Via);+ End;+ End+ Else If Kind = 'M' Then+ Begin+ Comp := FindCompByDes(Board, FieldP(Op, 2));+ If Comp = Nil Then Res := 'err:no component ' + FieldP(Op, 2)+ Else+ Begin+ Comp.BeginModify;+ Comp.Rotation := AdomInt(FieldP(Op, 5)) / 1000;+ Comp.MoveToXY(AdomInt(FieldP(Op, 3)), AdomInt(FieldP(Op, 4)));+ Comp.EndModify;+ If gInputError <> '' Then Res := 'err:bad number ' + gInputError;+ End;+ End+ Else If Kind = 'D' Then+ Begin+ Prim := Nil;+ If FieldP(Op, 2) = 'T' Then+ Prim := FindTrack(Board, AdomInt(FieldP(Op, 3)), AdomInt(FieldP(Op, 4)), AdomInt(FieldP(Op, 5)), AdomInt(FieldP(Op, 6)), AdomInt(FieldP(Op, 7)))+ Else If FieldP(Op, 2) = 'V' Then+ Prim := FindViaAt(Board, AdomInt(FieldP(Op, 3)), AdomInt(FieldP(Op, 4)));+ If Prim = Nil Then Res := 'err:not found'+ Else Board.RemovePCBObject(Prim);+ End+ Else If Kind = 'N' Then+ Begin+ N := 0;+ Prim := FirstOnNet(Board, FieldP(Op, 2));+ While (Prim <> Nil) And (N < 20000) Do+ Begin+ Board.RemovePCBObject(Prim);+ N := N + 1;+ Prim := FirstOnNet(Board, FieldP(Op, 2));+ End;+ Res := 'ok:' + IntToStr(N);+ End+ Else Res := 'err:unknown op ' + Kind;+ If Copy(Res, 1, 3) = 'err' Then Failed := Failed + 1 Else Okay := Okay + 1;+ If Results <> '' Then Results := Results + ',';+ Results := Results + '"' + JsonEsc(Res) + '"';+ End;+ PCBServer.PostProcess;+ gInputError := '';+ Board.ViewManager_FullUpdate;+ Ops.Free;+ Result := '{' + JRaw('ok','true') + ',' + JRaw('applied', IntToStr(Okay)) + ',' + JRaw('failed', IntToStr(Failed)) + ',' ++ JRaw('results', '[' + Results + ']') + '}';+End;++{ apply_pours: solid polygon pours, one op per payload line, RAW internal units:+ P|net|layer|pourIndex|x,y;x,y;...|removeDead a closed outline (the last point need not repeat the+ first); removeDead 0 keeps islands Altium calls dead+ All in one PreProcess/PostProcess; each pour is rebuilt (poured) after it is added. Answers+ results[] per line: "ok" or "err:<why>". }+{ a pour's connect style: Altium keeps no connect style on a polygon; a PolygonConnect rule+ scoped IsNamedPolygon('<name>') sets it for that one pour. Altium gives a newly added rule+ priority 1 and moves the rest down (measured AD 26.11.1, 2026-10-15), so it outranks the board's+ own. Writing Rule.Priority from a script halted the engine with an empty Error box (same+ date): do not reorder rules here. }+Function FindRuleNamed(Board : IPCB_Board; Kind : Integer; Name : String) : IPCB_Rule;+Var+ BIt : IPCB_BoardIterator;+ Rule : IPCB_Rule;+Begin+ Result := Nil;+ BIt := Board.BoardIterator_Create;+ BIt.AddFilter_ObjectSet(MkSet(eRuleObject));+ BIt.AddFilter_LayerSet(AllLayers);+ BIt.AddFilter_Method(eProcessAll);+ Rule := BIt.FirstPCBObject;+ While Rule <> Nil Do+ Begin+ If (Rule.RuleKind = Kind) And (Rule.Name = Name) Then Begin Result := Rule; Break; End;+ Rule := BIt.NextPCBObject;+ End;+ Board.BoardIterator_Destroy(BIt);+End;++Function PolyNamed(Board : IPCB_Board; Name : String) : Boolean;+Var+ BIt : IPCB_BoardIterator;+ Poly : IPCB_Polygon;+Begin+ Result := False;+ BIt := Board.BoardIterator_Create;+ BIt.AddFilter_ObjectSet(MkSet(ePolyObject));+ BIt.AddFilter_LayerSet(AllLayers);+ BIt.AddFilter_Method(eProcessAll);+ Poly := BIt.FirstPCBObject;+ While Poly <> Nil Do+ Begin+ If Poly.Name = Name Then Begin Result := True; Break; End;+ Poly := BIt.NextPCBObject;+ End;+ Board.BoardIterator_Destroy(BIt);+End;++{ Style: D direct (solid), R relief, N no connect. W/G raw units, E spokes (2 or 4). }+Procedure SetPourConnect(Board : IPCB_Board; PolyName, Style : String; W, G, E : Integer);+Var+ Rule : IPCB_Rule;+Begin+ Rule := FindRuleNamed(Board, eRule_PolygonConnectStyle, 'AdomPC ' + PolyName);+ If Rule <> Nil Then Board.RemovePCBObject(Rule);+ Rule := PCBServer.PCBRuleFactory(eRule_PolygonConnectStyle);+ Rule.Name := 'AdomPC ' + PolyName;+ Rule.Comment := PolyName;+ { IsNamedPolygon is the polygon itself; InNamedPolygon is 'belongs to' (its pour primitives) and+ never matched the polygon, so the catch-all relief stayed in force (measured 2026-10-15). The+ same OR on both sides: which of the pair (polygon, pad) Altium tests against Scope1 is not ours+ to know. }+ Rule.Scope1Expression := 'IsNamedPolygon(''' + PolyName + ''') Or IsPad Or IsVia';+ Rule.Scope2Expression := Rule.Scope1Expression;+ If Style = 'D' Then Rule.ConnectStyle := eDirectConnectToPlane+ Else If Style = 'N' Then Rule.ConnectStyle := eNoConnect+ Else+ Begin+ Rule.ConnectStyle := eReliefConnectToPlane;+ If W > 0 Then Rule.ReliefConductorWidth := W;+ { no air gap here: AD 26.11.1 keeps it per pad type (SetAirGapWidth(primitiveType, v));+ a plain AirGapWidth is 'Undeclared identifier' (measured 2026-10-15). Altium's default+ gap is 10 mil. }+ If E > 0 Then Rule.ReliefEntries := E;+ End;+ Board.AddPCBObject(Rule);+ PCBServer.SendMessageToRobots(Board.I_ObjectAddress, c_Broadcast, PCBM_BoardRegisteration, Rule.I_ObjectAddress);+End;++{ solid joints inside a pour: pads completely inside each rect (the bridge widens a joint box to+ the pads it touches) connect to this pour full width+ (KiCad's small solid zone over a pad, which Altium cannot pour as a separate overlapping+ polygon: a same-net polygon inside another pours nothing, measured 2026-10-15). One direct rule+ per rect; both scopes are the same OR so the pair matches in either order. Rects: 'l,b,r,t'+ RAW, ';'-joined. The rule's Comment holds the polygon name for cleanup. }+Procedure SetPourJoints(Board : IPCB_Board; PolyName, Rects : String);+Var+ RL : TStringList;+ Rule : IPCB_Rule;+ K : Integer;+ Q, R : String;+Begin+ RL := SplitSemi(Rects);+ For K := 0 To RL.Count - 1 Do+ Begin+ R := RL[K];+ { whole mils, widened outward: the region test wants the pad completely inside }+ Q := 'IsNamedPolygon(''' + PolyName + ''') Or InRegionAbsolute(' ++ IntToStr(Trunc(CoordToMils(AdomInt(Field(R, 1)))) - 1) + ',' + IntToStr(Trunc(CoordToMils(AdomInt(Field(R, 2)))) - 1) + ',' ++ IntToStr(Trunc(CoordToMils(AdomInt(Field(R, 3)))) + 1) + ',' + IntToStr(Trunc(CoordToMils(AdomInt(Field(R, 4)))) + 1) + ')';+ Rule := FindRuleNamed(Board, eRule_PolygonConnectStyle, 'AdomPJ ' + PolyName + ' #' + IntToStr(K + 1));+ If Rule <> Nil Then Board.RemovePCBObject(Rule);+ Rule := PCBServer.PCBRuleFactory(eRule_PolygonConnectStyle);+ Rule.Name := 'AdomPJ ' + PolyName + ' #' + IntToStr(K + 1);+ Rule.Comment := PolyName;+ Rule.Scope1Expression := Q;+ Rule.Scope2Expression := Q;+ Rule.ConnectStyle := eDirectConnectToPlane;+ Board.AddPCBObject(Rule);+ PCBServer.SendMessageToRobots(Board.I_ObjectAddress, c_Broadcast, PCBM_BoardRegisteration, Rule.I_ObjectAddress);+ End;+ RL.Free;+End;++{ a pour keepout: a polygon cutout region on one layer (pours stay out, tracks do not) }+Function AddCutout(Board : IPCB_Board; Lay : Integer; Pts : TStringList; Name : String) : String;+Var+ Reg : IPCB_Region;+ Cont : IPCB_Contour;+ K : Integer;+Begin+ Result := 'ok';+ Cont := PCBServer.PCBContourFactory;+ For K := 0 To Pts.Count - 1 Do Cont.AddPoint(AdomInt(Field(Pts[K], 1)), AdomInt(Field(Pts[K], 2)));+ If gInputError <> '' Then Begin Result := 'err:bad number ' + gInputError; Exit; End;+ Reg := PCBServer.PCBObjectFactory(eRegionObject, eNoDimension, eCreate_Default);+ Reg.BeginModify;+ Reg.Layer := Lay;+ Reg.Kind := eRegionKind_Cutout;+ Reg.Name := Name;+ Reg.SetOutlineContour(Cont);+ Reg.EndModify;+ RegisterPrim(Board, Reg);+End;++Function CmdApplyPours(Lines : TStringList; Payload : String) : String;+Var+ Board : IPCB_Board;+ Ops, Pts : TStringList;+ Op, Res, Results, P, PName : String;+ Poly : IPCB_Polygon;+ Seg : TPolySegment;+ Net : IPCB_Net;+ I, K, C, Okay, Failed : Integer;+Begin+ Board := PCBServer.GetCurrentPCBBoard;+ If Board = Nil Then Begin Result := '{' + JRaw('ok','false') + ',' + JStr('error','no current PCB board (open a .PcbDoc first)') + '}'; Exit; End;+ Ops := TStringList.Create;+ Ops.Text := Payload;+ Results := ''; Okay := 0; Failed := 0;+ PCBServer.PreProcess;+ For I := 0 To Ops.Count - 1 Do+ Begin+ Op := Trim(Ops[I]);+ If Op = '' Then Continue;+ Res := 'ok';+ gInputError := '';+ If FieldP(Op, 1) = 'C' Then+ Begin+ Pts := SplitSemi(FieldP(Op, 3));+ If Pts.Count < 3 Then Res := 'err:a cutout needs 3 or more points'+ Else Res := AddCutout(Board, AdomInt(FieldP(Op, 2)), Pts, FieldP(Op, 4));+ Pts.Free;+ End+ Else If FieldP(Op, 1) <> 'P' Then Res := 'err:unknown op'+ Else+ Begin+ Net := FindNet(Board, FieldP(Op, 2));+ Pts := SplitSemi(FieldP(Op, 5));+ If (Net = Nil) And (FieldP(Op, 2) <> '') Then Res := 'err:no net ' + FieldP(Op, 2)+ Else If Pts.Count < 3 Then Res := 'err:an outline needs 3 or more points'+ Else+ Begin+ Poly := PCBServer.PCBObjectFactory(ePolyObject, eNoDimension, eCreate_Default);+ Poly.BeginModify;+ Poly.PolygonType := eSignalLayerPolygon;+ Poly.Layer := AdomInt(FieldP(Op, 3));+ Poly.PolyHatchStyle := ePolySolid;+ PName := FieldP(Op, 7);+ If PName = '' Then PName := 'Adom ' + FieldP(Op, 2) + ' L' + FieldP(Op, 3);+ If PolyNamed(Board, PName) Then+ Begin+ K := 2;+ While PolyNamed(Board, PName + ' ' + IntToStr(K)) Do K := K + 1;+ PName := PName + ' ' + IntToStr(K);+ End;+ Poly.Name := PName;+ Poly.RemoveDead := FieldP(Op, 6) <> '0';+ { connected copper of any size stays, as in a KiCad zone: Altium's own default drops+ islands under 2500 sq mil, which emptied every small joint pour (measured+ 2026-10-15: a 1.8 mm box around a pad poured 0 mm2). Dead copper still goes. }+ Poly.RemoveIslandsByArea := FieldP(Op, 12) = '1';+ If AdomInt(FieldP(Op, 13)) > 0 Then+ Begin+ Poly.RemoveNarrowNecks := True;+ Poly.NeckWidthThreshold := AdomInt(FieldP(Op, 13));+ End;+ { pour over every same-net object, as a KiCad zone does: by default Altium pours AROUND+ same-net tracks, which leaves slivers that touch no pad and are removed as dead copper+ (measured 2026-10-15: a buck's SW patch poured 0 mm2 beside its own SW track) }+ Poly.PourOver := ePolygonPourOver_SameNet;+ If Net <> Nil Then Poly.Net := Net;+ Poly.PointCount := Pts.Count;+ For K := 0 To Pts.Count - 1 Do+ Begin+ P := Pts[K];+ Seg := Poly.Segments[K];+ Seg.Kind := ePolySegmentLine;+ Seg.vx := AdomInt(Field(P, 1));+ Seg.vy := AdomInt(Field(P, 2));+ Poly.Segments[K] := Seg;+ End;+ C := AdomInt(FieldP(Op, 4));+ If C > 0 Then Poly.PourIndex := C;+ Poly.EndModify;+ If gInputError <> '' Then Res := 'err:bad number ' + gInputError+ Else+ Begin+ RegisterPrim(Board, Poly);+ If FieldP(Op, 8) <> '' Then+ SetPourConnect(Board, PName, FieldP(Op, 8), AdomInt(FieldP(Op, 9)), AdomInt(FieldP(Op, 10)), AdomInt(FieldP(Op, 11)));+ If FieldP(Op, 14) <> '' Then SetPourJoints(Board, PName, FieldP(Op, 14));+ Res := 'ok:' + PName;+ Poly.Rebuild;+ Poly.GraphicallyInvalidate;+ End;+ End;+ Pts.Free;+ End;+ If Copy(Res, 1, 3) = 'err' Then Failed := Failed + 1 Else Okay := Okay + 1;+ If Results <> '' Then Results := Results + ',';+ Results := Results + '"' + JsonEsc(Res) + '"';+ End;+ PCBServer.PostProcess;+ gInputError := '';+ Board.ViewManager_FullUpdate;+ Ops.Free;+ Result := '{' + JRaw('ok','true') + ',' + JRaw('applied', IntToStr(Okay)) + ',' + JRaw('failed', IntToStr(Failed)) + ',' ++ JRaw('results', '[' + Results + ']') + '}';+End;++{ pour_state: every polygon with its outline AND the copper it actually poured: the region+ primitives inside it, each as a main contour plus holes, in RAW units. The bridge computes+ the areas (shoelace), so nothing here does floating point.+ polys [["net",layer,pourIndex,[[x,y]...outline],[ [[x,y]...main], [[[x,y]...hole]...] ]...]] }+Function ContourPts(Ct : IPCB_Contour) : String;+Var+ K : Integer;+ S : String;+Begin+ S := '';+ For K := 0 To Ct.Count - 1 Do+ Begin+ If K > 0 Then S := S + ',';+ S := S + '[' + IntToStr(Ct.x[K]) + ',' + IntToStr(Ct.y[K]) + ']';+ End;+ Result := '[' + S + ']';+End;++Function CmdPourState(Lines : TStringList) : String;+Var+ Board : IPCB_Board;+ BIt : IPCB_BoardIterator;+ GIt : IPCB_GroupIterator;+ Poly : IPCB_Polygon;+ Prim : IPCB_Primitive;+ Reg : IPCB_Region;+ Seg : TPolySegment;+ LG : TStringList;+ Outl, Fills, Holes : String;+ K, H : Integer;+Begin+ Board := PCBServer.GetCurrentPCBBoard;+ If Board = Nil Then Begin Result := '{' + JRaw('ok','false') + ',' + JStr('error','no current PCB board (open a .PcbDoc first)') + '}'; Exit; End;+ LG := TStringList.Create;+ BIt := Board.BoardIterator_Create;+ BIt.AddFilter_ObjectSet(MkSet(ePolyObject));+ BIt.AddFilter_LayerSet(AllLayers);+ BIt.AddFilter_Method(eProcessAll);+ Poly := BIt.FirstPCBObject;+ While Poly <> Nil Do+ Begin+ Outl := '';+ For K := 0 To Poly.PointCount - 1 Do+ Begin+ Seg := Poly.Segments[K];+ If K > 0 Then Outl := Outl + ',';+ Outl := Outl + '[' + IntToStr(Seg.vx) + ',' + IntToStr(Seg.vy) + ']';+ End;+ Fills := '';+ GIt := Poly.GroupIterator_Create;+ GIt.AddFilter_ObjectSet(MkSet(eRegionObject));+ GIt.AddFilter_LayerSet(AllLayers);+ Prim := GIt.FirstPCBObject;+ While Prim <> Nil Do+ Begin+ Reg := Prim;+ Holes := '';+ For H := 0 To Reg.HoleCount - 1 Do+ Begin+ If H > 0 Then Holes := Holes + ',';+ Holes := Holes + ContourPts(Reg.Holes[H]);+ End;+ If Fills <> '' Then Fills := Fills + ',';+ Fills := Fills + '[' + ContourPts(Reg.MainContour) + ',[' + Holes + ']]';+ Prim := GIt.NextPCBObject;+ End;+ Poly.GroupIterator_Destroy(GIt);+ LG.Add('[' + NetOf(Poly) + ',' + IntToStr(Poly.Layer) + ',' + IntToStr(Poly.PourIndex) + ',[' + Outl + '],[' + Fills + '],"' + JsonEsc(Poly.Name) + '"]');+ Poly := BIt.NextPCBObject;+ End;+ Board.BoardIterator_Destroy(BIt);+ Result := '{' + JRaw('ok','true') + ',' + JStr('file', Board.FileName) + ',' + JRaw('polys', JoinList(LG)) + '}';+ LG.Free;+End;++{ remove_pours: delete polygons. Payload lines: "net|layer" (layer 0 = any layer), or "*" for all. }+Function FirstPoly(Board : IPCB_Board; NetName : String; Lay : Integer; All : Boolean) : IPCB_Polygon;+Var+ BIt : IPCB_BoardIterator;+ Poly : IPCB_Polygon;+ N : String;+Begin+ Result := Nil;+ BIt := Board.BoardIterator_Create;+ BIt.AddFilter_ObjectSet(MkSet(ePolyObject));+ BIt.AddFilter_LayerSet(AllLayers);+ BIt.AddFilter_Method(eProcessAll);+ Poly := BIt.FirstPCBObject;+ While Poly <> Nil Do+ Begin+ N := '';+ If Poly.Net <> Nil Then N := Poly.Net.Name;+ { NetName '=<name>' picks a polygon by its name }+ If All Or ((N = NetName) And ((Lay = 0) Or (Poly.Layer = Lay))) Or ((Copy(NetName, 1, 1) = '=') And (Poly.Name = Copy(NetName, 2, Length(NetName)))) Then Begin Result := Poly; Break; End;+ Poly := BIt.NextPCBObject;+ End;+ Board.BoardIterator_Destroy(BIt);+End;++Function FirstAdomCutout(Board : IPCB_Board; Lay : Integer) : IPCB_Region;+Var+ BIt : IPCB_BoardIterator;+ Reg : IPCB_Region;+Begin+ Result := Nil;+ BIt := Board.BoardIterator_Create;+ BIt.AddFilter_ObjectSet(MkSet(eRegionObject));+ BIt.AddFilter_LayerSet(AllLayers);+ BIt.AddFilter_Method(eProcessAll);+ Reg := BIt.FirstPCBObject;+ While Reg <> Nil Do+ Begin+ If (Reg.Kind = eRegionKind_Cutout) And (Copy(Reg.Name, 1, 4) = 'Adom') And ((Lay = 0) Or (Reg.Layer = Lay)) Then Begin Result := Reg; Break; End;+ Reg := BIt.NextPCBObject;+ End;+ Board.BoardIterator_Destroy(BIt);+End;++{ the AdomPC/AdomPJ connect rule whose polygon (named in its Comment) is gone }+Function OrphanPourRule(Board : IPCB_Board) : IPCB_Rule;+Var+ BIt : IPCB_BoardIterator;+ Rule : IPCB_Rule;+ PN : String;+Begin+ Result := Nil;+ BIt := Board.BoardIterator_Create;+ BIt.AddFilter_ObjectSet(MkSet(eRuleObject));+ BIt.AddFilter_LayerSet(AllLayers);+ BIt.AddFilter_Method(eProcessAll);+ Rule := BIt.FirstPCBObject;+ While Rule <> Nil Do+ Begin+ If (Rule.RuleKind = eRule_PolygonConnectStyle) And ((Copy(Rule.Name, 1, 7) = 'AdomPC ') Or (Copy(Rule.Name, 1, 7) = 'AdomPJ ')) Then+ Begin+ PN := Rule.Comment;+ If PN = '' Then PN := Copy(Rule.Name, 8, Length(Rule.Name));+ If Not PolyNamed(Board, PN) Then Begin Result := Rule; Break; End;+ End;+ Rule := BIt.NextPCBObject;+ End;+ Board.BoardIterator_Destroy(BIt);+End;++Function CmdRemovePours(Lines : TStringList; Payload : String) : String;+Var+ Board : IPCB_Board;+ Ops : TStringList;+ Op : String;+ Poly : IPCB_Polygon;+ Reg : IPCB_Region;+ Rule : IPCB_Rule;+ I, N, NC, NR : Integer;+Begin+ Board := PCBServer.GetCurrentPCBBoard;+ If Board = Nil Then Begin Result := '{' + JRaw('ok','false') + ',' + JStr('error','no current PCB board (open a .PcbDoc first)') + '}'; Exit; End;+ Ops := TStringList.Create;+ Ops.Text := Payload;+ N := 0; NC := 0; NR := 0;+ PCBServer.PreProcess;+ For I := 0 To Ops.Count - 1 Do+ Begin+ Op := Trim(Ops[I]);+ If Op = '' Then Continue;+ If FieldP(Op, 1) = 'C' Then+ Begin+ Reg := FirstAdomCutout(Board, AdomInt(FieldP(Op, 2)));+ While (Reg <> Nil) And (NC < 5000) Do+ Begin+ Board.RemovePCBObject(Reg);+ NC := NC + 1;+ Reg := FirstAdomCutout(Board, AdomInt(FieldP(Op, 2)));+ End;+ Continue;+ End;+ Poly := FirstPoly(Board, FieldP(Op, 1), AdomInt(FieldP(Op, 2)), Op = '*');+ While (Poly <> Nil) And (N < 5000) Do+ Begin+ Board.RemovePCBObject(Poly);+ N := N + 1;+ Poly := FirstPoly(Board, FieldP(Op, 1), AdomInt(FieldP(Op, 2)), Op = '*');+ End;+ End;+ Rule := OrphanPourRule(Board);+ While (Rule <> Nil) And (NR < 5000) Do+ Begin+ Board.RemovePCBObject(Rule);+ NR := NR + 1;+ Rule := OrphanPourRule(Board);+ End;+ PCBServer.PostProcess;+ gInputError := '';+ Board.ViewManager_FullUpdate;+ Ops.Free;+ Result := '{' + JRaw('ok','true') + ',' + JRaw('removed', IntToStr(N)) + ',' + JRaw('cutoutsRemoved', IntToStr(NC)) + ',' + JRaw('rulesRemoved', IntToStr(NR)) + '}';+End;++{ routing_drc: the violations on the board right now (online DRC keeps them current as edits+ land, a batch DRC refreshes them all) with each one's location, plus the ratsnest count.+ violations [["rule","description",left,bottom,right,top]] RAW units }+Function CmdRoutingDrc(Lines : TStringList) : String;+Var+ Board : IPCB_Board;+ BIt : IPCB_BoardIterator;+ Prim : IPCB_Primitive;+ V : IPCB_Violation;+ R : TCoordRect;+ LV : TStringList;+ Rule : String;+ Unrouted, N : Integer;+Begin+ Board := PCBServer.GetCurrentPCBBoard;+ If Board = Nil Then Begin Result := '{' + JRaw('ok','false') + ',' + JStr('error','no current PCB board (open a .PcbDoc first)') + '}'; Exit; End;+ LV := TStringList.Create;+ Unrouted := 0; N := 0;+ BIt := Board.BoardIterator_Create;+ BIt.AddFilter_ObjectSet(MkSet(eViolationObject, eConnectionObject));+ BIt.AddFilter_LayerSet(AllLayers);+ BIt.AddFilter_Method(eProcessAll);+ Prim := BIt.FirstPCBObject;+ While Prim <> Nil Do+ Begin+ If Prim.ObjectId = eConnectionObject Then Unrouted := Unrouted + 1+ Else+ Begin+ V := Prim;+ N := N + 1;+ If N <= 500 Then+ Begin+ Rule := '';+ If V.Rule <> Nil Then Rule := V.Rule.Name;+ R := V.BoundingRectangle;+ LV.Add('["' + JsonEsc(Rule) + '","' + JsonEsc(V.Description) + '",' + IntToStr(R.Left) + ',' + IntToStr(R.Bottom) + ',' ++ IntToStr(R.Right) + ',' + IntToStr(R.Top) + ']');+ End;+ End;+ Prim := BIt.NextPCBObject;+ End;+ Board.BoardIterator_Destroy(BIt);+ Result := '{' + JRaw('ok','true') + ',' + JStr('file', Board.FileName) + ',' + JRaw('violationCount', IntToStr(N)) + ',' ++ JRaw('unrouted', IntToStr(Unrouted)) + ',' + JRaw('violations', JoinList(LV)) + '}';+ LV.Free;+End;++{ Outline vertices from the payload's O line into the board outline, RAW units:+ O|x,y,kind,cx,cy,radius,angle1MilliDeg,angle2MilliDeg;... kind 1 = an arc from this vertex to the next }+Function SetOutlineFrom(Board : IPCB_Board; Spec : String) : Integer;+Var+ BO : IPCB_BoardOutline;+ Seg : TPolySegment;+ Pts : TStringList;+ K : Integer;+ P : String;+Begin+ Result := 0;+ BO := Board.BoardOutline;+ If BO = Nil Then Exit;+ Pts := SplitSemi(Spec);+ If Pts.Count < 3 Then Begin Pts.Free; Exit; End;+ BO.Invalidate;+ BO.PointCount := Pts.Count;+ For K := 0 To Pts.Count - 1 Do+ Begin+ P := Pts[K];+ Seg := BO.Segments[K];+ Seg.vx := AdomInt(Field(P, 1));+ Seg.vy := AdomInt(Field(P, 2));+ If Field(P, 3) = '1' Then+ Begin+ Seg.Kind := ePolySegmentArc;+ Seg.cx := AdomInt(Field(P, 4));+ Seg.cy := AdomInt(Field(P, 5));+ Seg.Radius := AdomInt(Field(P, 6));+ Seg.Angle1 := AdomInt(Field(P, 7)) / 1000;+ Seg.Angle2 := AdomInt(Field(P, 8)) / 1000;+ End+ Else Seg.Kind := ePolySegmentLine;+ BO.Segments[K] := Seg;+ End;+ BO.Rebuild;+ BO.Validate;+ Result := Pts.Count;+ Pts.Free;+End;++{ A library footprint placed by its OWN origin (the library's 0,0), then moved and rotated as a+ whole: the coordinates a layout tool or a reference design gives mean the same here. }+{ A library stores a footprint either around the library's canvas origin (Altium's own editor: about+ 50000 mil out) or around 0,0 (bridge-built footprints once a body was aligned). Measured 2026-10-15:+ one PcbLib held both. The footprint's frame is whichever its pad cluster sits nearer to; the offset+ returned is what to take off every primitive. }+Function FpOffset(PLib : IPCB_Library; LibFp : IPCB_LibComponent; Var OX : Integer; Var OY : Integer) : Boolean;+Var+ GIt : IPCB_GroupIterator;+ Prim : IPCB_Primitive;+ R : TCoordRect;+ CL, CB, CR, CT, CX, CY, LX, LY : Integer;+ AnyP : Boolean;+Begin+ OX := 0; OY := 0; AnyP := False; CL := 0; CB := 0; CR := 0; CT := 0;+ GIt := LibFp.GroupIterator_Create;+ GIt.AddFilter_ObjectSet(MkSet(ePadObject));+ GIt.AddFilter_LayerSet(AllLayers);+ Prim := GIt.FirstPCBObject;+ While Prim <> Nil Do+ Begin+ R := Prim.BoundingRectangle;+ If Not AnyP Then Begin CL := R.Left; CB := R.Bottom; CR := R.Right; CT := R.Top; AnyP := True; End+ Else+ Begin+ If R.Left < CL Then CL := R.Left;+ If R.Bottom < CB Then CB := R.Bottom;+ If R.Right > CR Then CR := R.Right;+ If R.Top > CT Then CT := R.Top;+ End;+ Prim := GIt.NextPCBObject;+ End;+ LibFp.GroupIterator_Destroy(GIt);+ Result := False;+ If Not AnyP Then Exit;+ CX := (CL + CR) Div 2; CY := (CB + CT) Div 2;+ LX := PLib.Board.XOrigin; LY := PLib.Board.YOrigin;+ If (Abs(CX - LX) + Abs(CY - LY)) < (Abs(CX) + Abs(CY)) Then+ Begin+ OX := LX; OY := LY; Result := True;+ End;+End;++Function PlaceByOrigin(Board : IPCB_Board; PLib : IPCB_Library; LibFp : IPCB_LibComponent; X, Y, RotMilli : Integer) : IPCB_Component;+Var+ Inst : IPCB_Component;+ GIt : IPCB_GroupIterator;+ Prim, Cpy : IPCB_Primitive;+ OX, OY : Integer;+Begin+ FpOffset(PLib, LibFp, OX, OY);+ Inst := PCBServer.PCBObjectFactory(eComponentObject, eNoDimension, eCreate_Default);+ Inst.Layer := eTopLayer;+ Inst.NameOn := False;+ Inst.CommentOn := False;+ GIt := LibFp.GroupIterator_Create;+ GIt.AddFilter_LayerSet(AllLayers);+ Prim := GIt.FirstPCBObject;+ While Prim <> Nil Do+ Begin+ Cpy := Prim.Replicate;+ If (OX <> 0) Or (OY <> 0) Then Cpy.MoveByXY(-OX, -OY);+ Inst.AddPCBObject(Cpy);+ Prim := GIt.NextPCBObject;+ End;+ LibFp.GroupIterator_Destroy(GIt);+ Board.AddPCBObject(Inst);+ PCBServer.SendMessageToRobots(Board.I_ObjectAddress, c_Broadcast, PCBM_BoardRegisteration, Inst.I_ObjectAddress);+ Inst.BeginModify;+ Inst.MoveToXY(X, Y);+ Inst.Rotation := RotMilli / 1000;+ Inst.EndModify;+ Result := Inst;+End;++Function FindLibFp(PLib : IPCB_Library; Name : String) : IPCB_LibComponent;+Var+ I : Integer;+ F : IPCB_LibComponent;+Begin+ Result := Nil;+ For I := 0 To PLib.ComponentCount - 1 Do+ Begin+ F := PLib.GetComponent(I);+ If UpperCase(F.Name) = UpperCase(Name) Then Begin Result := F; Break; End;+ End;+End;++Function JoinSemi(L : TStringList) : String;+Var+ I : Integer;+Begin+ Result := '';+ For I := 0 To L.Count - 1 Do+ Begin+ If I > 0 Then Result := Result + ';';+ Result := Result + L[I];+ End;+End;++{ build_board: a NEW .PcbDoc from a parts list and a netlist, saved to pcb=<path>. The PcbLib with+ every footprint must be the CURRENT document. Payload lines, RAW units:+ O|<outline vertices, see SetOutlineFrom>+ C|ref|footprint|x|y|rotMilliDeg|comment+ N|ref|pad|net+ Answers placed, missing footprints, the nets made and pads wired. }+Function CmdBuildBoard(Lines : TStringList; Payload : String) : String;+Var+ PLib : IPCB_Library;+ Board : IPCB_Board;+ SD : IServerDocument;+ Ops, Fps, Assign : TStringList;+ Inst : IPCB_Component;+ LibFp : IPCB_LibComponent;+ Op, PcbPath, Missing, Outline, Refs : String;+ I, K, Placed, Wired, OutN : Integer;+Begin+ PcbPath := Param(Lines, 'pcb');+ PLib := PCBServer.GetCurrentPCBLibrary;+ If PLib = Nil Then Begin Result := '{' + JRaw('ok','false') + ',' + JStr('error','open the PcbLib with every footprint first (it must be the current document)') + '}'; Exit; End;+ If PcbPath = '' Then Begin Result := '{' + JRaw('ok','false') + ',' + JStr('error','pcb (the .PcbDoc path to save) is required') + '}'; Exit; End;+ Ops := TStringList.Create;+ Ops.Text := Payload;+ { every footprint must be in the library before anything is made }+ Fps := TStringList.Create;+ Missing := ''; Outline := '';+ For I := 0 To Ops.Count - 1 Do+ Begin+ Op := Trim(Ops[I]);+ If FieldP(Op, 1) = 'O' Then Outline := FieldP(Op, 2);+ If FieldP(Op, 1) = 'C' Then+ Begin+ If FindLibFp(PLib, FieldP(Op, 3)) = Nil Then Missing := Missing + FieldP(Op, 3) + ' ';+ End;+ End;+ If Missing <> '' Then+ Begin+ Result := '{' + JRaw('ok','false') + ',' + JStr('errorCode','footprint_not_found') + ',' + JStr('error','not in the current PcbLib: ' + Trim(Missing)) + '}';+ Ops.Free; Fps.Free; Exit;+ End;+ SD := CreateNewDocumentFromDocumentKind('PCB');+ If SD = Nil Then Begin Result := '{' + JRaw('ok','false') + ',' + JStr('error','could not create a PCB document') + '}'; Ops.Free; Fps.Free; Exit; End;+ Client.ShowDocument(SD);+ Board := PCBServer.GetCurrentPCBBoard;+ If Board = Nil Then Begin Result := '{' + JRaw('ok','false') + ',' + JStr('error','no current PCB board after create') + '}'; Ops.Free; Fps.Free; Exit; End;+ Placed := 0; Wired := 0; OutN := 0; Refs := '';+ gInputError := '';+ PCBServer.PreProcess;+ If Outline <> '' Then OutN := SetOutlineFrom(Board, Outline);+ For I := 0 To Ops.Count - 1 Do+ Begin+ Op := Trim(Ops[I]);+ If FieldP(Op, 1) <> 'C' Then Continue;+ LibFp := FindLibFp(PLib, FieldP(Op, 3));+ Inst := PlaceByOrigin(Board, PLib, LibFp, AdomInt(FieldP(Op, 4)), AdomInt(FieldP(Op, 5)), AdomInt(FieldP(Op, 6)));+ Inst.Name.Text := FieldP(Op, 2);+ Inst.Pattern := LibFp.Name;+ Inst.Comment.Text := FieldP(Op, 7);+ { the pad assignments for this part, as AssignPadNets takes them }+ Assign := TStringList.Create;+ For K := 0 To Ops.Count - 1 Do+ If (FieldP(Trim(Ops[K]), 1) = 'N') And (FieldP(Trim(Ops[K]), 2) = FieldP(Op, 2)) Then+ Begin+ Assign.Add(FieldP(Trim(Ops[K]), 3) + '=' + FieldP(Trim(Ops[K]), 4));+ Wired := Wired + 1;+ End;+ If Assign.Count > 0 Then+ Begin+ AssignPadNets(Board, Inst, JoinSemi(Assign));+ End;+ Assign.Free;+ Placed := Placed + 1;+ If Refs <> '' Then Refs := Refs + ',';+ Refs := Refs + '"' + JsonEsc(FieldP(Op, 2)) + '"';+ End;+ PCBServer.PostProcess;+ Board.ViewManager_FullUpdate;+ SD.SetFileName(PcbPath);+ SD.DoFileSave('');+ Ops.Free; Fps.Free;+ Result := '{' + JRaw('ok','true') + ',' + JStr('pcb', PcbPath) + ',' + JRaw('placed', IntToStr(Placed)) + ',' + JRaw('padsWired', IntToStr(Wired)) + ',' ++ JRaw('libOriginMil', '[' + IntToStr(Round(CoordToMils(PLib.Board.XOrigin))) + ',' + IntToStr(Round(CoordToMils(PLib.Board.YOrigin))) + ']') + ',' ++ JRaw('outlineVertices', IntToStr(OutN)) + ',' + JRaw('refs', '[' + Refs + ']') + ',' + JRaw('badNumber', JBool(gInputError <> '')) + '}';+ gInputError := '';+End;++{ set_outline: reshape the current board's outline (arcs allowed: rounded corners), one undo step.+ Payload: O|<vertices, see SetOutlineFrom> }+Function CmdSetOutline(Lines : TStringList; Payload : String) : String;+Var+ Board : IPCB_Board;+ N : Integer;+Begin+ Board := PCBServer.GetCurrentPCBBoard;+ If Board = Nil Then Begin Result := '{' + JRaw('ok','false') + ',' + JStr('error','no current PCB board (open a .PcbDoc first)') + '}'; Exit; End;+ PCBServer.PreProcess;+ N := SetOutlineFrom(Board, FieldP(Trim(Payload), 2));+ PCBServer.PostProcess;+ gInputError := '';+ Board.ViewManager_FullUpdate;+ Result := '{' + JRaw('ok', JBool(N >= 3)) + ',' + JRaw('vertices', IntToStr(N)) + '}';+End;++{ set_rule: write ONE design rule kind on the current board, every rule of that kind (each scope+ keeps its own scope; the values are the fab's floor). One kind per call, so a property this+ Altium build does not know fails alone. kind = clearance | width | via | hole | ring |+ holetohole | edge. Values are RAW units: a, b, c (min / preferred / max where they apply),+ d, e, f (via drill min / preferred / max). }+Function CmdSetRule(Lines : TStringList) : String;+Var+ Board : IPCB_Board;+ BIt : IPCB_BoardIterator;+ Rule : IPCB_Rule;+ Kind : String;+ A, B, C, D, E, F, N, Want : Integer;+ Created : Boolean;+Begin+ Board := PCBServer.GetCurrentPCBBoard;+ If Board = Nil Then Begin Result := '{' + JRaw('ok','false') + ',' + JStr('error','no current PCB board (open a .PcbDoc first)') + '}'; Exit; End;+ Created := False;+ Kind := Param(Lines, 'kind');+ A := StrToIntDef(Param(Lines, 'a'), 0); B := StrToIntDef(Param(Lines, 'b'), 0); C := StrToIntDef(Param(Lines, 'c'), 0);+ D := StrToIntDef(Param(Lines, 'd'), 0); E := StrToIntDef(Param(Lines, 'e'), 0); F := StrToIntDef(Param(Lines, 'f'), 0);+ If Kind = 'clearance' Then Want := eRule_Clearance+ Else If Kind = 'width' Then Want := eRule_MaxMinWidth+ Else If Kind = 'via' Then Want := eRule_RoutingViaStyle+ Else If Kind = 'hole' Then Want := eRule_MaxMinHoleSize+ Else If Kind = 'ring' Then Want := eRule_MinimumAnnularRing+ Else If Kind = 'holetohole' Then Want := eRule_HoleToHoleClearance+ Else If Kind = 'edge' Then Want := eRule_BoardOutlineClearance+ Else If Kind = 'polyconnect' Then Want := eRule_PolygonConnectStyle+ Else Begin Result := '{' + JRaw('ok','false') + ',' + JStr('error','unknown rule kind: ' + Kind) + '}'; Exit; End;+ N := 0;+ PCBServer.PreProcess;+ BIt := Board.BoardIterator_Create;+ BIt.AddFilter_ObjectSet(MkSet(eRuleObject));+ BIt.AddFilter_LayerSet(AllLayers);+ BIt.AddFilter_Method(eProcessAll);+ Rule := BIt.FirstPCBObject;+ While Rule <> Nil Do+ Begin+ { polyconnect sets the board's catch-all (All / All) rule only: per-pour rules keep theirs }+ If (Rule.RuleKind = Want) And ((Kind <> 'polyconnect') Or ((Rule.Scope1Expression = 'All') And (Rule.Scope2Expression = 'All'))) Then+ Begin+ Rule.BeginModify;+ If Kind = 'clearance' Then Rule.Gap := A+ Else If Kind = 'width' Then+ Begin+ Rule.MinWidth[eTopLayer] := A; Rule.FavoredWidth[eTopLayer] := B; Rule.MaxWidth[eTopLayer] := C;+ Rule.MinWidth[eBottomLayer] := A; Rule.FavoredWidth[eBottomLayer] := B; Rule.MaxWidth[eBottomLayer] := C;+ End+ Else If Kind = 'via' Then+ Begin+ Rule.MinWidth := A; Rule.PreferedWidth := B; Rule.MaxWidth := C;+ Rule.MinHoleWidth := D; Rule.PreferedHoleWidth := E; Rule.MaxHoleWidth := F;+ End+ Else If Kind = 'hole' Then Begin Rule.MinLimit := A; Rule.MaxLimit := C; End+ Else If Kind = 'ring' Then Rule.Minimum := A+ Else If Kind = 'holetohole' Then Rule.Gap := A+ Else If Kind = 'edge' Then Rule.Gap := A+ Else If Kind = 'polyconnect' Then+ Begin+ { A: 1 direct, 2 none, else relief with B spoke width (raw) and C spokes }+ If A = 1 Then Rule.ConnectStyle := eDirectConnectToPlane+ Else If A = 2 Then Rule.ConnectStyle := eNoConnect+ Else+ Begin+ Rule.ConnectStyle := eReliefConnectToPlane;+ If B > 0 Then Rule.ReliefConductorWidth := B;+ If C > 0 Then Rule.ReliefEntries := C;+ End;+ End;+ Rule.EndModify;+ N := N + 1;+ End;+ Rule := BIt.NextPCBObject;+ End;+ Board.BoardIterator_Destroy(BIt);+ { a board with no rule of this kind (a new board has no annular-ring or edge rule) gets one,+ scoped to everything }+ If N = 0 Then+ Begin+ Rule := PCBServer.PCBRuleFactory(Want);+ Rule.Name := 'Adom ' + Kind;+ Rule.Scope1Expression := 'All';+ Rule.Scope2Expression := 'All';+ If Kind = 'ring' Then Rule.Minimum := A+ Else If (Kind = 'edge') Or (Kind = 'clearance') Or (Kind = 'holetohole') Then Rule.Gap := A+ Else If Kind = 'hole' Then Begin Rule.MinLimit := A; Rule.MaxLimit := C; End;+ Board.AddPCBObject(Rule);+ PCBServer.SendMessageToRobots(Board.I_ObjectAddress, c_Broadcast, PCBM_BoardRegisteration, Rule.I_ObjectAddress);+ N := 1;+ Created := True;+ End;+ PCBServer.PostProcess;+ Result := '{' + JRaw('ok','true') + ',' + JStr('kind', Kind) + ',' + JRaw('rulesWritten', IntToStr(N)) + ',' + JRaw('created', JBool(Created)) + '}';+End;++{ zoom_rect: frame the current board's view on a rectangle, RAW units (l b r t). }+Function CmdZoomRect(Lines : TStringList) : String;+Var+ Board : IPCB_Board;+Begin+ Board := PCBServer.GetCurrentPCBBoard;+ If Board = Nil Then Begin Result := '{' + JRaw('ok','false') + ',' + JStr('error','no current PCB board (open a .PcbDoc first)') + '}'; Exit; End;+ Board.GraphicalView_ZoomOnRect(StrToIntDef(Param(Lines, 'l'), 0), StrToIntDef(Param(Lines, 'b'), 0), StrToIntDef(Param(Lines, 'r'), 0), StrToIntDef(Param(Lines, 't'), 0));+ Result := '{' + JRaw('ok','true') + '}';+End;++{ add_rule: a SCOPED rule (net, net class, any query) the fab-wide set_rule cannot express.+ kind clearance (a = gap) | width (a min, b preferred, c max), raw units; name is 'Adom <name>'+ and a second add with the same name replaces it. Lands at priority 1 (Altium puts a new rule+ first), so it outranks the board's catch-all of its kind. }+Function CmdAddRule(Lines : TStringList) : String;+Var+ Board : IPCB_Board;+ Rule : IPCB_Rule;+ Kind, Name : String;+ A, B, C, Want : Integer;+Begin+ Board := PCBServer.GetCurrentPCBBoard;+ If Board = Nil Then Begin Result := '{' + JRaw('ok','false') + ',' + JStr('error','no current PCB board (open a .PcbDoc first)') + '}'; Exit; End;+ Kind := Param(Lines, 'kind');+ Name := 'Adom ' + Param(Lines, 'name');+ A := StrToIntDef(Param(Lines, 'a'), 0); B := StrToIntDef(Param(Lines, 'b'), 0); C := StrToIntDef(Param(Lines, 'c'), 0);+ If Kind = 'clearance' Then Want := eRule_Clearance+ Else If Kind = 'width' Then Want := eRule_MaxMinWidth+ Else Begin Result := '{' + JRaw('ok','false') + ',' + JStr('error','unknown rule kind: ' + Kind) + '}'; Exit; End;+ PCBServer.PreProcess;+ Rule := FindRuleNamed(Board, Want, Name);+ If Rule <> Nil Then Board.RemovePCBObject(Rule);+ If Param(Lines, 'remove') = '1' Then+ Begin+ PCBServer.PostProcess;+ Result := '{' + JRaw('ok','true') + ',' + JStr('name', Name) + ',' + JRaw('removed', JBool(Rule <> Nil)) + '}';+ Exit;+ End;+ Rule := PCBServer.PCBRuleFactory(Want);+ Rule.Name := Name;+ Rule.Comment := Param(Lines, 'comment');+ Rule.Scope1Expression := Param(Lines, 'scope1');+ Rule.Scope2Expression := Param(Lines, 'scope2');+ If Kind = 'clearance' Then Rule.Gap := A+ Else+ Begin+ Rule.MinWidth[eTopLayer] := A; Rule.FavoredWidth[eTopLayer] := B; Rule.MaxWidth[eTopLayer] := C;+ Rule.MinWidth[eBottomLayer] := A; Rule.FavoredWidth[eBottomLayer] := B; Rule.MaxWidth[eBottomLayer] := C;+ End;+ Board.AddPCBObject(Rule);+ PCBServer.SendMessageToRobots(Board.I_ObjectAddress, c_Broadcast, PCBM_BoardRegisteration, Rule.I_ObjectAddress);+ PCBServer.PostProcess;+ Result := '{' + JRaw('ok','true') + ',' + JStr('name', Name) + ',' + JRaw('priority', IntToStr(Rule.Priority)) + '}';+End;++{ build_schematic: one sheet drawn from a netlist with the project library's symbols. The SchLib+ must be the current document. Each part is a fresh component with its symbol's display-mode-0+ primitives copied in (place_on_sch's measured lane: ISch_Component.Replicate does not carry the+ pins). Parts stand in one column per block, sized from the symbols; every pin on a net gets a+ short wire and a net label at its end, so the sheet compiles to the netlist.+ payload C|ref|libref|block|comment|footprint P|ref|pin|net (param pcblib: the footprints' .PcbLib)+ Coordinates in mil. }+Function LibCmpOf(SLib : ISch_Lib; LibRef : String) : ISch_Component;+Var+ Iter : ISch_Iterator;+ C : ISch_Component;+Begin+ Result := Nil;+ Iter := SLib.SchLibIterator_Create;+ Iter.AddFilter_ObjectSet(MkSet(eSchComponent));+ C := Iter.FirstSchObject;+ While C <> Nil Do+ Begin+ If UpperCase(C.LibReference) = UpperCase(LibRef) Then Begin Result := C; Break; End;+ C := Iter.NextSchObject;+ End;+ SLib.SchIterator_Destroy(Iter);+End;++Function IsDrawn(Prim : ISch_GraphicalObject) : Boolean;+Begin+ Result := ((Prim.ObjectID = ePin) Or (Prim.ObjectID = eRectangle) Or (Prim.ObjectID = eLine) Or+ (Prim.ObjectID = eArc) Or (Prim.ObjectID = eEllipse) Or (Prim.ObjectID = ePolygon) Or+ (Prim.ObjectID = ePolyline) Or (Prim.ObjectID = eLabel)) And (Prim.OwnerPartDisplayMode = 0);+End;++Function CmdBuildSchematic(Lines : TStringList; Payload : String) : String;+Var+ SLib : ISch_Lib;+ LibCmp : ISch_Component;+ Inst : ISch_Component;+ Prim, Cpy : ISch_GraphicalObject;+ PIt : ISch_Iterator;+ Pin : ISch_Pin;+ W : ISch_Wire;+ Impl : ISch_Implementation;+ Prm : ISch_Parameter;+ NL : ISch_NetLabel;+ Lb : ISch_Label;+ SD : IServerDocument;+ Sheet : ISch_Document;+ R : TCoordRect;+ Ops, Refs, LibRefs, Blocks, Comments, Fps, PinNet, BlockOrder, BL, BB, BR, BT, ColOf : TStringList;+ Op, SchPath, Missing, Net, NoNet, Blk : String;+ Cols, Idx : TStringList;+ I, K, N, NWires, NLabels, NPlaced, Col, NCols : Integer;+ BandW : Integer;+ L0, B0, R0, T0, ColW, ColH, ColX, YCur, SheetW, SheetH, DX, DY, HX, HY, EX, EY, Dir, PW, MaxH : Integer;+Begin+ SchPath := Param(Lines, 'sch');+ SLib := SchServer.GetCurrentSchDocument;+ If (SLib = Nil) Or (SLib.ObjectID <> eSchLib) Then+ Begin+ Result := '{' + JRaw('ok','false') + ',' + JStr('error','open the SchLib first (it must be the current document)') + '}';+ Exit;+ End;+ Ops := TStringList.Create; Ops.Text := Payload;+ Refs := TStringList.Create; LibRefs := TStringList.Create; Blocks := TStringList.Create; Comments := TStringList.Create; Fps := TStringList.Create;+ PinNet := TStringList.Create; BlockOrder := TStringList.Create;+ BL := TStringList.Create; BB := TStringList.Create; BR := TStringList.Create; BT := TStringList.Create; ColOf := TStringList.Create;+ Missing := '';+ For I := 0 To Ops.Count - 1 Do+ Begin+ Op := Trim(Ops[I]);+ If FieldP(Op, 1) = 'C' Then+ Begin+ Refs.Add(FieldP(Op, 2)); LibRefs.Add(FieldP(Op, 3)); Blocks.Add(FieldP(Op, 4)); Comments.Add(FieldP(Op, 5)); Fps.Add(FieldP(Op, 6));+ If BlockOrder.IndexOf(FieldP(Op, 4)) < 0 Then BlockOrder.Add(FieldP(Op, 4));+ If LibCmpOf(SLib, FieldP(Op, 3)) = Nil Then Missing := Missing + FieldP(Op, 3) + ' ';+ End+ Else If FieldP(Op, 1) = 'P' Then PinNet.Add(FieldP(Op, 2) + '.' + FieldP(Op, 3) + '=' + FieldP(Op, 4));+ End;+ If Missing <> '' Then+ Begin+ Result := '{' + JRaw('ok','false') + ',' + JStr('error','symbols not in the SchLib: ' + Trim(Missing)) + '}';+ Exit;+ End;+ { measure every part (mil, library frame) }+ For K := 0 To Refs.Count - 1 Do+ Begin+ LibCmp := LibCmpOf(SLib, LibRefs[K]);+ N := 0; L0 := 0; B0 := 0; R0 := 0; T0 := 0;+ PIt := LibCmp.SchIterator_Create;+ Prim := PIt.FirstSchObject;+ While Prim <> Nil Do+ Begin+ If IsDrawn(Prim) Then+ Begin+ R := Prim.BoundingRectangle;+ If (N = 0) Or (Round(CoordToMils(R.Left)) < L0) Then L0 := Round(CoordToMils(R.Left));+ If (N = 0) Or (Round(CoordToMils(R.Bottom)) < B0) Then B0 := Round(CoordToMils(R.Bottom));+ If (N = 0) Or (Round(CoordToMils(R.Right)) > R0) Then R0 := Round(CoordToMils(R.Right));+ If (N = 0) Or (Round(CoordToMils(R.Top)) > T0) Then T0 := Round(CoordToMils(R.Top));+ N := N + 1;+ End;+ Prim := PIt.NextSchObject;+ End;+ LibCmp.SchIterator_Destroy(PIt);+ BL.Add(IntToStr(L0)); BB.Add(IntToStr(B0)); BR.Add(IntToStr(R0)); BT.Add(IntToStr(T0));+ End;+ { columns: one per block, at most 6 parts tall (a taller block wraps into the next column),+ each wide enough for its widest part plus a wire stub and a net label on each side; the+ columns pack left to right into bands at most 22000 mil wide, so the sheet stays landscape+ and a zoom-all keeps the parts readable. ColOf: 'x,width,bandTop,block|i;i;...' }+ Cols := TStringList.Create;+ For Col := 0 To BlockOrder.Count - 1 Do+ Begin+ N := 0; Op := '';+ For K := 0 To Refs.Count - 1 Do+ If Blocks[K] = BlockOrder[Col] Then+ Begin+ If N = 6 Then Begin Cols.Add(BlockOrder[Col] + '|' + Op); Op := ''; N := 0; End;+ If Op <> '' Then Op := Op + ';';+ Op := Op + IntToStr(K);+ N := N + 1;+ End;+ If Op <> '' Then Cols.Add(BlockOrder[Col] + '|' + Op);+ End;+ NCols := Cols.Count;+ { the bands wrap inside the template's own width (its frame and title block stay true) }+ BandW := AdomInt(Param(Lines, 'tplW')) - 600;+ If BandW < 11000 Then BandW := 22600;+ SheetW := 0; MaxH := 0; ColX := 600; YCur := 0; ColH := 0; Blk := '';+ For Col := 0 To NCols - 1 Do+ Begin+ Idx := SplitSemi(FieldP(Cols[Col], 2));+ PW := 0; DY := 0;+ For I := 0 To Idx.Count - 1 Do+ Begin+ K := AdomInt(Idx[I]);+ If AdomInt(BR[K]) - AdomInt(BL[K]) > PW Then PW := AdomInt(BR[K]) - AdomInt(BL[K]);+ DY := DY + (AdomInt(BT[K]) - AdomInt(BB[K])) + 900;+ End;+ Idx.Free;+ ColW := PW + 2000;+ { a new band when this column would run past 22000 mil }+ If (ColX > 600) And (ColX + ColW > BandW) Then+ Begin+ YCur := YCur + ColH + 900;+ ColH := 0;+ ColX := 600;+ End;+ { the block's name heads its first column only }+ If FieldP(Cols[Col], 1) <> Blk Then Op := FieldP(Cols[Col], 1) Else Op := '';+ Blk := FieldP(Cols[Col], 1);+ ColOf.Add(IntToStr(ColX) + ',' + IntToStr(ColW) + ',' + IntToStr(YCur) + ',' + Op + '|' + FieldP(Cols[Col], 2));+ ColX := ColX + ColW;+ If ColX > SheetW Then SheetW := ColX;+ If DY > ColH Then ColH := DY;+ End;+ MaxH := YCur + ColH;+ SheetW := SheetW + 600;+ SheetH := MaxH + 3600;+ If SheetW < 11500 Then SheetW := 11500;+ If SheetH < 7600 Then SheetH := 7600;+ { the sheet is a FILE the bridge put in place (an Altium sheet template copied to sch) and opened+ inside its project: a new document from CreateNewDocumentFromDocumentKind joins whatever project+ is focused (another design's, or the agent's own), measured 2026-10-15 }+ SD := Nil;+ Try+ SD := Client.OpenDocument('SCH', SchPath);+ Except+ SD := Nil;+ End;+ If SD = Nil Then Begin Result := '{' + JRaw('ok','false') + ',' + JStr('error','could not open the sheet ' + SchPath) + '}'; Exit; End;+ Client.ShowDocument(SD);+ Sheet := SchServer.GetCurrentSchDocument;+ If (Sheet = Nil) Or (Sheet.ObjectID = eSchLib) Then Begin Result := '{' + JRaw('ok','false') + ',' + JStr('error','the sheet did not become the current document') + '}'; Exit; End;+ { the template's own size (and title block) when the parts fit it, else a custom size }+ If (AdomInt(Param(Lines, 'tplW')) >= SheetW) And (AdomInt(Param(Lines, 'tplH')) >= SheetH) Then+ Begin+ SheetW := AdomInt(Param(Lines, 'tplW')); SheetH := AdomInt(Param(Lines, 'tplH'));+ End+ Else+ Begin+ Sheet.UseCustomSheet := True;+ Sheet.CustomX := MilsToCoord(SheetW);+ Sheet.CustomY := MilsToCoord(SheetH);+ Sheet.CustomXZones := (SheetW + 2499) Div 2500;+ Sheet.CustomYZones := (SheetH + 2499) Div 2500;+ Sheet.CustomMarginWidth := MilsToCoord(200);+ End;+ NWires := 0; NLabels := 0; NPlaced := 0; NoNet := '';+ For Col := 0 To NCols - 1 Do+ Begin+ ColX := AdomInt(Field(FieldP(ColOf[Col], 1), 1)); ColW := AdomInt(Field(FieldP(ColOf[Col], 1), 2));+ Blk := Field(FieldP(ColOf[Col], 1), 4);+ If Blk <> '' Then+ Begin+ Lb := SchServer.SchObjectFactory(eLabel, eCreate_Default);+ Lb.Location := Point(MilsToCoord(ColX + 300), MilsToCoord(SheetH - 1500 - AdomInt(Field(FieldP(ColOf[Col], 1), 3))));+ Lb.Text := UpperCase(Blk);+ Sheet.RegisterSchObjectInContainer(Lb);+ SchServer.RobotManager.SendMessage(Sheet.I_ObjectAddress, c_BroadCast, SCHM_PrimitiveRegistration, Lb.I_ObjectAddress);+ End;+ { below the template's header strip (its notes and revision block) }+ YCur := SheetH - 2100 - AdomInt(Field(FieldP(ColOf[Col], 1), 3));+ Idx := SplitSemi(FieldP(ColOf[Col], 2));+ For I := 0 To Idx.Count - 1 Do+ Begin+ K := AdomInt(Idx[I]);+ LibCmp := LibCmpOf(SLib, LibRefs[K]);+ L0 := AdomInt(BL[K]); B0 := AdomInt(BB[K]); R0 := AdomInt(BR[K]); T0 := AdomInt(BT[K]);+ { snap to the 100 mil grid so pins land on the grid wires snap to }+ DX := ((ColX + ColW Div 2 - (L0 + R0) Div 2) Div 100) * 100;+ DY := ((YCur - 250 - T0) Div 100) * 100;+ Inst := SchServer.SchObjectFactory(eSchComponent, eCreate_Default);+ Inst.CurrentPartID := 1;+ Inst.DisplayMode := 0;+ Inst.LibReference := LibCmp.LibReference;+ Inst.ComponentDescription := LibCmp.ComponentDescription;+ Inst.Location := Point(MilsToCoord(DX), MilsToCoord(DY));+ PIt := LibCmp.SchIterator_Create;+ Prim := PIt.FirstSchObject;+ While Prim <> Nil Do+ Begin+ If IsDrawn(Prim) Then+ Begin+ Cpy := Prim.Replicate;+ Cpy.OwnerPartId := 1;+ Cpy.OwnerPartDisplayMode := 0;+ Cpy.MoveByXY(MilsToCoord(DX), MilsToCoord(DY));+ Inst.AddSchObject(Cpy);+ End;+ Prim := PIt.NextSchObject;+ End;+ LibCmp.SchIterator_Destroy(PIt);+ { the part's parameters (MPN, Manufacturer, distributor numbers): the BOM reads them from+ here, and copying only the drawing left every number column empty (measured 2026-10-15).+ Designator and Comment are the instance's own. }+ PIt := LibCmp.SchIterator_Create;+ PIt.AddFilter_ObjectSet(MkSet(eParameter));+ Prm := PIt.FirstSchObject;+ While Prm <> Nil Do+ Begin+ If (UpperCase(Prm.Name) <> 'COMMENT') And (UpperCase(Prm.Name) <> 'DESIGNATOR') Then+ Begin+ Cpy := Prm.Replicate;+ Cpy.OwnerPartId := 1;+ Cpy.MoveByXY(MilsToCoord(DX), MilsToCoord(DY));+ Inst.AddSchObject(Cpy);+ End;+ Prm := PIt.NextSchObject;+ End;+ LibCmp.SchIterator_Destroy(PIt);+ { the footprint link the copied primitives do not carry: without it every part compiles+ with 'Footprint ... cannot be found' }+ If Fps[K] <> '' Then+ Begin+ Impl := Inst.AddSchImplementation;+ Impl.ClearAllDatafileLinks;+ Impl.Description := 'Footprint';+ Impl.ModelName := Fps[K];+ Impl.ModelType := 'PCBLIB';+ Impl.AddDataFileLink(Fps[K], Param(Lines, 'pcblib'), 'PCBLIB');+ Impl.IsCurrent := True;+ End;+ Inst.Designator.Text := Refs[K];+ Inst.Designator.IsHidden := False;+ Inst.Designator.Justification := eJustify_BottomCenter;+ Inst.Designator.Location := Point(MilsToCoord(DX + (L0 + R0) Div 2), MilsToCoord(DY + T0 + 80));+ Inst.Comment.Text := Comments[K];+ Inst.Comment.IsHidden := False;+ Inst.Comment.Justification := eJustify_TopCenter;+ Inst.Comment.Location := Point(MilsToCoord(DX + (L0 + R0) Div 2), MilsToCoord(DY + B0 - 80));+ Sheet.RegisterSchObjectInContainer(Inst);+ SchServer.RobotManager.SendMessage(Sheet.I_ObjectAddress, c_BroadCast, SCHM_PrimitiveRegistration, Inst.I_ObjectAddress);+ NPlaced := NPlaced + 1;+ { a wire stub and a net label at every pin's hot spot (Location is the body end) }+ PIt := Inst.SchIterator_Create;+ PIt.AddFilter_ObjectSet(MkSet(ePin));+ Pin := PIt.FirstSchObject;+ While Pin <> Nil Do+ Begin+ Net := PinNet.Values[Refs[K] + '.' + Pin.Designator];+ Dir := Pin.Orientation;+ HX := Pin.Location.X; HY := Pin.Location.Y;+ If Dir = 0 Then HX := HX + Pin.PinLength+ Else If Dir = 1 Then HY := HY + Pin.PinLength+ Else If Dir = 2 Then HX := HX - Pin.PinLength+ Else HY := HY - Pin.PinLength;+ EX := HX; EY := HY;+ If Dir = 0 Then EX := HX + MilsToCoord(200)+ Else If Dir = 1 Then EY := HY + MilsToCoord(200)+ Else If Dir = 2 Then EX := HX - MilsToCoord(200)+ Else EY := HY - MilsToCoord(200);+ If Net = '' Then+ Begin+ If NoNet <> '' Then NoNet := NoNet + ';';+ NoNet := NoNet + Refs[K] + '.' + Pin.Designator;+ End+ Else+ Begin+ W := SchServer.SchObjectFactory(eWire, eCreate_Default);+ W.Location := Point(HX, HY);+ W.InsertVertex := 1;+ W.SetState_Vertex(1, Point(HX, HY));+ W.InsertVertex := 2;+ W.SetState_Vertex(2, Point(EX, EY));+ W.LineWidth := eSmall;+ Sheet.RegisterSchObjectInContainer(W);+ SchServer.RobotManager.SendMessage(Sheet.I_ObjectAddress, c_BroadCast, SCHM_PrimitiveRegistration, W.I_ObjectAddress);+ NWires := NWires + 1;+ NL := SchServer.SchObjectFactory(eNetLabel, eCreate_Default);+ NL.Text := Net;+ NL.Location := Point(EX, EY);+ { the label reads away from the part on every side }+ If (Dir = 1) Or (Dir = 3) Then NL.Orientation := eRotate90;+ If (Dir = 2) Or (Dir = 3) Then NL.Justification := eJustify_BottomRight;+ Sheet.RegisterSchObjectInContainer(NL);+ SchServer.RobotManager.SendMessage(Sheet.I_ObjectAddress, c_BroadCast, SCHM_PrimitiveRegistration, NL.I_ObjectAddress);+ NLabels := NLabels + 1;+ End;+ Pin := PIt.NextSchObject;+ End;+ Inst.SchIterator_Destroy(PIt);+ YCur := DY + B0 - 650;+ End;+ Idx.Free;+ End;+ If Param(Lines, 'title') <> '' Then SetDocParam(Sheet, 'Title', Param(Lines, 'title'));+ If Param(Lines, 'number') <> '' Then SetDocParam(Sheet, 'DocumentNumber', Param(Lines, 'number'));+ If Param(Lines, 'revision') <> '' Then SetDocParam(Sheet, 'Revision', Param(Lines, 'revision'));+ If Param(Lines, 'drawnBy') <> '' Then SetDocParam(Sheet, 'DrawnBy', Param(Lines, 'drawnBy'));+ Sheet.GraphicallyInvalidate;+ ResetParameters; AddStringParameter('Action','All'); RunProcess('Sch:Zoom');+ SD.DoFileSave('');+ Result := '{' + JRaw('ok','true') + ',' + JStr('sch', SchPath) + ',' + JRaw('placed', IntToStr(NPlaced)) + ',' ++ JRaw('wires', IntToStr(NWires)) + ',' + JRaw('labels', IntToStr(NLabels)) + ',' + JStr('pinsWithoutNet', NoNet) + ',' ++ JRaw('sheetMil', '[' + IntToStr(SheetW) + ',' + IntToStr(SheetH) + ']') + ',' + JRaw('columns', IntToStr(NCols)) + '}';+ Ops.Free; Refs.Free; LibRefs.Free; Blocks.Free; Comments.Free; Fps.Free; PinNet.Free; BlockOrder.Free;+ BL.Free; BB.Free; BR.Free; BT.Free; ColOf.Free; Cols.Free;+End;++{ project_nets: the FOCUSED project's compiled netlist, flattened: every net with its pins as+ designator.pin. Compile first (compile verb). }+Function CmdProjectNets(Lines : TStringList) : String;+Var+ WS : IWorkspace;+ Prj : IProject;+ Doc : IDocument;+ Net : INet;+ Pin : IPin;+ I, J : Integer;+ S, P : String;+Begin+ WS := GetWorkspace;+ Prj := Nil;+ If WS <> Nil Then Prj := WS.DM_FocusedProject;+ If Prj = Nil Then Begin Result := '{' + JRaw('ok','false') + ',' + JStr('error','no focused project') + '}'; Exit; End;+ Doc := Prj.DM_DocumentFlattened;+ If Doc = Nil Then Begin Result := '{' + JRaw('ok','false') + ',' + JStr('error','the project has no compiled document: compile it first') + ',' + JStr('project', Prj.DM_ProjectFullPath) + '}'; Exit; End;+ S := '';+ For I := 0 To Doc.DM_NetCount - 1 Do+ Begin+ Net := Doc.DM_Nets(I);+ P := '';+ For J := 0 To Net.DM_PinCount - 1 Do+ Begin+ Pin := Net.DM_Pins(J);+ If P <> '' Then P := P + ',';+ P := P + '"' + JsonEsc(Pin.DM_LogicalPartDesignator + '.' + Pin.DM_PinNumber) + '"';+ End;+ If S <> '' Then S := S + ',';+ S := S + '"' + JsonEsc(Net.DM_NetName) + '":[' + P + ']';+ End;+ Result := '{' + JRaw('ok','true') + ',' + JStr('project', Prj.DM_ProjectFullPath) + ',' + JRaw('netCount', IntToStr(Doc.DM_NetCount)) + ',' + JRaw('nets', '{' + S + '}') + '}';+End;++{ select_net: select every primitive of one net (tracks, arcs, vias, pads, fills, regions, pours) and+ deselect everything else; net empty = clear the selection. Answers the counts by kind. }+Function CmdSelectNet(Lines : TStringList) : String;+Var+ Board : IPCB_Board;+ BIt : IPCB_BoardIterator;+ Prim : IPCB_Primitive;+ NetName, N : String;+ NT, NA, NV, NP, NPoly, NO : Integer;+Begin+ Board := PCBServer.GetCurrentPCBBoard;+ If Board = Nil Then Begin Result := '{' + JRaw('ok','false') + ',' + JStr('error','no current PCB board (open a .PcbDoc first)') + '}'; Exit; End;+ NetName := Param(Lines, 'net');+ NT := 0; NA := 0; NV := 0; NP := 0; NPoly := 0; NO := 0;+ BIt := Board.BoardIterator_Create;+ BIt.AddFilter_ObjectSet(MkSet(eTrackObject, eArcObject, eViaObject, ePadObject, eFillObject, eRegionObject, ePolyObject));+ BIt.AddFilter_LayerSet(AllLayers);+ BIt.AddFilter_Method(eProcessAll);+ Prim := BIt.FirstPCBObject;+ While Prim <> Nil Do+ Begin+ N := '';+ If Prim.Net <> Nil Then N := Prim.Net.Name;+ If (NetName <> '') And (N = NetName) And (Not Prim.InPolygon) Then+ Begin+ Prim.Selected := True;+ If Prim.ObjectId = eTrackObject Then NT := NT + 1+ Else If Prim.ObjectId = eArcObject Then NA := NA + 1+ Else If Prim.ObjectId = eViaObject Then NV := NV + 1+ Else If Prim.ObjectId = ePadObject Then NP := NP + 1+ Else If Prim.ObjectId = ePolyObject Then NPoly := NPoly + 1+ Else NO := NO + 1;+ End+ Else If Prim.Selected Then Prim.Selected := False;+ Prim := BIt.NextPCBObject;+ End;+ Board.BoardIterator_Destroy(BIt);+ Board.ViewManager_FullUpdate;+ Result := '{' + JRaw('ok','true') + ',' + JStr('net', NetName) + ',' + JRaw('selected', IntToStr(NT + NA + NV + NP + NPoly + NO)) + ',' ++ JRaw('byKind', '{' + JRaw('tracks', IntToStr(NT)) + ',' + JRaw('arcs', IntToStr(NA)) + ',' + JRaw('vias', IntToStr(NV)) + ',' ++ JRaw('pads', IntToStr(NP)) + ',' + JRaw('pours', IntToStr(NPoly)) + ',' + JRaw('other', IntToStr(NO)) + '}') + '}';+End;++{ ------------------------------------------------------------- silkscreen --- }++Function TextJson(T : IPCB_Text; Shown : Boolean) : String;+Var+ R : TCoordRect;+Begin+ R := T.BoundingRectangle;+ Result := '[' + '"' + JsonEsc(T.Text) + '",' + IntToStr(T.XLocation) + ',' + IntToStr(T.YLocation) + ',' + IntToStr(T.Size) + ',' ++ IntToStr(Round(T.Rotation)) + ',' + JBool(Shown) + ',[' + IntToStr(R.Left) + ',' + IntToStr(R.Bottom) + ',' + IntToStr(R.Right) + ',' + IntToStr(R.Top) + ']]';+End;++{ silk_state: what the silkscreen planner needs that routing_state does not carry, RAW units.+ comps [["ref","pattern",x,y,rot,bottom,[body l,b,r,t],name,comment]] name/comment: [text,x,y,size,rot,shown,[box]]+ texts [[layer,text,x,y,size,rot,mirror,[box]]] free overlay text (not a component's)+ lines [[layer,x1,y1,x2,y2,width]] free overlay tracks }+Function CmdSilkState(Lines : TStringList) : String;+Var+ Board : IPCB_Board;+ BIt : IPCB_BoardIterator;+ Comp : IPCB_Component;+ Prim : IPCB_Primitive;+ Txt : IPCB_Text;+ Trk : IPCB_Track;+ R : TCoordRect;+ LC, LT, LL : TStringList;+Begin+ Board := PCBServer.GetCurrentPCBBoard;+ If Board = Nil Then Begin Result := '{' + JRaw('ok','false') + ',' + JStr('error','no current PCB board (open a .PcbDoc first)') + '}'; Exit; End;+ LC := TStringList.Create; LT := TStringList.Create; LL := TStringList.Create;+ BIt := Board.BoardIterator_Create;+ BIt.AddFilter_ObjectSet(MkSet(eComponentObject));+ BIt.AddFilter_LayerSet(AllLayers);+ BIt.AddFilter_Method(eProcessAll);+ Comp := BIt.FirstPCBObject;+ While Comp <> Nil Do+ Begin+ R := Comp.BoundingRectangleNoNameComment;+ LC.Add('["' + JsonEsc(Comp.Name.Text) + '","' + JsonEsc(Comp.Pattern) + '",' + IntToStr(Comp.x) + ',' + IntToStr(Comp.y) + ',' ++ IntToStr(Round(Comp.Rotation)) + ',' + JBool(Comp.Layer = eBottomLayer) + ',[' + IntToStr(R.Left) + ',' + IntToStr(R.Bottom) + ',' ++ IntToStr(R.Right) + ',' + IntToStr(R.Top) + '],' + TextJson(Comp.Name, Comp.NameOn) + ',' + TextJson(Comp.Comment, Comp.CommentOn) + ']');+ Comp := BIt.NextPCBObject;+ End;+ Board.BoardIterator_Destroy(BIt);+ BIt := Board.BoardIterator_Create;+ BIt.AddFilter_ObjectSet(MkSet(eTextObject, eTrackObject));+ BIt.AddFilter_LayerSet(MkSet(eTopOverlay, eBottomOverlay));+ BIt.AddFilter_Method(eProcessAll);+ Prim := BIt.FirstPCBObject;+ While Prim <> Nil Do+ Begin+ If (Not Prim.InComponent) And ((Prim.Layer = eTopOverlay) Or (Prim.Layer = eBottomOverlay)) Then+ Begin+ If Prim.ObjectId = eTextObject Then+ Begin+ Txt := Prim; R := Txt.BoundingRectangle;+ LT.Add('[' + IntToStr(Txt.Layer) + ',"' + JsonEsc(Txt.Text) + '",' + IntToStr(Txt.XLocation) + ',' + IntToStr(Txt.YLocation) + ',' ++ IntToStr(Txt.Size) + ',' + IntToStr(Round(Txt.Rotation)) + ',' + JBool(Txt.MirrorFlag) + ',[' + IntToStr(R.Left) + ',' ++ IntToStr(R.Bottom) + ',' + IntToStr(R.Right) + ',' + IntToStr(R.Top) + ']]');+ End+ Else+ Begin+ Trk := Prim;+ LL.Add('[' + IntToStr(Trk.Layer) + ',' + IntToStr(Trk.x1) + ',' + IntToStr(Trk.y1) + ',' + IntToStr(Trk.x2) + ',' + IntToStr(Trk.y2) + ',' + IntToStr(Trk.Width) + ']');+ End;+ End;+ Prim := BIt.NextPCBObject;+ End;+ Board.BoardIterator_Destroy(BIt);+ Result := '{' + JRaw('ok','true') + ',' + JRaw('top', IntToStr(eTopOverlay)) + ',' + JRaw('bottom', IntToStr(eBottomOverlay)) + ',' ++ JRaw('comps', JoinList(LC)) + ',' + JRaw('texts', JoinList(LT)) + ',' + JRaw('lines', JoinList(LL)) + '}';+ LC.Free; LT.Free; LL.Free;+End;++Function CompByRef(Board : IPCB_Board; Ref : String) : IPCB_Component;+Var+ BIt : IPCB_BoardIterator;+ Comp : IPCB_Component;+Begin+ Result := Nil;+ BIt := Board.BoardIterator_Create;+ BIt.AddFilter_ObjectSet(MkSet(eComponentObject));+ BIt.AddFilter_LayerSet(AllLayers);+ BIt.AddFilter_Method(eProcessAll);+ Comp := BIt.FirstPCBObject;+ While Comp <> Nil Do+ Begin+ If Comp.Name.Text = Ref Then Begin Result := Comp; Break; End;+ Comp := BIt.NextPCBObject;+ End;+ Board.BoardIterator_Destroy(BIt);+End;++{ apply_silk: payload lines, RAW units, one undo step.+ N|ref|x|y|size|rot|show the designator (x,y = the text's own location)+ V|ref|x|y|size|rot|show the comment (value)+ T|T or B|text|x|y|size|rot a free overlay text (B = bottom overlay, mirrored)+ L|T or B|x1|y1|x2|y2|width a free overlay line+ DT|layer|text|x|y delete the free overlay text at exactly that spot+ DL|layer|x1|y1|x2|y2 delete the free overlay line with exactly those ends }+Procedure SetText(T : IPCB_Text; X, Y, Sz, Rot : Integer);+Begin+ T.BeginModify;+ T.UseTTFonts := False;+ T.Size := Sz;+ T.Width := Sz Div 7;+ T.Rotation := Rot;+ T.XLocation := X;+ T.YLocation := Y;+ T.EndModify;+ T.GraphicallyInvalidate;+End;++Function CmdApplySilk(Lines : TStringList; Payload : String) : String;+Var+ Board : IPCB_Board;+ Ops : TStringList;+ Op, Kind, Res, Results : String;+ Comp : IPCB_Component;+ Txt : IPCB_Text;+ Trk : IPCB_Track;+ BIt : IPCB_BoardIterator;+ Prim : IPCB_Primitive;+ Lay, I, Okay, Failed : Integer;+Begin+ Board := PCBServer.GetCurrentPCBBoard;+ If Board = Nil Then Begin Result := '{' + JRaw('ok','false') + ',' + JStr('error','no current PCB board (open a .PcbDoc first)') + '}'; Exit; End;+ Ops := TStringList.Create; Ops.Text := Payload;+ Results := ''; Okay := 0; Failed := 0;+ PCBServer.PreProcess;+ For I := 0 To Ops.Count - 1 Do+ Begin+ Op := Trim(Ops[I]);+ If Op = '' Then Continue;+ Kind := FieldP(Op, 1); Res := 'ok'; gInputError := '';+ If (Kind = 'N') Or (Kind = 'V') Then+ Begin+ Comp := CompByRef(Board, FieldP(Op, 2));+ If Comp = Nil Then Res := 'err:no part ' + FieldP(Op, 2)+ Else+ Begin+ Comp.BeginModify;+ If Kind = 'N' Then+ Begin+ Comp.NameAutoPosition := eAutoPos_Manual;+ Comp.NameOn := FieldP(Op, 7) <> '0';+ SetText(Comp.Name, AdomInt(FieldP(Op, 3)), AdomInt(FieldP(Op, 4)), AdomInt(FieldP(Op, 5)), AdomInt(FieldP(Op, 6)));+ End+ Else+ Begin+ Comp.CommentAutoPosition := eAutoPos_Manual;+ Comp.CommentOn := FieldP(Op, 7) <> '0';+ SetText(Comp.Comment, AdomInt(FieldP(Op, 3)), AdomInt(FieldP(Op, 4)), AdomInt(FieldP(Op, 5)), AdomInt(FieldP(Op, 6)));+ End;+ Comp.EndModify;+ Comp.GraphicallyInvalidate;+ End;+ End+ Else If (Kind = 'T') Or (Kind = 'L') Then+ Begin+ If FieldP(Op, 2) = 'B' Then Lay := eBottomOverlay Else Lay := eTopOverlay;+ If Kind = 'T' Then+ Begin+ Txt := PCBServer.PCBObjectFactory(eTextObject, eNoDimension, eCreate_Default);+ Txt.Layer := Lay;+ Txt.Text := FieldP(Op, 3);+ Txt.MirrorFlag := Lay = eBottomOverlay;+ SetText(Txt, AdomInt(FieldP(Op, 4)), AdomInt(FieldP(Op, 5)), AdomInt(FieldP(Op, 6)), AdomInt(FieldP(Op, 7)));+ If gInputError = '' Then RegisterPrim(Board, Txt);+ End+ Else+ Begin+ Trk := PCBServer.PCBObjectFactory(eTrackObject, eNoDimension, eCreate_Default);+ Trk.Layer := Lay;+ Trk.x1 := AdomInt(FieldP(Op, 3)); Trk.y1 := AdomInt(FieldP(Op, 4));+ Trk.x2 := AdomInt(FieldP(Op, 5)); Trk.y2 := AdomInt(FieldP(Op, 6));+ Trk.Width := AdomInt(FieldP(Op, 7));+ If gInputError = '' Then RegisterPrim(Board, Trk);+ End;+ End+ Else If (Kind = 'DT') Or (Kind = 'DL') Then+ Begin+ Res := 'err:not found';+ BIt := Board.BoardIterator_Create;+ If Kind = 'DT' Then BIt.AddFilter_ObjectSet(MkSet(eTextObject)) Else BIt.AddFilter_ObjectSet(MkSet(eTrackObject));+ BIt.AddFilter_LayerSet(MkSet(eTopOverlay, eBottomOverlay));+ BIt.AddFilter_Method(eProcessAll);+ Prim := BIt.FirstPCBObject;+ While Prim <> Nil Do+ Begin+ If (Not Prim.InComponent) And (IntToStr(Prim.Layer) = FieldP(Op, 2)) Then+ Begin+ If Kind = 'DT' Then+ Begin+ Txt := Prim;+ If (Txt.Text = FieldP(Op, 3)) And (IntToStr(Txt.XLocation) = FieldP(Op, 4)) And (IntToStr(Txt.YLocation) = FieldP(Op, 5)) Then Begin Res := 'ok'; Break; End;+ End+ Else+ Begin+ Trk := Prim;+ If (IntToStr(Trk.x1) = FieldP(Op, 3)) And (IntToStr(Trk.y1) = FieldP(Op, 4)) And (IntToStr(Trk.x2) = FieldP(Op, 5)) And (IntToStr(Trk.y2) = FieldP(Op, 6)) Then Begin Res := 'ok'; Break; End;+ End;+ End;+ Prim := BIt.NextPCBObject;+ End;+ Board.BoardIterator_Destroy(BIt);+ If Res = 'ok' Then Board.RemovePCBObject(Prim);+ End+ Else Res := 'err:unknown op ' + Kind;+ If gInputError <> '' Then Res := 'err:bad number ' + gInputError;+ If Copy(Res, 1, 3) = 'err' Then Failed := Failed + 1 Else Okay := Okay + 1;+ If Results <> '' Then Results := Results + ',';+ Results := Results + '"' + JsonEsc(Res) + '"';+ End;+ PCBServer.PostProcess;+ gInputError := '';+ Board.ViewManager_FullUpdate;+ Ops.Free;+ Result := '{' + JRaw('ok','true') + ',' + JRaw('applied', IntToStr(Okay)) + ',' + JRaw('failed', IntToStr(Failed)) + ',' + JRaw('results', '[' + Results + ']') + '}';+End;++{ close_old_agents: every earlier generation of this agent's script project still open in Altium's+ Projects panel (each start opens a new AdomAgent_<gen>.PrjScr; the old ones piled up, ten deep by+ 2026-10-15). The running generation stays. Answers how many it closed. }+Function CloseOldAgents : Integer;+Var+ WS : IWorkspace;+ Prj : IProject;+ Doc : IServerDocument;+ I : Integer;+ N : String;+Begin+ Result := 0;+ WS := GetWorkspace;+ If WS = Nil Then Exit;+ For I := WS.DM_ProjectCount - 1 DownTo 0 Do+ Begin+ Prj := WS.DM_Projects(I);+ If Prj = Nil Then Continue;+ N := Prj.DM_ProjectFullPath;+ If (Pos('\AdomAgent_', N) > 0) And (Pos('.PRJSCR', UpperCase(N)) > 0) And (Pos(AGENT_GEN, N) = 0) Then+ Begin+ Doc := Nil;+ Try+ Doc := Client.GetDocumentByPath(Prj.DM_ProjectFullPath);+ Except+ Doc := Nil;+ End;+ If Doc <> Nil Then+ Begin+ Try+ Client.CloseDocument(Doc);+ Result := Result + 1;+ Except+ End;+ End;+ End;+ End;+End;++Function CmdCloseOldAgents(Lines : TStringList) : String;+Begin+ Result := '{' + JRaw('ok','true') + ',' + JRaw('closed', IntToStr(CloseOldAgents)) + '}';+End;+ Function Dispatch(Cmd : String; Lines : TStringList; Payload : String) : String; Begin If Cmd = 'ping' Then Result := CmdPing(Lines)@@ -4996,6 +6937,23 @@ Begin Else If Cmd = 'read_rules' Then Result := CmdReadRules(Lines) Else If Cmd = 'set_layer_visibility' Then Result := CmdSetLayerVisibility(Lines) Else If Cmd = 'footprint_primitives' Then Result := CmdFootprintPrimitives(Lines)+ Else If Cmd = 'routing_state' Then Result := CmdRoutingState(Lines)+ Else If Cmd = 'edit_board' Then Result := CmdEditBoard(Lines, Payload)+ Else If Cmd = 'apply_pours' Then Result := CmdApplyPours(Lines, Payload)+ Else If Cmd = 'pour_state' Then Result := CmdPourState(Lines)+ Else If Cmd = 'remove_pours' Then Result := CmdRemovePours(Lines, Payload)+ Else If Cmd = 'routing_drc' Then Result := CmdRoutingDrc(Lines)+ Else If Cmd = 'build_board' Then Result := CmdBuildBoard(Lines, Payload)+ Else If Cmd = 'set_outline' Then Result := CmdSetOutline(Lines, Payload)+ Else If Cmd = 'set_rule' Then Result := CmdSetRule(Lines)+ Else If Cmd = 'zoom_rect' Then Result := CmdZoomRect(Lines)+ Else If Cmd = 'add_rule' Then Result := CmdAddRule(Lines)+ Else If Cmd = 'build_schematic' Then Result := CmdBuildSchematic(Lines, Payload)+ Else If Cmd = 'project_nets' Then Result := CmdProjectNets(Lines)+ Else If Cmd = 'select_net' Then Result := CmdSelectNet(Lines)+ Else If Cmd = 'close_old_agents' Then Result := CmdCloseOldAgents(Lines)+ Else If Cmd = 'silk_state' Then Result := CmdSilkState(Lines)+ Else If Cmd = 'apply_silk' Then Result := CmdApplySilk(Lines, Payload) Else If Cmd = 'build_footprint' Then Result := CmdBuildFootprint(Lines) Else If Cmd = 'build_symbol_modes' Then Result := CmdBuildSymbolModes(Lines) Else If Cmd = 'symbol_mode' Then Result := CmdSymbolMode(Lines)
src/bridge.json+104−6@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "altium", "displayName": "Altium Designer",- "version": "0.2.46",+ "version": "0.2.47", "description": "Drive Altium Designer from Adom: a resident DelphiScript agent (eval + the 1,100-command process launcher), the X2 -R command line, UIA over the VCL shell, dialogs by PID, screenshots of every Altium window, and Altium 365.", "homepage": "https://wiki.adom.inc/adom/altium-bridge", "docs": "https://wiki.adom.inc/adom/altium-bridge",@@ -35,15 +35,20 @@ "altium_activate_document", "altium_add_body", "altium_add_models",+ "altium_add_rule", "altium_add_symbol_art", "altium_agent_recover", "altium_agent_start", "altium_agent_status", "altium_agent_stop", "altium_animate_view",+ "altium_apply_pours",+ "altium_apply_silkscreen", "altium_board_shape", "altium_board_summary",+ "altium_build_board", "altium_build_footprint",+ "altium_build_schematic", "altium_build_symbol_modes", "altium_check_dialogs", "altium_cleanup_libs",@@ -64,12 +69,12 @@ "altium_describe", "altium_diagnostics", "altium_dialog_log",- "altium_panel", "altium_dismiss_dialogs", "altium_ensure_licensed", "altium_eval", "altium_export_bom", "altium_export_gerber",+ "altium_export_manufacturing", "altium_export_step", "altium_extension_ping", "altium_extract_intlib",@@ -80,8 +85,6 @@ "altium_install", "altium_install_extension", "altium_install_library",- "altium_list_installed_libraries",- "altium_uninstall_library", "altium_install_library_bundle", "altium_install_receipt", "altium_instances",@@ -92,16 +95,21 @@ "altium_license_status", "altium_list_commands", "altium_list_designators",+ "altium_list_installed_libraries", "altium_list_library", "altium_list_nets",+ "altium_move_parts", "altium_net_connections", "altium_normalize_bodies", "altium_open_document", "altium_open_project",+ "altium_panel", "altium_pcb_report", "altium_place_on_pcb", "altium_place_on_sch",+ "altium_place_vias", "altium_plate_vias",+ "altium_pour_state", "altium_progress", "altium_read_board", "altium_read_rules",@@ -109,10 +117,17 @@ "altium_read_symbol", "altium_readiness", "altium_remove_footprint",+ "altium_remove_items",+ "altium_remove_pours", "altium_remove_symbol", "altium_rename_footprint", "altium_rename_symbol",+ "altium_repour", "altium_review_board",+ "altium_ripup_nets",+ "altium_route_net",+ "altium_routing_drc",+ "altium_routing_state", "altium_rules_by_kind", "altium_run_command", "altium_run_drc",@@ -121,9 +136,12 @@ "altium_run_script", "altium_save", "altium_schematic_data",+ "altium_select_net", "altium_selftest_script_error",+ "altium_set_design_rules", "altium_set_layer_visibility", "altium_set_models",+ "altium_set_outline", "altium_set_params", "altium_set_visibility", "altium_setup_trial",@@ -137,6 +155,7 @@ "altium_show_symbol", "altium_signin", "altium_signin_status",+ "altium_silk_state", "altium_state", "altium_status", "altium_swap_footprint",@@ -144,8 +163,10 @@ "altium_symbol_prims", "altium_test_board", "altium_uninstall",+ "altium_uninstall_library", "altium_window_info",- "altium_xcopy_footprint"+ "altium_xcopy_footprint",+ "altium_zoom_to" ], "statusVerb": "altium_status", "uninstall": "altium_uninstall",@@ -157,6 +178,10 @@ "launch": 240, "agent_start": 120, "run_outjob": 600,+ "silk_state": 180,+ "apply_silkscreen": 600,+ "select_net": 120,+ "export_manufacturing": 600, "export_step": 360, "export_gerber": 300, "run_drc": 300,@@ -170,7 +195,80 @@ "install_extension": 200, "install_library_bundle": 330, "install_library": 150,- "install_receipt": 10+ "install_receipt": 10,+ "read_board": 120,+ "review_board": 120,+ "import_part": 300,+ "test_board": 240,+ "plate_vias": 90,+ "install": 120,+ "selftest_script_error": 180,+ "add_symbol_art": 180,+ "place_on_sch": 90,+ "animate_view": 120,+ "close": 120,+ "board_summary": 120,+ "rules_by_kind": 120,+ "layers_in_use": 120,+ "read_schematic": 90,+ "cross_check": 120,+ "list_designators": 90,+ "component_data": 90,+ "component_pins": 90,+ "list_nets": 90,+ "net_connections": 120,+ "layers": 90,+ "copper_layers": 90,+ "read_rules": 90,+ "routing_state": 180,+ "route_net": 300,+ "move_parts": 300,+ "place_vias": 300,+ "remove_items": 300,+ "ripup_nets": 300,+ "apply_pours": 600,+ "pour_state": 180,+ "remove_pours": 300,+ "repour": 600,+ "routing_drc": 300,+ "build_board": 900,+ "set_outline": 120,+ "set_design_rules": 300,+ "build_schematic": 900,+ "footprint_primitives": 120,+ "schematic_data": 120,+ "show_project": 120,+ "show_schematic": 120,+ "show_2d_board": 120,+ "show_3d_board": 120,+ "show_library": 120,+ "show_footprint": 120,+ "show_3d_chip": 120,+ "board_shape": 120,+ "run_erc": 180,+ "export_bom": 300,+ "create_footprint": 90,+ "create_symbol": 90,+ "build_footprint": 240,+ "build_symbol_modes": 240,+ "add_body": 90,+ "add_models": 120,+ "normalize_bodies": 120,+ "set_params": 90,+ "set_visibility": 90,+ "read_symbol": 90,+ "clone_symbol": 90,+ "rename_symbol": 90,+ "rename_footprint": 90,+ "remove_footprint": 90,+ "copy_footprint": 90,+ "xcopy_footprint": 120,+ "swap_footprint": 240,+ "cleanup_libs": 90,+ "place_on_pcb": 90,+ "ensure_licensed": 300,+ "setup_trial": 300,+ "a365_files_upload": 120 }, "prefixes": [ {
tools/kicad2altium.py+8@@ -13,6 +13,14 @@ Spec lines (pipe-separated): Layers: overlay (F.SilkS) | fab (F.Fab -> Mechanical 13) | courtyard (F.CrtYd -> Mechanical 15) | balls (User.9 -> Mechanical 9 "Adom.SolderBalls") | comment (Cmts.User -> Mechanical 1) """++import sys as _sys+def _usage_guard():+ if len(_sys.argv) < 3 or _sys.argv[1] in ("-h", "--help"):+ print('usage: altium-kicad2altium <part.kicad_mod> <out.spec> [FootprintName] [description]\n Writes the line-oriented footprint spec altium_build_footprint / altium_import_part take (footprintSpec).')+ _sys.exit(0 if len(_sys.argv) > 1 and _sys.argv[1] in ("-h", "--help") else 2)+_usage_guard()+ import re, sys, json LAYER = {"F.SilkS": "overlay", "F.Fab": "fab", "F.CrtYd": "courtyard", "User.9": "balls",
tools/kicad_v10_to_v9.py+8@@ -48,6 +48,14 @@ Validate the result with KiCad 9 itself -- that is the only authority that matte from __future__ import annotations ++import sys as _sys+def _usage_guard():+ if len(_sys.argv) < 2 or _sys.argv[1] in ("-h", "--help"):+ print("usage: altium-kicad-downgrade <in.kicad_sym> [out.kicad_sym]\n Rewrites a KiCad 10 symbol library in KiCad 9 format so Altium's own KiCad importer reads it correctly.")+ _sys.exit(0 if len(_sys.argv) > 1 and _sys.argv[1] in ("-h", "--help") else 2)+_usage_guard()+ import argparse import sys from pathlib import Path
tools/symbol_modes.py+90−1@@ -10,6 +10,14 @@ die in the measured free middle and skip it when the middle is under 300 mil. Spec lines: META|libref|desc|designator MODE|i|name RECT|mode|x1|y1|x2|y2 PIN|mode|number|name|x|y|orient|len LINE|mode|x1|y1|x2|y2 """++import sys as _sys+def _usage_guard():+ if len(_sys.argv) < 5 or _sys.argv[1] in ("-h", "--help"):+ print('usage: altium-symbol-modes <part.kicad_sym> <out.spec> <SymbolName> <description>\n Writes ONE symbol with its display modes as the spec altium_build_symbol_modes / altium_import_part take (symbolSpec).')+ _sys.exit(0 if len(_sys.argv) > 1 and _sys.argv[1] in ("-h", "--help") else 2)+_usage_guard()+ import re, sys, json GROUPS = [@@ -25,7 +33,8 @@ PITCH, PLEN, CW, GAP_EDGE, GAP_MID = 100, 300, 55, 40, 200 def parse_pins(path): s = open(path).read() pins = []- for m in re.finditer(r'\(pin\s+(\w+)\s+(\w+)\s*\(at\s+([-\d.]+)\s+([-\d.]+)\s+(\d+)\)\s*\(length\s+([-\d.]+)\)', s):+ # the angle may be written 180.0 (a pin written that way was dropped, measured 2026-10-15)+ for m in re.finditer(r'\(pin\s+(\w+)\s+(\w+)\s*\(at\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\)\s*\(length\s+([-\d.]+)\)', s): tail = s[m.end():m.end() + 400] name = re.search(r'\(name\s+"([^"]*)"', tail); num = re.search(r'\(number\s+"([^"]*)"', tail) pins.append({"num": num.group(1), "name": name.group(1)})@@ -122,6 +131,79 @@ def chip_lines(body): (px + d, py + d, px - d, py + d), (px - d, py + d, px - d, py - d)] return L ++# ---- native: a small part (a resistor, a capacitor, an inductor, an LED) drawn as its KiCad symbol is,+# not as an IC box. KiCad symbols are y up in mm, as Altium's are; a KiCad pin's (at) is its wire end and+# its angle points toward the body, Altium's Location is the body end and its orientation points away.+import math++def _mil(v): return int(round(float(v) / 0.0254))++def _blocks(text, tag):+ out, i = [], 0+ key = "(" + tag+ while True:+ i = text.find(key, i)+ if i < 0: return out+ if text[i + len(key)] not in " \n\t\r":+ i += 1; continue+ depth, j = 0, i+ while j < len(text):+ if text[j] == "(": depth += 1+ elif text[j] == ")":+ depth -= 1+ if depth == 0: break+ j += 1+ out.append(text[i:j + 1]); i = j + 1++def _xy(block, key):+ m = re.search(r"\(" + key + r"\s+([-\d.]+)\s+([-\d.]+)", block)+ return (float(m.group(1)), float(m.group(2))) if m else None++def native_lines(path):+ """The symbol's own drawing and pins as spec lines (mode 0), or None when it is not a small part."""+ s = open(path).read()+ pins = _blocks(s, "pin")+ if not pins or len(pins) > 3: return None+ g = []+ for r in _blocks(s, "rectangle"):+ a, b = _xy(r, "start"), _xy(r, "end")+ if a and b: g.append("RECT|0|%d|%d|%d|%d" % (_mil(min(a[0], b[0])), _mil(min(a[1], b[1])), _mil(max(a[0], b[0])), _mil(max(a[1], b[1]))))+ for pl in _blocks(s, "polyline"):+ pts = [(_mil(x), _mil(y)) for x, y in re.findall(r"\(xy\s+([-\d.]+)\s+([-\d.]+)\)", pl)]+ for (x1, y1), (x2, y2) in zip(pts, pts[1:]): g.append("LINE|0|%d|%d|%d|%d" % (x1, y1, x2, y2))+ for ar in _blocks(s, "arc"):+ a, m, b = _xy(ar, "start"), _xy(ar, "mid"), _xy(ar, "end")+ if not (a and m and b): continue+ # the circle through the three points; the arc runs counterclockwise from one end through mid+ ax, ay, bx, by, cx_, cy_ = a[0], a[1], m[0], m[1], b[0], b[1]+ d = 2 * (ax * (by - cy_) + bx * (cy_ - ay) + cx_ * (ay - by))+ if abs(d) < 1e-9: g.append("LINE|0|%d|%d|%d|%d" % (_mil(ax), _mil(ay), _mil(cx_), _mil(cy_))); continue+ ux = ((ax*ax + ay*ay) * (by - cy_) + (bx*bx + by*by) * (cy_ - ay) + (cx_*cx_ + cy_*cy_) * (ay - by)) / d+ uy = ((ax*ax + ay*ay) * (cx_ - bx) + (bx*bx + by*by) * (ax - cx_) + (cx_*cx_ + cy_*cy_) * (bx - ax)) / d+ rad = math.hypot(ax - ux, ay - uy)+ ang = lambda px, py: math.degrees(math.atan2(py - uy, px - ux)) % 360+ sa, ma, ea = ang(ax, ay), ang(bx, by), ang(cx_, cy_)+ ccw = (ma - sa) % 360 < (ea - sa) % 360+ a1, a2 = (sa, ea) if ccw else (ea, sa)+ g.append("ARC|0|%d|%d|%d|%d|%d" % (_mil(ux), _mil(uy), _mil(rad), round(a1), round(a2)))+ for c in _blocks(s, "circle"):+ ctr = _xy(c, "center"); rm = re.search(r"\(radius\s+([-\d.]+)", c)+ if ctr and rm: g.append("ARC|0|%d|%d|%d|0|360" % (_mil(ctr[0]), _mil(ctr[1]), _mil(rm.group(1))))+ if not g: return None+ out = []+ for p in pins:+ m = re.search(r"\(at\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\)", p); ln = re.search(r"\(length\s+([-\d.]+)\)", p)+ num = re.search(r'\(number\s+"([^"]*)"', p); nm = re.search(r'\(name\s+"([^"]*)"', p)+ x, y, a = float(m.group(1)), float(m.group(2)), int(round(float(m.group(3)))) % 360+ L = float(ln.group(1)) if ln else 2.54+ dx, dy = {0: (1, 0), 90: (0, 1), 180: (-1, 0), 270: (0, -1)}.get(a, (1, 0))+ orient = {0: "left", 90: "down", 180: "right", 270: "up"}.get(a, "left")+ name = nm.group(1) if nm and nm.group(1) != "~" else ""+ out.append("PIN|0|%s|%s|%d|%d|%s|%d|hide" % (num.group(1) if num else "?", name, _mil(x + dx * L), _mil(y + dy * L), orient, _mil(L)))+ ref = re.search(r'\(property\s+"Reference"\s+"([A-Za-z]+)', s)+ return (ref.group(1) if ref else "U"), g + out+ def build(pins, libref, desc, out): looks = [("std", lambda p, g, c: two_column(p, g, c, wrap=True), False), ("2col", two_column, False), ("4side", four_side, False),@@ -144,5 +226,12 @@ def build(pins, libref, desc, out): if __name__ == "__main__": pins = parse_pins(sys.argv[1])+ nat = native_lines(sys.argv[1])+ if nat:+ # one mode, the part as its symbol draws it (a resistor reads as a resistor)+ des, body = nat+ open(sys.argv[2], "w").write("\n".join([f"META|{sys.argv[3]}|{sys.argv[4]}|{des}", "MODE|0|native"] + body) + "\n")+ print(json.dumps({"pins": len(pins), "modes": 1, "native": True, "lines": len(body) + 2, "out": sys.argv[2]}))+ sys.exit(0) modes, n = build(pins, sys.argv[3], sys.argv[4], sys.argv[2]) print(json.dumps({"pins": len(pins), "modes": modes, "lines": n, "out": sys.argv[2]}))