← Commit history

0.2.70 source (not yet released, needs signing): export_pdf assembly and fabrication drawings, board_features keepouts, add_rule polyconnect, recover activates parked scripts with OpenObject; 181 verbs; batteries 180/0 on AD 26.11.1 and Altium Designer Develop

Colby Knox ·14b1d12781 ·17h ago ·parent b694ed2
17 files changed +161−37
CHANGELOG.md+9
@@ -1,5 +1,14 @@ # Changelog +## 0.2.70 (2026-10-23)+**Drawings, keepouts and pour connections.**+- `altium_export_pdf {assembly, fabrication}`: Altium's own Assembly Drawings (top and bottom) and PCB Print fabrication drawing from its OutJob templates, in the same PDF as the sheets. Measured: a 6-page PDF of the battery project in 26 s.+- `altium_board_features {keepouts}`: keepout rectangles on the Keep-Out layer (every layer) or as a keepout fill on one copper layer; stitching vias in the same call keep out of them (50 planned around two keepouts, none inside).+- `altium_add_rule {kind:"polyconnect"}`: how one net's pads join pours (direct, relief with width and entries, or none), read back from Altium's descriptor.+- `recover` brings a parked agent script forward with Altium's own OpenObject when its file exists (no UI touched), and clicks its tab only when a redeploy deleted the file.+- Test points: Altium's scripting API reads a pad's test-point flags (fab and assembly, top and bottom) but refuses to write them (a runtime error, measured on Develop), so setting test points stays in Altium's UI.+- Verb battery: `export_pdf` with both drawings, and `board_features` with a keepout.+ ## 0.2.69 (2026-10-23) **The user gets Altium back (adom/altium-bridge#27), and ODB++ / IPC-2581.** - The agent loop leaves 15 s after the last request (it was 5 minutes). While it runs, Altium ignores the user's clicks: Barrett handed Altium back after a few verbs and his user sat at a frozen window until `altium_agent_stop`. Restarting costs 2 to 4 s on the next verb (measured 1.8 s).
README.md+3−3
@@ -242,7 +242,7 @@ All 181 verbs, by area (prefix `altium_`). `altium_describe` returns the live ca - `altium_route_net`: Route one net as tracks, segment by segment; a waypoint on another layer drops a via there. - `altium_move_parts`: Move and rotate placed components; each part is read back to 0.001 mm and the exact rotation. - `altium_place_vias`: Place vias (through, top to bottom).-- `altium_board_features`: Add the furniture every real board needs in one undo step: mounting holes (at points or the four corners), fiducials, and a stitching-via grid on a net that keeps clear of every other net's copper, every pad and the board edge.+- `altium_board_features`: Add the furniture every real board needs in one undo step: mounting holes (at points or the four corners), fiducials, keepout areas, and a stitching-via grid on a net that keeps clear of every other net's copper, every pad and the board edge. - `altium_sch_sheet_symbol`: Make a design hierarchical: a sheet symbol on the parent sheet for a child sheet, its entries joined to the parent's nets, and matching ports on the child. - `altium_netlist`: The compiled netlist of a project: every net with its pins (Designator.Pin) across every sheet, the hierarchy resolved; or one net; or compare with the nets you expect. - `altium_silkscreen_prep`: Clean the silkscreen off the copper with Altium's own Tools > Silkscreen Preparation: silk clipped clear of every mask opening (pad) by the board's silk rule, designators moved off pads where they fit, silk outside the board deleted; the silkscreen DRC hits counted before and after.@@ -260,7 +260,7 @@ All 181 verbs, by area (prefix `altium_`). `altium_describe` returns the live ca - `altium_set_outline`: Reshape the current board's outline (a rectangle with rounded corners, or a polygon). - `altium_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). - `altium_zoom_to`: Frame the PCB view on a rectangle, a part (or several) or a whole net, with a margin.-- `altium_add_rule`: Add one SCOPED design rule that set_design_rules' board-wide values cannot express: a net's or class's clearance or width, a length limit, a matched-length group, a differential pair's width and gap, or a routing via style.+- `altium_add_rule`: Add one SCOPED design rule that set_design_rules' board-wide values cannot express: a net's or class's clearance or width, a length limit, a matched-length group, a differential pair's width and gap, a routing via style, or how a net's pads join a pour (direct, relief, none). - `altium_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. - `altium_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). - `altium_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.@@ -303,7 +303,7 @@ All 181 verbs, by area (prefix `altium_`). `altium_describe` returns the live ca - `altium_annotate`: Number every '?' designator (R?, C?, U?) across the project's sheets: the lowest free number per prefix, top-left first on each sheet. - `altium_doc_params`: Read or set a sheet's document parameters: the title block (Title, DocumentNumber, Revision, DrawnBy, Author, CompanyName, ...). - `altium_update_pcb`: Design > Update PCB Document from the schematic (the engineering change order): parts added, removed or renamed, nets and footprints updated on the board.-- `altium_export_pdf`: Every sheet of a project as one PDF (and the board's composite print with board:true), from an OutJob in a throwaway project; the user's project is not touched.+- `altium_export_pdf`: Every sheet of a project as one PDF, plus on request the board's composite print, Altium's assembly drawings (top and bottom, designators on the parts) and its fabrication print, from an OutJob in a throwaway project; the user's project is not touched. - `altium_sch_zoom`: Find parts on a sheet: select them (everything else deselected), frame the view on them and return a proof frame. - `altium_autoroute`: Altium's own autorouter (Situs) on the current board: picks a strategy, keeps existing routing locked by default, routes, and answers the unconnected count and DRC errors before and after. - `altium_save`: Save the focused document, or open filePath first and save that (WorkspaceManager:SaveObject on the focused document).
bridge-rs/src/features.rs+25−7
@@ -61,14 +61,14 @@ fn obstacles(state: &Value, net: &str) -> (Vec<Ob>, Vec<(f64, f64)>) { /// has, at most `max`. Pure, for the tests. #[allow(clippy::too_many_arguments)] pub fn plan_stitching(state: &Value, net: &str, pitch: f64, size: f64, clearance: f64, edge: f64, region: Option<&[(f64, f64)]>, max: usize) -> Vec<(f64, f64)> {-    plan_stitching_with(state, net, pitch, size, clearance, edge, region, max, &[], true)+    plan_stitching_with(state, net, pitch, size, clearance, edge, region, max, &[], true, &[]) }  /// plan_stitching, also clear of `extra` discs (x, y, radius: holes and fiducials placed in the same call) and, with /// `avoid_parts`, out from under every part (the box around its pads, grown by the clearance). #[allow(clippy::too_many_arguments)] pub fn plan_stitching_with(state: &Value, net: &str, pitch: f64, size: f64, clearance: f64, edge: f64, region: Option<&[(f64, f64)]>, max: usize,-                           extra: &[(f64, f64, f64)], avoid_parts: bool) -> Vec<(f64, f64)> {+                           extra: &[(f64, f64, f64)], avoid_parts: bool, keepouts: &[(f64, f64, f64, f64)]) -> Vec<(f64, f64)> {     let outline: Vec<(f64, f64)> = state["outline"].as_array().cloned().unwrap_or_default().iter().map(pt).collect();     if outline.len() < 3 || pitch <= 0.0 { return vec![]; }     let area: Vec<(f64, f64)> = region.map(|r| r.to_vec()).unwrap_or_else(|| outline.clone());@@ -100,7 +100,8 @@ pub fn plan_stitching_with(state: &Value, net: &str, pitch: f64, size: f64, clea                     Ob::Disc(cx, cy, cr) => ((x - cx).powi(2) + (y - cy).powi(2)).sqrt() - cr - r >= clearance,                 })                 && same.iter().all(|(sx, sy)| ((x - sx).powi(2) + (y - sy).powi(2)).sqrt() >= pitch / 2.0)-                && boxes.values().all(|b| x + r + clearance <= b.0 || x - r - clearance >= b.2 || y + r + clearance <= b.1 || y - r - clearance >= b.3);+                && boxes.values().all(|b| x + r + clearance <= b.0 || x - r - clearance >= b.2 || y + r + clearance <= b.1 || y - r - clearance >= b.3)+                && keepouts.iter().all(|k| x + r <= k.0 || x - r >= k.2 || y + r <= k.1 || y - r >= k.3);             if ok { out.push(((x * 1000.0).round() / 1000.0, (y * 1000.0).round() / 1000.0)); }             x += pitch;         }@@ -180,6 +181,22 @@ pub fn board_features(args: &Value) -> Value {         fid_list.push(json!({"x": x, "y": y, "size": size, "opening": opening, "side": if layer == BOTTOM { "bottom" } else { "top" }}));     }     if !fid_list.is_empty() { planned.insert("fiducials".into(), json!(fid_list.clone())); }+    // keepouts: rectangles where nothing goes, on every layer (Keep-Out layer) or one copper layer+    let mut ko_list = Vec::new();+    let mut ko_rects: Vec<(f64, f64, f64, f64)> = Vec::new();+    for k in args.get("keepouts").and_then(|v| v.as_array()).cloned().unwrap_or_default() {+        let Some(r) = k.get("rect").and_then(|r| r.as_array()).filter(|a| a.len() == 4) else {+            return json!({"success": false, "errorCode": "bad_args", "error": "each keepout needs rect: [x1, y1, x2, y2] (mm)"});+        };+        let v: Vec<f64> = r.iter().map(|x| x.as_f64().unwrap_or(0.0)).collect();+        let (x1, y1, x2, y2) = (v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3]));+        let layer_name = k.get("layer").and_then(|l| l.as_str()).unwrap_or("all").to_string();+        let layer = if layer_name.eq_ignore_ascii_case("all") { 0 } else { match crate::routing::layer_id(&json!(layer_name)) { Some(l) => l, None => return json!({"success": false, "errorCode": "bad_args", "error": format!("keepout layer {layer_name} is not all, F.Cu, B.Cu or In<n>.Cu")}) } };+        ops.push(format!("K|{}|{}|{}|{}|{}", raw(x1), raw(y1), raw(x2), raw(y2), layer));+        ko_rects.push((x1, y1, x2, y2));+        ko_list.push(json!({"rect": [x1, y1, x2, y2], "layer": layer_name}));+    }+    if !ko_list.is_empty() { planned.insert("keepouts".into(), json!(ko_list)); }     // stitching: a via grid on one net, clear of everything else     if let Some(sv) = args.get("stitching") {         let Some(net) = sv.get("net").and_then(|n| n.as_str()) else {@@ -196,12 +213,12 @@ pub fn board_features(args: &Value) -> Value {         let mut extra: Vec<(f64, f64, f64)> = hole_list.iter().map(|h| (num(h, "x", 0.0), num(h, "y", 0.0), num(h, "pad", 3.2) / 2.0)).collect();         extra.extend(fid_list.iter().map(|fd| (num(fd, "x", 0.0), num(fd, "y", 0.0), num(fd, "opening", 2.0) / 2.0)));         let avoid_parts = sv.get("underParts").and_then(|b| b.as_bool()) != Some(true);-        let pts = plan_stitching_with(&st, net, pitch, size, clearance, edge, region.as_deref(), max, &extra, avoid_parts);+        let pts = plan_stitching_with(&st, net, pitch, size, clearance, edge, region.as_deref(), max, &extra, avoid_parts, &ko_rects);         for (x, y) in &pts { ops.push(format!("V|{}|{}|{}|{}|{}|{}|{}", raw(*x), raw(*y), raw(size), raw(drill), TOP, BOTTOM, net)); }         planned.insert("stitching".into(), json!({"net": net, "pitch": pitch, "size": size, "drill": drill, "clearance": clearance, "edge": edge, "count": pts.len(), "points": summary(&pts)}));     }     if ops.is_empty() {-        return json!({"success": false, "errorCode": "nothing_to_do", "error": "pass mountingHoles, fiducials and/or stitching",+        return json!({"success": false, "errorCode": "nothing_to_do", "error": "pass mountingHoles, fiducials, keepouts and/or stitching",             "_hint": "mountingHoles: [{x,y,drill?,plated?,pad?,net?}] or {corners:{inset:4}, drill:3.2}; fiducials: [{x,y,size?:1,opening?:2,side?}]; stitching: {net, pitch?:5, size?:0.6, drill?:0.3, clearance?:0.3, edge?:1, region?:[[x,y]..], max?:400}"});     }     if args.get("dryRun").and_then(|b| b.as_bool()) == Some(true) {@@ -252,9 +269,10 @@ mod tests {         // a part U1 whose two pads span x 6..9 at y 7.5: (7.5, 7.5) lies between them, under its body         b["pads"] = json!([{"ref": "U1", "x": 6.0, "y": 7.5, "sizeX": 0.6, "sizeY": 0.6, "drill": 0.0, "net": "SIG"},                            {"ref": "U1", "x": 9.0, "y": 7.5, "sizeX": 0.6, "sizeY": 0.6, "drill": 0.0, "net": "SIG"}]);-        let p = plan_stitching_with(&b, "GND", 5.0, 0.6, 0.3, 0.5, None, 100, &[(12.5, 2.5, 1.6)], true);+        let p = plan_stitching_with(&b, "GND", 5.0, 0.6, 0.3, 0.5, None, 100, &[(12.5, 2.5, 1.6)], true, &[(15.0, 0.0, 20.0, 10.0)]);         assert!(!p.contains(&(7.5, 7.5)) && !p.contains(&(12.5, 2.5)));-        let p = plan_stitching_with(&b, "GND", 5.0, 0.6, 0.3, 0.5, None, 100, &[], false);+        assert!(p.iter().all(|q| q.0 < 15.0), "the keepout at x 15..20 takes no via");+        let p = plan_stitching_with(&b, "GND", 5.0, 0.6, 0.3, 0.5, None, 100, &[], false, &[]);         assert!(p.contains(&(7.5, 7.5)));     } 
bridge-rs/src/lifecycle.rs+13−1
@@ -728,8 +728,20 @@ fn recover_impl(reason: &str, verify_by_restart: bool) -> Value {         .and_then(|r| r.get("matches").and_then(|m| m.as_array()).cloned()).unwrap_or_default().iter()         .filter_map(|m| m.get("name").and_then(|n| n.as_str())).filter(|n| n.to_lowercase().ends_with(".pas")).map(str::to_string).collect();     if let (Some(exe), Some(hw)) = (x2_exe(), main.filter(|_| !open_tabs.is_empty())) {+        // brought forward through Altium's own OpenObject when the script's file exists (no UI touched); a tab whose+        // file a redeploy deleted (an older agent generation) can only be reached by its tab         for name in &open_tabs {-            ab::call("desktop_ui_click", json!({"hwnd": hw, "name": name, "role": "button", "reason": "bring an agent script to the front so EditScript:Stop reaches it"}), &[], 15);+            let path = agent::workspace_dir().join(name);+            if path.exists() {+                let arg = format!("-RWorkspaceManager:OpenObject(ObjectKind=Document|FileName=\"{}\")", path.to_string_lossy());+                if let Ok(mut child) = spawn_helper(&exe, &arg) {+                    let _ = wait_helper(&mut child, Duration::from_secs(8));+                    let _ = child.kill();+                    let _ = child.wait();+                }+            } else {+                ab::call("desktop_ui_click", json!({"hwnd": hw, "name": name, "role": "button", "reason": "bring a parked agent script whose file is gone to the front so EditScript:Stop reaches it"}), &[], 15);+            }             std::thread::sleep(Duration::from_millis(600));             if let Ok(mut child) = spawn_helper(&exe, "-REditScript:Stop") {                 let _ = wait_helper(&mut child, Duration::from_secs(8));
bridge-rs/src/main.rs+1−1
@@ -42,7 +42,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.69";+pub const BRIDGE_VERSION: &str = "0.2.70";  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.rs+2−2
@@ -8,7 +8,7 @@ use crate::outputs;   /// INI as Altium writes it: [sections] of key=value lines, in order.-fn parse_ini(text: &str) -> Vec<(String, Vec<(String, String)>)> {+pub(crate) 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');@@ -22,7 +22,7 @@ fn parse_ini(text: &str) -> Vec<(String, Vec<(String, String)>)> { }  /// 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)>> {+pub(crate) 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();
bridge-rs/src/routing.rs+7−2
@@ -1122,7 +1122,7 @@ pub fn add_rule(args: &Value) -> Value {     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" | "length" | "matched" | "diffpair" | "via") { return err("bad_args", "remove needs kind and name".into(), ""); }+        if name.is_empty() || !matches!(kind.as_str(), "clearance" | "width" | "length" | "matched" | "diffpair" | "via" | "polyconnect") { 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 };     }@@ -1152,7 +1152,12 @@ pub fn add_rule(args: &Value) -> Value {             let v = |k: &str, d: f64| raw(mm(k).unwrap_or(d)).to_string();             p.extend([("a", v("minSize", size)), ("b", raw(size).to_string()), ("c", v("maxSize", size)), ("d", v("minDrill", drill)), ("e", raw(drill).to_string()), ("g", v("maxDrill", drill))]);         }-        _ => return err("bad_args", format!("kind \"{kind}\" is not clearance, width, length, matched, diffpair or via"), ""),+        "polyconnect" => {+            let style = st("style").to_lowercase();+            let a = match style.as_str() { "direct" => 1, "none" => 2, "relief" | "" => 0, _ => return err("bad_args", "polyconnect style is direct, relief or none".into(), "") };+            p.extend([("a", a.to_string()), ("b", raw(mm("reliefWidth").unwrap_or(0.0)).to_string()), ("c", args.get("reliefEntries").and_then(|v| v.as_i64()).unwrap_or(0).to_string())]);+        }+        _ => return err("bad_args", format!("kind \"{kind}\" is not clearance, width, length, matched, diffpair, via or polyconnect"), ""),     }     let r = agent::call("add_rule", &p, None, Duration::from_secs(60));     match r.get("data") {
bridge-rs/src/sch.rs+32−4
@@ -532,7 +532,10 @@ fn eco_rows(tree: &Value) -> Vec<String> { }  /// The OutJob for a schematic PDF (all the project's sheets, optionally a board composite print).-pub fn pdf_outjob(file_name: &str, board: bool) -> String {+pub fn pdf_outjob(file_name: &str, board: bool) -> String { pdf_outjob_with(file_name, board, &[]) }++/// The PDF OutJob, with more outputs (already numbered from 3) appended: Altium's own Assembly and PCB Print outputs.+pub fn pdf_outjob_with(file_name: &str, board: bool, extra: &[Vec<(String, String)>]) -> String {     let page = "Record=PageOptions|CenterHorizontal=True|CenterVertical=True|PrintScale=1.00|XCorrection=1.00|YCorrection=1.00|PrintKind=0|BorderSize=5000000|LeftOffset=0|BottomOffset=0|Orientation=2|PaperLength=1000|PaperWidth=1000|Scale=100|PaperSource=7|PrintQuality=-4|MediaType=1|DitherType=10|PaperKind=A4|PrintScaleMode=1";     let sch_view = "Record=SchPrintView|ShowNoERC=True|ShowParamSet=True|ShowProbe=True|ShowBlanket=True|ShowNote=True|ShowNoteCollapsed=True|ExpandDesignator=True|ExpandNetLabel=False|ExpandPort=False|ExpandSheetNum=False|ExpandDocNum=False|PrintArea=0";     let mut l: Vec<String> = ["[OutputJobFile]", "Version=1.0", "Caption=", "Description=Adom altium_export_pdf", "",@@ -557,6 +560,7 @@ pub fn pdf_outjob(file_name: &str, board: bool) -> String {             l.push(format!("Configuration2_Item{}=CArc=Full|CFill=Full|Comment=Full|Coordinate=Full|CPad=Full|CRegion=Full|CText=Full|CTrack=Full|CVia=Full|DDSymbolKind=0|DDSymbolSize=500000|DDSymbolSortKind=0|Designator=Full|Dimension=Full|DLayer1=TopLayer|DLayer2=BottomLayer|FArc=Full|FFill=Full|FPad=Full|FRegion=Full|FText=Full|FTrack=Full|FVia=Full|Layer={layer}|Polygon=Full|PrintOutIndex=0|Record=PcbPrintLayer", i + 3));         }     }+    for o in extra { for (k, v) in o { l.push(format!("{k}={v}")); } }     l.extend(["".into(), "[PublishSettings]".into(), format!("OutputFilePath1=\\{file_name}"), "ReleaseManaged1=0".into(), "OutputBasePath1=".into(),         "OutputPathMedia1=".into(), "OutputPathMediaValue1=".into(), "OutputPathOutputer1=".into(), "OutputPathOutputerPrefix1=".into(), "OutputPathOutputerValue1=".into(),         format!("OutputFileName1={file_name}"), "OutputFileNameMulti1=".into(), "UseOutputNameForMulti1=0".into(), "OutputFileNameSpecial1=".into(),@@ -578,7 +582,31 @@ pub fn export_pdf(args: &Value) -> Value {     if project.sheets.is_empty() { return json!({"success": false, "errorCode": "no_sheets", "error": "the project lists no sheet"}); }     let stem = Path::new(&prj).file_stem().map(|x| x.to_string_lossy().to_string()).unwrap_or("schematic".into());     let out = s(args, "output").unwrap_or_else(|| dir.join(format!("{stem}.pdf")).to_string_lossy().to_string());-    let with_board = args.get("board").and_then(|b| b.as_bool()) == Some(true) && !project.boards.is_empty();+    let want = |k: &str| args.get(k).and_then(|b| b.as_bool()) == Some(true);+    let drawings = want("assembly") || want("fabrication");+    if drawings && project.boards.is_empty() { return json!({"success": false, "errorCode": "no_board", "error": "assembly and fabrication drawings need a board in the project"}); }+    let with_board = (want("board") || drawings) && !project.boards.is_empty();+    // Altium's own drawing outputs, from its OutJob templates: Assembly (top and bottom assembly drawings) and PCB+    // Print (the fabrication print), numbered after the schematic (1) and the composite (2)+    let board_name = project.boards.first().map(|b| Path::new(b).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default()).unwrap_or_default();+    let mut extra: Vec<Vec<(String, String)>> = Vec::new();+    for (flag, tpl, kind) in [("assembly", "Assembly.OutJob", "Assembly"), ("fabrication", "Fabrication.OutJob", "PCB Print")] {+        if !want(flag) { continue; }+        let ini = match support::altium_public_file(&format!(r"OutputJobs\{tpl}")).and_then(|p| std::fs::read_to_string(&p).map_err(|e| format!("{p}: {e}"))) {+            Ok(t) => crate::manufacturing::parse_ini(&t),+            Err(e) => return json!({"success": false, "errorCode": "no_templates", "error": e}),+        };+        let n = 3 + extra.len();+        let Some(mut o) = crate::manufacturing::take_output(&ini, kind, n) else {+            return json!({"success": false, "errorCode": "template_output_missing", "error": format!("{tpl} has no {kind} output")});+        };+        for (k, v) in o.iter_mut() {+            if k == &format!("OutputDocumentPath{n}") { *v = board_name.clone(); }+            if k == &format!("OutputEnabled{n}") { *v = "1".into(); }+        }+        o.push((format!("OutputEnabled{n}_OutputMedium1"), n.to_string()));+        extra.push(o);+    }     for sh in &project.sheets { let _ = crate::surfaces::save(&json!({"filePath": sh})); }     let tmp = std::env::temp_dir().join("adom-altium-pdf").join(format!("{}", (support::now() * 1000.0) as u64));     let proj = tmp.join("AdomPdfExport.PrjPcb");@@ -589,7 +617,7 @@ pub fn export_pdf(args: &Value) -> Value {     for (i, d) in docs.iter().enumerate() { prj_text.push_str(&format!("[Document{}]\r\nDocumentPath={}\r\nAnnotationEnabled=1\r\n\r\n", i + 1, d)); }     prj_text.push_str(&format!("[Document{}]\r\nDocumentPath=AdomPdf.OutJob\r\n\r\n", docs.len() + 1));     let file_name = "AdomExport.pdf";-    if let Err(e) = std::fs::create_dir_all(&tmp).and_then(|_| std::fs::write(&oj, pdf_outjob(file_name, with_board))).and_then(|_| std::fs::write(&proj, prj_text)) {+    if let Err(e) = std::fs::create_dir_all(&tmp).and_then(|_| std::fs::write(&oj, pdf_outjob_with(file_name, with_board, &extra))).and_then(|_| std::fs::write(&proj, prj_text)) {         return json!({"success": false, "errorCode": "temp_write_failed", "error": e.to_string()});     }     let proj_s = proj.to_string_lossy().to_string();@@ -605,7 +633,7 @@ pub fn export_pdf(args: &Value) -> Value {     if let Err(e) = std::fs::copy(&src, &out) { return json!({"success": false, "errorCode": "copy_failed", "error": e.to_string(), "produced": src.to_string_lossy()}); }     let bytes = std::fs::metadata(&out).map(|m| m.len()).unwrap_or(0);     let _ = std::fs::remove_dir_all(&tmp);-    json!({"success": bytes > 0, "pdfPath": out, "outputBytes": bytes, "sheets": project.sheets.len(), "board": with_board, "healthCheck": r.get("healthCheck"), "elapsedSec": support::elapsed_round(t0),+    json!({"success": bytes > 0, "pdfPath": out, "outputBytes": bytes, "sheets": project.sheets.len(), "board": with_board, "assembly": want("assembly"), "fabrication": want("fabrication"), "healthCheck": r.get("healthCheck"), "elapsedSec": support::elapsed_round(t0),         "windowEtiquette": "background", "_hint": "One PDF of every sheet (and the board's composite print with board:true). Pull it with ab's pull_file."}) } 
bridge-rs/src/verbs.rs+7−7
@@ -462,8 +462,8 @@ fn describe() -> Value {             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, except an Adom edge rule an earlier bridge scoped to All, which becomes OnCopper (#26)."], 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 that set_design_rules' board-wide values cannot express: a net's or class's clearance or width, a length limit, a matched-length group, a differential pair's width and gap, or a routing via style.",-            json!({"kind": "clearance|width|length|matched|diffpair|via", "name": "string - the rule is named 'Adom <name>'; the same name replaces it", "scope1": "Altium query, e.g. InNet('SW'), InNetClass('DDR_D0'), InDifferentialPair('USB')", "scope2": "query (default All; clearance only)", "gap": "mm (clearance; diffpair: the pair's gap)", "min": "mm (width; length)", "preferred": "mm (width, optional)", "max": "mm (width, optional; length: required)", "tolerance": "mm (matched)", "width": "mm (diffpair)", "minWidth": "diffpair, optional", "maxWidth": "diffpair, optional", "minGap": "diffpair, optional", "maxGap": "diffpair, optional", "maxUncoupled": "mm, diffpair, optional", "size": "mm (via)", "drill": "mm (via)", "minSize": "via, optional", "maxSize": "via, optional", "minDrill": "via, optional", "maxDrill": "via, optional", "comment": "optional", "remove": "bool - delete the Adom rule of this kind and name instead"}),+        verb_doc("add_rule", "Add one SCOPED design rule that set_design_rules' board-wide values cannot express: a net's or class's clearance or width, a length limit, a matched-length group, a differential pair's width and gap, a routing via style, or how a net's pads join a pour (direct, relief, none).",+            json!({"kind": "clearance|width|length|matched|diffpair|via|polyconnect", "name": "string - the rule is named 'Adom <name>'; the same name replaces it", "scope1": "Altium query, e.g. InNet('SW'), InNetClass('DDR_D0'), InDifferentialPair('USB')", "scope2": "query (default All; clearance, and polyconnect: the polygon, e.g. InPolygon('GND pour'))", "style": "polyconnect: direct | relief | none", "reliefWidth": "mm, polyconnect relief spokes", "reliefEntries": "2 | 4, polyconnect relief", "gap": "mm (clearance; diffpair: the pair's gap)", "min": "mm (width; length)", "preferred": "mm (width, optional)", "max": "mm (width, optional; length: required)", "tolerance": "mm (matched)", "width": "mm (diffpair)", "minWidth": "diffpair, optional", "maxWidth": "diffpair, optional", "minGap": "diffpair, optional", "maxGap": "diffpair, optional", "maxUncoupled": "mm, diffpair, optional", "size": "mm (via)", "drill": "mm (via)", "minSize": "via, optional", "maxSize": "via, optional", "minDrill": "via, optional", "maxDrill": "via, 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})),@@ -748,8 +748,8 @@ fn describe() -> Value {             &["sch_connect", "compile", "schematic_data", "build_schematic"],             &["Altium never places a sheet entry by itself (its position stays 0,0 and it draws nowhere): the bridge puts each one on the symbol's edge, 100 mil apart.", "Ports already on the child (by name) are reported in alreadyThere, not doubled."],             json!({"sheet": "C:\\work\\Top.SchDoc", "child": "Power.SchDoc", "projectPath": "C:\\work\\Board.PrjPcb", "entries": [{"name": "VIN", "io": "input", "net": "VBUS"}, {"name": "3V3", "io": "output", "net": "3V3"}, {"name": "GND", "io": "passive", "net": "GND"}]})),-        verb_doc("board_features", "Add the furniture every real board needs in one undo step: mounting holes (at points or the four corners), fiducials, and a stitching-via grid on a net that keeps clear of every other net's copper, every pad and the board edge.",-            json!({"mountingHoles": "[{x, y, drill?: 3.2, plated?: false, pad?: drill+2.8 when plated, net?}] or {corners: {inset: 4}, drill?, plated?, net?}", "fiducials": "[{x, y, size?: 1, opening?: 2 (mask opening diameter), side?: top|bottom}]", "stitching": "{net (required), pitch?: 5, size?: 0.6, drill?: 0.3, clearance?: 0.3, edge?: 1 (from the outline), region?: [[x,y],..], max?: 400, underParts?: false (vias stay out of each part's pad box)}", "dryRun": "bool - plan only", "filePath": "the .PcbDoc (opened first; expectPath is opened too)", "expectPath": "optional", "expectedRevision": "optional"}),+        verb_doc("board_features", "Add the furniture every real board needs in one undo step: mounting holes (at points or the four corners), fiducials, keepout areas, and a stitching-via grid on a net that keeps clear of every other net's copper, every pad and the board edge.",+            json!({"mountingHoles": "[{x, y, drill?: 3.2, plated?: false, pad?: drill+2.8 when plated, net?}] or {corners: {inset: 4}, drill?, plated?, net?}", "fiducials": "[{x, y, size?: 1, opening?: 2 (mask opening diameter), side?: top|bottom}]", "keepouts": "[{rect: [x1, y1, x2, y2] mm, layer?: all (the Keep-Out layer, default) | F.Cu | B.Cu | In<n>.Cu}] - stitching in the same call keeps out of them", "stitching": "{net (required), pitch?: 5, size?: 0.6, drill?: 0.3, clearance?: 0.3, edge?: 1 (from the outline), region?: [[x,y],..], max?: 400, underParts?: false (vias stay out of each part's pad box)}", "dryRun": "bool - plan only", "filePath": "the .PcbDoc (opened first; expectPath is opened too)", "expectPath": "optional", "expectedRevision": "optional"}),             json!({"planned": "{mountingHoles, fiducials, stitching:{count, points}}", "applied": "int", "results": "[ok|err:..] per item", "state": "routing_state after"}), 180,             "dryRun first to see the stitching grid. After stitching, altium_repour so the net's pours join through the vias, then altium_run_drc.",             &["place_vias", "repour", "run_drc", "routing_state"],@@ -833,9 +833,9 @@ fn describe() -> Value {             &["annotate", "sch_place_part", "move_parts", "routing_drc"],             &["Save the sheets first (the sch_* verbs save by default).", "The project must contain a .PcbDoc."],             json!({"projectPath": "C:/proj/board.PrjPcb"})),-        verb_doc("export_pdf", "Every sheet of a project as one PDF (and the board's composite print with board:true), from an OutJob in a throwaway project; the user's project is not touched.",-            json!({"projectPath": "path to the .PrjPcb (required)", "output": "path of the .pdf, default <project>.pdf beside it", "board": "bool, default false", "timeout": "seconds, default 300"}),-            json!({"pdfPath": "path", "outputBytes": "int", "sheets": "int"}), 600,+        verb_doc("export_pdf", "Every sheet of a project as one PDF, plus on request the board's composite print, Altium's assembly drawings (top and bottom, designators on the parts) and its fabrication print, from an OutJob in a throwaway project; the user's project is not touched.",+            json!({"projectPath": "path to the .PrjPcb (required)", "output": "path of the .pdf, default <project>.pdf beside it", "board": "bool, default false - the composite print", "assembly": "bool, default false - Altium's Assembly Drawings output (needs a board in the project)", "fabrication": "bool, default false - Altium's PCB Print fabrication drawing", "timeout": "seconds, default 300"}),+            json!({"pdfPath": "path", "outputBytes": "int", "sheets": "int", "board": "bool", "assembly": "bool", "fabrication": "bool"}), 600,             "Pull the file with ab's pull_file.",             &["run_outjob", "export_manufacturing"], &[],             json!({"projectPath": "C:/proj/board.PrjPcb"})),
dev-skills/altium-bridge-test/run_verb_tests.py+6−3
@@ -270,8 +270,9 @@ def matrix(w):     # 0.2.66: board furniture after the placement checks (the vias would crowd find_space): 2 mm corner holes (under the     # fab hole rule), one fiducial, at most 4 GND stitching vias; every item must land     M["board_features"] = dict(phase=3.5, timeout=200, **ok(expectPath=LB, mountingHoles={"corners": {"inset": 2}, "drill": 2.0},-                                                            fiducials=[{"x": 20, "y": 17}], stitching={"net": "GND", "pitch": 5, "max": 4}),-                               check=lambda d: (d.get("applied") or 0) == 9 and d.get("failed") == 0)+                                                            fiducials=[{"x": 20, "y": 17}], keepouts=[{"rect": [34, 9, 37, 11]}],+                                                            stitching={"net": "GND", "pitch": 5, "max": 4}),+                               check=lambda d: (d.get("applied") or 0) == 10 and d.get("failed") == 0)      # ---- phase 3.5 (0.2.56-0.2.58): BOM and schematic editing on the lane project ----------------     # build_schematic made LP with LS (U1, U2) and LB. A third part goes on the sheet as U?, gets a@@ -297,7 +298,9 @@ def matrix(w):     M["update_pcb"] = dict(phase=3.5, timeout=650, **ok(projectPath=LP, execute=True, timeout=600),                            check=lambda d: "U3" in ((d.get("preview") or {}).get("addParts") or []) and d.get("executed") is True                                            and not any("BatteryPwr" in (r.get("change") or "") and r.get("enabled") for g in d.get("changeOrder") or [] for r in g.get("rows") or []))-    M["export_pdf"] = dict(phase=3.5, timeout=650, **ok(projectPath=LP, output=D + "/lane.pdf", timeout=600), check=lambda d: (d.get("outputBytes") or 0) > 0)+    # 0.2.70: with Altium's assembly drawings and fabrication print beside the sheets+    M["export_pdf"] = dict(phase=3.5, timeout=650, **ok(projectPath=LP, output=D + "/lane.pdf", assembly=True, fabrication=True, timeout=600),+                           check=lambda d: (d.get("outputBytes") or 0) > 0 and d.get("assembly") is True and d.get("fabrication") is True)     M["autoroute"] = dict(phase=3.5, timeout=650, **ok(filePath=LB, strategy="Default 2 Layer Board", timeout=600))     # 0.2.68: on the autorouted board: routed lengths with a matched group, then Altium's teardrops     M["net_lengths"] = dict(phase=3.5, **ok(filePath=LB, groups=[{"nets": ["N1", "GND"], "toleranceMm": 1}]),
package.json+6−2
@@ -1,6 +1,6 @@ {   "slug": "altium-bridge",-  "version": "0.2.69",+  "version": "0.2.70",   "type": "app",   "description": "Let an AI (Claude, ChatGPT, Codex or any agent) run Altium Designer for you: create symbols and footprints, place parts (also from Altium 365), wire and annotate schematics, Update PCB, set the stack-up and rules, route, pour copper, add mounting holes and stitching vias, run ERC and DRC, and make Gerbers, BOMs and STEP. Free in Hydrogen; bring your own AI and Altium licence.",   "tags": [@@ -159,7 +159,11 @@     "altium skew",     "altium odb++ export",     "altium ipc-2581 export",-    "export odb++ from altium"+    "export odb++ from altium",+    "altium assembly drawing pdf",+    "altium fabrication drawing",+    "altium keepout",+    "altium polygon connect style rule"   ],   "discovery_pitch": "An AI that runs Altium Designer in the user's own session: libraries, schematics, Update PCB, stack-up, routing, DRC, Gerbers and BOMs.",   "sample_prompts": [
page.json+7−3
@@ -4,7 +4,7 @@   "type": "app",   "title": "Altium Bridge: AI for Altium Designer",   "brief": "Let an AI (Claude, ChatGPT, Codex or any agent) run Altium Designer for you: create symbols and footprints, place parts (also from Altium 365), wire and annotate schematics, Update PCB, set the stack-up and rules, route, pour copper, add mounting holes and stitching vias, run ERC and DRC, and make Gerbers, BOMs and STEP. Free in Hydrogen; bring your own AI and Altium licence.",-  "version": "0.2.69",+  "version": "0.2.70",   "hero": {     "type": "image",     "path": "screenshots/hero.png"@@ -151,7 +151,11 @@     "altium skew",     "altium odb++ export",     "altium ipc-2581 export",-    "export odb++ from altium"+    "export odb++ from altium",+    "altium assembly drawing pdf",+    "altium fabrication drawing",+    "altium keepout",+    "altium polygon connect style rule"   ],   "discovery_pitch": "An AI that runs Altium Designer in the user's own session: libraries, schematics, Update PCB, stack-up, routing, DRC, Gerbers and BOMs.",   "metadata": {},@@ -206,4 +210,4 @@     "tools/symbol_modes.py",     "uninstall.sh"   ]-}\ No newline at end of file+}
skills/altium-boards/SKILL.md+7
@@ -266,6 +266,8 @@ altium_add_rule '{"kind":"diffpair","name":"USB","scope1":"InDifferentialPair(\' altium_add_rule '{"kind":"via","name":"Small","scope1":"InNetClass(\'Signal\')","size":0.5,"drill":0.25}' altium_component_classes '{"name":"CH1","members":["U1","R1","C1"],"room":true}'      # a class and a room fitted round it altium_teardrops '{"filePath":"C:\\work\\Board.PcbDoc"}'                           # Altium's Tools > Teardrops; remove:true takes them off+altium_add_rule '{"kind":"polyconnect","name":"GND direct","scope1":"InNet(\'GND\')","style":"direct"}'   # how a net's pads join pours (0.2.70)+altium_board_features '{"keepouts":[{"rect":[20,2,26,8]},{"rect":[20,12,26,18],"layer":"F.Cu"}]}'      # keepout areas (0.2.70) ```  - `net_lengths` sums each net's routed tracks and arcs (per layer, vias counted), the skew of every differential pair@@ -276,6 +278,11 @@ altium_teardrops '{"filePath":"C:\\work\\Board.PcbDoc"}'   uncoupled length, set on every layer) and `via` (size and drill) answer Altium's own `descriptor` for the rule. - `component_classes` makes a class from designators and a room (a confinement rule, 'Adom room <name>') around   them; scope rules with InComponentClass('<name>'). Without `name` it lists the classes with their members.+- `add_rule {kind:"polyconnect"}` sets how one net's pads and vias join pours: `style` direct, relief (`reliefWidth`,+  `reliefEntries` 2|4) or none; scope2 can name the pour (InPolygon). The board-wide default stays with+  `set_design_rules {polygonConnect}`.+- `board_features {keepouts}` draws keepout rectangles: on the Keep-Out layer (every layer, the default) or as a keepout+  fill on one copper layer; stitching vias in the same call keep out of them. - `teardrops` runs Altium's Tools > Teardrops with its dialog answered in the background (vias, SMD pads, tracks,   T-junctions; all or selected) and counts teardrops before and after. Measured: 0 to 12 on the battery board, and   `remove:true` back to 0. Repour and DRC afterwards.
skills/altium-outputs/SKILL.md+7
@@ -68,6 +68,13 @@ Altium need not be running. Run one container with `altium_run_outjob {outjobPat - OutJob→PDF was blocked in the predecessor bridge; on this one run it through   `altium_run_outjob` and verify the files[] like any medium. +## Assembly and fabrication drawings (0.2.70)++`altium_export_pdf {projectPath, assembly: true, fabrication: true}` adds Altium's own Assembly Drawings output (top and+bottom, parts with their designators) and its PCB Print fabrication drawing to the PDF of the sheets, taken from the+Assembly and Fabrication OutJob templates the install ships. Measured on the battery project: 6 pages (2 sheets, the+composite, the drawings) in 26 s. `board: true` alone adds just the composite print.+ ## ODB++ and IPC-2581 (0.2.69)  `altium_export_manufacturing {projectPath, pcbPath, formats: ["odb", "ipc2581"]}` writes them beside the Gerbers, drill,
src/BRIDGE_VERSION+1−1
@@ -1 +1 @@-0.2.69+0.2.70
src/agent/AdomAgent.pas.tmpl+27
@@ -6041,6 +6041,7 @@ Var     Via   : IPCB_Via;     Pad   : IPCB_Pad;     PC    : TPadCache;+    Fl    : IPCB_Fill;     Comp  : IPCB_Component;     Prim  : IPCB_Primitive;     Net   : IPCB_Net;@@ -6125,6 +6126,18 @@ Begin                 If gInputError <> '' Then Res := 'err:bad number ' + gInputError Else RegisterPrim(Board, Pad);             End;         End+        Else If Kind = 'K' Then+        Begin+            { a keepout rectangle: K|x1|y1|x2|y2|layer. Layer 0 = the Keep-Out layer (every layer); a copper layer id+              = a keepout fill on that layer only (IsKeepout). Measured on Develop 2026-10-23. }+            Fl := PCBServer.PCBObjectFactory(eFillObject, eNoDimension, eCreate_Default);+            Fl.X1Location := AdomInt(FieldP(Op, 2)); Fl.Y1Location := AdomInt(FieldP(Op, 3));+            Fl.X2Location := AdomInt(FieldP(Op, 4)); Fl.Y2Location := AdomInt(FieldP(Op, 5));+            Fl.Rotation := 0;+            If AdomInt(FieldP(Op, 6)) = 0 Then Fl.Layer := eKeepOutLayer+            Else Begin Fl.Layer := AdomInt(FieldP(Op, 6)); Fl.IsKeepout := True; End;+            If gInputError <> '' Then Res := 'err:bad number ' + gInputError Else RegisterPrim(Board, Fl);+        End         Else If Kind = 'M' Then         Begin             Comp := FindCompByDes(Board, FieldP(Op, 2));@@ -7042,6 +7055,7 @@ Begin     Else If Kind = 'matched' Then Want := eRule_MatchedLengths     Else If Kind = 'diffpair' Then Want := eRule_DifferentialPairsRouting     Else If Kind = 'via' Then Want := eRule_RoutingViaStyle+    Else If Kind = 'polyconnect' Then Want := eRule_PolygonConnectStyle     Else Begin Result := '{' + JRaw('ok','false') + ',' + JStr('error','unknown rule kind: ' + Kind) + '}'; Exit; End;     PCBServer.PreProcess;     Rule := FindRuleNamed(Board, Want, Name);@@ -7071,6 +7085,19 @@ Begin         End;         If U > 0 Then Rule.MaxUncoupledLength := U;     End+    Else If Kind = 'polyconnect' Then+    Begin+        { a = style (0 relief, 1 direct, 2 none), b = relief conductor width, c = relief entries; the same properties+          set_design_rules' board-wide polygon connect uses }+        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     Else If Kind = 'via' Then     Begin         Rule.MinWidth := A; Rule.PreferedWidth := B; Rule.MaxWidth := C;
src/bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "altium",   "displayName": "Altium Designer",-  "version": "0.2.69",+  "version": "0.2.70",   "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",