app
Altium Bridge: AI for Altium Designer
Public Made by Adomby adom
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.
← Commit history
0.2.71 source (not yet released, needs signing): impedance, current_capacity, fab_check, panelize, read_sheet_file; 186 verbs; batteries 185/0 on AD 26.11.1 and Altium Designer Develop
19 files changed
+1003−17
CHANGELOG.md+9README.md+12−6bridge-rs/src/current.rs+129bridge-rs/src/design.rs+1bridge-rs/src/fab.rs+243bridge-rs/src/impedance.rs+206bridge-rs/src/main.rs+5−1bridge-rs/src/schdoc.rs+207bridge-rs/src/support.rs+5−1bridge-rs/src/verbs.rs+57−2bridge-rs/testdata/lane.SchDocdev-skills/altium-bridge-test/run_verb_tests.py+12package.json+11−2page.json+2−2skills/altium-boards/SKILL.md+42−1skills/altium-schematic-edit/SKILL.md+11src/BRIDGE_VERSION+1−1src/agent/AdomAgent.pas.tmpl+39src/bridge.json+11−1CHANGELOG.md+9@@ -1,5 +1,14 @@ # Changelog +## 0.2.71 (2026-10-23)+**Impedance, current, fab limits and panels; sheets read without Altium.**+- New verb `altium_impedance`: the track width each copper layer needs for a single-ended target (50 ohm default) and the width and gap of a differential pair (90 ohm USB, 100 ohm LVDS), from the board's own stack-up: microstrip outside, stripline between planes inside (Hammerstad-Jensen, Wheeler, IPC-2141 coupling; the textbook 50 ohm line on 1.6 mm FR-4 comes out 3.0 mm). `apply:{scope}` writes one width or diffpair rule per layer, read back from Altium's descriptor (measured on the lane board: 0.558 mm, 21.968 mil). `warnings` names a dielectric under 0.05 mm (the Bluetooth Sentinel example's flex layer) or a width no fab makes.+- New verb `altium_current_capacity`: each power net's narrowest track on every layer against the width IPC-2221 needs for its current at the allowed rise, with each layer's real copper weight, and what one of its vias carries. Measured on the lane board: GND's 0.3 mm tracks short of the 1.33 mm 3 A needs; 4 vias of 0.95 A each.+- New verb `altium_fab_check`: the minimums a fab's order form asks for (narrowest copper track, via drill, diameter and ring, pad hole and ring, SMD pad, clearance rule, copper layers, board size with arc edges), each with where it is, and which fall outside the fab's limits.+- New verb `altium_panelize`: a new .PcbDoc holding Altium's own embedded board array of the board (rows x cols, gap, rails), the panel outline, three tooling holes and three fiducials on the rails. The array stays linked to the board. Measured: a 2 x 3 panel of the lane board, 134 x 52 mm, in 1.9 s. An existing panel is replaced only with `overwrite:true` (Altium's blank save-over Confirm would stall the agent).+- New verb `altium_read_sheet_file`: a .SchDoc read straight from the file, no Altium needed: every part with designator, library reference, comment, UniqueID, position, pins per part and parameters, in 1 to 2 ms. `altium_schematic_data` falls back to it when the live read times out (`fromFile`), and the UniqueID check around the sheet verbs uses it.+- Verb battery: all five on the lane board.+ ## 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.
README.md+12−6@@ -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 -181 commands, tested on Altium Designer 26.11 and Altium Designer Develop. It runs in **Hydrogen**, Adom's free+186 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. 181 verbs; each row names the ones+Tested on Altium Designer 26.11.1 and Altium Designer Develop. 186 verbs; each row names the ones behind it (prefix `altium_`). | Area | What the AI can do | Verbs |@@ -41,12 +41,13 @@ behind it (prefix `altium_`). | 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, 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` |+| Signal and power | The trace width for a target impedance on every copper layer of the real stack-up (50 ohm single-ended, 90 or 100 ohm pairs with their gap), written as rules if asked; whether each power net's narrowest track and its vias carry its current (IPC-2221) | `impedance`, `current_capacity` | | 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, 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` |+| Manufacturing outputs | Gerbers, NC drill, pick and place, BOM, ODB++ and IPC-2581 in one call, checking that the outline and every copper layer reached the Gerbers; the board checked against a fab's limits before ordering; a step-and-repeat panel with rails, tooling holes and fiducials; any OutJob, and read an OutJob's containers and settings; the schematic as a PDF with assembly and fabrication drawings; STEP; silkscreen cleaned off the pads first with Altium's Silkscreen Preparation | `export_manufacturing`, `fab_check`, `panelize`, `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; a .SchDoc read straight from the file without Altium | `read_board`, `routing_state`, `schematic_data`, `read_sheet_file`, `part_parameters`, `read_rules`, `pour_state`, `silk_state`, `pcb_report` | | Seeing it | Open any surface with a screenshot, 2D and 3D board views, zoom to parts or nets, camera moves | `show_*`, `zoom_to`, `select_net`, `animate_view` | | Running Altium | Launch in the background, look after the licence seat, close nuisance dialogs, work with several Altiums at once, run any DelphiScript or Altium command, install Altium | `launch`, `close`, `ensure_licensed`, `instances`, `check_dialogs`, `eval`, `run_command`, `install` | @@ -66,7 +67,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 181 `altium_*` verbs.+**DelphiScript agent** inside it, and gives the AI 186 `altium_*` verbs. - **Page and docs:** https://wiki.adom.inc/adom/altium-bridge - **Bridge SDK:** https://wiki.adom.inc/adom/adom-bridge-sdk@@ -166,7 +167,7 @@ way to read it). A stopped engine is restarted with `altium_agent_recover`. <!-- verbs:start (tools/readme_verbs.py writes this list) --> -All 181 verbs, by area (prefix `altium_`). `altium_describe` returns the live catalog with every input, output, hint and example.+All 186 verbs, by area (prefix `altium_`). `altium_describe` returns the live catalog with every input, output, hint and example. ### Running Altium: launch, close, health, dialogs @@ -249,6 +250,11 @@ All 181 verbs, by area (prefix `altium_`). `altium_describe` returns the live ca - `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_read_sheet_file`: Read a schematic sheet straight from its .SchDoc file, without Altium (it need not be installed or running): every part with its designator, library reference, comment, UniqueID, part number, position, parameters and pins, in milliseconds.+- `altium_fab_check`: Before ordering: the board's fab-relevant minimums measured from the live board (narrowest track, smallest via drill, diameter and annular ring, smallest pad hole and ring, smallest SMD pad, clearance rule, copper layers, board size), each with where it is; with a fab's limits, which ones fall short.+- `altium_panelize`: Step and repeat a board into a manufacturing panel: a new .PcbDoc holding Altium's own embedded board array of the board (rows x cols, a gap between boards), with rails, a panel outline, three tooling holes and three fiducials on the rails.+- `altium_impedance`: Controlled impedance from the board's own stack-up: the track width each copper layer needs for a single-ended target (50 ohm default) and the width and gap for a differential pair (90 ohm USB, 100 ohm Ethernet/LVDS), microstrip outside and stripline inside; optionally written as width or diff-pair rules per layer.+- `altium_current_capacity`: Will this trace carry the current? For each power net and its current: the narrowest track on every layer it is routed on against the width IPC-2221 needs for the allowed temperature rise (with each layer's real copper weight), and what one of its vias carries. - `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.
bridge-rs/src/current.rsadded+129@@ -0,0 +1,129 @@+//! Current capacity of a net's copper (IPC-2221): the width each layer needs for a current at an allowed temperature+//! rise, against the narrowest track the net actually has there, and how many of its vias one layer change needs.+use serde_json::{json, Map, Value};++fn f(v: &Value) -> f64 { v.as_f64().unwrap_or(0.0) }+fn r3(v: f64) -> f64 { (v * 1000.0).round() / 1000.0 }+fn r2(v: f64) -> f64 { (v * 100.0).round() / 100.0 }++const MIL: f64 = 0.0254;++/// IPC-2221's chart as a formula: the cross-section (mil^2) for `amps` at `rise` degrees C, outer or inner layer.+fn area_mil2(amps: f64, rise: f64, outer: bool) -> f64 {+ let k = if outer { 0.048 } else { 0.024 };+ (amps / (k * rise.powf(0.44))).powf(1.0 / 0.725)+}++/// The track width (mm) `amps` needs on copper `copper_um` thick.+pub fn width_for(amps: f64, rise: f64, copper_um: f64, outer: bool) -> f64 {+ let t_mil = copper_um / 1000.0 / MIL;+ area_mil2(amps, rise, outer) / t_mil * MIL+}++/// What one via carries: its barrel (plating `plating_um` around a `drill` mm hole) as an inner conductor.+pub fn via_amps(drill: f64, plating_um: f64, rise: f64) -> f64 {+ let t = plating_um / 1000.0;+ let a_mil2 = std::f64::consts::PI * (drill + t) * t / (MIL * MIL);+ 0.024 * rise.powf(0.44) * a_mil2.powf(0.725)+}++/// routing_state's layer name to its index in the stack (F.Cu first, In<n>.Cu, B.Cu last).+fn stack_index(layer: &str, copper: usize) -> Option<usize> {+ match layer {+ "F.Cu" => Some(0),+ "B.Cu" => Some(copper.saturating_sub(1)),+ l => l.strip_prefix("In").and_then(|r| r.strip_suffix(".Cu")).and_then(|n| n.parse().ok()),+ }+}++/// Per net asked: each layer it is routed on with its narrowest track and the width the current needs. Pure.+pub fn check(state: &Value, copper_um: &[f64], nets: &Map<String, Value>, rise: f64, plating_um: f64) -> Vec<Value> {+ let tracks = state["tracks"].as_array().cloned().unwrap_or_default();+ let arcs = state["arcs"].as_array().cloned().unwrap_or_default();+ let vias = state["vias"].as_array().cloned().unwrap_or_default();+ let polys = state["polygons"].as_array().cloned().unwrap_or_default();+ let n = copper_um.len().max(2);+ nets.iter().map(|(net, amps)| {+ let amps = f(amps);+ let same = |v: &Value| v.as_str().map(|s| s.eq_ignore_ascii_case(net)).unwrap_or(false);+ // the narrowest piece of copper on each layer, tracks and arcs alike+ let mut narrow: std::collections::BTreeMap<String, (f64, Value)> = Default::default();+ let mut see = |layer: &str, w: f64, at: Value| {+ let e = narrow.entry(layer.to_string()).or_insert((f64::MAX, Value::Null));+ if w < e.0 { *e = (w, at); }+ };+ for t in tracks.iter().filter(|t| same(&t["net"])) { see(t["layer"].as_str().unwrap_or("?"), f(&t["width"]), json!([t["start"]["x"], t["start"]["y"]])); }+ for a in arcs.iter().filter(|a| same(&a["net"])) { see(a["layer"].as_str().unwrap_or("?"), f(&a["width"]), json!([a["center"]["x"], a["center"]["y"]])); }+ let mut short = Vec::new();+ let layers: Vec<Value> = narrow.into_iter().filter(|(l, _)| l.ends_with(".Cu")).map(|(layer, (w, at))| {+ let i = stack_index(&layer, n).unwrap_or(0);+ let outer = i == 0 || i + 1 == n;+ let cu = copper_um.get(i).copied().filter(|c| *c > 0.0).unwrap_or(35.0);+ let need = width_for(amps, rise, cu, outer);+ if w + 1e-9 < need { short.push(layer.clone()); }+ json!({"layer": layer, "copperUm": cu, "narrowestMm": r3(w), "at": at, "neededMm": r3(need), "ok": w + 1e-9 >= need})+ }).collect();+ let net_vias: Vec<&Value> = vias.iter().filter(|v| same(&v["net"])).collect();+ let smallest = net_vias.iter().map(|v| f(&v["drill"])).fold(f64::MAX, f64::min);+ let via = if net_vias.is_empty() { Value::Null } else {+ let each = via_amps(smallest, plating_um, rise);+ json!({"count": net_vias.len(), "smallestDrillMm": r3(smallest), "ampsEach": r2(each), "neededPerLayerChange": (amps / each).ceil() as i64})+ };+ let pours: Vec<Value> = polys.iter().filter(|p| same(&p["net"])).map(|p| p["layer"].clone()).collect();+ json!({"net": net, "amps": amps, "layers": layers, "shortOn": short, "ok": short.is_empty(), "vias": via, "poursOn": pours})+ }).collect()+}++/// altium_current_capacity {filePath?, nets: {net: amps}, riseC?, platingUm?}+pub fn current_capacity(args: &Value) -> Value {+ if let Some(g) = crate::support::need_licensed() { return g; }+ let nets = match args.get("nets").and_then(|v| v.as_object()) {+ Some(m) if !m.is_empty() && m.values().all(|v| v.as_f64().map(|a| a > 0.0).unwrap_or(false)) => m.clone(),+ _ => return json!({"success": false, "errorCode": "bad_args", "error": "nets is {net: amps}, e.g. {\"VIN\": 3, \"GND\": 3}"}),+ };+ let rise = args.get("riseC").and_then(|v| v.as_f64()).unwrap_or(10.0);+ let plating = args.get("platingUm").and_then(|v| v.as_f64()).unwrap_or(25.0);+ 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 missing: Vec<&String> = nets.keys().filter(|k| !st["nets"].as_array().map(|a| a.iter().any(|n| n.as_str().map(|s| s.eq_ignore_ascii_case(k)).unwrap_or(false))).unwrap_or(false)).collect();+ if !missing.is_empty() { return json!({"success": false, "errorCode": "no_such_net", "error": format!("not nets on this board: {missing:?}"), "nets": st["nets"]}); }+ let stack = crate::agent::call("layer_stack", &[], None, std::time::Duration::from_secs(60));+ let copper: Vec<f64> = stack.get("data").and_then(|d| d["layers"].as_array().cloned()).unwrap_or_default().iter().map(|l| f(&l["copperUm"])).collect();+ let rows = check(&st, &copper, &nets, rise, plating);+ let all_ok = rows.iter().all(|r| r["ok"].as_bool() == Some(true));+ json!({"success": true, "riseC": rise, "platingUm": plating, "nets": rows, "ok": all_ok,+ "_hint": if all_ok { "Every routed layer of these nets carries its current within the temperature rise (IPC-2221). Vias: a layer change needs neededPerLayerChange of them side by side." }+ else { "shortOn names the layers whose narrowest copper is under neededMm: widen it (altium_set_track_width, or a width rule with altium_add_rule) or pour the net (altium_apply_pours). A pour on the same layer (poursOn) may already carry the current; the check reads tracks only." }})+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn ipc_2221_matches_its_chart() {+ // 1 oz (35 um) outer copper, 10 C rise: 1 A needs about 0.30 mm (12 mil), 3 A about 1.4 mm+ let w1 = width_for(1.0, 10.0, 35.0, true);+ assert!((0.25..0.35).contains(&w1), "{w1}");+ let w3 = width_for(3.0, 10.0, 35.0, true);+ assert!((1.2..1.6).contains(&w3), "{w3}");+ // inner layers need about 2.6x the width+ assert!(width_for(1.0, 10.0, 35.0, false) / w1 > 2.4);+ // a 0.3 mm via with 25 um plating carries roughly 1 to 1.5 A at 10 C+ let v = via_amps(0.3, 25.0, 10.0);+ assert!((0.8..1.6).contains(&v), "{v}");+ }++ #[test]+ fn check_finds_the_narrow_layer() {+ let st = json!({"tracks": [{"net": "VIN", "layer": "F.Cu", "width": 1.5, "start": {"x": 0.0, "y": 0.0}}, {"net": "VIN", "layer": "B.Cu", "width": 0.3, "start": {"x": 4.0, "y": 1.0}}],+ "vias": [{"net": "VIN", "drill": 0.3}, {"net": "VIN", "drill": 0.4}], "polygons": [{"net": "VIN", "layer": "B.Cu"}]});+ let nets = json!({"VIN": 3.0}).as_object().unwrap().clone();+ let r = &check(&st, &[35.0, 35.0], &nets, 10.0, 25.0)[0];+ assert_eq!(r["shortOn"], json!(["B.Cu"]));+ assert_eq!(r["vias"]["smallestDrillMm"], 0.3);+ assert!(r["vias"]["neededPerLayerChange"].as_i64().unwrap() >= 2);+ assert_eq!(r["poursOn"], json!(["B.Cu"]));+ }+}
bridge-rs/src/design.rs+1@@ -571,3 +571,4 @@ mod room_tests { assert!(pads_box(&st, &["U7".to_string()], 1.0).is_none()); } }+
bridge-rs/src/fab.rsadded+243@@ -0,0 +1,243 @@+//! The board's fab-relevant minimums, measured from the live board, and how they compare with a fab's limits: the+//! numbers a fab's order form and capability page ask about, so the AI can check a board before ordering.+use serde_json::{json, Map, Value};++fn f(v: &Value) -> f64 { v.as_f64().unwrap_or(0.0) }+fn r3(v: f64) -> f64 { (v * 1000.0).round() / 1000.0 }++/// The outline's bounding box (x0, y0, x1, y1) in mm, arc edges included.+pub fn outline_bbox(state: &Value) -> Option<(f64, f64, f64, f64)> {+ let mut o: Vec<(f64, f64)> = state["outline"].as_array().cloned().unwrap_or_default().iter().map(|p| (f(&p[0]), f(&p[1]))).collect();+ // an arc edge bulges past its end points: sample it (the Bluetooth Sentinel example measured 80 x 4.5 mm by its+ // vertices alone)+ for e in state["outlineEdges"].as_array().cloned().unwrap_or_default() {+ if e["kind"].as_str() != Some("arc") { continue; }+ let (cx, cy, r) = (f(&e["center"][0]), f(&e["center"][1]), f(&e["radius"]));+ let (a1, mut a2) = (f(&e["angle1"]), f(&e["angle2"]));+ if a2 <= a1 { a2 += 360.0; }+ let mut a = a1;+ while a <= a2 + 1e-9 { o.push((cx + r * a.to_radians().cos(), cy + r * a.to_radians().sin())); a += 2.0; }+ }+ if o.is_empty() { return None; }+ let (x0, x1) = o.iter().fold((f64::MAX, f64::MIN), |a, p| (a.0.min(p.0), a.1.max(p.0)));+ let (y0, y1) = o.iter().fold((f64::MAX, f64::MIN), |a, p| (a.0.min(p.1), a.1.max(p.1)));+ Some((x0, y0, x1, y1))+}++/// The smallest of each feature with where it is. Pure, for the tests.+pub fn measure(state: &Value) -> Map<String, Value> {+ let mut m = Map::new();+ let mut min_by = |key: &str, items: Vec<(f64, Value)>| {+ if let Some((v, at)) = items.into_iter().filter(|(v, _)| *v > 0.0).min_by(|a, b| a.0.partial_cmp(&b.0).unwrap()) {+ m.insert(key.into(), json!({"mm": r3(v), "at": at}));+ }+ };+ // copper only: outline and drawing lines on mechanical layers (L57...) are not tracks a fab etches+ let copper = |l: &Value| l.as_str().map(|s| s == "F.Cu" || s == "B.Cu" || (s.starts_with("In") && s.ends_with(".Cu"))).unwrap_or(false);+ min_by("track", state["tracks"].as_array().cloned().unwrap_or_default().iter().filter(|t| copper(&t["layer"])).map(|t| (f(&t["width"]), json!({"net": t["net"], "layer": t["layer"], "from": [t["start"]["x"], t["start"]["y"]]}))).collect());+ let vias = state["vias"].as_array().cloned().unwrap_or_default();+ min_by("viaDrill", vias.iter().map(|v| (f(&v["drill"]), json!({"net": v["net"], "at": [v["position"]["x"], v["position"]["y"]]}))).collect());+ min_by("viaDiameter", vias.iter().map(|v| (f(&v["size"]), json!({"net": v["net"], "at": [v["position"]["x"], v["position"]["y"]]}))).collect());+ min_by("viaAnnularRing", vias.iter().map(|v| ((f(&v["size"]) - f(&v["drill"])) / 2.0, json!({"net": v["net"], "at": [v["position"]["x"], v["position"]["y"]]}))).collect());+ let pads = state["pads"].as_array().cloned().unwrap_or_default();+ let drilled: Vec<&Value> = pads.iter().filter(|p| f(&p["drill"]) > 0.0).collect();+ let label = |p: &Value| json!({"pad": format!("{}.{}", p["ref"].as_str().unwrap_or(""), p["name"].as_str().unwrap_or("")), "at": [p["x"], p["y"]]});+ min_by("padHole", drilled.iter().map(|p| (f(&p["drill"]), label(p))).collect());+ // a pad's ring on its narrow side (a round hole in an oblong pad)+ min_by("padAnnularRing", drilled.iter().filter(|p| f(&p["sizeX"]) > f(&p["drill"])).map(|p| ((f(&p["sizeX"]).min(f(&p["sizeY"])) - f(&p["drill"])) / 2.0, label(p))).collect());+ min_by("smdPad", pads.iter().filter(|p| f(&p["drill"]) == 0.0).map(|p| (f(&p["sizeX"]).min(f(&p["sizeY"])), label(p))).collect());+ if let Some((x0, y0, x1, y1)) = outline_bbox(state) {+ m.insert("boardSize".into(), json!({"mm": [r3(x1 - x0), r3(y1 - y0)]}));+ }+ m.insert("counts".into(), json!({"tracks": state["tracks"].as_array().map(|a| a.len()), "vias": vias.len(), "pads": pads.len(), "drilledPads": drilled.len()}));+ m+}++/// The measured values against the caller's limits; each limit names its feature (min*, or max for board and layers).+pub fn compare(meas: &Map<String, Value>, limits: &Map<String, Value>, clearance: Option<f64>, layers: Option<i64>) -> Vec<Value> {+ let mut out = Vec::new();+ let got = |k: &str| meas.get(k).and_then(|v| v["mm"].as_f64());+ for (limit, feature) in [("minTrack", "track"), ("minViaDrill", "viaDrill"), ("minViaDiameter", "viaDiameter"), ("minViaAnnularRing", "viaAnnularRing"),+ ("minPadHole", "padHole"), ("minPadAnnularRing", "padAnnularRing"), ("minSmdPad", "smdPad")] {+ if let (Some(l), Some(v)) = (limits.get(limit).and_then(|x| x.as_f64()), got(feature)) {+ if v + 1e-9 < l { out.push(json!({"limit": limit, "limitMm": l, "boardMm": v, "at": meas[feature]["at"]})); }+ }+ }+ if let (Some(l), Some(c)) = (limits.get("minClearance").and_then(|x| x.as_f64()), clearance) {+ if c + 1e-9 < l { out.push(json!({"limit": "minClearance", "limitMm": l, "boardMm": c, "at": "the board's clearance rule"})); }+ }+ if let (Some(l), Some(n)) = (limits.get("maxLayers").and_then(|x| x.as_i64()), layers) {+ if n > l { out.push(json!({"limit": "maxLayers", "limit_": l, "board": n})); }+ }+ if let (Some(lim), Some(sz)) = (limits.get("maxBoardMm").and_then(|x| x.as_array()), meas.get("boardSize").and_then(|b| b["mm"].as_array())) {+ let (lw, lh) = (lim.first().and_then(|x| x.as_f64()).unwrap_or(f64::MAX), lim.get(1).and_then(|x| x.as_f64()).unwrap_or(f64::MAX));+ let (w, h) = (sz[0].as_f64().unwrap_or(0.0), sz[1].as_f64().unwrap_or(0.0));+ // either orientation fits+ if !((w <= lw && h <= lh) || (w <= lh && h <= lw)) { out.push(json!({"limit": "maxBoardMm", "limitMm": [lw, lh], "boardMm": [w, h]})); }+ }+ out+}++/// The smallest clearance gap among the board's clearance rules, from their descriptors ("Gap=10mil" / "Gap=0.2mm").+pub fn min_clearance(rules: &[Value]) -> Option<f64> {+ rules.iter().filter(|r| r["kind"].as_i64() == Some(0) || r["kindName"].as_str() == Some("Clearance") || r["name"].as_str().map(|n| n.to_lowercase().contains("clearance")).unwrap_or(false))+ .filter(|r| r["enabled"].as_bool() != Some(false))+ .filter_map(|r| {+ let d = r["descriptor"].as_str()?;+ let i = d.find("Gap=")? + 4;+ let num: String = d[i..].chars().take_while(|c| c.is_ascii_digit() || *c == '.').collect();+ let v: f64 = num.parse().ok()?;+ let unit = &d[i + num.len()..];+ Some(if unit.starts_with("mil") { v * 0.0254 } else { v })+ }).reduce(f64::min).map(r3)+}++/// altium_fab_check {filePath?, limits?}+pub fn fab_check(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 mut meas = measure(&st);+ let rules = crate::agent::call("read_rules", &[], None, std::time::Duration::from_secs(60));+ let rule_list = rules.get("data").and_then(|d| d.get("rules")).and_then(|r| r.as_array()).cloned().unwrap_or_default();+ let clearance = min_clearance(&rule_list);+ if let Some(c) = clearance { meas.insert("clearanceRule".into(), json!({"mm": c})); }+ let stack = crate::agent::call("layer_stack", &[], None, std::time::Duration::from_secs(60));+ let layers = stack.get("data").and_then(|d| d.get("copperLayers")).and_then(|v| v.as_i64());+ if let Some(n) = layers { meas.insert("copperLayers".into(), json!(n)); }+ let limits = args.get("limits").and_then(|l| l.as_object()).cloned().unwrap_or_default();+ let fails = compare(&meas, &limits, clearance, layers);+ json!({"success": true, "measured": meas, "limitsChecked": limits.keys().collect::<Vec<_>>(), "outsideLimits": fails, "fits": if limits.is_empty() { Value::Null } else { json!(fails.is_empty()) },+ "_hint": if limits.is_empty() { "Measured only. Pass the fab's limits (from its capability page) as limits:{minTrack, minClearance, minViaDrill, minViaDiameter, minViaAnnularRing, minPadHole, minPadAnnularRing, minSmdPad, maxLayers, maxBoardMm:[w,h]} in mm to check them." }+ else { "outsideLimits names each feature below the fab's limit and where it is. Fix them (altium_set_track_width, altium_add_rule, a footprint change) or choose the fab's advanced service; then run altium_run_drc." }})+}++/// The panel's layout in mm, relative to the panel's lower-left corner: its size, where each board's outline corner+/// lands, tooling holes in the rails' corners and fiducials beside them. Pure, for the tests.+pub fn panel_layout(w: f64, h: f64, rows: i64, cols: i64, gap: f64, rail: f64) -> Value {+ let pw = 2.0 * rail + cols as f64 * w + (cols - 1) as f64 * gap;+ let ph = 2.0 * rail + rows as f64 * h + (rows - 1) as f64 * gap;+ let mut boards = Vec::new();+ for r in 0..rows { for c in 0..cols { boards.push(json!([r3(rail + c as f64 * (w + gap)), r3(rail + r as f64 * (h + gap))])); } }+ // rails 5 mm or wider carry 3 tooling holes (the fourth corner left bare so the panel only loads one way) and+ // 3 fiducials, each 5 mm in from its hole+ let (mut holes, mut fids) = (Vec::new(), Vec::new());+ if rail >= 5.0 {+ let m = rail / 2.0;+ for (x, y) in [(m, m), (pw - m, m), (m, ph - m)] { holes.push(json!([r3(x), r3(y)])); }+ for (x, y) in [(m + 5.0, m), (pw - m - 5.0, m), (m + 5.0, ph - m)] { fids.push(json!([r3(x), r3(y)])); }+ }+ json!({"size": [r3(pw), r3(ph)], "boards": boards, "toolingHoles": holes, "fiducials": fids})+}++/// altium_panelize {pcbPath, panelPath, rows, cols, gapMm?, railMm?, toolingHoles?, overwrite?}+pub fn panelize(args: &Value) -> Value {+ use crate::support::s;+ if let Some(g) = crate::support::need_licensed() { return g; }+ let (src, panel) = match (s(args, "pcbPath"), s(args, "panelPath")) {+ (Some(a), Some(b)) => (a, b),+ _ => return json!({"success": false, "errorCode": "bad_args", "error": "pcbPath (the board) and panelPath (the new panel .PcbDoc) are required"}),+ };+ if !panel.to_ascii_lowercase().ends_with(".pcbdoc") { return json!({"success": false, "errorCode": "bad_args", "error": "panelPath must end in .PcbDoc"}); }+ let int = |k: &str, d: i64| args.get(k).and_then(|v| v.as_i64()).unwrap_or(d);+ let (rows, cols) = (int("rows", 1), int("cols", 2));+ if rows < 1 || cols < 1 || rows * cols > 400 { return json!({"success": false, "errorCode": "bad_args", "error": "rows and cols are 1 or more, at most 400 boards"}); }+ let num = |k: &str, d: f64| args.get(k).and_then(|v| v.as_f64()).unwrap_or(d);+ let (gap, rail) = (num("gapMm", 2.0), num("railMm", 5.0));+ if gap < 0.0 || rail < 0.0 { return json!({"success": false, "errorCode": "bad_args", "error": "gapMm and railMm are 0 or more"}); }+ // Altium asks before it saves over a file (a blank Confirm, measured 2026-10-23): never let it, replace on purpose+ if std::path::Path::new(&panel).exists() {+ if args.get("overwrite").and_then(|b| b.as_bool()) != Some(true) {+ return json!({"success": false, "errorCode": "panel_exists", "error": format!("{panel} already exists"), "_hint": "Pass overwrite:true to close it in Altium (unsaved) and replace it, or choose another panelPath."});+ }+ // a document with that path may be open twice (a run cut short leaves its unsaved panel behind)+ for _ in 0..3 {+ let c = crate::agent::call("close_document", &[("path", panel.clone())], None, std::time::Duration::from_secs(30));+ if c.get("data").and_then(|d| d.get("ok")).and_then(|v| v.as_bool()) != Some(true) { break; }+ }+ if let Err(e) = std::fs::remove_file(&panel) { return json!({"success": false, "errorCode": "panel_locked", "error": format!("could not replace {panel}: {e}")}); }+ }+ if let Some(e) = crate::support::must_open(&src, 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 (x0, y0, x1, y1) = match outline_bbox(&st) { Some(b) => b, None => return json!({"success": false, "errorCode": "no_outline", "error": "the board has no outline to step and repeat"}) };+ let (w, h) = (x1 - x0, y1 - y0);+ let lay = panel_layout(w, h, rows, cols, gap, rail);+ use crate::routing::raw;+ let r = crate::agent::call("panelize", &[("path", src.clone()), ("panel", panel.clone()), ("rows", rows.to_string()), ("cols", cols.to_string()),+ ("colStep", raw(w + gap).to_string()), ("rowStep", raw(h + gap).to_string()), ("bx0", raw(x0).to_string()), ("by0", raw(y0).to_string()), ("rail", raw(rail).to_string())],+ None, std::time::Duration::from_secs(120));+ let d = match r.get("data") { Some(d) if d.get("ok").and_then(|v| v.as_bool()) == Some(true) => d.clone(), _ => return r };+ // the panel's own origin, in the same absolute mm the outline and features take+ let (ox, oy) = (d["originX"].as_f64().unwrap_or(0.0) / raw(1.0) as f64, d["originY"].as_f64().unwrap_or(0.0) / raw(1.0) as f64);+ let size = [lay["size"][0].as_f64().unwrap_or(0.0), lay["size"][1].as_f64().unwrap_or(0.0)];+ let outline = crate::routing::set_outline(&json!({"outline": {"rect": [ox, oy, size[0], size[1]]}}));+ let mut tooling = Value::Null;+ if args.get("toolingHoles").and_then(|b| b.as_bool()).unwrap_or(true) && !lay["toolingHoles"].as_array().map(|a| a.is_empty()).unwrap_or(true) {+ let at = |k: &str| lay[k].as_array().cloned().unwrap_or_default().iter().map(|p| json!({"x": ox + f(&p[0]), "y": oy + f(&p[1])})).collect::<Vec<_>>();+ let holes: Vec<Value> = at("toolingHoles").into_iter().map(|mut h| { h["drill"] = json!(2.0); h }).collect();+ let r = crate::features::board_features(&json!({"mountingHoles": holes, "fiducials": at("fiducials")}));+ tooling = json!({"planned": r["planned"], "applied": r["applied"], "failed": r["failed"]});+ }+ let saved = crate::surfaces::save(&json!({}));+ json!({"success": outline.get("success").and_then(|b| b.as_bool()) == Some(true), "panelPath": panel, "source": src, "rows": rows, "cols": cols,+ "boardMm": [r3(w), r3(h)], "panelMm": lay["size"], "gapMm": gap, "railMm": rail, "boardsAt": lay["boards"],+ "outline": outline, "toolingHoles": tooling, "saved": saved.get("success").cloned().unwrap_or(Value::Null),+ "_hint": "The panel is a new .PcbDoc holding an embedded board array of the source board, which stays linked: edit the board and the panel follows. Run altium_export_manufacturing on the panel for panel Gerbers. A gap of 0 suits V-score panels; a gap of 2 mm or more suits tab routing with mouse bites, which the fab adds from its own rules unless you draw them."})+}++#[cfg(test)]+mod tests {+ use super::*;++ fn board() -> Value {+ json!({"tracks": [{"width": 0.25, "net": "A", "layer": "F.Cu", "start": {"x": 0.0, "y": 0.0}}, {"width": 0.1, "net": "B", "layer": "B.Cu", "start": {"x": 5.0, "y": 5.0}}, {"width": 0.05, "net": "", "layer": "L57", "start": {"x": 0.0, "y": 0.0}}],+ "vias": [{"size": 0.6, "drill": 0.3, "net": "GND", "position": {"x": 1.0, "y": 1.0}}, {"size": 0.45, "drill": 0.25, "net": "B", "position": {"x": 2.0, "y": 2.0}}],+ "pads": [{"ref": "J1", "name": "1", "x": 0.0, "y": 0.0, "sizeX": 1.7, "sizeY": 1.7, "drill": 1.0}, {"ref": "U1", "name": "3", "x": 3.0, "y": 0.0, "sizeX": 0.3, "sizeY": 1.0, "drill": 0.0}],+ "outline": [[0, 0], [50, 0], [50, 30], [0, 30]]})+ }++ #[test]+ fn the_smallest_features_are_found_with_where_they_are() {+ let m = measure(&board());+ assert_eq!(m["track"]["mm"], json!(0.1));+ assert_eq!(m["track"]["at"]["net"], json!("B"));+ assert_eq!((m["viaDrill"]["mm"].clone(), m["viaDiameter"]["mm"].clone(), m["viaAnnularRing"]["mm"].clone()), (json!(0.25), json!(0.45), json!(0.1)));+ assert_eq!((m["padHole"]["mm"].clone(), m["padAnnularRing"]["mm"].clone(), m["smdPad"]["mm"].clone()), (json!(1.0), json!(0.35), json!(0.3)));+ assert_eq!(m["boardSize"]["mm"], json!([50.0, 30.0]));+ }++ #[test]+ fn arc_edges_count_toward_the_board_size() {+ let b = json!({"outline": [[0, 0], [10, 0]], "outlineEdges": [{"kind": "arc", "center": [5.0, 0.0], "radius": 5.0, "angle1": 0.0, "angle2": 180.0}]});+ assert_eq!(measure(&b)["boardSize"]["mm"], json!([10.0, 5.0]));+ }++ #[test]+ fn limits_name_what_falls_short_and_a_board_fits_either_way_round() {+ let m = measure(&board());+ let lim = json!({"minTrack": 0.127, "minViaDrill": 0.3, "maxBoardMm": [30, 60], "maxLayers": 2, "minClearance": 0.127});+ let f = compare(&m, lim.as_object().unwrap(), Some(0.1), Some(2));+ let names: Vec<&str> = f.iter().map(|x| x["limit"].as_str().unwrap()).collect();+ assert_eq!(names, vec!["minTrack", "minViaDrill", "minClearance"]);+ }++ #[test]+ fn clearance_is_read_from_rule_descriptors_in_mil_or_mm() {+ let rules = vec![json!({"kind": 0, "descriptor": "Clearance Constraint (Gap=10mil) (All),(All)", "enabled": true}),+ json!({"kind": 0, "descriptor": "Clearance Constraint (Gap=0.15mm) (InNet('SW')),(All)", "enabled": true}),+ json!({"kind": 0, "descriptor": "Clearance Constraint (Gap=0.05mm) (All),(All)", "enabled": false})];+ assert_eq!(min_clearance(&rules), Some(0.15));+ }+ #[test]+ fn panel_layout_places_boards_and_rails() {+ let l = panel_layout(40.0, 20.0, 2, 3, 2.0, 5.0);+ assert_eq!(l["size"], json!([134.0, 52.0]));+ assert_eq!(l["boards"].as_array().unwrap().len(), 6);+ assert_eq!(l["boards"][4], json!([47.0, 27.0]));+ assert_eq!(l["toolingHoles"].as_array().unwrap().len(), 3);+ assert_eq!(l["fiducials"][1], json!([126.5, 2.5]));+ assert!(panel_layout(40.0, 20.0, 1, 2, 0.0, 3.0)["toolingHoles"].as_array().unwrap().is_empty());+ }+}
bridge-rs/src/impedance.rsadded+206@@ -0,0 +1,206 @@+//! Controlled impedance from the board's own stack-up: the track width (and pair gap) each copper layer needs for a+//! target impedance, by closed-form models (Hammerstad-Jensen microstrip, Wheeler stripline, IPC-2141 coupling). Within+//! a few percent of a field solver for ordinary FR-4 geometry; the fab's stack-up and its solver have the last word.+use serde_json::{json, Value};+use std::f64::consts::PI;++fn r3(v: f64) -> f64 { (v * 1000.0).round() / 1000.0 }+fn r1(v: f64) -> f64 { (v * 10.0).round() / 10.0 }++/// Free-space impedance of a microstrip of width ratio u (Hammerstad-Jensen).+fn z01(u: f64) -> f64 {+ let f = 6.0 + (2.0 * PI - 6.0) * (-(30.666 / u).powf(0.7528)).exp();+ 60.0 * (f / u + (1.0 + 4.0 / (u * u)).sqrt()).ln()+}++fn eeff0(u: f64, er: f64) -> f64 {+ let a = 1.0 + ((u.powi(4) + (u / 52.0).powi(2)) / (u.powi(4) + 0.432)).ln() / 49.0 + (1.0 + (u / 18.1).powi(3)).ln() / 18.7;+ let b = 0.564 * ((er - 0.9) / (er + 3.0)).powf(0.053);+ (er + 1.0) / 2.0 + (er - 1.0) / 2.0 * (1.0 + 10.0 / u).powf(-a * b)+}++/// Uncoated microstrip: width w over a plane h below, copper t thick, dielectric er (any length unit).+pub fn microstrip(w: f64, h: f64, t: f64, er: f64) -> f64 {+ let u = w / h;+ let tn = t / h;+ let coth = |x: f64| x.cosh() / x.sinh();+ let du1 = if tn > 0.0 { tn / PI * (1.0 + 4.0 * std::f64::consts::E / (tn * coth((6.517 * u).sqrt()).powi(2))).ln() } else { 0.0 };+ let dur = 0.5 * (1.0 + 1.0 / (er - 1.0).sqrt().cosh()) * du1;+ let (u1, ur) = (u + du1, u + dur);+ let ee = eeff0(ur, er) * (z01(u1) / z01(ur)).powi(2);+ z01(ur) / ee.sqrt()+}++/// Centred stripline (Wheeler 1978, thickness corrected): width w between planes b apart, copper t.+fn stripline_sym(w: f64, b: f64, t: f64, er: f64) -> f64 {+ let x = t / b;+ let bt = b - t;+ let dw = if x > 0.0 {+ let n = 2.0 / (1.0 + 2.0 * x / (3.0 * (1.0 - x)));+ bt * (x / (PI * (1.0 - x))) * (1.0 - 0.5 * ((x / (2.0 - x)).powi(2) + (0.0796 * x / (w / b + 1.1 * x)).powf(n)).ln())+ } else { 0.0 };+ let m = (w + dw) / bt;+ let k = 8.0 / (PI * m);+ 30.0 / er.sqrt() * (1.0 + 4.0 / (PI * m) * (k + (k * k + 6.27).sqrt())).ln()+}++/// Stripline h1 above and h2 below the track (planes h1 + t + h2 apart): Cohn's combination of the two centred cases.+pub fn stripline(w: f64, h1: f64, h2: f64, t: f64, er: f64) -> f64 {+ let za = stripline_sym(w, 2.0 * h1 + t, t, er);+ let zb = stripline_sym(w, 2.0 * h2 + t, t, er);+ 2.0 * za * zb / (za + zb)+}++/// Edge-coupled differential impedance (IPC-2141): the single-ended z0, gap s, and h (microstrip) or plane spacing b+/// (stripline).+pub fn differential(z0: f64, s: f64, hb: f64, strip: bool) -> f64 {+ if strip { 2.0 * z0 * (1.0 - 0.374 * (-2.9 * s / hb).exp()) } else { 2.0 * z0 * (1.0 - 0.48 * (-0.96 * s / hb).exp()) }+}++/// The width (same unit as the geometry) where `z(w)` meets `target`; z falls as w grows. None when out of range.+fn solve(target: f64, lo: f64, hi: f64, z: impl Fn(f64) -> f64) -> Option<f64> {+ if z(lo) < target || z(hi) > target { return None; }+ let (mut a, mut b) = (lo, hi);+ for _ in 0..80 {+ let m = (a + b) / 2.0;+ if z(m) > target { a = m } else { b = m }+ }+ Some((a + b) / 2.0)+}++/// One copper layer's geometry from the stack (mm): the dielectric to the copper above and below, and their Dk.+#[derive(Debug, Clone)]+pub struct Geo { pub name: String, pub t: f64, pub above: Option<(f64, f64)>, pub below: Option<(f64, f64)> }++/// The stack summary (`layer_stack` data) as each copper layer's geometry.+pub fn geometry(stack: &Value) -> Vec<Geo> {+ let ls: Vec<Value> = stack["layers"].as_array().cloned().unwrap_or_default();+ let um = |v: &Value| v.as_f64().unwrap_or(0.0) / 1000.0;+ let diel = |l: &Value| -> Option<(f64, f64)> {+ let h = um(&l["dielectricUm"]);+ if h <= 0.0 { None } else { Some((h, l["dk1000"].as_f64().map(|d| d / 1000.0).unwrap_or(4.3))) }+ };+ (0..ls.len()).map(|i| Geo {+ name: ls[i]["name"].as_str().unwrap_or("").to_string(),+ t: um(&ls[i]["copperUm"]).max(0.0),+ above: if i > 0 { diel(&ls[i - 1]) } else { None },+ below: diel(&ls[i]),+ }).collect()+}++/// Width (and gap) for each layer, mm. `diff` is the differential target; `gap` fixes the pair gap (else gap = width).+pub fn widths(geo: &[Geo], z0: Option<f64>, diff: Option<f64>, gap: Option<f64>) -> Vec<Value> {+ geo.iter().map(|g| {+ let (strip, z): (bool, Box<dyn Fn(f64) -> f64>) = match (g.above, g.below) {+ (Some((h1, e1)), Some((h2, e2))) => {+ let er = (e1 * h1 + e2 * h2) / (h1 + h2);+ let t = g.t;+ (true, Box::new(move |w| stripline(w, h1, h2, t, er)))+ }+ (None, Some((h, er))) | (Some((h, er)), None) => { let t = g.t; (false, Box::new(move |w| microstrip(w, h, t, er))) }+ (None, None) => return json!({"layer": g.name, "error": "no dielectric next to this layer in the stack"}),+ };+ // the spacing the coupling term uses: h for microstrip, plane to plane for stripline+ let hb = match (g.above, g.below) { (Some((a, _)), Some((b, _))) => a + b, (Some((h, _)), None) | (None, Some((h, _))) => h, _ => 1.0 };+ let mut o = json!({"layer": g.name, "model": if strip { "stripline" } else { "microstrip" }, "copperUm": r1(g.t * 1000.0),+ "dielectricMm": [g.above.map(|a| r3(a.0)), g.below.map(|b| r3(b.0))]});+ if let Some(t) = z0 {+ match solve(t, 0.01, 20.0, &z) { Some(w) => { o["width"] = json!(r3(w)); o["z0"] = json!(r1(z(w))); } None => { o["z0Error"] = json!(format!("{t} ohm is out of reach on this layer (0.01 to 20 mm)")); } }+ }+ if let Some(t) = diff {+ let zd = |w: f64| differential(z(w), gap.unwrap_or(w), hb, strip);+ match solve(t, 0.01, 20.0, zd) {+ Some(w) => { o["diffWidth"] = json!(r3(w)); o["diffGap"] = json!(r3(gap.unwrap_or(w))); o["zdiff"] = json!(r1(zd(w))); o["diffZ0"] = json!(r1(z(w))); }+ None => { o["diffError"] = json!(format!("{t} ohm differential is out of reach on this layer with that gap")); }+ }+ }+ o+ }).collect()+}++/// altium_impedance {filePath?, z0?, diff?, gapMm?, layers?, apply?: {scope, name?}}+pub fn impedance(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 z0 = args.get("z0").and_then(|v| v.as_f64());+ let diff = args.get("diff").and_then(|v| v.as_f64());+ let z0 = if z0.is_none() && diff.is_none() { Some(50.0) } else { z0 };+ let gap = args.get("gapMm").and_then(|v| v.as_f64());+ let r = crate::agent::call("layer_stack", &[], None, std::time::Duration::from_secs(60));+ let stack = match r.get("data") { Some(d) if d.get("ok").and_then(|v| v.as_bool()) == Some(true) => d.clone(), _ => return r };+ let mut geo = geometry(&stack);+ if let Some(want) = args.get("layers").and_then(|v| v.as_array()) {+ let names: Vec<String> = want.iter().filter_map(|v| v.as_str()).map(|s| s.to_ascii_lowercase()).collect();+ geo.retain(|g| names.contains(&g.name.to_ascii_lowercase()));+ }+ let rows = widths(&geo, z0, diff, gap);+ // a rigid-flex stack (the Bluetooth Sentinel example) has a coverlay a few microns thick next to a copper layer:+ // the numbers are right for that geometry and useless for a rigid board, so say which rows to doubt+ let mut warnings = Vec::new();+ for (g, row) in geo.iter().zip(&rows) {+ for d in [g.above, g.below].iter().flatten() {+ if d.0 < 0.05 { warnings.push(format!("{}: a {} mm dielectric next to it (a flex coverlay or a stack-up typo?): check the stack in the Layer Stack Manager", g.name, r3(d.0))); break; }+ }+ for k in ["width", "diffWidth", "diffGap"] {+ if let Some(w) = row[k].as_f64() { if w < 0.075 { warnings.push(format!("{}: {k} {w} mm is below what most fabs make (0.075 to 0.1 mm)", g.name)); } }+ }+ }+ let mut applied = Vec::new();+ if let Some(ap) = args.get("apply") {+ let scope = match ap.get("scope").and_then(|v| v.as_str()) { Some(s) => s.to_string(), None => return json!({"success": false, "errorCode": "bad_args", "error": "apply.scope is the Altium query the rule covers, e.g. InNetClass('RF') or InDifferentialPair('USB')"}) };+ let base = ap.get("name").and_then(|v| v.as_str()).unwrap_or("Impedance");+ for row in &rows {+ let layer = row["layer"].as_str().unwrap_or("");+ let q = format!("({scope}) And OnLayer('{layer}')");+ let a = if diff.is_some() {+ let (Some(w), Some(s)) = (row["diffWidth"].as_f64(), row["diffGap"].as_f64()) else { continue };+ json!({"kind": "diffpair", "name": format!("{base} {layer}"), "scope1": q, "width": w, "gap": s, "minWidth": r3(w * 0.9), "maxWidth": r3(w * 1.1), "minGap": r3(s * 0.9), "maxGap": r3(s * 1.1)})+ } else {+ let Some(w) = row["width"].as_f64() else { continue };+ json!({"kind": "width", "name": format!("{base} {layer}"), "scope1": q, "min": r3(w * 0.9), "preferred": w, "max": r3(w * 1.1)})+ };+ let res = crate::routing::add_rule(&a);+ applied.push(json!({"layer": layer, "rule": a["name"], "success": res.get("success").cloned().unwrap_or(json!(false)), "error": res.get("error").cloned().unwrap_or(Value::Null)}));+ }+ }+ json!({"success": true, "targets": {"z0": z0, "diff": diff, "gapMm": gap}, "layers": rows, "applied": if applied.is_empty() { Value::Null } else { json!(applied) }, "warnings": warnings,+ "stack": {"copperLayers": stack["copperLayers"], "totalUm": stack["totalUm"]},+ "_hint": "Widths are for uncoated copper: solder mask lowers an outer layer's impedance by about 1 to 3 ohm. Inner layers assume planes on both neighbours. Within a few percent of a field solver; send the fab the target and let its stack-up tune it. apply:{scope} writes one width or diffpair rule per layer (OnLayer) at +/-10%."})+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn microstrip_matches_the_textbook_50_ohm_line() {+ // FR-4 er 4.4, 1.6 mm, 35 um: a 50 ohm line is about 3.0 mm wide (Pozar, field solvers 2.9 to 3.1)+ let w = solve(50.0, 0.01, 20.0, |w| microstrip(w, 1.6, 0.035, 4.4)).unwrap();+ assert!((2.8..3.2).contains(&w), "{w}");+ }++ #[test]+ fn stripline_matches_the_textbook_case() {+ // centred stripline, planes 1 mm apart, er 4.3, t 17 um: IPC-2141's formula puts 50 ohm near 0.40 mm+ let w = solve(50.0, 0.01, 20.0, |w| stripline(w, 0.4915, 0.4915, 0.017, 4.3)).unwrap();+ assert!((0.38..0.48).contains(&w), "{w}");+ // off centre, the nearer plane pulls the impedance down+ assert!(stripline(0.3, 0.2, 0.8, 0.017, 4.3) < stripline(0.3, 0.5, 0.5, 0.017, 4.3));+ }++ #[test]+ fn geometry_and_widths_from_a_two_layer_stack() {+ let st = json!({"layers": [{"name": "Top Layer", "copperUm": 36, "dielectricUm": 320, "dk1000": 4800}, {"name": "Bottom Layer", "copperUm": 36}]});+ let g = geometry(&st);+ assert_eq!(g.len(), 2);+ assert!(g[1].above.is_some() && g[1].below.is_none());+ let rows = widths(&g, Some(50.0), Some(90.0), None);+ let w = rows[0]["width"].as_f64().unwrap();+ assert!((0.45..0.65).contains(&w), "{w}");+ assert_eq!(rows[0]["model"], "microstrip");+ // gap = width: a 90 ohm pair's tracks come out near the 50 ohm width+ assert!((rows[0]["diffWidth"].as_f64().unwrap() - w).abs() < 0.1);+ assert!(widths(&g, None, Some(90.0), Some(0.2))[0]["diffWidth"].as_f64().unwrap() < rows[0]["diffWidth"].as_f64().unwrap());+ assert!((rows[0]["zdiff"].as_f64().unwrap() - 90.0).abs() < 0.5);+ }+}
bridge-rs/src/main.rs+5−1@@ -7,13 +7,17 @@ mod agent; mod board; mod bom; mod sch;+mod schdoc; mod design; mod dialogs; mod health; mod install;+mod fab; mod features; mod footprint; mod hier;+mod current;+mod impedance; mod inspect; mod lengths; mod instance;@@ -42,7 +46,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.70";+pub const BRIDGE_VERSION: &str = "0.2.71"; 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/schdoc.rsadded+207@@ -0,0 +1,207 @@+//! Read an Altium schematic straight from its file, without Altium: a .SchDoc is an OLE compound file whose+//! "FileHeader" stream holds the sheet as `|KEY=value|...` records. Parts, designators, parameters, pins, positions+//! and UniqueIDs come out in milliseconds, with Altium closed.+use serde_json::{json, Map, Value};+use std::collections::BTreeMap;++const END: u32 = 0xFFFF_FFFA; // ENDOFCHAIN and the other markers sit at or above this++fn u16_at(b: &[u8], o: usize) -> Option<u16> { b.get(o..o + 2).map(|s| u16::from_le_bytes([s[0], s[1]])) }+fn u32_at(b: &[u8], o: usize) -> Option<u32> { b.get(o..o + 4).map(|s| u32::from_le_bytes([s[0], s[1], s[2], s[3]])) }+fn u64_at(b: &[u8], o: usize) -> Option<u64> { b.get(o..o + 8).map(|s| u64::from_le_bytes([s[0], s[1], s[2], s[3], s[4], s[5], s[6], s[7]])) }++/// The named streams of a compound file (MS-CFB): FAT and DIFAT chains, the directory, the mini stream.+pub fn cfb_streams(b: &[u8]) -> Result<BTreeMap<String, Vec<u8>>, String> {+ if b.len() < 512 || b[..8] != [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1] { return Err("not a compound file (no OLE signature)".into()); }+ let ss = 1usize << u16_at(b, 0x1E).ok_or("short header")?;+ let mss = 1usize << u16_at(b, 0x20).ok_or("short header")?;+ let (nfat, dir1, cutoff, mf1, nmf, df1, mut ndf) = (u32_at(b, 0x2C).unwrap_or(0) as usize, u32_at(b, 0x30).unwrap_or(END), u32_at(b, 0x38).unwrap_or(4096) as u64,+ u32_at(b, 0x3C).unwrap_or(END), u32_at(b, 0x40).unwrap_or(0), u32_at(b, 0x44).unwrap_or(END), u32_at(b, 0x48).unwrap_or(0));+ let sector = |s: u32| -> Option<&[u8]> { let o = (s as usize + 1) * ss; b.get(o..o + ss) };+ let mut difat: Vec<u32> = (0..109).filter_map(|i| u32_at(b, 0x4C + i * 4)).collect();+ let mut s = df1;+ while s < END && ndf > 0 {+ let sec = sector(s).ok_or("bad DIFAT sector")?;+ let n = ss / 4;+ difat.extend((0..n - 1).filter_map(|i| u32_at(sec, i * 4)));+ s = u32_at(sec, (n - 1) * 4).unwrap_or(END);+ ndf -= 1;+ }+ let mut fat: Vec<u32> = Vec::new();+ for &fs in difat.iter().take(nfat) {+ let sec = sector(fs).ok_or("bad FAT sector")?;+ fat.extend((0..ss / 4).filter_map(|i| u32_at(sec, i * 4)));+ }+ let chain = |start: u32, table: &[u32]| -> Vec<u32> {+ let mut out = Vec::new();+ let mut s = start;+ while s < END && (s as usize) < table.len() && out.len() < table.len() { out.push(s); s = table[s as usize]; }+ out+ };+ let read = |start: u32, size: u64| -> Vec<u8> {+ let mut v = Vec::new();+ for s in chain(start, &fat) { if let Some(sec) = sector(s) { v.extend_from_slice(sec); } }+ v.truncate(size.min(v.len() as u64) as usize);+ v+ };+ let dir = read(dir1, u64::MAX);+ let ents: Vec<(String, u8, u32, u64)> = dir.chunks(128).filter(|e| e.len() == 128).map(|e| {+ let nl = u16_at(e, 64).unwrap_or(0) as usize;+ let units: Vec<u16> = (0..nl.saturating_sub(2) / 2).filter_map(|i| u16_at(e, i * 2)).collect();+ (String::from_utf16_lossy(&units), e[66], u32_at(e, 0x74).unwrap_or(END), u64_at(e, 0x78).unwrap_or(0))+ }).collect();+ let root = ents.first().ok_or("no root entry")?;+ let mini = read(root.2, root.3);+ let mut minifat: Vec<u32> = Vec::new();+ if nmf > 0 { for s in chain(mf1, &fat) { if let Some(sec) = sector(s) { minifat.extend((0..ss / 4).filter_map(|i| u32_at(sec, i * 4))); } } }+ let mut out = BTreeMap::new();+ for (name, typ, start, size) in ents.iter().skip(1) {+ if *typ != 2 { continue; }+ let data = if *size < cutoff {+ let mut v = Vec::new();+ for s in chain(*start, &minifat) { let o = s as usize * mss; if let Some(m) = mini.get(o..o + mss) { v.extend_from_slice(m); } }+ v.truncate(*size as usize);+ v+ } else { read(*start, *size) };+ out.insert(name.clone(), data);+ }+ Ok(out)+}++/// The text records of a FileHeader stream, keys upper-cased; a `%UTF8%KEY` value (UTF-8) replaces KEY's (stored in+/// the local codepage, so "±20%" read back garbled).+pub fn records(data: &[u8]) -> Vec<BTreeMap<String, String>> {+ let mut out = Vec::new();+ let mut i = 0;+ while i + 4 <= data.len() {+ let n = u32_at(data, i).unwrap_or(0);+ let (len, typ) = ((n & 0x00FF_FFFF) as usize, n >> 24);+ i += 4;+ let body = &data[i..(i + len).min(data.len())];+ i += len;+ if typ != 0 { continue; }+ let mut m = BTreeMap::new();+ let mut utf8: Vec<(String, String)> = Vec::new();+ for part in body.split(|&c| c == b'|') {+ let part: &[u8] = if part.last() == Some(&0) { &part[..part.len() - 1] } else { part };+ let Some(eq) = part.iter().position(|&c| c == b'=') else { continue };+ let key = String::from_utf8_lossy(&part[..eq]).to_ascii_uppercase();+ let raw = &part[eq + 1..];+ if let Some(k) = key.strip_prefix("%UTF8%") { utf8.push((k.to_string(), String::from_utf8_lossy(raw).to_string())); continue; }+ m.insert(key, raw.iter().map(|&c| c as char).collect::<String>());+ }+ for (k, v) in utf8 { m.insert(k, v); }+ out.push(m);+ }+ out+}++fn num(r: &BTreeMap<String, String>, k: &str) -> i64 { r.get(k).and_then(|v| v.parse::<i64>().ok()).unwrap_or(0) }+/// A coordinate in mil: the value is in 10 mil units, with a _FRAC part in 1/100000 of one.+fn mil(r: &BTreeMap<String, String>, k: &str) -> f64 { (num(r, k) as f64 + num(r, &format!("{k}_FRAC")) as f64 / 100_000.0) * 10.0 }++/// The sheet's components, each with its designator, parameters and (its own part's) pins. Pure, for the tests.+pub fn components(recs: &[BTreeMap<String, String>], with_pins: bool, with_params: bool) -> Vec<Value> {+ // the first record is the file header; owners count from the record after it+ let body: &[BTreeMap<String, String>] = if recs.first().map(|r| r.contains_key("HEADER")).unwrap_or(false) { &recs[1..] } else { recs };+ let mut owned: BTreeMap<i64, Vec<&BTreeMap<String, String>>> = BTreeMap::new();+ for r in body { if let Some(o) = r.get("OWNERINDEX").and_then(|v| v.parse::<i64>().ok()) { owned.entry(o).or_default().push(r); } }+ let mut out = Vec::new();+ for (i, c) in body.iter().enumerate() {+ if c.get("RECORD").map(|r| r.as_str()) != Some("1") { continue; }+ let kids = owned.get(&(i as i64)).cloned().unwrap_or_default();+ let field = |rec: &str, name: &str| kids.iter().find(|k| k.get("RECORD").map(|r| r.as_str()) == Some(rec) && k.get("NAME").map(|n| n.eq_ignore_ascii_case(name)).unwrap_or(false)).and_then(|k| k.get("TEXT").cloned());+ let part = num(c, "CURRENTPARTID").max(1);+ let mode = num(c, "DISPLAYMODE");+ let mut o = Map::new();+ o.insert("designator".into(), json!(kids.iter().find(|k| k.get("RECORD").map(|r| r.as_str()) == Some("34")).and_then(|k| k.get("TEXT").cloned()).unwrap_or_default()));+ o.insert("libReference".into(), json!(c.get("LIBREFERENCE").cloned().unwrap_or_default()));+ o.insert("comment".into(), json!(field("41", "Comment").unwrap_or_default()));+ o.insert("description".into(), json!(c.get("COMPONENTDESCRIPTION").cloned().unwrap_or_default()));+ o.insert("uniqueId".into(), json!(c.get("UNIQUEID").cloned().unwrap_or_default()));+ o.insert("partId".into(), json!(part));+ o.insert("partCount".into(), json!((num(c, "PARTCOUNT") - 1).max(1)));+ o.insert("xMil".into(), json!(mil(c, "LOCATION.X").round()));+ o.insert("yMil".into(), json!(mil(c, "LOCATION.Y").round()));+ o.insert("orientation".into(), json!(num(c, "ORIENTATION")));+ o.insert("sourceLibrary".into(), json!(c.get("SOURCELIBRARYNAME").cloned().unwrap_or_default()));+ if with_params {+ let ps: Vec<Value> = kids.iter().filter(|k| k.get("RECORD").map(|r| r.as_str()) == Some("41"))+ .filter_map(|k| Some(json!({"name": k.get("NAME")?, "value": k.get("TEXT").cloned().unwrap_or_default()}))).collect();+ o.insert("params".into(), json!(ps));+ }+ if with_pins {+ // a placed part holds every part's and display mode's pins: the ones it shows are its own part, the shared+ // part 0, and its display mode+ let pins: Vec<Value> = kids.iter().filter(|k| k.get("RECORD").map(|r| r.as_str()) == Some("2"))+ .filter(|k| { let p = num(k, "OWNERPARTID"); (p == part || p == 0) && num(k, "OWNERPARTDISPLAYMODE") == mode })+ .map(|k| json!({"number": k.get("DESIGNATOR").cloned().unwrap_or_default(), "name": k.get("NAME").cloned().unwrap_or_default(), "electrical": num(k, "ELECTRICAL")})).collect();+ o.insert("pins".into(), json!(pins));+ }+ out.push(Value::Object(o));+ }+ out+}++/// altium_read_sheet_file {path, pins?, params?}: the sheet's parts read from the file, no Altium needed.+pub fn read_sheet_file(args: &Value) -> Value {+ let Some(path) = crate::support::s(args, "path").or_else(|| crate::support::s(args, "filePath")).filter(|p| p.to_lowercase().ends_with(".schdoc")) else {+ return json!({"success": false, "errorCode": "bad_args", "error": "path (a .SchDoc) is required"});+ };+ let t0 = std::time::Instant::now();+ let b = match std::fs::read(&path) { Ok(b) => b, Err(e) => return json!({"success": false, "errorCode": "not_found", "error": format!("{path}: {e}")}) };+ let streams = match cfb_streams(&b) { Ok(s) => s, Err(e) => return json!({"success": false, "errorCode": "not_a_schdoc", "error": e, "_hint": "Only binary .SchDoc files (Altium's default) are read; an ASCII-format sheet is not."}) };+ let Some(fh) = streams.get("FileHeader") else { return json!({"success": false, "errorCode": "not_a_schdoc", "error": "no FileHeader stream"}) };+ let recs = records(fh);+ let pins = args.get("pins").and_then(|v| v.as_bool()) != Some(false);+ let params = args.get("params").and_then(|v| v.as_bool()) == Some(true);+ let comps = components(&recs, pins, params);+ let header = recs.first().and_then(|r| r.get("HEADER").cloned()).unwrap_or_default();+ json!({"success": true, "path": path, "format": header, "records": recs.len(), "count": comps.len(), "components": comps, "elapsedMs": t0.elapsed().as_millis() as u64,+ "_hint": "Read from the saved file, without Altium: unsaved edits in an open Altium are not here (altium_schematic_data reads the live sheet). Coordinates are mil; pins are each part's own plus the shared part 0."})+}++#[cfg(test)]+mod tests {+ use super::*;++ fn comps(bytes: &[u8]) -> Vec<Value> {+ let s = cfb_streams(bytes).unwrap();+ components(&records(&s["FileHeader"]), true, true)+ }++ #[test]+ fn the_lane_sheet_reads_like_altium_reports_it() {+ let c = comps(include_bytes!("../testdata/lane.SchDoc"));+ let des: Vec<&str> = c.iter().map(|x| x["designator"].as_str().unwrap()).collect();+ assert_eq!(des, vec!["U2", "U3", "U1", "R90"]);+ // the same ids altium_schematic_data reported live (2026-10-23)+ assert_eq!(c[0]["uniqueId"], json!("HXDEWKXY"));+ assert_eq!(c[0]["xMil"], json!(2400.0));+ assert_eq!(c[0]["pins"].as_array().unwrap().len(), 8);+ assert_eq!(c[3]["libReference"], json!("RES-2"));+ }++ #[test]+ fn a_two_part_mcu_shows_each_part_its_own_pins_and_utf8_text() {+ // Altium's own example sheet, not redistributed here: read from an install (or ALTIUM_EXAMPLE_SCHDOC) when one+ // is reachable, else skipped+ let ex = "Examples/Bluetooth Sentinel/Microcontroller_STM32F101.SchDoc";+ let tries: Vec<String> = std::env::var("ALTIUM_EXAMPLE_SCHDOC").into_iter()+ .chain(["ADDevelop", "AD26"].iter().map(|v| format!("/mnt/c/Users/Public/Documents/Altium/{v}/{ex}"))).collect();+ let Some(bytes) = tries.iter().find_map(|p| std::fs::read(p).ok()) else { eprintln!("skipped: no Altium example sheet at {tries:?}"); return };+ let c = comps(&bytes);+ assert_eq!(c.len(), 16);+ let u2: Vec<&Value> = c.iter().filter(|x| x["designator"] == json!("U2")).collect();+ assert_eq!(u2.len(), 2);+ assert_eq!((u2[0]["partId"].clone(), u2[1]["partId"].clone(), u2[0]["partCount"].clone()), (json!(1), json!(2), json!(2)));+ let (a, b) = (u2[0]["pins"].as_array().unwrap().len(), u2[1]["pins"].as_array().unwrap().len());+ assert!(a > 0 && b > 0 && a + b <= 37 + 37, "each part lists its own pins ({a} + {b})");+ assert!(u2[0]["description"].as_str().unwrap().starts_with("STM32 ARM-based 32-bit MCU"));+ }++ #[test]+ fn a_file_that_is_not_a_compound_file_is_refused() {+ assert!(cfb_streams(b"hello world, definitely not OLE").is_err());+ }+}
bridge-rs/src/support.rs+5−1@@ -302,7 +302,11 @@ pub fn altium_public_file(rel: &str) -> Result<String, String> { /// id; None when the file cannot be read. pub fn sheet_unique_ids(path: &str) -> Option<Vec<String>> { let b = std::fs::read(path).ok()?;- Some(unique_ids_in(&b))+ // the compound file read properly (a record can straddle two sectors); the raw byte scan stays as the fallback+ match crate::schdoc::cfb_streams(&b).ok().and_then(|s| s.get("FileHeader").cloned()) {+ Some(fh) => Some(crate::schdoc::components(&crate::schdoc::records(&fh), false, false).iter().filter_map(|c| c["uniqueId"].as_str().map(str::to_string)).collect()),+ None => Some(unique_ids_in(&b)),+ } } pub fn unique_ids_in(b: &[u8]) -> Vec<String> {
bridge-rs/src/verbs.rs+57−2@@ -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", "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",+ "routing_state", "route_net", "move_parts", "place_vias", "board_features", "sch_sheet_symbol", "netlist", "silkscreen_prep", "net_lengths", "teardrops", "component_classes", "read_sheet_file", "fab_check", "panelize", "impedance", "current_capacity", "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",@@ -706,6 +706,41 @@ 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("current_capacity", "Will this trace carry the current? For each power net and its current: the narrowest track on every layer it is routed on against the width IPC-2221 needs for the allowed temperature rise (with each layer's real copper weight), and what one of its vias carries.",+ json!({"filePath": "the .PcbDoc (opened first; else the current board)", "nets": "{net: amps}, e.g. {VIN: 3, GND: 3}", "riseC": "allowed temperature rise, default 10", "platingUm": "via barrel plating, default 25"}),+ json!({"nets": "[{net, amps, layers:[{layer, copperUm, narrowestMm, at, neededMm, ok}], shortOn, ok, vias:{count, smallestDrillMm, ampsEach, neededPerLayerChange}, poursOn}]", "ok": "bool"}), 120,+ "Widen what shortOn names (altium_set_track_width, altium_add_rule kind width) or pour the net (altium_apply_pours); a layer change needs neededPerLayerChange vias side by side (altium_place_vias).",+ &["routing_state", "layer_stack", "set_track_width", "add_rule", "apply_pours", "place_vias"],+ &["IPC-2221 (conservative, the chart fabs quote); IPC-2152 allows more on inner layers next to planes.", "Reads tracks and arcs: a pour on the same layer (poursOn) may already carry the current."],+ json!({"nets": {"VIN": 3, "SW": 3, "GND": 3}, "riseC": 10})),+ verb_doc("impedance", "Controlled impedance from the board's own stack-up: the track width each copper layer needs for a single-ended target (50 ohm default) and the width and gap for a differential pair (90 ohm USB, 100 ohm Ethernet/LVDS), microstrip outside and stripline inside; optionally written as width or diff-pair rules per layer.",+ json!({"filePath": "the .PcbDoc (opened first; else the current board)", "z0": "single-ended ohm (default 50 when diff is not given)", "diff": "differential ohm, e.g. 90 or 100", "gapMm": "fix the pair gap (default: gap = width)", "layers": "[layer names] (default every copper layer)", "apply": "{scope: Altium query e.g. InNetClass('RF') or InDifferentialPair('USB'), name?} - one rule per layer at +/-10%"}),+ json!({"layers": "[{layer, model, copperUm, dielectricMm:[above, below], width, z0, diffWidth, diffGap, zdiff}]", "applied": "[{layer, rule, success}] | null"}), 120,+ "Then altium_add_rule or apply writes the rules; altium_diff_pairs lists the pairs to scope them to. Tell the fab the target impedance: it tunes the stack-up with its own solver.",+ &["layer_stack", "add_rule", "diff_pairs", "net_classes"],+ &["Closed-form models (Hammerstad-Jensen, Wheeler, IPC-2141 coupling): within a few percent of a field solver on FR-4; uncoated (mask lowers outer layers 1 to 3 ohm).", "Inner layers assume planes on both neighbours."],+ json!({"diff": 90, "z0": 50, "apply": {"scope": "InDifferentialPair('USB')", "name": "USB 90"}})),+ verb_doc("panelize", "Step and repeat a board into a manufacturing panel: a new .PcbDoc holding Altium's own embedded board array of the board (rows x cols, a gap between boards), with rails, a panel outline, three tooling holes and three fiducials on the rails. The array stays linked to the board.",+ json!({"pcbPath": "the board's .PcbDoc", "panelPath": "the new panel .PcbDoc (beside the board, or anywhere)", "rows": "default 1", "cols": "default 2", "gapMm": "between boards, default 2 (0 for V-score)", "railMm": "rail width, default 5 (0 for none)", "toolingHoles": "default true: 2 mm unplated holes and 1 mm fiducials when rails are 5 mm or wider", "overwrite": "bool - replace an existing panelPath (closed in Altium unsaved first); without it an existing file is refused"}),+ json!({"panelPath": "path", "boardMm": "[w, h] of one board's outline", "panelMm": "[w, h]", "boardsAt": "[[x, y]] each board's outline corner from the panel's corner", "toolingHoles": "the board_features reply", "saved": "bool"}), 180,+ "altium_export_manufacturing on the panel gives panel Gerbers; altium_fab_check {maxBoardMm} checks the panel against the fab's size limit.",+ &["fab_check", "export_manufacturing", "board_features", "set_outline"],+ &["Opens the board, then creates and saves the panel: the panel ends as the current document.", "Board size is the outline's bounding box, arcs included."],+ json!({"pcbPath": "C:/proj/Board.PcbDoc", "panelPath": "C:/proj/Panel.PcbDoc", "rows": 2, "cols": 3, "gapMm": 2, "railMm": 5})),+ verb_doc("fab_check", "Before ordering: the board's fab-relevant minimums measured from the live board (narrowest track, smallest via drill, diameter and annular ring, smallest pad hole and ring, smallest SMD pad, clearance rule, copper layers, board size), each with where it is; with a fab's limits, which ones fall short.",+ json!({"filePath": "the .PcbDoc (opened first)", "limits": "{minTrack, minClearance, minViaDrill, minViaDiameter, minViaAnnularRing, minPadHole, minPadAnnularRing, minSmdPad, maxLayers, maxBoardMm:[w,h]} in mm - from the fab's capability page; omit to measure only"}),+ json!({"measured": "{track, viaDrill, viaDiameter, viaAnnularRing, padHole, padAnnularRing, smdPad: {mm, at}, clearanceRule, copperLayers, boardSize, counts}", "outsideLimits": "[{limit, limitMm, boardMm, at}]", "fits": "bool | null"}), 180,+ "Look up the fab's own limits (its capability page) and pass them: the bridge does not carry fab numbers, they change. The measured values are what a fab's order form asks for.",+ &["routing_state", "read_rules", "layer_stack", "export_manufacturing", "run_drc"],+ &["Measures the board as it is now, from the live document: routed copper, vias and pads.", "Board size is the outline's bounding box."],+ json!({"limits": {"minTrack": 0.127, "minClearance": 0.127, "minViaDrill": 0.3, "minViaDiameter": 0.5, "maxLayers": 2}})),+ verb_doc("read_sheet_file", "Read a schematic sheet straight from its .SchDoc file, without Altium (it need not be installed or running): every part with its designator, library reference, comment, UniqueID, part number, position, parameters and pins, in milliseconds.",+ json!({"path": "the .SchDoc (alias filePath)", "pins": "bool, default true - each part's own pins plus the shared part 0", "params": "bool, default false - every parameter with its value"}),+ json!({"components": "[{designator, libReference, comment, description, uniqueId, partId, partCount, xMil, yMil, orientation, sourceLibrary, params?, pins?}]", "count": "int", "format": "the file header", "elapsedMs": "int"}), 30,+ "The saved file, not the live sheet: an open Altium's unsaved edits are not in it (altium_schematic_data reads those). Useful when Altium is closed, busy or slow, and to check UniqueIDs before Update PCB.",+ &["schematic_data", "part_parameters", "bom", "netlist"],+ &["Binary .SchDoc only (Altium's default); an ASCII-format sheet is refused by name.", "No connectivity: nets come from altium_netlist."],+ json!({"path": "C:\\\\work\\\\Power.SchDoc", "params": true})), 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,@@ -1585,7 +1620,22 @@ pub fn dispatch(command: &str, args: &Value, _forwarded: &[(String, String)]) -> // a sheet's parameters are bulky and most callers want pins. let params = if args.get("params").and_then(|b| b.as_bool()) == Some(true) { "1" } else { "0" }; let names_only = if args.get("paramNamesOnly").and_then(|b| b.as_bool()) == Some(true) { "1" } else { "0" };- board_read("schematic_data", vec![("designator", s(args, "designator").unwrap_or_default()), ("limit", s(args, "limit").unwrap_or("500".into())), ("pins", pins.into()), ("params", params.into()), ("paramNamesOnly", names_only.into())], 120)+ let r = board_read("schematic_data", vec![("designator", s(args, "designator").unwrap_or_default()), ("limit", s(args, "limit").unwrap_or("500".into())), ("pins", pins.into()), ("params", params.into()), ("paramNamesOnly", names_only.into())], 120);+ // Altium did not answer in time (#28: a 6-part sheet with params ran out its 120 s): the saved file still+ // has the parts, read without Altium+ let timed_out = r.get("errorCode").and_then(|e| e.as_str()).map(|e| e.contains("timed_out") || e.contains("timeout")).unwrap_or(false);+ match (timed_out, s(args, "filePath").or_else(|| s(args, "sheet"))) {+ (true, Some(p)) => {+ let mut f = crate::schdoc::read_sheet_file(&json!({"path": p, "pins": pins == "1", "params": params == "1"}));+ if f.get("success").and_then(|b| b.as_bool()) == Some(true) {+ f["fromFile"] = json!(true);+ f["liveReadError"] = r.get("errorCode").cloned().unwrap_or(Value::Null);+ f["_hint"] = json!("Altium did not answer in time, so this is the SAVED file read without Altium (altium_read_sheet_file): unsaved edits are not in it. Report the live timeout on adom/altium-bridge#28 with the sheet if you can.");+ f+ } else { r }+ }+ _ => r,+ } } // ---- surfaces ---- "show_project" => surfaces::show_project(args),@@ -1660,6 +1710,11 @@ pub fn dispatch(command: &str, args: &Value, _forwarded: &[(String, String)]) -> "net_lengths" => crate::lengths::net_lengths(args), "teardrops" => crate::route::teardrops(args), "component_classes" => crate::design::component_classes(args),+ "read_sheet_file" => crate::schdoc::read_sheet_file(args),+ "fab_check" => crate::fab::fab_check(args),+ "panelize" => crate::fab::panelize(args),+ "impedance" => crate::impedance::impedance(args),+ "current_capacity" => crate::current::current_capacity(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),
bridge-rs/testdata/lane.SchDocadded⋯ 1 unchanged line ⋯
dev-skills/altium-bridge-test/run_verb_tests.py+12@@ -307,6 +307,18 @@ def matrix(w): 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.71: the sheet read from its file (no Altium), the fab minimums, impedance and current from the stack-up, and+ # a 1 x 2 panel of the lane board (overwrite: the panel of the last run is still there)+ M["read_sheet_file"] = dict(phase=3.5, **ok(path=LS), check=lambda d: any(c.get("designator") == "U1" for c in d.get("components") or []))+ M["fab_check"] = dict(phase=3.5, timeout=200, **ok(filePath=LB, limits={"minTrack": 0.1, "maxLayers": 2}),+ check=lambda d: ((d.get("measured") or {}).get("boardSize") or {}).get("mm") == [40.0, 20.0] and d.get("fits") is True)+ M["impedance"] = dict(phase=3.5, timeout=150, **ok(filePath=LB, z0=50, diff=90),+ check=lambda d: len(d.get("layers") or []) == 2 and 0.3 < ((d.get("layers") or [{}])[0].get("width") or 0) < 1.0+ and abs(((d.get("layers") or [{}])[0].get("zdiff") or 0) - 90) < 1)+ M["current_capacity"] = dict(phase=3.5, timeout=150, **ok(filePath=LB, nets={"N1": 0.5}),+ check=lambda d: len(((d.get("nets") or [{}])[0]).get("layers") or []) >= 1)+ M["panelize"] = dict(phase=3.5, timeout=200, **ok(pcbPath=LB, panelPath=D + "/lane-panel.PcbDoc", rows=1, cols=2, overwrite=True),+ check=lambda d: d.get("panelMm") == [92.0, 30.0] and d.get("saved") is True and ((d.get("toolingHoles") or {}).get("applied") or 0) == 6) # 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.70",+ "version": "0.2.71", "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": [@@ -163,7 +163,16 @@ "altium assembly drawing pdf", "altium fabrication drawing", "altium keepout",- "altium polygon connect style rule"+ "altium polygon connect style rule",+ "altium impedance calculator",+ "altium controlled impedance trace width",+ "altium 90 ohm usb differential pair width",+ "altium trace current capacity",+ "will this trace carry 3a altium",+ "altium panelize",+ "altium panel array step and repeat",+ "altium fab capability check",+ "read altium schdoc without altium" ], "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+2−2@@ -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.70",+ "version": "0.2.71", "hero": { "type": "image", "path": "screenshots/hero.png"@@ -210,4 +210,4 @@ "tools/symbol_modes.py", "uninstall.sh" ]-}+}
skills/altium-boards/SKILL.md+42−1@@ -3,7 +3,8 @@ name: altium-boards description: >- Altium boards through altium-bridge: test boards, shapes, stack-up (planes, 4 layers), net classes, diff pairs, track widths, mounting holes, fiducials, stitching vias, free space, footprint swaps,- readback, 3D. Use when asked for an altium stack-up, net class, mounting holes, where a part fits.+ readback, 3D, impedance, current capacity, fab limits, panels. Use when asked for an altium stack-up, net class,+ mounting holes, where a part fits, a 50 or 90 ohm trace width, will a trace carry 3 A, or panelize a board. --- Parent skill: **altium-bridge**@@ -256,6 +257,46 @@ altium_board_features '{"mountingHoles":[{"x":10,"y":10,"drill":3.2,"plated":tru 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`. +## Impedance, current and fab limits (0.2.71)++```bash+altium_impedance '{"z0":50,"diff":90}' # width per layer, pair width and gap+altium_impedance '{"diff":90,"apply":{"scope":"InDifferentialPair(\'USB\')","name":"USB 90"}}' # one diffpair rule per layer+altium_current_capacity '{"nets":{"VIN":3,"SW":3,"GND":3},"riseC":10}' # narrowest track vs IPC-2221, vias+altium_fab_check '{"limits":{"minTrack":0.127,"minClearance":0.127,"minViaDrill":0.3,"maxLayers":2}}'+```++- `impedance` reads the board's own stack-up (`layer_stack`: copper weight, the dielectric on each side, its Dk) and+ solves each copper layer: microstrip on the outer layers, stripline between planes inside. Closed-form models+ (Hammerstad-Jensen, Wheeler, the IPC-2141 coupling term), within a few percent of a field solver on FR-4; checked+ against the textbook cases (a 50 ohm line on 1.6 mm FR-4 is about 3 mm wide). Uncoated: solder mask lowers an outer+ layer by 1 to 3 ohm. `gapMm` fixes the pair gap (else gap = width). `apply:{scope}` writes one width or diffpair rule per+ layer (`OnLayer`) at +/-10%, each answering Altium's descriptor. `warnings` names a layer next to a dielectric under+ 0.05 mm (a flex coverlay: the Bluetooth Sentinel example's Mid-Layer 2) or a width under what fabs make. Send the fab+ the target impedance: it tunes the stack-up with its own solver.+- `current_capacity` takes each net's current and compares the narrowest track or arc it has on every routed layer with+ the width IPC-2221 needs at `riseC` (default 10 C) for that layer's copper weight (inner layers need about 2.6 times+ the width). `vias` says what one via carries (barrel `platingUm`, default 25) and how many a layer change needs side by+ side. It reads tracks only: `poursOn` lists the net's pours, which may carry the current already.+- `fab_check` measures what a fab's order form asks: narrowest copper track, smallest via drill, diameter and ring,+ smallest pad hole and ring, smallest SMD pad, the clearance rule, copper layers and board size (arcs in the outline+ included). With the fab's `limits` (from its capability page) `outsideLimits` names each miss and where it is.++## Panels (0.2.71)++```bash+altium_panelize '{"pcbPath":"C:\\work\\Board.PcbDoc","panelPath":"C:\\work\\Panel.PcbDoc","rows":2,"cols":3,"gapMm":2,"railMm":5}'+```++- A new .PcbDoc holding Altium's own embedded board array of the board: rows x cols with `gapMm` between boards (0 for+ V-score, 2 or more for tab routing), `railMm` rails, the panel outline, and on rails 5 mm or wider three 2 mm tooling+ holes (the fourth corner bare so the panel loads one way) with a fiducial beside each. The array stays linked to the+ board: edit the board and the panel follows. Measured on the lane board: a 2 x 3 panel, 134 x 52 mm, in 1.9 s.+- An existing `panelPath` is refused unless `overwrite:true` (it is closed in Altium unsaved, then replaced): Altium+ asks before it saves over a file, and that blank Confirm would stall the agent.+- Then `altium_export_manufacturing` on the panel for panel Gerbers, and `altium_fab_check {limits:{maxBoardMm}}` against+ the fab's panel size. Mouse bites and V-score lines are the fab's to add unless you draw them.+ ## Lengths, length rules, rooms and teardrops (0.2.68) ```bash
skills/altium-schematic-edit/SKILL.md+11@@ -102,6 +102,17 @@ Every sheet-editing verb (`sch_edit`, `sch_connect`, `sch_place_part`, `sch_swap carries `uniqueIdsReissued: [{sheet, lost, new}]` and a `uniqueIdWarning`. Then, before Update PCB, re-link the parts in Altium (Project > Component Links), or take the duplicate sheet out of the project and restore the copy. +## Read a sheet without Altium (0.2.71)++```bash+altium_read_sheet_file '{"path":"C:\\work\\Power.SchDoc","params":true}'+```++Reads the .SchDoc file itself (Altium need not be installed or running): every part with its designator, library+reference, comment, UniqueID, part number, position, pins (each part of a multi-part symbol its own pins, plus the+shared part 0) and, with `params`, every parameter. 1 to 2 ms a sheet. `altium_schematic_data` falls back to it when the+live read times out (`fromFile: true`, `liveReadError`), and the UniqueID check around the sheet verbs uses it.+ ## Hierarchical sheets and the netlist (0.2.67) ```bash
src/BRIDGE_VERSION+1−1@@ -1 +1 @@-0.2.70+0.2.71
src/agent/AdomAgent.pas.tmpl+39@@ -7430,6 +7430,44 @@ Begin Result := '{' + JRaw('ok','true') + ',' + JStr('class', Name) + ',' + JRaw('members', '[' + M + ']') + ',' + JRaw('missing', '[' + Missing + ']') + ',' + JRaw('room', S) + '}'; End; +{ panelize: a new board holding an embedded board array of the CURRENT board (IPCB_EmbeddedBoard, measured on Develop+ 2026-10-23). Params: path (the source .PcbDoc), panel (the new .PcbDoc), rows, cols, colStep, rowStep (internal+ units, board size + gap), bx0, by0 (the source outline's lower-left, absolute), rail. The array's first board lands+ with its outline corner at (panel origin + rail). }+Function CmdPanelize(Lines : TStringList) : String;+Var+ Src, PB : IPCB_Board;+ SD : IServerDocument;+ E : IPCB_EmbeddedBoard;+ OffX, OffY, Rail : Integer;+Begin+ Src := PCBServer.GetCurrentPCBBoard;+ If Src = Nil Then Begin Result := '{' + JRaw('ok','false') + ',' + JStr('error','the source board is not the current document') + '}'; Exit; End;+ OffX := AdomInt(Param(Lines, 'bx0')) - Src.XOrigin;+ OffY := AdomInt(Param(Lines, 'by0')) - Src.YOrigin;+ Rail := AdomInt(Param(Lines, 'rail'));+ SD := CreateNewDocumentFromDocumentKind('PCB');+ PB := PCBServer.GetCurrentPCBBoard;+ If (PB = Nil) Or (PB = Src) Then Begin Result := '{' + JRaw('ok','false') + ',' + JStr('error','the new panel board did not become current') + '}'; Exit; End;+ PCBServer.PreProcess;+ E := PCBServer.PCBObjectFactory(eEmbeddedBoardObject, eNoDimension, eCreate_Default);+ E.DocumentPath := Param(Lines, 'path');+ E.RowCount := AdomInt(Param(Lines, 'rows'));+ E.ColCount := AdomInt(Param(Lines, 'cols'));+ E.ColSpacing := AdomInt(Param(Lines, 'colStep'));+ E.RowSpacing := AdomInt(Param(Lines, 'rowStep'));+ E.XLocation := PB.XOrigin + Rail - OffX;+ E.YLocation := PB.YOrigin + Rail - OffY;+ PB.AddPCBObject(E);+ PCBServer.SendMessageToRobots(PB.I_ObjectAddress, c_Broadcast, PCBM_BoardRegisteration, E.I_ObjectAddress);+ PCBServer.PostProcess;+ PB.ViewManager_FullUpdate;+ SD.SetFileName(Param(Lines, 'panel'));+ SD.DoFileSave('');+ Result := '{' + JRaw('ok','true') + ',' + JStr('panel', Param(Lines, 'panel')) + ',' + JRaw('originX', IntToStr(PB.XOrigin)) + ',' + JRaw('originY', IntToStr(PB.YOrigin)) + ',' ++ JRaw('rows', IntToStr(E.RowCount)) + ',' + JRaw('cols', IntToStr(E.ColCount)) + '}';+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;@@ -8676,6 +8714,7 @@ Begin 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 = 'panelize' Then Result := CmdPanelize(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+11−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "altium", "displayName": "Altium Designer",- "version": "0.2.70",+ "version": "0.2.71", "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",@@ -94,6 +94,11 @@ "altium_net_lengths", "altium_teardrops", "altium_component_classes",+ "altium_read_sheet_file",+ "altium_fab_check",+ "altium_panelize",+ "altium_impedance",+ "altium_current_capacity", "altium_find_space", "altium_find_symbol", "altium_footprint_primitives",@@ -211,6 +216,9 @@ "default": 60, "verbs": { "launch": 240,+ "panelize": 180,+ "impedance": 120,+ "current_capacity": 120, "agent_start": 120, "run_outjob": 600, "silk_state": 180,@@ -329,6 +337,8 @@ "net_lengths": 180, "teardrops": 300, "component_classes": 120,+ "read_sheet_file": 30,+ "fab_check": 180, "sch_swap_part": 180, "selection": 60, "check_orientation": 120,