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.72 source (not yet released, needs signing): panelize V-score and mouse-bite tabs per JLCPCB with its rules checked, height_check, copper_balance, return_path, remove_items rounding fix; 189 verbs; batteries 188/0 on AD 26.11.1 and Altium Designer Develop
17 files changed
+1143−149
CHANGELOG.md+9README.md+11−7bridge-rs/src/bodies.rs+159bridge-rs/src/coppermap.rs+330bridge-rs/src/fab.rs−84bridge-rs/src/hier.rs+22−14bridge-rs/src/main.rs+4−1bridge-rs/src/panel.rs+426bridge-rs/src/routing.rs+1−1bridge-rs/src/verbs.rs+32−8dev-skills/altium-bridge-test/run_verb_tests.py+16−2package.json+11−2page.json+3−12skills/altium-boards/SKILL.md+54−12src/BRIDGE_VERSION+1−1src/agent/AdomAgent.pas.tmpl+56−3src/bridge.json+8−2CHANGELOG.md+9@@ -1,5 +1,14 @@ # Changelog +## 0.2.72 (2026-10-23)+**Panels the way JLCPCB reads them, 3D fit, copper balance and return paths.**+- `altium_panelize {separation}`: `vscore` puts the boards edge to edge with a V-CUT line across the whole panel on Mechanical 1 (the outline layer the Gerbers carry) and "V-CUT" written at both ends, as JLCPCB asks, and checks JLCPCB's published V-cut rules (panel 70 to 475 mm, board 0.6 mm or thicker, copper 0.4 mm from a cut, at most 25 cuts, rail widths, no arcs on a cut). `tabs` draws mouse bites per JLCPCB's guide (five 0.6 mm holes a tab, two tabs an edge and one more each 55 mm, a third of a hole into the board, none on the rails) with the rout path; it refuses a board whose own outline on a mechanical layer would cut every copy free. The panel frame is now drawn on Mechanical 1 (the panel's Gerbers had the boards' outlines but no frame), copper outside the board is flagged, and `projectPath` adds the panel to the project for its Gerbers.+- New verb `altium_height_check`: each part's height from its 3D bodies, parts over a top or bottom height limit or under a lid feature in a region, and bodies that collide or come within a gap. Measured on the Bluetooth Sentinel example (152 bodies, 5 s): J1 4.1 mm under the board; a part moved onto another read as a 3.29 mm collision.+- New verb `altium_copper_balance`: copper share per layer from a raster of the live copper (tracks, pads, vias, poured regions, planes), and the difference across each mirrored pair.+- New verb `altium_return_path`: per signal net and layer, the track length over no pour or plane copper on either neighbouring layer, and where the first gap starts. On the Sentinel example: the antenna trace (no ground under it by design) and parts of VBAT.+- `altium_remove_items` finds tracks and vias within 1.3 um of the coordinates given: routing_state rounds to 0.1 um, so a track off the 0.1 um grid (a board outline on Mechanical 1) was never found.+- Verb battery: `panelize` with V-score and its JLCPCB rules, `height_check`, `copper_balance`, `return_path`.+ ## 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.
README.md+11−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 -186 commands, tested on Altium Designer 26.11 and Altium Designer Develop. It runs in **Hydrogen**, Adom's free+189 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. 186 verbs; each row names the ones+Tested on Altium Designer 26.11.1 and Altium Designer Develop. 189 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` |+| 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); signal tracks that run over a gap in the reference copper next to them (return paths); copper balance per layer | `impedance`, `current_capacity`, `return_path`, `copper_balance` |+| 3D and enclosure | Each part's height from its 3D bodies, the parts over an enclosure's height limit or under a lid feature, and bodies that collide or sit too close | `height_check` | | 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, 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` |+| 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, separated by V-score lines or mouse-bite tabs drawn the way JLCPCB reads them, with JLCPCB's panel rules checked; 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` |@@ -67,7 +68,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 186 `altium_*` verbs.+**DelphiScript agent** inside it, and gives the AI 189 `altium_*` verbs. - **Page and docs:** https://wiki.adom.inc/adom/altium-bridge - **Bridge SDK:** https://wiki.adom.inc/adom/adom-bridge-sdk@@ -167,7 +168,7 @@ way to read it). A stopped engine is restarted with `altium_agent_recover`. <!-- verbs:start (tools/readme_verbs.py writes this list) --> -All 186 verbs, by area (prefix `altium_`). `altium_describe` returns the live catalog with every input, output, hint and example.+All 189 verbs, by area (prefix `altium_`). `altium_describe` returns the live catalog with every input, output, hint and example. ### Running Altium: launch, close, health, dialogs @@ -252,9 +253,12 @@ All 186 verbs, by area (prefix `altium_`). `altium_describe` returns the live ca - `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_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), with rails, the panel frame on the outline layer, three tooling holes and three fiducials; boards separated by JLCPCB-style V-score lines or mouse-bite tabs, with JLCPCB's rules checked. - `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_height_check`: Will it fit the enclosure? Each part's height from its 3D bodies (top and bottom side), the parts over a height limit board-wide or under a lid feature in a region, and part bodies that collide or come closer than a gap.+- `altium_copper_balance`: How much of each copper layer is copper (tracks, pads, vias and what Altium actually poured, inside the outline; a plane counts full), and how far apart each mirrored pair of layers is: the copper balance a fab asks about because an unbalanced stack bows and twists in reflow.+- `altium_return_path`: Where signal tracks lose their reference: for every signal net, the length of track on each layer that runs over no pour or plane copper on either neighbouring layer (a split, a gap, a void), with where the first gap starts. - `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/bodies.rsadded+159@@ -0,0 +1,159 @@+//! The board in 3D from its parts' bodies: how tall each part stands on its side, which ones break an enclosure's+//! height limit (board-wide, or under a lid feature in a region), and which bodies collide or come closer than a gap.+//! Bodies are compared as boxes (each body's footprint rectangle and its standoff-to-top range); a part made of+//! several bodies is several boxes, so L-shapes stay honest. Altium's Component Clearance DRC checks the real shapes.+use crate::routing::PER_MM;+use serde_json::{json, Value};+use std::collections::BTreeMap;++fn r3(v: f64) -> f64 { (v * 1000.0).round() / 1000.0 }++/// One body as a box in mm: its part, side (top/bottom), x and y extent, and z from the board surface it sits on.+#[derive(Debug, Clone)]+pub struct BodyBox { pub part: String, pub top: bool, pub x0: f64, pub y0: f64, pub x1: f64, pub y1: f64, pub z0: f64, pub z1: f64 }++/// The `bodies` agent rows ([part, layer, overall, standoff, l, b, r, t], internal units) as boxes in mm.+pub fn boxes(rows: &[Value]) -> Vec<BodyBox> {+ let n = |r: &Value, i: usize| r[i].as_f64().unwrap_or(0.0) / PER_MM;+ rows.iter().filter(|r| r[0].as_str().map(|s| s != "-").unwrap_or(false)).map(|r| BodyBox {+ part: r[0].as_str().unwrap_or("").to_string(), top: r[1].as_i64() != Some(32),+ x0: n(r, 4), y0: n(r, 5), x1: n(r, 6), y1: n(r, 7), z0: n(r, 3), z1: n(r, 2),+ }).collect()+}++/// Each part's height above its side and its footprint in mm, tallest first.+pub fn heights(bs: &[BodyBox]) -> Vec<Value> {+ let mut m: BTreeMap<&str, (bool, f64, f64, f64, f64, f64)> = BTreeMap::new();+ for b in bs {+ let e = m.entry(&b.part).or_insert((b.top, 0.0, f64::MAX, f64::MAX, f64::MIN, f64::MIN));+ e.1 = e.1.max(b.z1);+ e.2 = e.2.min(b.x0); e.3 = e.3.min(b.y0); e.4 = e.4.max(b.x1); e.5 = e.5.max(b.y1);+ }+ let mut v: Vec<Value> = m.into_iter().map(|(p, (top, h, x0, y0, x1, y1))| json!({"ref": p, "side": if top { "top" } else { "bottom" }, "heightMm": r3(h), "rect": [r3(x0), r3(y0), r3(x1), r3(y1)]})).collect();+ v.sort_by(|a, b| b["heightMm"].as_f64().partial_cmp(&a["heightMm"].as_f64()).unwrap());+ v+}++/// Body pairs of different parts on the same side that overlap in 3D (`collisions`), or whose boxes come within+/// `gap` mm of each other in x and y where their heights overlap (`tooClose`). Pure.+pub fn clashes(bs: &[BodyBox], gap: f64) -> (Vec<Value>, Vec<Value>) {+ let mut idx: Vec<usize> = (0..bs.len()).collect();+ idx.sort_by(|a, b| bs[*a].x0.partial_cmp(&bs[*b].x0).unwrap());+ let mut seen: BTreeMap<(String, String), (bool, f64, Value)> = BTreeMap::new();+ for (k, &i) in idx.iter().enumerate() {+ let a = &bs[i];+ for &j in &idx[k + 1..] {+ let b = &bs[j];+ if b.x0 > a.x1 + gap { break; }+ if a.part == b.part || a.top != b.top { continue; }+ let dz = a.z1.min(b.z1) - a.z0.max(b.z0);+ if dz <= 0.0 { continue; } // one sits above the other (a part under a connector's overhang)+ let dx = (b.x0 - a.x1).max(a.x0 - b.x1);+ let dy = (b.y0 - a.y1).max(a.y0 - b.y1);+ let (hit, d) = if dx < 0.0 && dy < 0.0 { (true, -(dx.max(dy))) } else { (false, (dx.max(0.0).powi(2) + dy.max(0.0).powi(2)).sqrt()) };+ if !hit && d >= gap { continue; }+ let key = if a.part < b.part { (a.part.clone(), b.part.clone()) } else { (b.part.clone(), a.part.clone()) };+ let at = json!([r3((a.x0.max(b.x0) + a.x1.min(b.x1)) / 2.0), r3((a.y0.max(b.y0) + a.y1.min(b.y1)) / 2.0)]);+ // per pair keep the worst: a collision over a near miss, then the deepest or the closest+ let worse = match seen.get(&key) { None => true, Some((h, v, _)) => (hit && !h) || (hit == *h && if hit { d > *v } else { d < *v }) };+ if worse { seen.insert(key, (hit, d, at)); }+ }+ }+ let (mut col, mut near) = (Vec::new(), Vec::new());+ for ((p, q), (hit, d, at)) in seen {+ if hit { col.push(json!({"parts": [p, q], "overlapMm": r3(d), "at": at})); } else { near.push(json!({"parts": [p, q], "gapMm": r3(d), "at": at})); }+ }+ (col, near)+}++/// altium_height_check {filePath?, maxTopMm?, maxBottomMm?, regions?: [{rect, maxMm, side?, name?}], minGapMm?}+pub fn height_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 r = crate::agent::call("bodies", &[], 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 };+ let bs = boxes(&d["bodies"].as_array().cloned().unwrap_or_default());+ let hs = heights(&bs);+ let num = |k: &str| args.get(k).and_then(|v| v.as_f64());+ let mut over = Vec::new();+ for (side, lim) in [("top", num("maxTopMm")), ("bottom", num("maxBottomMm"))] {+ let Some(lim) = lim else { continue };+ for h in hs.iter().filter(|h| h["side"] == side) {+ let mm = h["heightMm"].as_f64().unwrap_or(0.0);+ if mm > lim { over.push(json!({"ref": h["ref"], "side": side, "heightMm": mm, "limitMm": lim, "overByMm": r3(mm - lim)})); }+ }+ }+ // a region is a lid feature over part of the board (a boss, a rib, a display): parts under it must stay lower+ let mut region_hits = Vec::new();+ for (i, rg) in args.get("regions").and_then(|v| v.as_array()).cloned().unwrap_or_default().iter().enumerate() {+ let rc: Vec<f64> = rg["rect"].as_array().map(|a| a.iter().filter_map(|v| v.as_f64()).collect()).unwrap_or_default();+ let Some(max) = rg["maxMm"].as_f64() else { return json!({"success": false, "errorCode": "bad_args", "error": "each region is {rect:[x1,y1,x2,y2], maxMm, side?, name?}"}) };+ if rc.len() != 4 { return json!({"success": false, "errorCode": "bad_args", "error": "region.rect is [x1, y1, x2, y2] in mm"}); }+ let (rx0, ry0, rx1, ry1) = (rc[0].min(rc[2]), rc[1].min(rc[3]), rc[0].max(rc[2]), rc[1].max(rc[3]));+ let top = rg["side"].as_str() != Some("bottom");+ let name = rg["name"].as_str().map(|s| s.to_string()).unwrap_or(format!("region {}", i + 1));+ let mut worst: BTreeMap<&str, f64> = BTreeMap::new();+ for b in bs.iter().filter(|b| b.top == top && b.x1 > rx0 && b.x0 < rx1 && b.y1 > ry0 && b.y0 < ry1 && b.z1 > max) {+ let e = worst.entry(&b.part).or_insert(0.0);+ *e = e.max(b.z1);+ }+ for (p, h) in worst { region_hits.push(json!({"region": name, "ref": p, "heightMm": r3(h), "maxMm": max, "overByMm": r3(h - max)})); }+ }+ let gap = num("minGapMm").unwrap_or(0.0);+ let (collisions, too_close) = clashes(&bs, gap);+ // parts with no body have no height: say so, a height check that skips them is not a pass+ let st = crate::routing::routing_state(&json!({}));+ let with: std::collections::BTreeSet<&str> = bs.iter().map(|b| b.part.as_str()).collect();+ let no_body: Vec<Value> = st["components"].as_array().cloned().unwrap_or_default().iter().filter_map(|c| c["ref"].as_str().map(|s| s.to_string())).filter(|r| !with.contains(r.as_str())).map(Value::from).collect();+ let tallest = |side: &str| hs.iter().find(|h| h["side"] == side).cloned().unwrap_or(Value::Null);+ let ok = over.is_empty() && region_hits.is_empty() && collisions.is_empty() && too_close.is_empty();+ json!({"success": true, "ok": ok, "complete": no_body.is_empty(), "bodies": bs.len(), "parts": hs.len(), "tallest": {"top": tallest("top"), "bottom": tallest("bottom")},+ "overLimit": over, "regionHits": region_hits, "collisions": collisions, "tooClose": too_close, "minGapMm": gap,+ "noBody": no_body, "heights": hs.iter().take(args.get("limit").and_then(|v| v.as_u64()).unwrap_or(25) as usize).collect::<Vec<_>>(),+ "_hint": "Heights are each part's tallest body above the surface it sits on (bottom-side parts below the bottom). collisions and tooClose compare body boxes; Altium's Component Clearance DRC (altium_run_drc) checks the real shapes. ok covers the parts that have bodies; complete is false while noBody lists parts with no 3D body and so no height: give them one (altium_add_body) before trusting the check."})+}++#[cfg(test)]+mod tests {+ use super::*;++ fn bx(p: &str, top: bool, x0: f64, y0: f64, x1: f64, y1: f64, z0: f64, z1: f64) -> BodyBox {+ BodyBox { part: p.into(), top, x0, y0, x1, y1, z0, z1 }+ }++ #[test]+ fn rows_become_boxes_in_mm() {+ let k = PER_MM;+ let b = boxes(&[json!(["J1", 32, 4.1 * k, 4.0 * k, 0.0, 0.0, 10.0 * k, 5.0 * k]), json!(["-", 0, 1, 0, 0, 0, 1, 1])]);+ assert_eq!(b.len(), 1);+ assert!(!b[0].top && (b[0].z1 - 4.1).abs() < 1e-9 && (b[0].x1 - 10.0).abs() < 1e-9);+ }++ #[test]+ fn heights_take_each_parts_tallest_body() {+ let bs = vec![bx("J1", true, 0.0, 0.0, 10.0, 5.0, 0.0, 3.2), bx("J1", true, 0.0, 0.0, 10.0, 5.0, 4.0, 4.1), bx("R1", true, 20.0, 0.0, 21.0, 0.5, 0.0, 0.4)];+ let h = heights(&bs);+ assert_eq!(h[0]["ref"], "J1");+ assert_eq!(h[0]["heightMm"], 4.1);+ assert_eq!(h[1]["rect"], json!([20.0, 0.0, 21.0, 0.5]));+ }++ #[test]+ fn clashes_find_overlaps_near_misses_and_ignore_stacked_and_opposite_sides() {+ let bs = vec![+ bx("U1", true, 0.0, 0.0, 5.0, 5.0, 0.0, 1.0),+ bx("C1", true, 4.0, 1.0, 6.0, 2.0, 0.0, 0.5), // overlaps U1 by 1 mm in x+ bx("C2", true, 5.2, 3.0, 6.0, 4.0, 0.0, 0.5), // 0.2 mm from U1+ bx("C3", false, 1.0, 1.0, 2.0, 2.0, 0.0, 0.5), // under U1, other side+ bx("D1", true, 1.0, 1.0, 2.0, 2.0, 1.5, 2.0), // above U1's top+ ];+ let (col, near) = clashes(&bs, 0.3);+ assert_eq!(col.len(), 1);+ assert_eq!(col[0]["parts"], json!(["C1", "U1"]));+ assert_eq!(col[0]["overlapMm"], 1.0);+ assert_eq!(near.len(), 1);+ assert_eq!(near[0]["parts"], json!(["C2", "U1"]));+ assert_eq!(near[0]["gapMm"], 0.2);+ assert!(clashes(&bs, 0.0).1.is_empty());+ }+}
bridge-rs/src/coppermap.rsadded+330@@ -0,0 +1,330 @@+//! The copper of each layer as a raster (0.1 mm by default) inside the board outline, from the live board: tracks,+//! arcs, pads, vias and the copper Altium actually poured (its regions minus their holes); an internal plane counts as+//! copper everywhere. From it: how much of each layer is copper and how evenly the stack is balanced (bow and twist),+//! and where signal tracks run over a gap in the copper of the layer next to them (the return current's path).+use serde_json::{json, Value};+use std::collections::BTreeMap;++fn f(v: &Value) -> f64 { v.as_f64().unwrap_or(0.0) }+fn r1(v: f64) -> f64 { (v * 10.0).round() / 10.0 }+fn r3(v: f64) -> f64 { (v * 1000.0).round() / 1000.0 }++/// A layer's copper as cells of `res` mm from (x0, y0).+#[derive(Clone)]+pub struct Raster { pub x0: f64, pub y0: f64, pub res: f64, pub w: usize, pub h: usize, pub cells: Vec<bool> }++impl Raster {+ pub fn new(x0: f64, y0: f64, x1: f64, y1: f64, res: f64) -> Raster {+ let w = (((x1 - x0) / res).ceil() as usize).max(1);+ let h = (((y1 - y0) / res).ceil() as usize).max(1);+ Raster { x0, y0, res, w, h, cells: vec![false; w * h] }+ }+ fn centre(&self, i: usize, j: usize) -> (f64, f64) { (self.x0 + (i as f64 + 0.5) * self.res, self.y0 + (j as f64 + 0.5) * self.res) }+ /// cells whose centre is in [x0, x1] x [y0, y1], clipped+ fn span(&self, x0: f64, y0: f64, x1: f64, y1: f64) -> (usize, usize, usize, usize) {+ let c = |v: f64, o: f64, n: usize| (((v - o) / self.res - 0.5).max(0.0) as usize).min(n);+ let (i0, j0) = (c(x0, self.x0, self.w), c(y0, self.y0, self.h));+ let (i1, j1) = ((((x1 - self.x0) / self.res + 0.5).max(0.0) as usize).min(self.w), (((y1 - self.y0) / self.res + 0.5).max(0.0) as usize).min(self.h));+ (i0, j0, i1, j1)+ }+ pub fn at(&self, x: f64, y: f64) -> bool {+ let (i, j) = ((x - self.x0) / self.res, (y - self.y0) / self.res);+ if i < 0.0 || j < 0.0 || i >= self.w as f64 || j >= self.h as f64 { return false; }+ self.cells[j as usize * self.w + i as usize]+ }+ /// a segment of width w with round ends (a track, an arc's piece, an oval pad)+ pub fn capsule(&mut self, ax: f64, ay: f64, bx: f64, by: f64, w: f64) {+ let r = w / 2.0;+ let (i0, j0, i1, j1) = self.span(ax.min(bx) - r, ay.min(by) - r, ax.max(bx) + r, ay.max(by) + r);+ let (dx, dy) = (bx - ax, by - ay);+ let l2 = dx * dx + dy * dy;+ for j in j0..j1 { for i in i0..i1 {+ let (x, y) = self.centre(i, j);+ let t = if l2 > 0.0 { (((x - ax) * dx + (y - ay) * dy) / l2).clamp(0.0, 1.0) } else { 0.0 };+ let (px, py) = (ax + t * dx - x, ay + t * dy - y);+ if px * px + py * py <= r * r { self.cells[j * self.w + i] = true; }+ } }+ }+ /// a rectangle sx by sy centred at (cx, cy), turned `rot` degrees+ pub fn rect(&mut self, cx: f64, cy: f64, sx: f64, sy: f64, rot: f64) {+ let (c, s) = (rot.to_radians().cos(), rot.to_radians().sin());+ let e = (sx * sx + sy * sy).sqrt() / 2.0;+ let (i0, j0, i1, j1) = self.span(cx - e, cy - e, cx + e, cy + e);+ for j in j0..j1 { for i in i0..i1 {+ let (x, y) = self.centre(i, j);+ let (u, v) = ((x - cx) * c + (y - cy) * s, -(x - cx) * s + (y - cy) * c);+ if u.abs() <= sx / 2.0 && v.abs() <= sy / 2.0 { self.cells[j * self.w + i] = true; }+ } }+ }+ /// polygons filled by the even-odd rule (an outline with its holes), set to `on`+ pub fn polys(&mut self, rings: &[Vec<(f64, f64)>], on: bool) {+ let ys: Vec<f64> = rings.iter().flatten().map(|p| p.1).collect();+ if ys.is_empty() { return; }+ let (lo, hi) = (ys.iter().cloned().fold(f64::MAX, f64::min), ys.iter().cloned().fold(f64::MIN, f64::max));+ let (_, j0, _, j1) = self.span(self.x0, lo, self.x0, hi);+ for j in j0..j1 {+ let y = self.y0 + (j as f64 + 0.5) * self.res;+ let mut xs: Vec<f64> = Vec::new();+ for ring in rings {+ let n = ring.len();+ for k in 0..n {+ let (a, b) = (ring[k], ring[(k + 1) % n]);+ if (a.1 <= y) != (b.1 <= y) { xs.push(a.0 + (y - a.1) / (b.1 - a.1) * (b.0 - a.0)); }+ }+ }+ xs.sort_by(|a, b| a.partial_cmp(b).unwrap());+ for p in xs.chunks(2) {+ if p.len() < 2 { continue; }+ let (i0, _, i1, _) = self.span(p[0], y, p[1], y);+ for i in i0..i1 { self.cells[j * self.w + i] = on; }+ }+ }+ }+ pub fn and(&mut self, mask: &Raster) { for (c, m) in self.cells.iter_mut().zip(&mask.cells) { *c = *c && *m; } }+ pub fn count(&self) -> usize { self.cells.iter().filter(|c| **c).count() }+}++/// The stack's copper layers in order as (routing_state name, plane?, plane net).+pub fn stack_layers(stack: &Value) -> Vec<(String, bool, String)> {+ let ls: Vec<Value> = stack["layers"].as_array().cloned().unwrap_or_default();+ let n = ls.len();+ ls.iter().enumerate().map(|(i, l)| {+ if l["kind"] == "plane" { return (format!("L{}", 38 + l["plane"].as_i64().unwrap_or(1)), true, l["net"].as_str().unwrap_or("").to_string()); }+ let name = if i == 0 { "F.Cu".to_string() } else if i + 1 == n { "B.Cu".to_string() } else { format!("In{}.Cu", l["mid"].as_i64().unwrap_or(i as i64)) };+ (name, false, String::new())+ }).collect()+}++/// The outline as a ring in mm (arc edges sampled).+fn outline_ring(st: &Value) -> Vec<(f64, f64)> {+ let mut ring = Vec::new();+ for e in st["outlineEdges"].as_array().cloned().unwrap_or_default() {+ if e["kind"] == "arc" {+ 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; }+ for k in 0..=16 { let t = (a1 + (a2 - a1) * k as f64 / 16.0).to_radians(); ring.push((cx + r * t.cos(), cy + r * t.sin())); }+ } else { ring.push((f(&e["start"][0]), f(&e["start"][1]))); }+ }+ if ring.is_empty() { ring = st["outline"].as_array().cloned().unwrap_or_default().iter().map(|p| (f(&p[0]), f(&p[1]))).collect(); }+ ring+}++/// Each layer's copper raster inside the outline. `refs` None: all copper. Some(nets): only the copper a return current+/// can use: pours, planes, those nets' own copper, and net-less copper (Altium lists a hatched pour's tracks with no+/// net: 8777 of the Bluetooth Sentinel example's 9378 tracks). Pure: routing_state, pour_state's zones, the stack.+pub fn rasters(st: &Value, zones: &[Value], layers: &[(String, bool, String)], res: f64, refs: Option<&[String]>) -> (Raster, BTreeMap<String, Raster>) {+ let ring = outline_ring(st);+ let (x0, x1) = ring.iter().fold((f64::MAX, f64::MIN), |a, p| (a.0.min(p.0), a.1.max(p.0)));+ let (y0, y1) = ring.iter().fold((f64::MAX, f64::MIN), |a, p| (a.0.min(p.1), a.1.max(p.1)));+ let mut board = Raster::new(x0, y0, x1, y1, res);+ board.polys(&[ring], true);+ let mut out = BTreeMap::new();+ for (name, plane, _) in layers {+ let mut r = Raster::new(x0, y0, x1, y1, res);+ if *plane { r.cells = board.cells.clone(); out.insert(name.clone(), r); continue; }+ {+ let keep = |n: &Value| match refs { None => true, Some(rs) => { let n = n.as_str().unwrap_or(""); n.is_empty() || rs.iter().any(|r| r.eq_ignore_ascii_case(n)) } };+ let on_layer = |l: &Value| l.as_str().map(|s| s == name || s == "*.Cu").unwrap_or(false);+ let on = |p: &Value| on_layer(&p["layer"]) && keep(&p["net"]);+ for t in st["tracks"].as_array().cloned().unwrap_or_default().iter().filter(|t| on(t)) {+ r.capsule(f(&t["start"]["x"]), f(&t["start"]["y"]), f(&t["end"]["x"]), f(&t["end"]["y"]), f(&t["width"]));+ }+ for a in st["arcs"].as_array().cloned().unwrap_or_default().iter().filter(|a| on(a)) {+ let (cx, cy, rad) = (f(&a["center"]["x"]), f(&a["center"]["y"]), f(&a["radius"]));+ let (s, mut e) = (f(&a["startAngle"]), f(&a["endAngle"]));+ if e <= s { e += 360.0; }+ let k = ((e - s) / 10.0).ceil().max(1.0) as usize;+ for q in 0..k {+ let (t0, t1) = ((s + (e - s) * q as f64 / k as f64).to_radians(), (s + (e - s) * (q + 1) as f64 / k as f64).to_radians());+ r.capsule(cx + rad * t0.cos(), cy + rad * t0.sin(), cx + rad * t1.cos(), cy + rad * t1.sin(), f(&a["width"]));+ }+ }+ for p in st["pads"].as_array().cloned().unwrap_or_default().iter().filter(|p| on(p)) {+ let (sx, sy) = (f(&p["sizeX"]), f(&p["sizeY"]));+ match p["shape"].as_str() {+ Some("round") | Some("circle") if (sx - sy).abs() > 1e-6 => {+ let (c, s) = (f(&p["rotation"]).to_radians().cos(), f(&p["rotation"]).to_radians().sin());+ let (l, w) = ((sx - sy).abs() / 2.0, sx.min(sy));+ let (ux, uy) = if sx > sy { (c * l, s * l) } else { (-s * l, c * l) };+ r.capsule(f(&p["x"]) - ux, f(&p["y"]) - uy, f(&p["x"]) + ux, f(&p["y"]) + uy, w);+ }+ Some("round") | Some("circle") => r.capsule(f(&p["x"]), f(&p["y"]), f(&p["x"]), f(&p["y"]), sx),+ _ => r.rect(f(&p["x"]), f(&p["y"]), sx, sy, f(&p["rotation"])),+ }+ }+ for v in st["vias"].as_array().cloned().unwrap_or_default().iter().filter(|v| keep(&v["net"])) {+ r.capsule(f(&v["position"]["x"]), f(&v["position"]["y"]), f(&v["position"]["x"]), f(&v["position"]["y"]), f(&v["size"]));+ }+ }+ for z in zones.iter().filter(|z| z["layer"].as_str() == Some(name)) {+ for fill in z["fills"].as_array().cloned().unwrap_or_default() {+ let pts = |v: &Value| v.as_array().cloned().unwrap_or_default().iter().map(|p| (f(&p[0]), f(&p[1]))).collect::<Vec<_>>();+ let mut rings = vec![pts(&fill["outline"])];+ rings.extend(fill["holes"].as_array().cloned().unwrap_or_default().iter().map(pts));+ r.polys(&rings, true);+ }+ }+ r.and(&board);+ out.insert(name.clone(), r);+ }+ (board, out)+}++/// Per layer: copper area and share of the board; per mirrored pair (1 with n, 2 with n-1...): the difference.+pub fn balance(board: &Raster, maps: &BTreeMap<String, Raster>, layers: &[(String, bool, String)]) -> (Vec<Value>, Vec<Value>) {+ let cell = board.res * board.res;+ let total = board.count() as f64;+ let pct = |name: &str| maps.get(name).map(|r| 100.0 * r.count() as f64 / total.max(1.0)).unwrap_or(0.0);+ let rows: Vec<Value> = layers.iter().map(|(n, plane, net)| {+ let mut o = json!({"layer": n, "copperMm2": r1(maps.get(n).map(|r| r.count()).unwrap_or(0) as f64 * cell), "percent": r1(pct(n))});+ if *plane { o["plane"] = json!(net); }+ o+ }).collect();+ let k = layers.len();+ let pairs = (0..k / 2).map(|i| {+ let (a, b) = (&layers[i].0, &layers[k - 1 - i].0);+ let d = (pct(a) - pct(b)).abs();+ json!({"layers": [a, b], "percent": [r1(pct(a)), r1(pct(b))], "differencePoints": r1(d), "ok": d <= 20.0})+ }).collect();+ (rows, pairs)+}++/// Per signal net: track length on each layer, and the length that runs over no reference copper on either+/// neighbouring layer, with where the first gap starts. `skip` nets are the references themselves. Pure.+pub fn return_paths(st: &Value, refs: &BTreeMap<String, Raster>, layers: &[(String, bool, String)], skip: &[String], step: f64) -> Vec<Value> {+ let idx: BTreeMap<&str, usize> = layers.iter().enumerate().map(|(i, l)| (l.0.as_str(), i)).collect();+ let mut acc: BTreeMap<(String, String), (f64, f64, Option<(f64, f64)>)> = BTreeMap::new();+ for t in st["tracks"].as_array().cloned().unwrap_or_default() {+ let (net, layer) = (t["net"].as_str().unwrap_or(""), t["layer"].as_str().unwrap_or(""));+ if net.is_empty() || skip.iter().any(|s| s.eq_ignore_ascii_case(net)) { continue; }+ let Some(&i) = idx.get(layer) else { continue };+ let nb: Vec<&Raster> = [i.checked_sub(1), Some(i + 1)].iter().flatten().filter_map(|j| layers.get(*j)).filter_map(|l| refs.get(&l.0)).collect();+ let (ax, ay, bx, by) = (f(&t["start"]["x"]), f(&t["start"]["y"]), f(&t["end"]["x"]), f(&t["end"]["y"]));+ let len = ((bx - ax).powi(2) + (by - ay).powi(2)).sqrt();+ let n = (len / step).ceil().max(1.0) as usize;+ let e = acc.entry((net.to_string(), layer.to_string())).or_insert((0.0, 0.0, None));+ e.0 += len;+ for k in 0..n {+ let s = (k as f64 + 0.5) / n as f64;+ let (x, y) = (ax + s * (bx - ax), ay + s * (by - ay));+ if !nb.iter().any(|r| r.at(x, y)) {+ e.1 += len / n as f64;+ if e.2.is_none() { e.2 = Some((r3(x), r3(y))); }+ }+ }+ }+ let mut out: Vec<Value> = acc.into_iter().filter(|(_, v)| v.1 > 0.0).map(|((net, layer), (len, gap, at))| json!({+ "net": net, "layer": layer, "lengthMm": r3(len), "unreferencedMm": r3(gap), "percent": r1(100.0 * gap / len.max(1e-9)), "firstGapAt": at.map(|p| json!([p.0, p.1]))})).collect();+ out.sort_by(|a, b| b["unreferencedMm"].as_f64().partial_cmp(&a["unreferencedMm"].as_f64()).unwrap());+ out+}++/// The live board's state, pours and stack, or the error to answer.+fn gather(args: &Value) -> Result<(Value, Vec<Value>, Vec<(String, bool, String)>), Value> {+ if let Some(g) = crate::support::need_licensed() { return Err(g); }+ if let Some(p) = crate::support::s(args, "filePath") { if let Some(e) = crate::support::must_open(&p, 60) { return Err(e); } }+ let st = crate::routing::routing_state(&json!({}));+ if st.get("success").and_then(|b| b.as_bool()) != Some(true) { return Err(st); }+ if st["outline"].as_array().map(|a| a.len() < 3).unwrap_or(true) { return Err(json!({"success": false, "errorCode": "no_outline", "error": "the board has no outline"})); }+ let pours = crate::routing::pour_state(&json!({}));+ let zones = pours["zones"].as_array().cloned().unwrap_or_default();+ let stack = crate::agent::call("layer_stack", &[], None, std::time::Duration::from_secs(60));+ let layers = stack.get("data").map(stack_layers).unwrap_or_default();+ if layers.is_empty() { return Err(json!({"success": false, "errorCode": "no_stack", "error": "the layer stack could not be read", "detail": stack})); }+ Ok((st, zones, layers))+}++fn res_of(args: &Value) -> f64 { args.get("resolutionMm").and_then(|v| v.as_f64()).unwrap_or(0.1).clamp(0.02, 1.0) }++/// altium_copper_balance {filePath?, resolutionMm?}+pub fn copper_balance(args: &Value) -> Value {+ let (st, zones, layers) = match gather(args) { Ok(g) => g, Err(e) => return e };+ let res = res_of(args);+ let (board, maps) = rasters(&st, &zones, &layers, res, None);+ let (rows, pairs) = balance(&board, &maps, &layers);+ let unpoured: Vec<Value> = st["polygons"].as_array().cloned().unwrap_or_default().iter()+ .filter(|g| !zones.iter().any(|z| z["layer"] == g["layer"] && z["net"] == g["net"] && z["filled"] == json!(true)))+ .map(|g| json!({"net": g["net"], "layer": g["layer"]})).collect();+ let ok = pairs.iter().all(|p| p["ok"] == json!(true));+ json!({"success": true, "boardMm2": r1(board.count() as f64 * res * res), "resolutionMm": res, "layers": rows, "pairs": pairs, "balanced": ok,+ "poursWithoutRegions": unpoured,+ "_hint": if ok { "Mirrored layers carry similar copper: the stack should stay flat through reflow." } else { "A mirrored pair differs by more than 20 points of copper: the board can bow or twist through reflow. Pour the lighter layer (altium_apply_pours, a GND pour or copper thieving), or ask the fab to add thieving on its side." }})+}++/// altium_return_path {filePath?, nets?, referenceNets?, resolutionMm?}+pub fn return_path(args: &Value) -> Value {+ let (st, zones, layers) = match gather(args) { Ok(g) => g, Err(e) => return e };+ let res = res_of(args);+ // the reference nets: the ones that own pours or planes, unless named+ let mut skip: Vec<String> = args.get("referenceNets").and_then(|v| v.as_array()).map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect()).unwrap_or_default();+ if skip.is_empty() {+ skip = st["polygons"].as_array().cloned().unwrap_or_default().iter().filter_map(|g| g["net"].as_str().map(String::from))+ .chain(zones.iter().filter_map(|z| z["net"].as_str().map(String::from))).chain(layers.iter().filter(|l| l.1).map(|l| l.2.clone()))+ .filter(|n| !n.is_empty()).collect();+ skip.sort(); skip.dedup();+ }+ let (_, refs) = rasters(&st, &zones, &layers, res, Some(&skip));+ let mut rows = return_paths(&st, &refs, &layers, &skip, res);+ if let Some(want) = args.get("nets").and_then(|v| v.as_array()) {+ let w: Vec<String> = want.iter().filter_map(|v| v.as_str()).map(|s| s.to_ascii_lowercase()).collect();+ rows.retain(|r| r["net"].as_str().map(|n| w.contains(&n.to_ascii_lowercase())).unwrap_or(false));+ }+ let no_ref: Vec<&String> = layers.iter().map(|l| &l.0).filter(|n| refs.get(*n).map(|r| r.count() == 0).unwrap_or(true)).collect();+ let note = if skip.is_empty() { json!("No net owns a pour or a plane on this board: there is no reference copper, so every signal is reported. Pour GND (altium_apply_pours) or name referenceNets.") } else { Value::Null };+ json!({"success": true, "referenceNets": skip, "note": note, "layersWithNoReferenceCopper": no_ref, "nets": rows, "ok": rows.is_empty(), "resolutionMm": res,+ "_hint": if rows.is_empty() { "Every signal track runs over pour or plane copper on a neighbouring layer." } else { "Each row is a net whose tracks on that layer run over no pour or plane on either neighbouring layer for unreferencedMm (from firstGapAt). Its return current detours around the gap: more loop area, more emission and crosstalk. Close the gap (extend the pour: altium_apply_pours), route the net over solid copper, or add stitching capacitors or vias where it changes reference." }})+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn raster_shapes_have_their_area() {+ let mut r = Raster::new(0.0, 0.0, 20.0, 20.0, 0.05);+ r.rect(5.0, 5.0, 4.0, 2.0, 0.0);+ assert!((r.count() as f64 * 0.0025 - 8.0).abs() < 0.3);+ let mut c = Raster::new(0.0, 0.0, 20.0, 20.0, 0.05);+ c.capsule(10.0, 10.0, 10.0, 10.0, 4.0);+ assert!((c.count() as f64 * 0.0025 - std::f64::consts::PI * 4.0).abs() < 0.3);+ let mut p = Raster::new(0.0, 0.0, 20.0, 20.0, 0.05);+ p.polys(&[vec![(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)], vec![(2.0, 2.0), (4.0, 2.0), (4.0, 4.0), (2.0, 4.0)]], true);+ assert!((p.count() as f64 * 0.0025 - 96.0).abs() < 0.5);+ assert!(!p.at(3.0, 3.0) && p.at(6.0, 6.0));+ }++ fn st() -> Value {+ json!({"outline": [[0, 0], [20, 0], [20, 10], [0, 10]],+ "outlineEdges": [{"kind": "line", "start": [0.0, 0.0]}, {"kind": "line", "start": [20.0, 0.0]}, {"kind": "line", "start": [20.0, 10.0]}, {"kind": "line", "start": [0.0, 10.0]}],+ "tracks": [{"net": "SIG", "layer": "F.Cu", "width": 0.2, "start": {"x": 1.0, "y": 5.0}, "end": {"x": 19.0, "y": 5.0}}]})+ }++ #[test]+ fn balance_and_return_path_on_a_split_ground() {+ let layers = vec![("F.Cu".to_string(), false, String::new()), ("B.Cu".to_string(), false, String::new())];+ // GND poured on B.Cu over x 0..8 and 12..20: a 4 mm split under the signal+ let zones = vec![json!({"layer": "B.Cu", "net": "GND", "filled": true, "fills": [+ {"outline": [[0, 0], [8, 0], [8, 10], [0, 10]], "holes": []}, {"outline": [[12, 0], [20, 0], [20, 10], [12, 10]], "holes": []}]})];+ let (board, maps) = rasters(&st(), &zones, &layers, 0.1, None);+ let (rows, pairs) = balance(&board, &maps, &layers);+ assert!((rows[1]["percent"].as_f64().unwrap() - 80.0).abs() < 1.0);+ assert!((rows[0]["percent"].as_f64().unwrap() - 1.8).abs() < 0.5);+ assert_eq!(pairs[0]["ok"], json!(false));+ let (_, refs) = rasters(&st(), &zones, &layers, 0.1, Some(&["GND".to_string()]));+ // the signal itself is not reference copper+ assert!(!refs["F.Cu"].at(10.0, 5.0));+ let rp = return_paths(&st(), &refs, &layers, &["GND".to_string()], 0.1);+ assert_eq!(rp.len(), 1);+ assert!((rp[0]["unreferencedMm"].as_f64().unwrap() - 4.0).abs() < 0.2);+ assert_eq!(rp[0]["firstGapAt"][0].as_f64().unwrap().floor(), 8.0);+ }++ #[test]+ fn stack_names_match_routing_state() {+ let s = json!({"layers": [{"name": "Top"}, {"kind": "plane", "plane": 1, "net": "GND"}, {"kind": "signal", "mid": 1}, {"name": "Bottom"}]});+ let l = stack_layers(&s);+ assert_eq!(l.iter().map(|x| x.0.as_str()).collect::<Vec<_>>(), vec!["F.Cu", "L39", "In1.Cu", "B.Cu"]);+ assert!(l[1].1 && l[1].2 == "GND");+ }+}
bridge-rs/src/fab.rs−84@@ -113,80 +113,6 @@ pub fn fab_check(args: &Value) -> Value { 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::*;@@ -230,14 +156,4 @@ mod tests { 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/hier.rs+22−14@@ -66,20 +66,10 @@ pub fn sheet_symbol(args: &Value) -> Value { } // the project lists the child (a sheet outside the project is not part of the design) if let Some(prj) = support::s(args, "projectPath") {- let text = std::fs::read(&prj).map(|b| String::from_utf8_lossy(&b).to_string()).unwrap_or_default();- if text.is_empty() { return json!({"success": false, "errorCode": "project_not_found", "error": format!("{prj} could not be read")}); }- let prj_dir = Path::new(&prj).parent().map(|d| d.to_string_lossy().to_string()).unwrap_or_default();- if let Some(new_text) = add_document(&text, &crate::routing::rel_to(&prj_dir, &child)) {- // rewriting an open project's file makes Altium ask to reload it: save and close its members first- let (open, unlisted) = crate::routing::project_members(Path::new(&prj));- if !unlisted.is_empty() {- return json!({"success": false, "errorCode": "project_has_unlisted_documents", "error": format!("{prj} is open with documents its file does not list: {}", unlisted.join(", ")),- "_hint": "Save or close those documents first (altium_save / altium_close_document), then call again."});- }- crate::routing::close_project_saved(&prj, &open);- if let Err(e) = std::fs::write(&prj, new_text) { return json!({"success": false, "errorCode": "write_failed", "error": format!("{prj}: {e}")}); }- agent::call("open", &[("path", prj.clone())], None, Duration::from_secs(120));- steps.push(json!({"addedToProject": prj}));+ match add_to_project(&prj, &child) {+ Ok(true) => steps.push(json!({"addedToProject": prj})),+ Ok(false) => {}+ Err(e) => return e, } } // the symbol on the parent@@ -167,6 +157,24 @@ pub fn netlist(args: &Value) -> Value { out } +/// List `doc` in the project file `prj` (relative to it). Rewriting an open project's file makes Altium ask to reload+/// it, so its open members are saved and closed first and the project reopened after. Ok(false) when it was listed.+pub(crate) fn add_to_project(prj: &str, doc: &str) -> Result<bool, Value> {+ let text = std::fs::read(prj).map(|b| String::from_utf8_lossy(&b).to_string()).unwrap_or_default();+ if text.is_empty() { return Err(json!({"success": false, "errorCode": "project_not_found", "error": format!("{prj} could not be read")})); }+ let prj_dir = Path::new(prj).parent().map(|d| d.to_string_lossy().to_string()).unwrap_or_default();+ let Some(new_text) = add_document(&text, &crate::routing::rel_to(&prj_dir, doc)) else { return Ok(false) };+ let (open, unlisted) = crate::routing::project_members(Path::new(prj));+ if !unlisted.is_empty() {+ return Err(json!({"success": false, "errorCode": "project_has_unlisted_documents", "error": format!("{prj} is open with documents its file does not list: {}", unlisted.join(", ")),+ "_hint": "Save or close those documents first (altium_save / altium_close_document), then call again."}));+ }+ crate::routing::close_project_saved(prj, &open);+ if let Err(e) = std::fs::write(prj, new_text) { return Err(json!({"success": false, "errorCode": "write_failed", "error": format!("{prj}: {e}")})); }+ agent::call("open", &[("path", prj.to_string())], None, Duration::from_secs(120));+ Ok(true)+}+ #[cfg(test)] mod tests { use super::*;
bridge-rs/src/main.rs+4−1@@ -5,6 +5,7 @@ mod a365; mod ab; mod agent; mod board;+mod bodies; mod bom; mod sch; mod schdoc;@@ -16,10 +17,12 @@ mod fab; mod features; mod footprint; mod hier;+mod coppermap; mod current; mod impedance; mod inspect; mod lengths;+mod panel; mod instance; mod library; mod license;@@ -46,7 +49,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.71";+pub const BRIDGE_VERSION: &str = "0.2.72"; 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/panel.rsadded+426@@ -0,0 +1,426 @@+//! Manufacturing panels: a step-and-repeat of a board as Altium's own embedded board array, with rails, tooling+//! holes and fiducials, and how the boards come apart: V-score lines or mouse-bite tabs drawn the way JLCPCB reads+//! them, with JLCPCB's rules checked (jlcpcb.com/blog/v-cut-panelization-standards, /blog/mouse-bite-panelization-guide,+//! read 2026-10-23).+use serde_json::{json, Value};++fn f(v: &Value) -> f64 { v.as_f64().unwrap_or(0.0) }+fn r3(v: f64) -> f64 { (v * 1000.0).round() / 1000.0 }++/// Mechanical 1: the layer the bridge's Gerbers carry the outline on (GM1), where JLCPCB wants V-CUT marked.+const MECH1: i64 = 57;+/// A mouse-bite tab: five 0.6 mm holes 0.4 mm apart edge to edge (JLCPCB: 5 to 8 a set, 0.35 to 0.4 mm).+const BITE_HOLE: f64 = 0.6;+const BITE_PITCH: f64 = 1.0;+const BITES: i64 = 5;+const TAB: f64 = 5.0;++/// The panel in mm from its lower-left corner: its size, each board's outline corner, tooling holes and fiducials.+/// `edge` is the routed space between the rails and the boards (tabs); 0 for V-score.+pub fn panel_layout(w: f64, h: f64, rows: i64, cols: i64, gap: f64, rail: f64, edge: f64) -> Value {+ let m = rail + edge;+ let pw = 2.0 * m + cols as f64 * w + (cols - 1) as f64 * gap;+ let ph = 2.0 * m + 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(m + c as f64 * (w + gap)), r3(m + 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 k = rail / 2.0;+ for (x, y) in [(k, k), (pw - k, k), (k, ph - k)] { holes.push(json!([r3(x), r3(y)])); }+ for (x, y) in [(k + 5.0, k), (pw - k - 5.0, k), (k + 5.0, ph - k)] { fids.push(json!([r3(x), r3(y)])); }+ }+ json!({"size": [r3(pw), r3(ph)], "boards": boards, "toolingHoles": holes, "fiducials": fids})+}++/// V-score: the x of every vertical cut and the y of every horizontal one (boards edge to edge, gap 0). With rails the+/// panel's own edges are milled, not cut, but the rails' inner edges are cuts.+pub fn vcut_lines(w: f64, h: f64, rows: i64, cols: i64, rail: f64) -> (Vec<f64>, Vec<f64>) {+ let keep = |i: i64, n: i64| rail > 0.0 || (i != 0 && i != n);+ ((0..=cols).filter(|c| keep(*c, cols)).map(|c| r3(rail + c as f64 * w)).collect(),+ (0..=rows).filter(|r| keep(*r, rows)).map(|r| r3(rail + r as f64 * h)).collect())+}++/// Each cut as a line across the whole panel (JLCPCB: a V-cut runs edge to edge, no jumps), with "V-CUT" written at+/// both ends: in the rails (their inner half, clear of the tooling holes and fiducials at rail/2), or just outside the+/// panel without them. A label near the far edge goes on the other side of its line. Texts are (x, y, rotation).+pub fn vcut_marks(xs: &[f64], ys: &[f64], pw: f64, ph: f64, rail: f64) -> (Vec<[f64; 4]>, Vec<(f64, f64, i64)>) {+ const LEN: f64 = 5.0; // "V-CUT" at 1 mm high+ let (mut lines, mut texts) = (Vec::new(), Vec::new());+ let inside = rail >= 3.0;+ let beside = |p: f64, span: f64| if p + 0.4 + LEN > span - 0.5 { p - 0.4 - LEN } else { p + 0.4 };+ for &x in xs {+ lines.push([x, 0.0, x, ph]);+ let (lo, hi) = if inside { (rail - 1.3, ph - rail + 0.3) } else { (-2.0, ph + 1.0) };+ texts.push((r3(beside(x, pw)), r3(lo), 0));+ texts.push((r3(beside(x, pw)), r3(hi), 0));+ }+ for &y in ys {+ lines.push([0.0, y, pw, y]);+ // turned 90 degrees a text grows up from its anchor and its letters lie to the anchor's left+ let (lo, hi) = if inside { (rail - 0.3, pw - rail + 1.3) } else { (-1.0, pw + 2.0) };+ texts.push((r3(lo), r3(beside(y, ph)), 90));+ texts.push((r3(hi), r3(beside(y, ph)), 90));+ }+ (lines, texts)+}++fn copper(l: &Value) -> bool { l.as_str().map(|s| s.ends_with(".Cu")).unwrap_or(false) }++/// The nearest copper to the outline's bounding box (mm, with where), and copper whose centre lies outside it (the+/// panel would repeat that into the rails and the neighbours).+pub fn edge_clearance(state: &Value, bb: (f64, f64, f64, f64)) -> (Option<(f64, Value)>, Vec<Value>) {+ let (x0, y0, x1, y1) = bb;+ let mut pts: Vec<(f64, f64, f64, Value)> = Vec::new();+ let arr = |k: &str| state[k].as_array().cloned().unwrap_or_default();+ for t in arr("tracks").iter().filter(|t| copper(&t["layer"])) {+ for p in [&t["start"], &t["end"]] { pts.push((f(&p["x"]), f(&p["y"]), f(&t["width"]) / 2.0, json!({"track": t["net"], "layer": t["layer"]}))); }+ }+ for a in arr("arcs").iter().filter(|a| copper(&a["layer"])) {+ let (cx, cy, r) = (f(&a["center"]["x"]), f(&a["center"]["y"]), f(&a["radius"]));+ let (s, mut e) = (f(&a["startAngle"]), f(&a["endAngle"]));+ if e <= s { e += 360.0; }+ for k in 0..=8 {+ let t = (s + (e - s) * k as f64 / 8.0).to_radians();+ pts.push((cx + r * t.cos(), cy + r * t.sin(), f(&a["width"]) / 2.0, json!({"arc": a["net"], "layer": a["layer"]})));+ }+ }+ for v in arr("vias") { pts.push((f(&v["position"]["x"]), f(&v["position"]["y"]), f(&v["size"]) / 2.0, json!({"via": v["net"]}))); }+ for p in arr("pads") {+ let (rf, nm) = (p["ref"].as_str().unwrap_or(""), p["name"].as_str().unwrap_or(""));+ pts.push((f(&p["x"]), f(&p["y"]), f(&p["sizeX"]).max(f(&p["sizeY"])) / 2.0, json!({"pad": if rf.is_empty() { nm.to_string() } else { format!("{rf}.{nm}") }})));+ }+ for g in arr("polygons").iter().filter(|g| copper(&g["layer"])) {+ for p in g["points"].as_array().cloned().unwrap_or_default() { pts.push((f(&p[0]), f(&p[1]), 0.0, json!({"pour": g["net"], "layer": g["layer"]}))); }+ }+ let mut near: Option<(f64, Value)> = None;+ let mut outside = Vec::new();+ for (x, y, half, what) in pts {+ let inside = x >= x0 && x <= x1 && y >= y0 && y <= y1;+ if !inside {+ if outside.len() < 10 { let mut o = what.clone(); o["at"] = json!([r3(x), r3(y)]); outside.push(o); }+ continue;+ }+ let d = (x - x0).min(x1 - x).min(y - y0).min(y1 - y) - half;+ if near.as_ref().map(|n| d < n.0).unwrap_or(true) { let mut o = what; o["at"] = json!([r3(x), r3(y)]); near = Some((r3(d), o)); }+ }+ (near, outside)+}++/// Does the board draw a line of its outline on a mechanical layer? Every copy in the array brings it along closed,+/// and a router follows it all the way round.+pub fn mech_outline(state: &Value, bb: (f64, f64, f64, f64)) -> bool {+ let (x0, y0, x1, y1) = bb;+ let on = |x: f64, y: f64| (x - x0).abs() < 0.05 || (x - x1).abs() < 0.05 || (y - y0).abs() < 0.05 || (y - y1).abs() < 0.05;+ let mech = |l: &Value| l.as_str().and_then(|s| s.strip_prefix('L')).and_then(|n| n.parse::<i64>().ok()).map(|n| (57..=72).contains(&n)).unwrap_or(false);+ state["tracks"].as_array().map(|ts| ts.iter().any(|t| mech(&t["layer"]) && on(f(&t["start"]["x"]), f(&t["start"]["y"])) && on(f(&t["end"]["x"]), f(&t["end"]["y"])))).unwrap_or(false)+}++fn rule(name: &str, ok: Option<bool>, detail: String) -> Value { json!({"rule": name, "ok": ok, "detail": detail}) }++/// JLCPCB's V-cut rules against this panel. `ok: null` is a note, not a pass or a fail.+#[allow(clippy::too_many_arguments)]+pub fn jlc_vscore(pw: f64, ph: f64, thick: Option<f64>, clear: Option<f64>, cuts: (usize, usize), rail: f64, w: f64, h: f64, rounded: bool) -> Vec<Value> {+ let mut out = Vec::new();+ let size_ok = pw >= 70.0 && ph >= 70.0 && pw <= 475.0 && ph <= 475.0;+ out.push(rule("panel 70 x 70 mm to 475 x 475 mm", Some(size_ok), if size_ok { format!("{} x {} mm", r3(pw), r3(ph)) } else {+ format!("{} x {} mm: add rows or columns, or widen the rails (railMm), to reach 70 mm each way", r3(pw), r3(ph)) }));+ out.push(match thick {+ Some(t) => rule("board 0.6 mm thick or more", Some(t >= 0.6), format!("{} mm (V-cut is not offered on 0.4 mm boards)", r3(t))),+ None => rule("board 0.6 mm thick or more", None, "the stack-up's thickness could not be read".into()),+ });+ out.push(match clear {+ Some(c) => rule("copper 0.4 mm or more from every cut (1.0 mm recommended, 2.0 mm by tall parts)", Some(c >= 0.4),+ format!("nearest copper {c} mm from the board edge{}", if (0.4..1.0).contains(&c) { ": passes, under the 1.0 mm JLCPCB recommends" } else { "" })),+ None => rule("copper 0.4 mm or more from every cut", None, "no copper on the board".into()),+ });+ out.push(rule("at most 25 cuts each way", Some(cuts.0 <= 25 && cuts.1 <= 25), format!("{} vertical, {} horizontal", cuts.0, cuts.1)));+ let span = if rail > 0.0 { w.min(h).min(rail) } else { w.min(h) };+ out.push(rule("parallel cuts 2 mm apart or more", Some(span >= 2.0), format!("closest pair {} mm apart", r3(span))));+ let need = if thick.map(|t| t <= 0.8).unwrap_or(false) { 5.0 } else { 3.0 };+ out.push(if rail > 0.0 { rule("rails 3 mm wide or more (5 mm on 0.8 mm boards and thinner)", Some(rail >= need), format!("{rail} mm, {need} mm needed")) }+ else { rule("rails", None, "none: fine for bare boards, assembly needs rails (JLCPCB adds tooling holes and fiducials on them)".into()) });+ out.push(rule("straight board edges on the cuts (no arcs)", Some(!rounded), if rounded { "the outline has arcs (rounded corners): a V-cut is straight, so the corners come out square or need milling. Square the corners, or use tabs".into() } else { "rectangular".into() }));+ if w.min(h) < 15.0 { out.push(rule("pieces under 15 mm on a side cost extra", None, format!("{} x {} mm boards", r3(w), r3(h)))); }+ out.push(rule("V-cut is not offered with Economic Assembly", None, "order Standard Assembly for an assembled V-cut panel".into()));+ out+}++/// JLCPCB's mouse-bite rules against this panel.+pub fn jlc_tabs(gap: f64, pw: f64, ph: f64, clear: Option<f64>) -> Vec<Value> {+ let mut out = vec![+ rule("gap between boards 1.2 mm or more (1.6 or 2.0 typical)", Some(gap >= 1.2), format!("{gap} mm")),+ rule("0.6 mm holes, 5 to a set, 0.35 to 0.4 mm apart", Some(true), "five 0.6 mm holes at 1.0 mm pitch (0.4 mm between them), centred a third of a hole into the board".into()),+ rule("at least 2 sets an edge, one every 50 to 60 mm", Some(true), "2 tabs on edges of 15 mm and longer, one more each 55 mm; 1 on shorter edges".into()),+ rule("no mouse bites on the rail side", Some(true), "holes on the board side only".into()),+ rule("longest panel side under 250 mm (recommended)", Some(pw.max(ph) < 250.0), format!("{} mm", r3(pw.max(ph)))),+ ];+ if let Some(c) = clear { out.push(rule("copper clear of the bitten edge", Some(c >= 0.5), format!("nearest copper {c} mm from the board edge (mouse bites leave about 0.5 mm of rough edge)"))); }+ out+}++/// Where the tabs go along an edge of length `l`: their centres from the edge's start.+fn tab_centres(l: f64) -> Vec<f64> {+ if l < TAB + 1.0 { return vec![]; }+ let n = if l < 15.0 { 1 } else { ((l / 55.0).ceil() as usize).max(2) };+ (0..n).map(|k| l * (k as f64 + 0.5) / n as f64).collect()+}++/// The span [a, b] with the `cuts` spans taken out.+fn broken(a: f64, b: f64, cuts: &[(f64, f64)]) -> Vec<(f64, f64)> {+ let mut out = Vec::new();+ let mut s = a;+ let mut cs = cuts.to_vec();+ cs.sort_by(|p, q| p.0.partial_cmp(&q.0).unwrap());+ for (c0, c1) in cs { if c0 > s { out.push((s, c0.min(b))); } s = s.max(c1); }+ if s < b { out.push((s, b)); }+ out+}++/// Mouse-bite tabs: the rout path on the outline layer (each board's edges broken at its tabs, the tab sides across+/// the gap, the rails' inner edge broken where tabs meet it) and the holes, all in panel mm. The rout path is drawn by+/// the bridge because the copies bring no outline of their own (the verb refuses a board that draws one).+pub fn tab_plan(w: f64, h: f64, rows: i64, cols: i64, gap: f64, rail: f64) -> (Vec<[f64; 4]>, Vec<[f64; 2]>, usize) {+ let lay = panel_layout(w, h, rows, cols, gap, rail, if rail > 0.0 { gap } else { 0.0 });+ let (pw, ph) = (f(&lay["size"][0]), f(&lay["size"][1]));+ let (mut lines, mut holes, mut tabs) = (Vec::new(), Vec::new(), 0usize);+ // the rails' inner edge, broken where outer tabs land: bottom, top, left, right+ let mut rail_cuts: [Vec<(f64, f64)>; 4] = Default::default();+ let off = BITE_HOLE / 2.0 - BITE_HOLE / 3.0;+ for r in 0..rows {+ for c in 0..cols {+ let b = &lay["boards"][(r * cols + c) as usize];+ let (bx, by) = (f(&b[0]), f(&b[1]));+ // (side, outer, along-x?, fixed coordinate, start, length, outward sign)+ for (side, outer, horiz, fixed, start, len, out) in [+ (0usize, r == 0, true, by, bx, w, -1.0), (1, r == rows - 1, true, by + h, bx, w, 1.0),+ (2, c == 0, false, bx, by, h, -1.0), (3, c == cols - 1, false, bx + w, by, h, 1.0)] {+ if outer && rail <= 0.0 { continue; } // the panel's own edge: the outline covers it+ let centres = tab_centres(len);+ let spans: Vec<(f64, f64)> = centres.iter().map(|t| (start + t - TAB / 2.0, start + t + TAB / 2.0)).collect();+ for (a, z) in broken(start, start + len, &spans) {+ lines.push(if horiz { [a, fixed, z, fixed] } else { [fixed, a, fixed, z] });+ }+ let far = fixed + out * gap;+ for &(a, z) in &spans {+ tabs += 1;+ // the tab's two sides across the gap: drawn from the right and top edges (the neighbour's left and+ // bottom edges are the same lines) and from every edge that faces a rail+ if side == 1 || side == 3 || outer {+ for p in [a, z] { lines.push(if horiz { [p, fixed, p, far] } else { [fixed, p, far, p] }); }+ }+ if outer { rail_cuts[side].push((a, z)); }+ let mid = (a + z) / 2.0;+ for k in 0..BITES {+ let along = mid + (k - BITES / 2) as f64 * BITE_PITCH;+ let across = fixed + out * off;+ holes.push(if horiz { [r3(along), r3(across)] } else { [r3(across), r3(along)] });+ }+ }+ }+ }+ }+ if rail > 0.0 {+ let (ix0, iy0, ix1, iy1) = (rail, rail, pw - rail, ph - rail);+ for (side, fixed, a, z, horiz) in [(0usize, iy0, ix0, ix1, true), (1, iy1, ix0, ix1, true), (2, ix0, iy0, iy1, false), (3, ix1, iy0, iy1, false)] {+ for (p, q) in broken(a, z, &rail_cuts[side]) { lines.push(if horiz { [p, fixed, q, fixed] } else { [fixed, p, fixed, q] }); }+ }+ }+ let lines = lines.into_iter().map(|l| [r3(l[0]), r3(l[1]), r3(l[2]), r3(l[3])]).collect();+ (lines, holes, tabs)+}++/// altium_panelize {pcbPath, panelPath, rows, cols, separation?, gapMm?, railMm?, toolingHoles?, projectPath?, overwrite?}+pub fn panelize(args: &Value) -> Value {+ use crate::routing::raw;+ use crate::support::s;+ if let Some(g) = crate::support::need_licensed() { return g; }+ let bad = |e: &str| json!({"success": false, "errorCode": "bad_args", "error": e});+ let (src, panel) = match (s(args, "pcbPath"), s(args, "panelPath")) {+ (Some(a), Some(b)) => (a, b),+ _ => return bad("pcbPath (the board) and panelPath (the new panel .PcbDoc) are required"),+ };+ if !panel.to_ascii_lowercase().ends_with(".pcbdoc") { return bad("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 bad("rows and cols are 1 or more, at most 400 boards"); }+ let sep = s(args, "separation").unwrap_or_else(|| "none".into()).to_ascii_lowercase();+ if !["none", "vscore", "tabs"].contains(&sep.as_str()) { return bad("separation is none, vscore (V-cut lines, boards edge to edge) or tabs (mouse bites)"); }+ let num = |k: &str| args.get(k).and_then(|v| v.as_f64());+ let gap = match (sep.as_str(), num("gapMm")) {+ ("vscore", Some(g)) if g != 0.0 => return bad("V-score boards sit edge to edge: leave gapMm out or pass 0"),+ ("vscore", _) => 0.0,+ (_, Some(g)) => g,+ _ => 2.0,+ };+ let rail = num("railMm").unwrap_or(5.0);+ if gap < 0.0 || rail < 0.0 { return bad("gapMm and railMm are 0 or more"); }+ if sep == "tabs" && gap < 1.2 { return bad("mouse bites need a gap of 1.2 mm or more (JLCPCB: 1.6 or 2.0 typical)"); }+ // 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 bb = match crate::fab::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 (x0, y0, x1, y1) = bb;+ let (w, h) = (x1 - x0, y1 - y0);+ if sep == "tabs" && mech_outline(&st, bb) {+ return json!({"success": false, "errorCode": "outline_on_mechanical_layer",+ "error": "the board draws its outline on a mechanical layer, and every copy in the panel brings that outline along closed: a router following it cuts each board free, so mouse-bite tabs cannot hold",+ "_hint": "Use separation:\"vscore\" (boards edge to edge, JLCPCB's V-cut), or panelize without separation and order 'Panel by JLCPCB' with mouse bites, or move the board's outline to a mechanical layer the Gerbers leave out."});+ }+ let (clear, outside) = edge_clearance(&st, bb);+ let rounded = st["outlineEdges"].as_array().map(|es| es.iter().any(|e| e["kind"] == "arc")).unwrap_or(false);+ let stack = crate::agent::call("layer_stack", &[], None, std::time::Duration::from_secs(60));+ let thick = stack.get("data").and_then(|d| d["totalUm"].as_f64()).map(|u| u / 1000.0);+ let edge = if sep == "tabs" && rail > 0.0 { gap } else { 0.0 };+ let lay = panel_layout(w, h, rows, cols, gap, rail, edge);+ let (pw, ph) = (f(&lay["size"][0]), f(&lay["size"][1]));+ 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 + edge).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) = (f(&d["originX"]) / raw(1.0) as f64, f(&d["originY"]) / raw(1.0) as f64);+ let outline = crate::routing::set_outline(&json!({"outline": {"rect": [ox, oy, pw, ph]}}));+ // the board shape alone is not in the Gerbers: the panel's frame goes on Mechanical 1 with the copies' outlines+ let frame = crate::agent::call("outline_tracks", &[("op", "draw".into()), ("mech", "1".into())], None, std::time::Duration::from_secs(60));+ let mut ops: Vec<String> = Vec::new();+ let line = |l: &[f64; 4]| format!("T|{}|{}|{}|{}|{}|{MECH1}|", raw(ox + l[0]), raw(oy + l[1]), raw(ox + l[2]), raw(oy + l[3]), raw(0.1));+ let mut sep_out = json!({"kind": sep});+ let mut jlc = Vec::new();+ match sep.as_str() {+ "vscore" => {+ let (xs, ys) = vcut_lines(w, h, rows, cols, rail);+ let (lines, texts) = vcut_marks(&xs, &ys, pw, ph, rail);+ ops.extend(lines.iter().map(line));+ ops.extend(texts.iter().map(|(x, y, rot)| format!("X|{}|{}|{}|{}|{MECH1}|{rot}|V-CUT", raw(ox + x), raw(oy + y), raw(1.0), raw(0.12))));+ jlc = jlc_vscore(pw, ph, thick, clear.as_ref().map(|c| c.0), (xs.len(), ys.len()), rail, w, h, rounded);+ sep_out["cutsX"] = json!(xs);+ sep_out["cutsY"] = json!(ys);+ }+ "tabs" => {+ let (lines, holes, tabs) = tab_plan(w, h, rows, cols, gap, rail);+ ops.extend(lines.iter().map(line));+ ops.extend(holes.iter().enumerate().map(|(i, p)| format!("P|{}|{}|{}|{}|0|74||MB{}|", raw(ox + p[0]), raw(oy + p[1]), raw(BITE_HOLE), raw(BITE_HOLE), i + 1)));+ jlc = jlc_tabs(gap, pw, ph, clear.as_ref().map(|c| c.0));+ sep_out["tabs"] = json!(tabs);+ sep_out["holes"] = json!(holes.len());+ sep_out["routLines"] = json!(lines.len());+ }+ _ => {}+ }+ if !ops.is_empty() {+ let before = crate::routing::routing_state(&json!({}));+ let a = crate::routing::apply(&ops, &before, "panel marks");+ sep_out["applied"] = a["applied"].clone();+ sep_out["failed"] = a["failed"].clone();+ }+ 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!({}));+ let mut project = Value::Null;+ if let Some(prj) = s(args, "projectPath") {+ // the saved panel is open outside the project, which add_to_project refuses (it would be lost on reload)+ crate::agent::call("close_document", &[("path", panel.clone())], None, std::time::Duration::from_secs(30));+ project = match crate::hier::add_to_project(&prj, &panel) { Ok(added) => json!({"path": prj, "added": added}), Err(e) => e };+ }+ let mut warnings = Vec::new();+ if !outside.is_empty() { warnings.push(json!({"copperOutsideOutline": outside, "why": "every copy repeats it into the rails and the neighbouring boards: move it onto the board or delete it"})); }+ let fails: Vec<&Value> = jlc.iter().filter(|r| r["ok"] == json!(false)).collect();+ 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"],+ "separation": sep_out, "jlcpcb": if jlc.is_empty() { Value::Null } else { json!({"rules": jlc, "fits": fails.is_empty()}) },+ "nearestCopperToEdge": clear.map(|(mm, mut what)| { what["mm"] = json!(mm); what }), "warnings": warnings,+ "outline": {"success": outline["success"], "drawnOnMech1": frame.get("data").map(|d| d["drawn"].clone())}, "toolingHoles": tooling,+ "project": project, "saved": saved.get("success").cloned().unwrap_or(Value::Null),+ "_hint": "The panel holds Altium's embedded board array of the board, which stays linked: edit the board and the panel follows. The outline layer (Mechanical 1, GM1) carries the panel frame, every board's outline and the V-CUT lines or tab rout path. With projectPath the panel joins the project: altium_export_manufacturing {pcbPath: the panel} makes its Gerbers. jlcpcb.rules lists each JLCPCB rule with ok true, false or null (a note)."})+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn panel_layout_places_boards_and_rails() {+ let l = panel_layout(40.0, 20.0, 2, 3, 2.0, 5.0, 0.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, 0.0)["toolingHoles"].as_array().unwrap().is_empty());+ // tabs: the boards stand a gap off the rails+ assert_eq!(panel_layout(40.0, 20.0, 1, 1, 2.0, 5.0, 2.0)["boards"][0], json!([7.0, 7.0]));+ }++ #[test]+ fn vcut_lines_run_across_the_panel_at_every_board_edge() {+ let (xs, ys) = vcut_lines(40.0, 20.0, 2, 3, 5.0);+ assert_eq!(xs, vec![5.0, 45.0, 85.0, 125.0]);+ assert_eq!(ys, vec![5.0, 25.0, 45.0]);+ // no rails: the panel's edges are milled, only the shared edges are cut+ let (xs, ys) = vcut_lines(40.0, 20.0, 2, 3, 0.0);+ assert_eq!((xs, ys), (vec![40.0, 80.0], vec![20.0]));+ let (lines, texts) = vcut_marks(&[5.0], &[5.0], 130.0, 50.0, 5.0);+ assert_eq!(lines, vec![[5.0, 0.0, 5.0, 50.0], [0.0, 5.0, 130.0, 5.0]]);+ assert_eq!(texts.len(), 4);+ // in the rail's inner half, clear of the fiducial at (7.5, 2.5) and its 2 mm opening+ assert_eq!(texts[0], (5.4, 3.7, 0));+ // near the right edge the label goes left of its line+ let (_, t2) = vcut_marks(&[125.0], &[], 130.0, 50.0, 5.0);+ assert_eq!(t2[0].0, 119.6);+ }++ #[test]+ fn jlc_vscore_rules_catch_a_small_thin_rounded_panel() {+ let r = jlc_vscore(130.0, 50.0, Some(0.39), Some(0.3), (4, 3), 5.0, 40.0, 20.0, true);+ let bad: Vec<&str> = r.iter().filter(|x| x["ok"] == json!(false)).map(|x| x["rule"].as_str().unwrap()).collect();+ assert_eq!(bad.len(), 4, "{bad:?}");+ assert!(bad.iter().any(|b| b.starts_with("panel 70")) && bad.iter().any(|b| b.starts_with("board 0.6")) && bad.iter().any(|b| b.starts_with("copper")) && bad.iter().any(|b| b.starts_with("straight")));+ let good = jlc_vscore(130.0, 90.0, Some(1.6), Some(1.2), (4, 5), 5.0, 40.0, 20.0, false);+ assert!(good.iter().all(|x| x["ok"] != json!(false)));+ }++ #[test]+ fn edge_clearance_finds_the_nearest_copper_and_strays() {+ let st = json!({"tracks": [{"layer": "F.Cu", "width": 0.2, "net": "A", "start": {"x": 0.5, "y": 10.0}, "end": {"x": 20.0, "y": 10.0}},+ {"layer": "L57", "width": 0.1, "net": "", "start": {"x": 0.0, "y": 0.0}, "end": {"x": 40.0, "y": 0.0}}],+ "pads": [{"ref": "U3", "name": "1", "x": 60.0, "y": 5.0, "sizeX": 1.0, "sizeY": 1.0}]});+ let (near, out) = edge_clearance(&st, (0.0, 0.0, 40.0, 20.0));+ assert_eq!(near.unwrap().0, 0.4);+ assert_eq!(out.len(), 1);+ assert!(mech_outline(&st, (0.0, 0.0, 40.0, 20.0)));+ }++ #[test]+ fn tab_plan_follows_jlcpcb() {+ let (lines, holes, tabs) = tab_plan(40.0, 20.0, 1, 2, 2.0, 5.0);+ // two boards, 4 edges each with 2 tabs (40 mm edges: 2; 20 mm: 2)+ assert_eq!(tabs, 16);+ assert_eq!(holes.len(), 16 * 5);+ // holes sit a third of a hole into the board: 0.1 mm off the edge into the gap+ assert!(holes.contains(&[17.0, 6.9]));+ // a tab between the boards is bridged: no rout line crosses x = 47..49 at the tab's height+ assert!(!lines.iter().any(|l| l[0] == l[2] && (l[0] - 47.0).abs() < 1e-9 && l[1] <= 12.0 && l[3] >= 12.0));+ assert_eq!(broken(0.0, 10.0, &[(2.0, 4.0), (6.0, 8.0)]), vec![(0.0, 2.0), (4.0, 6.0), (8.0, 10.0)]);+ assert_eq!(tab_centres(40.0), vec![10.0, 30.0]);+ assert_eq!(tab_centres(120.0).len(), 3);+ }+}
bridge-rs/src/routing.rs+1−1@@ -11,7 +11,7 @@ use std::time::Duration; use crate::agent; /// Internal units per mm: 10000 per mil, 1 mil = 0.0254 mm.-const PER_MM: f64 = 10000.0 / 0.0254;+pub(crate) const PER_MM: f64 = 10000.0 / 0.0254; pub const TOP: i64 = 1; pub const BOTTOM: i64 = 32;
bridge-rs/src/verbs.rs+32−8@@ -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", "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",+ "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", "height_check", "copper_balance", "return_path", "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,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("copper_balance", "How much of each copper layer is copper (tracks, pads, vias and what Altium actually poured, inside the outline; a plane counts full), and how far apart each mirrored pair of layers is: the copper balance a fab asks about because an unbalanced stack bows and twists in reflow.",+ json!({"filePath": "the .PcbDoc (opened first; else the current board)", "resolutionMm": "raster cell, default 0.1"}),+ json!({"boardMm2": "number", "layers": "[{layer, copperMm2, percent, plane?}]", "pairs": "[{layers, percent, differencePoints, ok}] - layer 1 with n, 2 with n-1...", "balanced": "bool", "poursWithoutRegions": "[{net, layer}] - pours not poured, or hatched from tracks (counted through their tracks)"}), 180,+ "Pour the lighter layer of an unbalanced pair (altium_apply_pours: a GND pour, or copper thieving) or ask the fab for thieving; then run it again.",+ &["apply_pours", "pour_state", "layer_stack", "fab_check"],+ &["A raster of the live board's copper: within a cell of the true area (0.1 mm default).", "A pour counts only once poured (regions); a hatched pour built from tracks is counted through its tracks only when the board lists them."],+ json!({})),+ verb_doc("return_path", "Where signal tracks lose their reference: for every signal net, the length of track on each layer that runs over no pour or plane copper on either neighbouring layer (a split, a gap, a void), with where the first gap starts. The return current detours around those gaps: loop area, emissions, crosstalk.",+ json!({"filePath": "the .PcbDoc (opened first; else the current board)", "nets": "[net] - only these (default every signal net)", "referenceNets": "[net] - the reference nets (default: every net that owns a pour or a plane)", "resolutionMm": "raster cell and sampling step, default 0.1"}),+ json!({"nets": "[{net, layer, lengthMm, unreferencedMm, percent, firstGapAt}] worst first", "referenceNets": "[net]", "layersWithNoReferenceCopper": "[layer]", "ok": "bool"}), 180,+ "Extend or join the pour under the net (altium_apply_pours), reroute it over solid copper (altium_route_net), or add a stitching via or capacitor where it changes reference.",+ &["apply_pours", "pour_state", "route_net", "net_lengths", "board_features"],+ &["Reference copper is pours, planes, the reference nets' own copper and net-less copper (a hatched pour's tracks): another signal's track under a net is not a reference.", "Checks the two neighbouring copper layers; a board with no pours or planes has nothing to reference (layersWithNoReferenceCopper)."],+ json!({"nets": ["USB_DP", "USB_DN", "CLK"]})),+ verb_doc("height_check", "Will it fit the enclosure? Each part's height from its 3D bodies (top and bottom side), the parts over a height limit board-wide or under a lid feature in a region, and part bodies that collide or come closer than a gap.",+ json!({"filePath": "the .PcbDoc (opened first; else the current board)", "maxTopMm": "height limit above the top side", "maxBottomMm": "limit below the bottom side", "regions": "[{rect:[x1,y1,x2,y2] mm, maxMm, side?: top|bottom, name?}] - lid features over part of the board", "minGapMm": "report bodies closer than this (default 0: collisions only)", "limit": "how many of the tallest parts to list (default 25)"}),+ json!({"ok": "bool", "tallest": "{top, bottom}", "overLimit": "[{ref, side, heightMm, limitMm, overByMm}]", "regionHits": "[{region, ref, heightMm, maxMm}]", "collisions": "[{parts, overlapMm, at}]", "tooClose": "[{parts, gapMm, at}]", "noBody": "[ref] - parts with no 3D body (no height)", "heights": "[{ref, side, heightMm, rect}]"}), 120,+ "Move or swap what overLimit and regionHits name (altium_move_parts, altium_swap_footprint); separate colliding parts. Parts in noBody need a body (altium_add_body) before the check means anything for them.",+ &["add_body", "move_parts", "find_space", "run_drc", "show_3d_board"],+ &["Bodies are compared as boxes, each body its own box; Altium's Component Clearance DRC checks the real shapes.", "A height is the body's top above the surface its part sits on; the board's own thickness is not included."],+ json!({"maxTopMm": 8, "maxBottomMm": 2, "regions": [{"rect": [10, 10, 30, 25], "maxMm": 3, "name": "display boss"}], "minGapMm": 0.25})), 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,@@ -720,13 +741,13 @@ fn describe() -> Value { &["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.",+ 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), with rails, the panel frame on the outline layer, three tooling holes and three fiducials; boards separated by JLCPCB-style V-score lines or mouse-bite tabs, with JLCPCB's rules checked. The array stays linked to the board.",+ json!({"pcbPath": "the board's .PcbDoc", "panelPath": "the new panel .PcbDoc", "rows": "default 1", "cols": "default 2", "separation": "none (default) | vscore (boards edge to edge, V-CUT lines across the panel on Mechanical 1 with 'V-CUT' written at both ends, as JLCPCB asks) | tabs (mouse bites: 0.6 mm holes five to a tab, the rout path drawn on Mechanical 1)", "gapMm": "between boards, default 2 (vscore: 0)", "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", "projectPath": "a .PrjPcb the panel joins (so altium_export_manufacturing can make its Gerbers)", "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", "separation": "{kind, cutsX, cutsY | tabs, holes, routLines, applied, failed}", "jlcpcb": "{rules:[{rule, ok (true|false|null = a note), detail}], fits}", "nearestCopperToEdge": "{mm, at}", "warnings": "[{copperOutsideOutline}]", "project": "{path, added}", "saved": "bool"}), 240,+ "Fix what jlcpcb.rules marks false (panel under 70 mm: more rows or wider rails; copper under 0.4 mm from a cut; rounded corners on a V-cut) and run it again with overwrite:true. Then altium_export_manufacturing {projectPath, pcbPath: the panel}.", &["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})),+ &["Opens the board, then creates and saves the panel: the panel ends as the current document.", "tabs refuses a board that draws its own outline on a mechanical layer: every copy brings it along closed and a router would cut each board free.", "JLCPCB's rules as published (V-cut standards and mouse-bite guide, read 2026-10-23): V-cut panels 70 to 475 mm, 0.6 mm boards or thicker, copper 0.4 mm (1.0 recommended) from a cut, at most 25 cuts a side; mouse bites 0.6 mm holes 5 to a set, gaps of 1.2 mm or more."],+ json!({"pcbPath": "C:/proj/Board.PcbDoc", "panelPath": "C:/proj/Panel.PcbDoc", "rows": 3, "cols": 3, "separation": "vscore", "railMm": 5, "projectPath": "C:/proj/Board.PrjPcb"})), 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,@@ -1712,9 +1733,12 @@ pub fn dispatch(command: &str, args: &Value, _forwarded: &[(String, String)]) -> "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),+ "panelize" => crate::panel::panelize(args), "impedance" => crate::impedance::impedance(args), "current_capacity" => crate::current::current_capacity(args),+ "height_check" => crate::bodies::height_check(args),+ "copper_balance" => crate::coppermap::copper_balance(args),+ "return_path" => crate::coppermap::return_path(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+16−2@@ -317,8 +317,22 @@ def matrix(w): 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.72: V-score like JLCPCB asks (V-CUT lines across the panel on Mechanical 1) with its rules checked; the lane+ # board is 0.39 mm thick with rounded corners, so the rules must say it does not fit+ M["panelize"] = dict(phase=3.5, timeout=260, **ok(pcbPath=LB, panelPath=D + "/lane-panel.PcbDoc", rows=1, cols=2, separation="vscore", overwrite=True),+ check=lambda d: d.get("panelMm") == [90.0, 30.0] and d.get("saved") is True and ((d.get("toolingHoles") or {}).get("applied") or 0) == 6+ and ((d.get("separation") or {}).get("applied") or 0) > 0 and ((d.get("separation") or {}).get("failed") or 0) == 0+ and (d.get("jlcpcb") or {}).get("fits") is False)+ # 0.2.72: heights from the 3D bodies (most lane parts have none, which the reply must say)+ M["height_check"] = dict(phase=3.5, timeout=200, **ok(filePath=LB, maxTopMm=0.1, minGapMm=0.2),+ check=lambda d: isinstance(d.get("noBody"), list) and (d.get("parts") or 0) + len(d.get("noBody") or []) >= 3+ and (d.get("complete") is False or len(d.get("overLimit") or []) >= 1))+ # 0.2.72: the copper raster: balance per layer (the 40 x 20 mm lane outline with 1 mm round corners is 799 mm2) and+ # signal tracks over no reference copper (the lane board has no pours, so every routed signal is reported)+ M["copper_balance"] = dict(phase=3.5, timeout=260, **ok(filePath=LB),+ check=lambda d: len(d.get("layers") or []) == 2 and abs((d.get("boardMm2") or 0) - 799.1) < 3 and len(d.get("pairs") or []) == 1)+ M["return_path"] = dict(phase=3.5, timeout=260, **ok(filePath=LB),+ check=lambda d: isinstance(d.get("nets"), list) and isinstance(d.get("layersWithNoReferenceCopper"), list)) # 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.71",+ "version": "0.2.72", "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": [@@ -172,7 +172,16 @@ "altium panelize", "altium panel array step and repeat", "altium fab capability check",- "read altium schdoc without altium"+ "read altium schdoc without altium",+ "altium v-score panel",+ "altium v-cut panel jlcpcb",+ "altium mouse bites panel",+ "panelize for jlcpcb",+ "altium component height check enclosure",+ "altium 3d clearance check",+ "altium copper balance",+ "altium return path check",+ "signal crossing a split plane 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+3−12@@ -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.71",+ "version": "0.2.72", "hero": { "type": "image", "path": "screenshots/hero.png"@@ -155,16 +155,7 @@ "altium assembly drawing pdf", "altium fabrication drawing", "altium keepout",- "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"+ "altium polygon connect style rule" ], "discovery_pitch": "An AI that runs Altium Designer in the user's own session: libraries, schematics, Update PCB, stack-up, routing, DRC, Gerbers and BOMs.", "metadata": {},@@ -219,4 +210,4 @@ "tools/symbol_modes.py", "uninstall.sh" ]-}+}
skills/altium-boards/SKILL.md+54−12@@ -3,8 +3,9 @@ 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, 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.+ readback, 3D heights and clearance, impedance, current capacity, copper balance, return paths, fab limits, panels+ with V-score or mouse bites. Use when asked for an altium stack-up, net class, mounting holes, a 50 or 90 ohm trace+ width, will a trace carry 3 A, does it fit the enclosure, copper balance, return path, or panelize a board. --- Parent skill: **altium-bridge**@@ -282,20 +283,61 @@ altium_fab_check '{"limits":{"minTrack":0.127,"minClearance":0.127,"minViaDrill" 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)+## Panels: V-score and mouse bites the way JLCPCB reads them (0.2.71, separation 0.2.72) ```bash-altium_panelize '{"pcbPath":"C:\\work\\Board.PcbDoc","panelPath":"C:\\work\\Panel.PcbDoc","rows":2,"cols":3,"gapMm":2,"railMm":5}'+altium_panelize '{"pcbPath":"C:\\work\\Board.PcbDoc","panelPath":"C:\\work\\Panel.PcbDoc","rows":3,"cols":3,"separation":"vscore","railMm":5,"projectPath":"C:\\work\\Board.PrjPcb"}'+altium_panelize '{"pcbPath":"C:\\work\\Board.PcbDoc","panelPath":"C:\\work\\Panel.PcbDoc","rows":2,"cols":2,"separation":"tabs","gapMm":2}' ``` -- 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.+- A new .PcbDoc holding Altium's own embedded board array of the board (stays linked: edit the board and the panel+ follows; the array stores the board's path relatively, so the folder can move), `railMm` rails, three 2 mm tooling+ holes (the fourth corner bare so the panel loads one way) and three fiducials on rails 5 mm or wider. The panel frame+ is drawn on Mechanical 1, the layer the Gerbers carry the outline on (GM1); each copy brings its own outline along.+- `separation:"vscore"`: boards edge to edge (gap 0), a V-CUT line across the whole panel on Mechanical 1 at every+ board edge (and at the rails' inner edges) with "V-CUT" written at both ends in the rails: JLCPCB's own instruction+ (mark V-CUT on the outline layer; a cut runs edge to edge, no jumps). `jlcpcb.rules` checks the published V-cut+ standards: panel 70 x 70 to 475 x 475 mm, board 0.6 mm or thicker, copper 0.4 mm from every cut (1.0 recommended),+ at most 25 cuts each way, cuts 2 mm apart, rails 3 mm (5 mm for boards 0.8 mm and thinner), no arcs on a cut edge;+ notes for pieces under 15 mm (extra cost) and Economic Assembly (no V-cut). Each rule answers ok true, false or null.+- `separation:"tabs"`: mouse bites per JLCPCB's guide: a 2 mm gap (1.2 minimum), tabs of five 0.6 mm unplated holes+ at 1.0 mm pitch a third of a hole into the board, two tabs an edge and one more each 55 mm, holes on the board side+ only (none on the rails), and the rout path drawn on Mechanical 1 (board edges broken at the tabs, tab sides across+ the gap, the rails' inner edge). It refuses a board that draws its own outline on a mechanical layer+ (`outline_on_mechanical_layer`): every copy brings that outline along closed and a router would cut each board free.+ Use vscore, or panelize without separation and order Panel by JLCPCB.+- `warnings.copperOutsideOutline`: copper off the board (a parked part) is repeated into the rails and neighbours.+- `projectPath` adds the panel to the project, so `altium_export_manufacturing {projectPath, pcbPath: the panel}`+ makes the panel's Gerbers. An existing `panelPath` is replaced only with `overwrite:true` (Altium's blank save-over+ Confirm would stall the agent).+- Measured: a 2 x 3 V-score panel of the lane board in 2.3 s (21 marks); a 2 x 2 tab panel with 32 tabs and 160 holes.++## 3D: heights, enclosure limits and body clearance (0.2.72)++```bash+altium_height_check '{"maxTopMm":8,"maxBottomMm":2,"regions":[{"rect":[10,10,30,25],"maxMm":3,"name":"display boss"}],"minGapMm":0.25}'+```++- Each part's height is its tallest 3D body's top above the surface it sits on (bottom-side parts below the bottom).+ `overLimit` names parts over `maxTopMm` / `maxBottomMm`, `regionHits` parts under a lid feature that are taller than+ its `maxMm`, `collisions` bodies of different parts that overlap and `tooClose` ones within `minGapMm`. Bodies are+ boxes (each body its own); Altium's Component Clearance DRC checks the real shapes. `noBody` parts have no height and+ make `complete` false. Measured on the Bluetooth Sentinel example: 152 bodies in 5 s, J1 (the coin cell holder) 4.1 mm+ under the board; C20 moved onto U4 read as a 3.29 mm collision.++## Copper balance and return paths (0.2.72)++```bash+altium_copper_balance '{}' # copper % per layer, mirrored pairs compared+altium_return_path '{"nets":["USB_DP","USB_DN","CLK"]}' # signal track over no pour or plane next to it+```++- Both rasterise the live board's copper (0.1 mm): tracks, arcs, pads, vias and what Altium actually poured (regions+ minus holes; a hatched pour through its tracks); an internal plane is copper everywhere. `copper_balance` gives each+ layer's copper share and compares layer 1 with n, 2 with n-1: more than 20 points apart and the board can bow or+ twist in reflow. `return_path` reports, per signal net and layer, the track length over no reference copper on either+ neighbouring layer (pours, planes, the reference nets' copper), with where the first gap starts. On the Sentinel+ example it found the antenna trace ANT_RF (no ground under it by design) and parts of VBAT over gaps. ## Lengths, length rules, rooms and teardrops (0.2.68)
src/BRIDGE_VERSION+1−1@@ -1 +1 @@-0.2.71+0.2.72
src/agent/AdomAgent.pas.tmpl+56−3@@ -5975,9 +5975,11 @@ Begin Trk := BIt.FirstPCBObject; While Trk <> Nil Do Begin+ { within 50 units (1.3 um): routing_state rounds to 0.1 um, about 39 units, so an off-grid track never matched+ exactly (a board outline on Mechanical 1 could not be removed, 2026-10-23) } If (Not Trk.InComponent) And (Trk.Layer = Lay) And- (((Trk.x1 = X1) And (Trk.y1 = Y1) And (Trk.x2 = X2) And (Trk.y2 = Y2)) Or- ((Trk.x1 = X2) And (Trk.y1 = Y2) And (Trk.x2 = X1) And (Trk.y2 = Y1))) Then+ (((Abs(Trk.x1 - X1) <= 50) And (Abs(Trk.y1 - Y1) <= 50) And (Abs(Trk.x2 - X2) <= 50) And (Abs(Trk.y2 - Y2) <= 50)) Or+ ((Abs(Trk.x1 - X2) <= 50) And (Abs(Trk.y1 - Y2) <= 50) And (Abs(Trk.x2 - X1) <= 50) And (Abs(Trk.y2 - Y1) <= 50))) Then Begin Result := Trk; Break; End;@@ -5999,7 +6001,8 @@ Begin Via := BIt.FirstPCBObject; While Via <> Nil Do Begin- If (Via.x = X) And (Via.y = Y) Then Begin Result := Via; Break; End;+ { within 50 units: routing_state's mm are rounded to 0.1 um (see FindTrack) }+ If (Abs(Via.x - X) <= 50) And (Abs(Via.y - Y) <= 50) Then Begin Result := Via; Break; End; Via := BIt.NextPCBObject; End; Board.BoardIterator_Destroy(BIt);@@ -6041,6 +6044,7 @@ Var Via : IPCB_Via; Pad : IPCB_Pad; PC : TPadCache;+ Txt : IPCB_Text; Fl : IPCB_Fill; Comp : IPCB_Component; Prim : IPCB_Primitive;@@ -6126,6 +6130,18 @@ Begin If gInputError <> '' Then Res := 'err:bad number ' + gInputError Else RegisterPrim(Board, Pad); End; End+ Else If Kind = 'X' Then+ Begin+ { a stroke text: X|x|y|height|stroke|layer|rotation|text (a panel's V-CUT marks on the outline layer;+ measured on Develop 2026-10-23) }+ Txt := PCBServer.PCBObjectFactory(eTextObject, eNoDimension, eCreate_Default);+ Txt.XLocation := AdomInt(FieldP(Op, 2)); Txt.YLocation := AdomInt(FieldP(Op, 3));+ Txt.Size := AdomInt(FieldP(Op, 4)); Txt.Width := AdomInt(FieldP(Op, 5));+ Txt.Layer := AdomInt(FieldP(Op, 6));+ Txt.Rotation := AdomInt(FieldP(Op, 7));+ Txt.Text := FieldP(Op, 8);+ If gInputError <> '' Then Res := 'err:bad number ' + gInputError Else RegisterPrim(Board, Txt);+ End Else If Kind = 'K' Then Begin { a keepout rectangle: K|x1|y1|x2|y2|layer. Layer 0 = the Keep-Out layer (every layer); a copper layer id@@ -7468,6 +7484,42 @@ Begin JRaw('rows', IntToStr(E.RowCount)) + ',' + JRaw('cols', IntToStr(E.ColCount)) + '}'; End; +{ bodies: every 3D body on the board as [component, componentLayer, overallHeight, standoffHeight, left, bottom, right,+ top] in internal units. OverallHeight is the body's top above the board surface its part sits on and StandoffHeight+ its bottom (measured on the Bluetooth Sentinel example, Develop 2026-10-23); a part with several bodies has several+ rows. A body with no component (a free 3D body) is named '-'. }+Function CmdBodies(Lines : TStringList) : String;+Var+ Board : IPCB_Board;+ BIt : IPCB_BoardIterator;+ Body : IPCB_ComponentBody;+ R : TCoordRect;+ S, Nm : String;+ Lyr, N : 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;+ S := ''; N := 0;+ BIt := Board.BoardIterator_Create;+ BIt.AddFilter_ObjectSet(MkSet(eComponentBodyObject));+ BIt.AddFilter_LayerSet(AllLayers);+ BIt.AddFilter_Method(eProcessAll);+ Body := BIt.FirstPCBObject;+ While (Body <> Nil) And (N < 20000) Do+ Begin+ Nm := '-'; Lyr := 0;+ If Body.Component <> Nil Then Begin Nm := Body.Component.Name.Text; Lyr := Body.Component.Layer; End;+ R := Body.BoundingRectangle;+ If N > 0 Then S := S + ',';+ S := S + '["' + JsonEsc(Nm) + '",' + IntToStr(Lyr) + ',' + IntToStr(Body.OverallHeight) + ',' + IntToStr(Body.StandoffHeight) + ',' ++ IntToStr(R.Left) + ',' + IntToStr(R.Bottom) + ',' + IntToStr(R.Right) + ',' + IntToStr(R.Top) + ']';+ N := N + 1;+ Body := BIt.NextPCBObject;+ End;+ Board.BoardIterator_Destroy(BIt);+ Result := '{' + JRaw('ok','true') + ',' + JStr('file', Board.FileName) + ',' + JRaw('count', IntToStr(N)) + ',' + JRaw('bodies', '[' + 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;@@ -8715,6 +8767,7 @@ Begin 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 = 'bodies' Then Result := CmdBodies(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+8−2@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "altium", "displayName": "Altium Designer",- "version": "0.2.71",+ "version": "0.2.72", "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",@@ -99,6 +99,9 @@ "altium_panelize", "altium_impedance", "altium_current_capacity",+ "altium_height_check",+ "altium_copper_balance",+ "altium_return_path", "altium_find_space", "altium_find_symbol", "altium_footprint_primitives",@@ -216,9 +219,12 @@ "default": 60, "verbs": { "launch": 240,- "panelize": 180,+ "panelize": 240, "impedance": 120, "current_capacity": 120,+ "height_check": 150,+ "copper_balance": 240,+ "return_path": 240, "agent_start": 120, "run_outjob": 600, "silk_state": 180,