← Commit history

0.2.68 source (not yet released, needs signing): altium_net_lengths (length per net, pair skew, matched groups), add_rule kinds length/matched/diffpair/via, altium_component_classes (classes with rooms), altium_teardrops (Altium's Tools > Teardrops); 181 verbs; batteries 180/0 on AD 26.11.1 and Altium Designer Develop

Colby Knox ·edfac8b8a8 ·19h ago ·parent 251e581
16 files changed +507−23
CHANGELOG.md+8
@@ -1,5 +1,13 @@ # Changelog +## 0.2.68 (2026-10-23)+**Length, rules, rooms and teardrops.**+- New verb `altium_net_lengths`: each net's routed length (tracks and arcs, per layer, vias counted), the skew of every differential pair the board defines, and matched-length groups against a tolerance (spread, longest net, how short each net is). Measured on the battery board, and Altium's own Matched Lengths DRC agreed with it.+- `altium_add_rule` kinds `length`, `matched`, `diffpair` (width and gap on every layer, maximum uncoupled length) and `via` (size and drill), each answering Altium's own descriptor. A diff pair's widths and gaps are per layer: setting only the top layer moved only the descriptor's minimum (measured on Develop).+- New verb `altium_component_classes`: list, make or remove component classes, each with an optional room (a confinement rule fitted around the members' pads, or a rectangle) for channel layouts.+- New verb `altium_teardrops`: Altium's own Tools > Teardrops, its dialog answered in the background (add or remove; vias, SMD pads, tracks, T-junctions; all or selected), teardrops counted before and after. Its Add/Remove and All/Selected radio buttons take a UIA select, not a click (a click answered false and left the dialog on Add).+- Verb battery: `net_lengths`, `component_classes` (with a room) and `teardrops` on the lane board. 181 verbs.+ ## 0.2.67 (2026-10-23) **Hierarchical schematics and the netlist.** - New verb `altium_sch_sheet_symbol`: a sheet symbol on a parent sheet for a child sheet, one entry per signal joined to a parent net (a stub and a net label), and a port per entry on the child. A child that does not exist yet is made from Altium's ANSI B template and added to the project (its open members saved and closed first, so Altium never asks to reload). Altium never positions an entry added through the API (it stays at 0,0 and draws nowhere, measured on Develop); the bridge puts each one on the symbol's edge.
README.md+10−7
@@ -15,7 +15,7 @@ normal session. Ask in plain words and it: - makes the outputs: Gerbers, NC drill, pick and place, BOMs grouped by MPN (JLCPCB, Mouser, DigiKey files),   schematic PDF and STEP, checking that the outline and every copper layer reached the Gerbers -178 commands, tested on Altium Designer 26.11 and Altium Designer Develop. It runs in **Hydrogen**, Adom's free+181 commands, tested on Altium Designer 26.11 and Altium Designer Develop. It runs in **Hydrogen**, Adom's free desktop app, through Adom Bridge. You bring your own AI subscription and your own Altium licence.  ::youtube{id="dFecpcIzdSE" title="An AI designs a 12 V to 5 V buck converter in Altium Designer"}@@ -29,7 +29,7 @@ and the verb prefix `altium_`.  ## What it can do -Tested on Altium Designer 26.11.1 and Altium Designer Develop. 178 verbs; each row names the ones+Tested on Altium Designer 26.11.1 and Altium Designer Develop. 181 verbs; each row names the ones behind it (prefix `altium_`).  | Area | What the AI can do | Verbs |@@ -40,10 +40,10 @@ behind it (prefix `altium_`). | Schematics | Draw a schematic from a netlist, place library parts, wire pins (wire, net label or power port), move, rotate, rename and delete parts, swap a part for another without breaking its connections, annotate, fill the title block, cross-probe, hierarchical sheets (sheet symbols with entries, ports on the child), the compiled netlist checked net by net | `build_schematic`, `sch_place_part`, `sch_connect`, `sch_edit`, `sch_swap_part`, `annotate`, `doc_params`, `sch_zoom`, `sch_sheet_symbol`, `netlist` | | Checks | Compile and ERC with the Messages panel read back, DRC with each violation's location, a 14-check design review, schematic-to-board cross-check | `compile`, `run_erc`, `run_drc`, `routing_drc`, `review_board`, `cross_check` | | Schematic to board | Update PCB with the whole change order previewed row by row first, keeping board-only parts, net classes and rules | `update_pcb` |-| Board setup | Build a board from a netlist, shape the outline, edit the stack-up (add planes and inner layers, set copper, dielectrics and total thickness), write a fab's design rules, add scoped rules, net classes and differential pairs | `build_board`, `set_outline`, `board_outline`, `layer_stack`, `set_design_rules`, `add_rule`, `net_classes`, `diff_pairs` |+| Board setup | Build a board from a netlist, shape the outline, edit the stack-up (add planes and inner layers, set copper, dielectrics and total thickness), write a fab's design rules, add scoped rules, net classes and differential pairs, length limits, matched-length groups, differential-pair width and gap, via styles, component classes with rooms | `build_board`, `set_outline`, `board_outline`, `layer_stack`, `set_design_rules`, `add_rule`, `net_classes`, `diff_pairs`, `component_classes` | | Board furniture | Mounting holes (at points or the corners), fiducials with a mask opening and no paste, a stitching-via grid that keeps clear of other nets, pads and parts | `board_features` | | Placement | Move and rotate parts or shift them by an offset (read back to 0.001 mm), find free space for a part near another, suggest rotations that shorten connections, copy one channel's layout to the others, swap footprints, read or set the selection | `move_parts`, `find_space`, `check_orientation`, `replicate_layout`, `swap_footprint`, `selection` |-| Routing and copper | Route a net with waypoints and vias, run Altium's autorouter, rip up nets, resize routed tracks, pour polygons with keepouts, repour, place vias, clean up the silkscreen | `route_net`, `autoroute`, `ripup_nets`, `set_track_width`, `apply_pours`, `repour`, `place_vias`, `apply_silkscreen` |+| Routing and copper | Route a net with waypoints and vias, run Altium's autorouter, rip up nets, resize routed tracks, pour polygons with keepouts, repour, place vias, clean up the silkscreen, add teardrops, measure routed length per net and the skew of each pair | `route_net`, `autoroute`, `ripup_nets`, `set_track_width`, `apply_pours`, `repour`, `place_vias`, `apply_silkscreen`, `teardrops`, `net_lengths` | | BOM | Bill of materials straight from Altium, grouped by MPN with a variant applied, JLCPCB / Mouser / DigiKey files, ordering gaps flagged; write parameters back to the sheets; assembly variants | `bom`, `sheet_params`, `variants` | | Manufacturing outputs | Gerbers, NC drill, pick and place and BOM in one call, checking that the outline and every copper layer reached the Gerbers; any OutJob, and read an OutJob's containers and settings; the schematic as a PDF; STEP; silkscreen cleaned off the pads first with Altium's Silkscreen Preparation | `export_manufacturing`, `run_outjob`, `outjob_info`, `export_pdf`, `export_step`, `silkscreen_prep` | | Reading the design | The whole board as data in mm, schematic parts with pins, rules, layers, poured copper, silkscreen, every part parameter in the project | `read_board`, `routing_state`, `schematic_data`, `part_parameters`, `read_rules`, `pour_state`, `silk_state`, `pcb_report` |@@ -66,7 +66,7 @@ Current, heat and EMC analysis of an Altium board runs through [AI Flow](https:/  Altium Designer is the user's own **host app**. The bridge finds an installed Altium (`%ProgramFiles%\Altium\AD*\X2.EXE`, Designer or Develop), starts it in the background, runs a small-**DelphiScript agent** inside it, and gives the AI 178 `altium_*` verbs.+**DelphiScript agent** inside it, and gives the AI 181 `altium_*` verbs.  - **Page and docs:** https://wiki.adom.inc/adom/altium-bridge - **Bridge SDK:** https://wiki.adom.inc/adom/adom-bridge-sdk@@ -166,7 +166,7 @@ way to read it). A stopped engine is restarted with `altium_agent_recover`.  <!-- verbs:start (tools/readme_verbs.py writes this list) --> -All 178 verbs, by area (prefix `altium_`). `altium_describe` returns the live catalog with every input, output, hint and example.+All 181 verbs, by area (prefix `altium_`). `altium_describe` returns the live catalog with every input, output, hint and example.  ### Running Altium: launch, close, health, dialogs @@ -246,6 +246,9 @@ All 178 verbs, by area (prefix `altium_`). `altium_describe` returns the live ca - `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.+- `altium_net_lengths`: Routed length of every net (or the ones named) on the live board, by layer with its vias; the skew inside each differential pair; and the spread of matched-length groups against a tolerance.+- `altium_teardrops`: Add (or remove) teardrops with Altium's own Tools > Teardrops: on vias and through-hole pads, SMD pads, track width changes and T-junctions, for the whole board or the selection; teardrops counted before and after.+- `altium_component_classes`: List, make or remove component classes on the board, each optionally with a room: a placement area that keeps its parts together (fitted around the parts or a rectangle). - `altium_remove_items`: Delete specific free tracks and vias, given exactly as altium_routing_state returned them. - `altium_ripup_nets`: Delete every free track, arc and via on each named net. - `altium_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.@@ -257,7 +260,7 @@ All 178 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 (a net's clearance or width, a net class's, any Altium query) that set_design_rules' board-wide values cannot express.+- `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_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.
SKILL.md+2
@@ -36,6 +36,8 @@ Say **altium** to reach this bridge. Every verb returns `success`, a stable `err | Find a part on the sheet or board | `altium_sch_zoom`, `altium_zoom_to` | | Place, route, pour, silkscreen a board | `altium_move_parts`, `altium_route_net`, `altium_autoroute` (Situs), `altium_place_vias`, `altium_apply_pours`, `altium_apply_silkscreen`, `altium_set_design_rules`, `altium_layer_stack`, `altium_find_space` (free spots near a part; overlap check) (**altium-boards**) | | Silkscreen off the pads before fab or a render (Tools > Silkscreen Preparation) | `altium_silkscreen_prep {filePath}` (runs inside `altium_export_manufacturing` by default) (**altium-outputs**) |+| Routed net lengths, pair skew, matched-length groups; length, matched, diff-pair and via rules | `altium_net_lengths`, `altium_add_rule {kind: length\|matched\|diffpair\|via}` (**altium-boards**) |+| Component classes and rooms; teardrops | `altium_component_classes {name, members, room}`, `altium_teardrops` (**altium-boards**) | | Mounting holes, fiducials, stitching vias | `altium_board_features {mountingHoles, fiducials, stitching, dryRun}` (**altium-boards**) | | What is selected in Altium; select parts to show the user | `altium_selection {select?}` (**altium-boards**) | | Rotations, channel copies, a precise clearance check | `altium_check_orientation`, `altium_replicate_layout`, `altium_find_space {check, precise}` (**altium-boards**) |
bridge-rs/src/design.rs+66
@@ -27,6 +27,59 @@ fn open_board(args: &Value) -> Option<Value> {     s(args, "filePath").and_then(|p| support::must_open(&p, 60)) } +/// The box around the named parts' pads (absolute mm, as altium_routing_state reports them), grown by `margin`. Pure.+pub fn pads_box(state: &Value, members: &[String], margin: f64) -> Option<(f64, f64, f64, f64)> {+    let mut b: Option<(f64, f64, f64, f64)> = None;+    for p in state["pads"].as_array().cloned().unwrap_or_default() {+        if !members.iter().any(|m| p["ref"].as_str().map(|r| r.eq_ignore_ascii_case(m)).unwrap_or(false)) { continue; }+        let (x, y) = (p["x"].as_f64().unwrap_or(0.0), p["y"].as_f64().unwrap_or(0.0));+        let h = (p["sizeX"].as_f64().unwrap_or(0.0).powi(2) + p["sizeY"].as_f64().unwrap_or(0.0).powi(2)).sqrt() / 2.0;+        b = Some(match b { None => (x - h, y - h, x + h, y + h), Some(o) => (o.0.min(x - h), o.1.min(y - h), o.2.max(x + h), o.3.max(y + h)) });+    }+    b.map(|o| (o.0 - margin, o.1 - margin, o.2 + margin, o.3 + margin))+}++/// altium_component_classes {filePath?, name?, members?, room?, remove?}: without name, list them.+pub fn component_classes(args: &Value) -> Value {+    if let Some(e) = open_board(args) { return e; }+    let Some(name) = s(args, "name") else {+        let r = agent::call("comp_classes", &[("op", "list".into())], None, Duration::from_secs(60));+        return match agent_data(&r) { Ok(d) => json!({"success": true, "classes": d["classes"], "_hint": "Make one with {name, members:[designators], room?:true}."}), Err(e) => e };+    };+    if args.get("remove").and_then(|b| b.as_bool()) == Some(true) {+        let r = agent::call("comp_classes", &[("op", "remove".into()), ("name", name.clone())], None, Duration::from_secs(60));+        return match agent_data(&r) { Ok(d) => json!({"success": true, "class": name, "removed": d["removed"], "roomRemoved": d["roomRemoved"]}), Err(e) => e };+    }+    let members: Vec<String> = args.get("members").and_then(|m| m.as_array()).map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default();+    if members.is_empty() { return json!({"success": false, "errorCode": "bad_args", "error": "members (designators) are required"}); }+    let mut p: Vec<(&str, String)> = vec![("op", "set".into()), ("name", name.clone()), ("members", members.join(";"))];+    // room: true (fitted around the members' pads), {marginMm}, or {rect:[x1, y1, x2, y2]} in mm+    let room = args.get("room").cloned().unwrap_or(Value::Null);+    let mut rect = Value::Null;+    if room == json!(true) || room.is_object() {+        let r = match room.get("rect").and_then(|r| r.as_array()).filter(|a| a.len() == 4) {+            Some(a) => { let v: Vec<f64> = a.iter().map(|x| x.as_f64().unwrap_or(0.0)).collect(); Some((v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3]))) }+            None => {+                let st = crate::routing::routing_state(&json!({}));+                pads_box(&st, &members, room.get("marginMm").and_then(|m| m.as_f64()).unwrap_or(1.0))+            }+        };+        let Some((x1, y1, x2, y2)) = r else { return json!({"success": false, "errorCode": "no_room_area", "error": "none of the members has a pad on the board to fit the room around; pass room:{rect:[x1,y1,x2,y2]}"}) };+        use crate::routing::raw;+        p.extend([("room", "1".into()), ("rx1", raw(x1).to_string()), ("ry1", raw(y1).to_string()), ("rx2", raw(x2).to_string()), ("ry2", raw(y2).to_string())]);+        rect = json!([(x1 * 1000.0).round() / 1000.0, (y1 * 1000.0).round() / 1000.0, (x2 * 1000.0).round() / 1000.0, (y2 * 1000.0).round() / 1000.0]);+    }+    let r = agent::call("comp_classes", &p, None, Duration::from_secs(60));+    match agent_data(&r) {+        Ok(d) => {+            let missing = d["missing"].as_array().map(|a| !a.is_empty()).unwrap_or(false);+            json!({"success": !missing, "errorCode": if missing { json!("parts_not_on_board") } else { Value::Null }, "class": name, "members": d["members"], "missing": d["missing"], "roomMm": rect, "room": d["room"],+                "_hint": "The class can scope rules (InComponentClass('<name>')); the room keeps its parts inside the rectangle (DRC reports a part outside). altium_replicate_layout copies one channel's placement to the others. Save with altium_save."})+        }+        Err(e) => e,+    }+}+ /// altium_net_classes {filePath?, name?, nets?, width?, clearance?, remove?}: without name, list them. pub fn net_classes(args: &Value) -> Value {     if let Some(e) = open_board(args) { return e; }@@ -505,3 +558,16 @@ pub fn replicate_layout(args: &Value) -> Value {         "errorCode": m.get("errorCode"), "mismatches": m.get("mismatches"),         "_hint": "Each target channel's anchor stayed put and its other parts took the source's offsets and rotations (turned by the anchors' rotation difference). Routing is not copied: route the targets (altium_route_net) or use Altium's Copy Room Formats for copper. Check with altium_find_space {check:true} and altium_routing_drc."}) }++#[cfg(test)]+mod room_tests {+    use super::*;+    #[test]+    fn a_room_fits_the_members_pads_plus_the_margin() {+        let st = json!({"pads": [{"ref": "U1", "x": 10.0, "y": 10.0, "sizeX": 0.6, "sizeY": 0.8}, {"ref": "U1", "x": 14.0, "y": 12.0, "sizeX": 0.6, "sizeY": 0.8},+                                 {"ref": "R9", "x": 30.0, "y": 30.0, "sizeX": 1.0, "sizeY": 1.0}]});+        let b = pads_box(&st, &["u1".to_string()], 1.0).unwrap();+        assert!((b.0 - 8.5).abs() < 1e-9 && (b.3 - 13.5).abs() < 1e-9);+        assert!(pads_box(&st, &["U7".to_string()], 1.0).is_none());+    }+}
bridge-rs/src/lengths.rsadded+117
@@ -0,0 +1,117 @@+//! Routed length per net from the live board (tracks and arcs, per layer, with the vias), the skew inside each+//! differential pair, and the spread of a matched-length group against its tolerance.+use serde_json::{json, Map, Value};+use std::collections::BTreeMap;++fn f(v: &Value) -> f64 { v.as_f64().unwrap_or(0.0) }+fn r3(v: f64) -> f64 { (v * 1000.0).round() / 1000.0 }++/// Per net: total routed length (mm), length by layer, via count and segment count. Pure, for the tests.+pub fn lengths(state: &Value) -> BTreeMap<String, Value> {+    let mut acc: BTreeMap<String, (f64, BTreeMap<String, f64>, usize, usize)> = BTreeMap::new();+    let mut add = |net: &str, layer: &str, len: f64| {+        if net.is_empty() { return; }+        let e = acc.entry(net.to_string()).or_default();+        e.0 += len;+        *e.1.entry(layer.to_string()).or_insert(0.0) += len;+        e.3 += 1;+    };+    for t in state["tracks"].as_array().cloned().unwrap_or_default() {+        let len = ((f(&t["end"]["x"]) - f(&t["start"]["x"])).powi(2) + (f(&t["end"]["y"]) - f(&t["start"]["y"])).powi(2)).sqrt();+        add(t["net"].as_str().unwrap_or(""), t["layer"].as_str().unwrap_or("?"), len);+    }+    for a in state["arcs"].as_array().cloned().unwrap_or_default() {+        let mut span = f(&a["endAngle"]) - f(&a["startAngle"]);+        if span <= 0.0 { span += 360.0; }+        add(a["net"].as_str().unwrap_or(""), a["layer"].as_str().unwrap_or("?"), f(&a["radius"]) * span.to_radians());+    }+    for v in state["vias"].as_array().cloned().unwrap_or_default() {+        if let Some(n) = v["net"].as_str().filter(|n| !n.is_empty()) { acc.entry(n.to_string()).or_default().2 += 1; }+    }+    acc.into_iter().map(|(n, (len, by, vias, segs))| {+        let by: Map<String, Value> = by.into_iter().map(|(k, v)| (k, json!(r3(v)))).collect();+        (n.clone(), json!({"net": n, "lengthMm": r3(len), "byLayer": by, "vias": vias, "segments": segs}))+    }).collect()+}++fn len_of(l: &BTreeMap<String, Value>, net: &str) -> f64 {+    l.iter().find(|(k, _)| k.eq_ignore_ascii_case(net)).map(|(_, v)| f(&v["lengthMm"])).unwrap_or(0.0)+}++/// Skew of each pair (pos minus neg). Pure, for the tests.+pub fn pair_skew(l: &BTreeMap<String, Value>, pairs: &[Value]) -> Vec<Value> {+    pairs.iter().map(|p| {+        let (pos, neg) = (p["pos"].as_str().unwrap_or(""), p["neg"].as_str().unwrap_or(""));+        let (a, b) = (len_of(l, pos), len_of(l, neg));+        json!({"name": p.get("name").cloned().unwrap_or(json!(format!("{pos}/{neg}"))), "pos": pos, "neg": neg, "posMm": r3(a), "negMm": r3(b), "skewMm": r3(a - b)})+    }).collect()+}++/// A matched-length group: its shortest and longest net and whether the spread is within the tolerance. Pure.+pub fn group(l: &BTreeMap<String, Value>, nets: &[String], tol: f64) -> Value {+    let vals: Vec<(String, f64)> = nets.iter().map(|n| (n.clone(), len_of(l, n))).collect();+    let (min, max) = vals.iter().fold((f64::MAX, f64::MIN), |a, v| (a.0.min(v.1), a.1.max(v.1)));+    let spread = if vals.is_empty() { 0.0 } else { max - min };+    let longest = vals.iter().max_by(|a, b| a.1.partial_cmp(&b.1).unwrap()).map(|v| v.0.clone());+    json!({"nets": vals.iter().map(|(n, v)| json!({"net": n, "lengthMm": r3(*v), "shortByMm": r3(max - v)})).collect::<Vec<_>>(),+           "minMm": r3(if vals.is_empty() { 0.0 } else { min }), "maxMm": r3(if vals.is_empty() { 0.0 } else { max }), "spreadMm": r3(spread),+           "toleranceMm": tol, "withinTolerance": spread <= tol + 1e-9, "longest": longest})+}++/// altium_net_lengths {filePath?, nets?, pairs?, groups?:[{nets, toleranceMm}]}+pub fn net_lengths(args: &Value) -> Value {+    if let Some(g) = crate::support::need_licensed() { return g; }+    if let Some(p) = crate::support::s(args, "filePath") { if let Some(e) = crate::support::must_open(&p, 60) { return e; } }+    let st = crate::routing::routing_state(&json!({}));+    if st.get("success").and_then(|b| b.as_bool()) != Some(true) { return st; }+    let all = lengths(&st);+    let want: Vec<String> = args.get("nets").and_then(|n| n.as_array()).map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default();+    let nets: Vec<Value> = if want.is_empty() { all.values().cloned().collect() } else {+        want.iter().map(|n| all.iter().find(|(k, _)| k.eq_ignore_ascii_case(n)).map(|(_, v)| v.clone()).unwrap_or(json!({"net": n, "lengthMm": 0.0, "unrouted": true}))).collect()+    };+    // pairs: as given, else every differential pair the board defines+    let pairs: Vec<Value> = match args.get("pairs").and_then(|p| p.as_array()) {+        Some(p) => p.clone(),+        None => crate::design::diff_pairs(&json!({})).get("pairs").and_then(|p| p.as_array()).cloned().unwrap_or_default(),+    };+    let groups: Vec<Value> = args.get("groups").and_then(|g| g.as_array()).cloned().unwrap_or_default().iter().map(|g| {+        let n: Vec<String> = g["nets"].as_array().cloned().unwrap_or_default().iter().filter_map(|x| x.as_str().map(str::to_string)).collect();+        group(&all, &n, g.get("toleranceMm").and_then(|t| t.as_f64()).unwrap_or(0.5))+    }).collect();+    let out_of_tol = groups.iter().filter(|g| g["withinTolerance"] == json!(false)).count();+    json!({"success": true, "units": "mm", "nets": nets, "pairs": pair_skew(&all, &pairs), "groups": groups, "groupsOutOfTolerance": out_of_tol,+        "_hint": "Lengths are the routed copper of each net (tracks and arcs; vias counted, not added). A net with branches sums all of them, so read a multi-pin net as total copper, not pin to pin. Lengthen the short nets of a group (altium_route_net with a detour) until spreadMm is within toleranceMm; add a Matched Lengths rule with altium_add_rule."})+}++#[cfg(test)]+mod tests {+    use super::*;++    fn board() -> Value {+        json!({"tracks": [+            {"start": {"x": 0.0, "y": 0.0}, "end": {"x": 10.0, "y": 0.0}, "layer": "F.Cu", "net": "USB_P"},+            {"start": {"x": 10.0, "y": 0.0}, "end": {"x": 10.0, "y": 5.0}, "layer": "B.Cu", "net": "USB_P"},+            {"start": {"x": 0.0, "y": 1.0}, "end": {"x": 12.0, "y": 1.0}, "layer": "F.Cu", "net": "USB_N"}],+            "arcs": [{"center": {"x": 0.0, "y": 0.0}, "radius": 2.0, "startAngle": 0.0, "endAngle": 90.0, "layer": "F.Cu", "net": "CLK"}],+            "vias": [{"position": {"x": 10.0, "y": 0.0}, "net": "USB_P"}]})+    }++    #[test]+    fn lengths_add_tracks_and_arcs_per_layer() {+        let l = lengths(&board());+        assert_eq!(l["USB_P"]["lengthMm"], json!(15.0));+        assert_eq!(l["USB_P"]["byLayer"]["B.Cu"], json!(5.0));+        assert_eq!(l["USB_P"]["vias"], json!(1));+        assert_eq!(l["CLK"]["lengthMm"], json!(3.142));+    }++    #[test]+    fn pairs_report_skew_and_groups_their_spread() {+        let l = lengths(&board());+        let s = pair_skew(&l, &[json!({"name": "USB", "pos": "USB_P", "neg": "USB_N"})]);+        assert_eq!(s[0]["skewMm"], json!(3.0));+        let g = group(&l, &["USB_P".into(), "usb_n".into()], 2.0);+        assert_eq!((g["spreadMm"].clone(), g["withinTolerance"].clone(), g["longest"].clone()), (json!(3.0), json!(false), json!("USB_P")));+        assert_eq!(g["nets"][1]["shortByMm"], json!(3.0));+    }+}
bridge-rs/src/main.rs+2−1
@@ -15,6 +15,7 @@ mod features; mod footprint; mod hier; mod inspect;+mod lengths; mod instance; mod library; mod license;@@ -41,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.67";+pub const BRIDGE_VERSION: &str = "0.2.68";  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/route.rs+53
@@ -95,6 +95,59 @@ pub fn silkscreen_prep(args: &Value) -> Value {                  else { "Some silk still touches a mask opening (after.silkToSolderMask): text that could not move within Max Distance. altium_silk_state lists it; altium_apply_silkscreen moves it." }}) } +fn teardrop_count() -> Value {+    agent::call("teardrop_count", &[], None, Duration::from_secs(60)).get("data").and_then(|d| d.get("teardrops")).cloned().unwrap_or(Value::Null)+}++/// altium_teardrops {filePath?, remove?, selectedOnly?, vias?, smdPads?, tracks?, tJunctions?, force?, save?}: Altium's own+/// Tools > Teardrops, its dialog answered in the background, teardrops counted before and after.+pub fn teardrops(args: &Value) -> Value {+    if let Some(g) = support::need_licensed() { return g; }+    let t0 = support::now();+    if let Some(p) = s(args, "filePath") { if let Some(e) = support::must_open(&p, 60) { return e; } }+    let before = teardrop_count();+    agent::call("run_process", &[("process", "PCB:TearDropSelectedPads".into()), ("params", "".into())], None, Duration::from_secs(6));+    let mut dlg = None;+    for _ in 0..40 { if let Some(w) = find_window("teardrops") { dlg = Some(w); break; } std::thread::sleep(Duration::from_millis(500)); }+    let Some(dlg) = dlg else {+        return json!({"success": false, "errorCode": "teardrop_dialog_not_found", "_hint": "PCB:TearDropSelectedPads did not open its dialog: is a PCB the current document? (filePath opens one)"});+    };+    let h = dlg.get("hwnd").cloned().unwrap_or(Value::Null);+    let b = |k: &str, d: bool| args.get(k).and_then(|v| v.as_bool()).unwrap_or(d);+    let mut steps = Vec::new();+    // working mode and objects: radio buttons, which take a UIA select (they expose no Invoke: a click by automation+    // id answered false and left the dialog on Add, measured 2026-10-23)+    let mut pick = |name: &str, what: &str| {+        let r = ab::call("desktop_ui_select", json!({"hwnd": h, "name": name, "role": "radiobutton"}), &[], 15);+        steps.push(json!({what: name, "success": r.get("success")}));+    };+    pick(if b("remove", false) { "Remove" } else { "Add" }, "mode");+    pick(if b("selectedOnly", false) { "Selected only" } else { "All" }, "objects");+    let tree = ab::call("desktop_ui_tree", json!({"hwnd": h, "maxDepth": 12, "maxNodes": 300, "roleFilter": ["checkbox"]}), &[], 20);+    for (name, want) in [("Via/TH Pad", b("vias", true)), ("SMD Pad", b("smdPads", true)), ("Tracks", b("tracks", true)), ("T-Junction", b("tJunctions", true)),+                         ("Force teardrops", b("force", false)), ("Generate report", false)] {+        let on = checkbox_state(&tree, name).map(|x| x.eq_ignore_ascii_case("On")).unwrap_or(false);+        if on != want {+            let r = ab::call("desktop_ui_toggle", json!({"hwnd": h, "name": name}), &[], 15);+            steps.push(json!({"toggle": name, "to": want, "success": r.get("success")}));+        }+    }+    let go = ab::call("desktop_ui_click", json!({"hwnd": h, "automationId": "Ok"}), &[], 30);+    steps.push(json!({"uiClick": "OK", "success": go.get("success")}));+    let wait = timeout_of(args, 300);+    let t1 = std::time::Instant::now();+    std::thread::sleep(Duration::from_secs(2));+    while t1.elapsed() < wait && !agent::is_responsive() { std::thread::sleep(Duration::from_millis(600)); }+    let left = crate::dialogs::scan_scoped(true);+    let after = teardrop_count();+    let saved = match s(args, "filePath") {+        Some(p) if args.get("save").and_then(|v| v.as_bool()) != Some(false) => json!(crate::surfaces::save(&json!({"filePath": p})).get("success").and_then(|v| v.as_bool()) != Some(false)),+        _ => json!(false),+    };+    json!({"success": left.is_empty(), "teardropsBefore": before, "teardropsAfter": after, "steps": steps, "dialogsLeft": left, "saved": saved, "seconds": support::elapsed_round(t0),+        "_hint": "Teardrops are part of the copper: run altium_repour (pours flow around them) and altium_run_drc after. remove:true takes them all off again."})+}+ /// altium_autoroute {filePath?, strategy?, lockPreroutes?, ripupViolations?, save?, timeout?} pub fn autoroute(args: &Value) -> Value {     if let Some(g) = support::need_licensed() { return g; }
bridge-rs/src/routing.rs+18−3
@@ -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") { return err("bad_args", "remove needs kind and name".into(), ""); }+        if name.is_empty() || !matches!(kind.as_str(), "clearance" | "width" | "length" | "matched" | "diffpair" | "via") { 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 };     }@@ -1137,11 +1137,26 @@ pub fn add_rule(args: &Value) -> Value {             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"), ""),+        "length" => {+            let (Some(min), Some(max)) = (mm("min").or(Some(0.0)), mm("max")) else { return err("bad_args", "length needs max (and min) in mm".into(), "") };+            p.push(("a", raw(min).to_string())); p.push(("c", raw(max).to_string()));+        }+        "matched" => { let Some(t) = mm("tolerance") else { return err("bad_args", "matched needs tolerance (mm)".into(), "scope1 names the group, e.g. InNetClass('DDR_D0')") }; p.push(("a", raw(t).to_string())); }+        "diffpair" => {+            let (Some(w), Some(g)) = (mm("width"), mm("gap")) else { return err("bad_args", "diffpair needs width and gap (mm); minWidth/maxWidth/minGap/maxGap/maxUncoupled optional".into(), "scope1: InDifferentialPair('USB') or All") };+            let v = |k: &str, d: f64| raw(mm(k).unwrap_or(d)).to_string();+            p.extend([("a", v("minWidth", w)), ("b", raw(w).to_string()), ("c", v("maxWidth", w)), ("d", v("minGap", g)), ("e", raw(g).to_string()), ("g", v("maxGap", g)), ("u", v("maxUncoupled", 0.0))]);+        }+        "via" => {+            let (Some(size), Some(drill)) = (mm("size"), mm("drill")) else { return err("bad_args", "via needs size and drill (mm); minSize/maxSize/minDrill/maxDrill optional".into(), "") };+            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"), ""),     }     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"],+        Some(d) if d.get("ok").and_then(|v| v.as_bool()) == Some(true) => json!({"success": true, "name": d["name"], "priority": d["priority"], "descriptor": d["descriptor"],             "_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,     }
bridge-rs/src/verbs.rs+27−3
@@ -22,7 +22,7 @@ pub const KNOWN: &[&str] = &[     "list_nets", "net_connections", "layers", "copper_layers", "read_rules",     "set_layer_visibility", "footprint_primitives", "schematic_data", "layer_stack", "net_classes", "diff_pairs", "board_outline", "set_track_width", "find_space", "selection", "check_orientation", "replicate_layout", "outjob_info", "find_symbol", "part_parameters",     // the live-board lane AI Flow drives-    "routing_state", "route_net", "move_parts", "place_vias", "board_features", "sch_sheet_symbol", "netlist", "silkscreen_prep", "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",+    "routing_state", "route_net", "move_parts", "place_vias", "board_features", "sch_sheet_symbol", "netlist", "silkscreen_prep", "net_lengths", "teardrops", "component_classes", "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",@@ -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 (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"}),+        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"}),             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})),@@ -706,6 +706,27 @@ fn describe() -> Value {             &["workspace_search", "sch_place_part", "annotate", "sch_connect", "update_pcb"],             &["Takes 15-70 s. Uses real clicks (double-click the result, click the sheet): Altium comes to the front.", "The part lands near the view's lower-left corner first, then moves to x,y: wires do not follow, so connect it after."],             json!({"sheet": "C:/proj/Main.SchDoc", "query": "100nF 0402 50V", "x": 3000, "y": 2000})),+        verb_doc("component_classes", "List, make or remove component classes on the board, each optionally with a room: a placement area that keeps its parts together (fitted around the parts or a rectangle). The classes scope rules and group a channel for replicate_layout.",+            json!({"filePath": "the .PcbDoc (opened first)", "name": "class name (omit to list)", "members": "[designators]", "room": "true (fit around the members' pads, 1 mm margin) | {marginMm} | {rect: [x1, y1, x2, y2] mm}", "remove": "bool - delete the class and its room"}),+            json!({"classes": "[{name, members}] (list)", "members": "[designator]", "missing": "[designator not on the board]", "roomMm": "[x1, y1, x2, y2]", "room": "Altium's description of the room rule"}), 120,+            "Use a class per channel or block: rules scope with InComponentClass('<name>'), and the room shows the block's area to the user and to DRC.",+            &["net_classes", "replicate_layout", "add_rule", "find_space"],+            &["The room is a confinement rule ('Adom room <name>', top layer): a part outside it is a DRC violation, not a move.", "The same name replaces the class and its room."],+            json!({"name": "CH1", "members": ["U1", "R1", "C1"], "room": true})),+        verb_doc("teardrops", "Add (or remove) teardrops with Altium's own Tools > Teardrops: on vias and through-hole pads, SMD pads, track width changes and T-junctions, for the whole board or the selection; teardrops counted before and after.",+            json!({"filePath": "the .PcbDoc (opened first)", "remove": "bool, default false (add)", "selectedOnly": "bool, default false (all)", "vias": "bool, default true", "smdPads": "bool, default true", "tracks": "bool, default true", "tJunctions": "bool, default true", "force": "bool, default false: add even where a teardrop breaks a rule", "save": "bool, default true"}),+            json!({"teardropsBefore": "int", "teardropsAfter": "int", "steps": "[mode, objects, toggles, OK]", "dialogsLeft": "[anything Altium asked]"}), 300,+            "Add teardrops after routing and before the final pour and DRC: altium_repour, then altium_run_drc.",+            &["repour", "run_drc", "routing_state", "selection"],+            &["Altium's own sizes are used (via 70% width / 30% length, pads 200% / 100%, curved).", "force:true places teardrops that violate clearance: leave it off unless the user asks."],+            json!({"filePath": "C:\\\\work\\\\Board.PcbDoc"})),+        verb_doc("net_lengths", "Routed length of every net (or the ones named) on the live board, by layer with its vias; the skew inside each differential pair; and the spread of matched-length groups against a tolerance.",+            json!({"filePath": "the .PcbDoc (opened first)", "nets": "[net] - default every routed net", "pairs": "[{name?, pos, neg}] - default the board's differential pairs", "groups": "[{nets:[..], toleranceMm?: 0.5}] - matched-length groups (a DDR byte lane, a parallel bus)"}),+            json!({"nets": "[{net, lengthMm, byLayer, vias, segments}]", "pairs": "[{name, pos, neg, posMm, negMm, skewMm}]", "groups": "[{nets:[{net, lengthMm, shortByMm}], spreadMm, withinTolerance, longest}]", "groupsOutOfTolerance": "int"}), 180,+            "Lengthen the short nets of a group with altium_route_net (a detour) until spreadMm is within tolerance; Altium's interactive length tuning is the hand alternative.",+            &["routing_state", "route_net", "diff_pairs", "add_rule"],+            &["A net with branches sums every branch: a multi-pin net's length is its total copper, not pin to pin.", "Vias are counted, not added to the length."],+            json!({"groups": [{"nets": ["D0", "D1", "D2", "D3"], "toleranceMm": 0.25}]})),         verb_doc("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.",             json!({"filePath": "the .PcbDoc (opened first)", "moveText": "bool, default true: move text off pads (within Altium's Max Distance) instead of clipping it", "deleteOutsideBoard": "bool, default true", "clipLocked": "bool, default false", "minRemainingMil": "shortest silk segment kept (Altium's default 5.9 mil)", "selectedOnly": "bool, default false", "check": "bool, default true: DRC before and after", "save": "bool, default true"}),             json!({"before": "{silkToSolderMask, silkToSilk, silkToBoardRegion, maskSliver}", "after": "{same}", "silkOffCopper": "bool", "steps": "[toggles, OK]", "dialogsLeft": "[anything Altium asked]"}), 600,@@ -1589,6 +1610,9 @@ pub fn dispatch(command: &str, args: &Value, _forwarded: &[(String, String)]) ->         "sch_sheet_symbol" => crate::hier::sheet_symbol(args),         "netlist" => crate::hier::netlist(args),         "silkscreen_prep" => crate::route::silkscreen_prep(args),+        "net_lengths" => crate::lengths::net_lengths(args),+        "teardrops" => crate::route::teardrops(args),+        "component_classes" => crate::design::component_classes(args),         "footprint_from_land_pattern" => crate::footprint::from_land_pattern(args),         "find_symbol" => crate::inspect::find_symbol(args),         "part_parameters" => crate::inspect::part_parameters(args),
dev-skills/altium-bridge-test/run_verb_tests.py+8
@@ -244,6 +244,9 @@ def matrix(w):     M["net_classes"] = dict(phase=3.5, **ok(filePath=LB, name="BatteryPwr", nets=["GND", "N1"], width=0.3, clearance=0.25),                             check=lambda d: d.get("members") == 2 and all(r.get("success") for r in d.get("rules") or []))     M["diff_pairs"] = dict(phase=3.5, **ok(filePath=LB, name="BAT", pos="N1", neg="GND"))+    # 0.2.68: a component class with a fitted room (lengths and teardrops run after autoroute: ripup_nets clears N1 above)+    M["component_classes"] = dict(phase=3.5, **ok(filePath=LB, name="BatCh1", members=["U1"], room=True),+                                  check=lambda d: d.get("members") == ["U1"] and bool(d.get("room")))     M["set_track_width"] = dict(phase=3.5, timeout=300, **ok(filePath=LB, netClass="BatteryPwr", widthMm=0.3))     M["board_outline"] = dict(phase=3.5, **ok(filePath=LB, mech=1), check=lambda d: (d.get("drawn") or 0) > 0 or (d.get("alreadyThere") or 0) > 0)     # 0.2.65: selection (select U1, U2 and read them back), orientation advice, layout replication (U2 keeps its@@ -293,6 +296,11 @@ def matrix(w):                                            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)     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}]),+                            check=lambda d: any((n.get("lengthMm") or 0) > 0 for n in d.get("nets") or []) and len(d.get("groups") or []) == 1)+    M["teardrops"] = dict(phase=3.5, timeout=320, **ok(filePath=LB),+                          check=lambda d: (d.get("teardropsAfter") or 0) > (d.get("teardropsBefore") or 0) and not d.get("dialogsLeft"))     # 0.2.60: a variant written into the project file and read back from Altium     M["variants"] = dict(phase=3.5, timeout=400, **ok(projectPath=LP, name="BatteryLite", notFitted=["U2"], timeout=380),                          check=lambda d: any(v.get("name") == "BatteryLite" for v in (d.get("altium") or {}).get("variants") or []))
package.json+11−2
@@ -1,6 +1,6 @@ {   "slug": "altium-bridge",-  "version": "0.2.67",+  "version": "0.2.68",   "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": [@@ -147,7 +147,16 @@     "altium silkscreen over pads",     "altium silkscreen preparation",     "clip silkscreen altium",-    "altium silk to solder mask"+    "altium silk to solder mask",+    "altium net length",+    "altium length matching",+    "altium matched length rule",+    "altium differential pair rule",+    "altium teardrops",+    "altium room",+    "altium component class",+    "altium via style rule",+    "altium skew"   ],   "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+12−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.67",+  "version": "0.2.68",   "hero": {     "type": "image",     "path": "screenshots/hero.png"@@ -139,7 +139,16 @@     "altium silkscreen over pads",     "altium silkscreen preparation",     "clip silkscreen altium",-    "altium silk to solder mask"+    "altium silk to solder mask",+    "altium net length",+    "altium length matching",+    "altium matched length rule",+    "altium differential pair rule",+    "altium teardrops",+    "altium room",+    "altium component class",+    "altium via style rule",+    "altium skew"   ],   "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": {},@@ -194,4 +203,4 @@     "tools/symbol_modes.py",     "uninstall.sh"   ]-}\ No newline at end of file+}
skills/altium-boards/SKILL.md+24
@@ -255,3 +255,27 @@ altium_board_features '{"mountingHoles":[{"x":10,"y":10,"drill":3.2,"plated":tru   `clearance` mm from every other net's copper and every pad, out from under parts (their pad box; `underParts:true`   allows it), and clear of this call's holes and fiducials. Pour the net first: on a board with no pour the vias   join nothing and DRC reports them as net antennae. Then `altium_repour` and `altium_run_drc`.++## Lengths, length rules, rooms and teardrops (0.2.68)++```bash+altium_net_lengths '{"groups":[{"nets":["D0","D1","D2","D3"],"toleranceMm":0.25}]}'   # routed length, pair skew, group spread+altium_add_rule '{"kind":"length","name":"CLK len","scope1":"InNet(\'CLK\')","min":10,"max":40}'+altium_add_rule '{"kind":"matched","name":"Byte0","scope1":"InNetClass(\'DDR_D0\')","tolerance":0.25}'+altium_add_rule '{"kind":"diffpair","name":"USB","scope1":"InDifferentialPair(\'USB\')","width":0.2,"gap":0.15,"maxUncoupled":5}'+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+```++- `net_lengths` sums each net's routed tracks and arcs (per layer, vias counted), the skew of every differential pair+  the board defines, and each group's spread against its tolerance with how short each net is. A net with branches+  sums them all. Measured on the battery board: N1 13.6 mm on F.Cu, GND 32.3 mm on both layers with 4 vias, a 1 mm+  group of the two out of tolerance by 18.7 mm, which Altium's own Matched Lengths DRC agreed with.+- `add_rule` kinds `length` (min/max), `matched` (tolerance), `diffpair` (width, gap, optional min/max and maximum+  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.+- `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.
src/BRIDGE_VERSION+1−1
@@ -1 +1 @@-0.2.67+0.2.68
src/agent/AdomAgent.pas.tmpl+141−2
@@ -7027,15 +7027,20 @@ Var     Board : IPCB_Board;     Rule  : IPCB_Rule;     Kind, Name : String;-    A, B, C, Want : Integer;+    A, B, C, D, E, G, U, L, 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);+    D := StrToIntDef(Param(Lines, 'd'), 0); E := StrToIntDef(Param(Lines, 'e'), 0); G := StrToIntDef(Param(Lines, 'g'), 0); U := StrToIntDef(Param(Lines, 'u'), 0);     If Kind = 'clearance' Then Want := eRule_Clearance     Else If Kind = 'width' Then Want := eRule_MaxMinWidth+    Else If Kind = 'length' Then Want := eRule_MaxMinLength+    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 Begin Result := '{' + JRaw('ok','false') + ',' + JStr('error','unknown rule kind: ' + Kind) + '}'; Exit; End;     PCBServer.PreProcess;     Rule := FindRuleNamed(Board, Want, Name);@@ -7051,7 +7056,25 @@ Begin     Rule.Comment := Param(Lines, 'comment');     Rule.Scope1Expression := Param(Lines, 'scope1');     Rule.Scope2Expression := Param(Lines, 'scope2');+    { measured on Develop 2026-10-23: the length, matched-length, diff-pair and via-style properties below; a diff pair's+      widths and gaps are per layer (setting one layer moved only the descriptor's minimum) }     If Kind = 'clearance' Then Rule.Gap := A+    Else If Kind = 'length' Then Begin Rule.MinLimit := A; Rule.MaxLimit := C; End+    Else If Kind = 'matched' Then Rule.Tolerance := A+    Else If Kind = 'diffpair' Then+    Begin+        For L := eTopLayer To eBottomLayer Do+        Begin+            Rule.MinWidth[L] := A; Rule.PreferedWidth[L] := B; Rule.MaxWidth[L] := C;+            Rule.MinGap[L] := D; Rule.PreferedGap[L] := E; Rule.MaxGap[L] := G;+        End;+        If U > 0 Then Rule.MaxUncoupledLength := U;+    End+    Else If Kind = 'via' Then+    Begin+        Rule.MinWidth := A; Rule.PreferedWidth := B; Rule.MaxWidth := C;+        Rule.MinHoleWidth := D; Rule.PreferedHoleWidth := E; Rule.MaxHoleWidth := G;+    End     Else     Begin         SetWidthAllLayers(Rule, A, B, C);@@ -7059,7 +7082,7 @@ Begin     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)) + '}';+    Result := '{' + JRaw('ok','true') + ',' + JStr('name', Name) + ',' + JRaw('priority', IntToStr(Rule.Priority)) + ',' + JStr('descriptor', Rule.Descriptor) + '}'; End;  { build_schematic: one sheet drawn from a netlist with the project library's symbols. The SchLib@@ -7285,6 +7308,120 @@ Begin     Result := '{' + JRaw('ok','true') + ',' + JRaw('placed', '[' + S + ']') + ',' + JRaw('alreadyThere', '[' + Skip + ']') + '}'; End; +{ comp_classes: component classes on the current board, with an optional room (a confinement rule named+  'Adom room <class>' scoped InComponentClass, measured on Develop 2026-10-23). op=list | set | remove; name; members+  (designators, ';'); room=1 with rx1, ry1, rx2, ry2 (internal units, absolute like routing_state and edit_board). }+Function CmdCompClasses(Lines : TStringList) : String;+Var+    Board : IPCB_Board;+    BIt, CIt : IPCB_BoardIterator;+    Cls, Old : IPCB_ObjectClass;+    Rule, OldRoom : IPCB_Rule;+    Comp  : IPCB_Component;+    Op, Name, Mem, D, S, M, Missing, Q : String;+    P, K  : Integer;+Begin+    Board := PCBServer.GetCurrentPCBBoard;+    If Board = Nil Then Begin Result := '{' + JRaw('ok','false') + ',' + JStr('errorCode','no_current_board') + ',' + JStr('error','no current PCB board (open a .PcbDoc first)') + '}'; Exit; End;+    Op := Param(Lines, 'op'); Name := Param(Lines, 'name');+    Old := Nil; OldRoom := Nil; S := '';+    BIt := Board.BoardIterator_Create;+    BIt.AddFilter_ObjectSet(MkSet(eClassObject));+    BIt.AddFilter_LayerSet(AllLayers);+    BIt.AddFilter_Method(eProcessAll);+    Cls := BIt.FirstPCBObject;+    While Cls <> Nil Do+    Begin+        If Cls.MemberKind = eClassMemberKind_Component Then+        Begin+            If (Name <> '') And (UpperCase(Cls.Name) = UpperCase(Name)) Then Old := Cls;+            If (Op = 'list') And (Not Cls.SuperClass) Then+            Begin+                M := '';+                CIt := Board.BoardIterator_Create;+                CIt.AddFilter_ObjectSet(MkSet(eComponentObject));+                CIt.AddFilter_LayerSet(AllLayers);+                CIt.AddFilter_Method(eProcessAll);+                Comp := CIt.FirstPCBObject;+                While Comp <> Nil Do+                Begin+                    If Cls.IsMember(Comp) Then Begin If M <> '' Then M := M + ','; M := M + '"' + JsonEsc(Comp.Name.Text) + '"'; End;+                    Comp := CIt.NextPCBObject;+                End;+                Board.BoardIterator_Destroy(CIt);+                If S <> '' Then S := S + ',';+                S := S + '{' + JStr('name', Cls.Name) + ',' + JRaw('members', '[' + M + ']') + '}';+            End;+        End;+        Cls := BIt.NextPCBObject;+    End;+    Board.BoardIterator_Destroy(BIt);+    If Op = 'list' Then Begin Result := '{' + JRaw('ok','true') + ',' + JRaw('classes', '[' + S + ']') + '}'; Exit; End;+    If Name = '' Then Begin Result := '{' + JRaw('ok','false') + ',' + JStr('errorCode','bad_args') + ',' + JStr('error','name is required') + '}'; Exit; End;+    OldRoom := FindRuleNamed(Board, eRule_ConfinementConstraint, 'Adom room ' + Name);+    PCBServer.PreProcess;+    If Old <> Nil Then Board.RemovePCBObject(Old);+    If OldRoom <> Nil Then Board.RemovePCBObject(OldRoom);+    If Op = 'remove' Then+    Begin+        PCBServer.PostProcess;+        Result := '{' + JRaw('ok','true') + ',' + JStr('class', Name) + ',' + JRaw('removed', JBool(Old <> Nil)) + ',' + JRaw('roomRemoved', JBool(OldRoom <> Nil)) + '}';+        Exit;+    End;+    Cls := PCBServer.PCBClassFactoryByClassMember(eClassMemberKind_Component);+    Cls.Name := Name;+    Mem := Param(Lines, 'members'); K := 0; Missing := ''; M := '';+    While Mem <> '' Do+    Begin+        P := Pos(';', Mem);+        If P = 0 Then Begin D := Mem; Mem := ''; End Else Begin D := Copy(Mem, 1, P - 1); Mem := Copy(Mem, P + 1, Length(Mem)); End;+        If D <> '' Then+        Begin+            Comp := FindCompByDes(Board, D);+            If Comp = Nil Then Begin If Missing <> '' Then Missing := Missing + ','; Missing := Missing + '"' + JsonEsc(D) + '"'; End+            Else Begin Cls.AddMemberByName(Comp.Name.Text); K := K + 1; If M <> '' Then M := M + ','; M := M + '"' + JsonEsc(Comp.Name.Text) + '"'; End;+        End;+    End;+    Board.AddPCBObject(Cls);+    PCBServer.SendMessageToRobots(Board.I_ObjectAddress, c_Broadcast, PCBM_BoardRegisteration, Cls.I_ObjectAddress);+    S := 'null';+    If Param(Lines, 'room') = '1' Then+    Begin+        Q := Chr(39);+        Rule := PCBServer.PCBRuleFactory(eRule_ConfinementConstraint);+        Rule.Name := 'Adom room ' + Name;+        Rule.Scope1Expression := 'InComponentClass(' + Q + Name + Q + ')';+        Rule.Kind := eConfineIn;+        Rule.Layer := eTopLayer;+        Rule.BoundingRect := RectToCoordRect(Rect(AdomInt(Param(Lines, 'rx1')), AdomInt(Param(Lines, 'ry2')), AdomInt(Param(Lines, 'rx2')), AdomInt(Param(Lines, 'ry1'))));+        Board.AddPCBObject(Rule);+        PCBServer.SendMessageToRobots(Board.I_ObjectAddress, c_Broadcast, PCBM_BoardRegisteration, Rule.I_ObjectAddress);+        S := '"' + JsonEsc(Rule.Descriptor) + '"';+    End;+    PCBServer.PostProcess;+    Result := '{' + JRaw('ok','true') + ',' + JStr('class', Name) + ',' + JRaw('members', '[' + M + ']') + ',' + JRaw('missing', '[' + Missing + ']') + ',' + JRaw('room', S) + '}';+End;++{ teardrop_count: how many primitives on the current board are teardrops (IPCB_Primitive.TearDrop, measured on+  Develop 2026-10-23). }+Function CmdTeardropCount(Lines : TStringList) : String;+Var+    Board : IPCB_Board;+    It    : IPCB_BoardIterator;+    P     : IPCB_Primitive;+    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;+    N := 0;+    It := Board.BoardIterator_Create;+    It.AddFilter_ObjectSet(AllObjects); It.AddFilter_LayerSet(AllLayers); It.AddFilter_Method(eProcessAll);+    P := It.FirstPCBObject;+    While P <> Nil Do Begin If P.TearDrop Then N := N + 1; P := It.NextPCBObject; End;+    Board.BoardIterator_Destroy(It);+    Result := '{' + JRaw('ok','true') + ',' + JRaw('teardrops', IntToStr(N)) + '}';+End;+ Function CmdSchConnect(Lines : TStringList; Payload : String) : String; Var     Doc   : ISch_Document;@@ -8509,6 +8646,8 @@ Begin     Else If Cmd = 'sch_connect' Then Result := CmdSchConnect(Lines, Payload)     Else If Cmd = 'sheet_symbol' Then Result := CmdSheetSymbol(Lines, Payload)     Else If Cmd = 'sheet_ports' Then Result := CmdSheetPorts(Lines, Payload)+    Else If Cmd = 'teardrop_count' Then Result := CmdTeardropCount(Lines)+    Else If Cmd = 'comp_classes' Then Result := CmdCompClasses(Lines)     Else If Cmd = 'sch_edit' Then Result := CmdSchEdit(Lines, Payload)     Else If Cmd = 'sch_place_part' Then Result := CmdSchPlacePart(Lines)     Else If Cmd = 'doc_params' Then Result := CmdDocParams(Lines)
src/bridge.json+7−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "altium",   "displayName": "Altium Designer",-  "version": "0.2.67",+  "version": "0.2.68",   "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",@@ -91,6 +91,9 @@     "altium_netlist",     "altium_footprint_from_land_pattern",     "altium_silkscreen_prep",+    "altium_net_lengths",+    "altium_teardrops",+    "altium_component_classes",     "altium_find_space",     "altium_find_symbol",     "altium_footprint_primitives",@@ -323,6 +326,9 @@       "netlist": 300,       "footprint_from_land_pattern": 300,       "silkscreen_prep": 600,+      "net_lengths": 180,+      "teardrops": 300,+      "component_classes": 120,       "sch_swap_part": 180,       "selection": 60,       "check_orientation": 120,