← All Pull Requests

altium-lane: plan_route takes the board's width and via rules (#21) #4

Merged Approved opened by Colby Knox 2026-10-28

altium-lane, for #21: plan_route takes the board's width and via rules, so its routes pass the board's DRC. This changes engine/src/features.rs and engine/src/route.rs only.

The problem, found live: Altium's lane board has Altium's default Width rule (10 mil, 0.254 mm). altium_plan_route with apply:true routed N1 at the engine's 0.25 mm default, and routing_drc flagged it: "Actual Width = 9.843mil, Target Width = 10mil". A planned route should pass the board it was planned on.

The fix:

  • features::width_style(rules) reads the board's preferred track width in mm. It takes the standard form {kind: "width", preferredMm} (or widthMm), or Altium's Width descriptor ("Width Constraint (Min=10mil) (Max=10mil) (Preferred=10mil) (All)"). It uses the board-wide rule, highest priority first, and its Preferred value, else its Min. This mirrors via_style.
  • plan_route's defaults: the width comes from widthMm, else the width rule, else 0.25. The via comes from viaSizeMm/viaDrillMm, else the via rule (the existing via_style), else 0.6/0.3. planner.widthFrom and planner.viaFrom say which.
  • The rules are read once, and the clearance uses the same list.

Checked:

  • 2 new tests pass: width_style's three forms with the board-wide rule winning, and plan_route following the rules with widthMm still winning. That makes 65 unit and 5 conformance tests.
  • Live on Altium Develop, from a dev build of this engine: N1 on the lane board planned at 0.254 mm with widthFrom: "the board's width rule". With apply:true it routed as one straight top-layer track. routing_drc then reported only the expected unrouted GND; the Width violation was gone.

Diff Skip to comments

--- a/engine/src/features.rs+++ b/engine/src/features.rs@@ -1,411 +1,441 @@⋯ 187 unchanged lines ⋯ }  +/// The board's preferred track width in mm. A standard rule gives it as `{kind: "width", preferredMm}` (or `widthMm`);+/// Altium's Width rule descriptor is read too, e.g. "Width Constraint (Min=10mil) (Max=10mil) (Preferred=10mil) (All)":+/// the board-wide one (scope All), highest priority first, its Preferred else its Min. Pure.+pub fn width_style(rules: &[Value]) -> Option<f64> {+    if let Some(r) = rules.iter().filter(|r| r["enabled"] != json!(false))+        .find(|r| r["kind"].as_str().map(|k| k.eq_ignore_ascii_case("width")).unwrap_or(false)) {+        if let Some(v) = r["preferredMm"].as_f64().or_else(|| r["widthMm"].as_f64()) { return Some(v); }+    }+    let num = |d: &str, key: &str| -> Option<f64> {+        let i = d.find(&format!("({key}="))? + key.len() + 2;+        let s = &d[i..d[i..].find(')')? + i];+        let v: f64 = s.trim_end_matches(char::is_alphabetic).parse().ok()?;+        Some(if s.ends_with("mil") { v * 0.0254 } else { v })+    };+    let mut rs: Vec<&Value> = rules.iter().filter(|r| r["enabled"] != json!(false) && r["descriptor"].as_str().map(|d| d.starts_with("Width")).unwrap_or(false)).collect();+    rs.sort_by_key(|r| (r["scope1"].as_str() != Some("All"), r["priority"].as_i64().unwrap_or(99)));+    let d = rs.first()?["descriptor"].as_str()?;+    num(d, "Preferred").or_else(|| num(d, "Min")).map(|v| (v * 10000.0).round() / 10000.0)+}+ /// Thermal via grid points inside a pad (x, y, sx, sy, rotation mm): `pitch` apart, `inset` from the pad's edge plus /// the via's radius, centred. Pure. pub fn thermal_grid(x: f64, y: f64, sx: f64, sy: f64, rot: f64, pitch: f64, size: f64, inset: f64) -> Vec<(f64, f64)> {⋯ 214 unchanged lines ⋯         assert_eq!(via_fence(&json!({"board": st, "args": {"viaNet": "NOPE", "nets": ["RF"]}}))["errorCode"], "no_such_net");         assert_eq!(thermal_vias(&json!({"board": st, "args": {"ref": "U9"}}))["errorCode"], "part_not_found");     }+    #[test]+    fn width_style_reads_the_standard_rule_and_altiums_descriptor() {+        assert_eq!(width_style(&[json!({"kind": "width", "preferredMm": 0.2})]), Some(0.2));+        let alt = vec![json!({"enabled": true, "scope1": "InNet('VIN')", "priority": 1, "descriptor": "Width Constraint (Min=20mil) (Max=40mil) (Preferred=30mil) (InNet('VIN'))"}),+                       json!({"enabled": true, "scope1": "All", "priority": 2, "descriptor": "Width Constraint (Min=10mil) (Max=10mil) (Preferred=10mil) (All)"})];+        assert_eq!(width_style(&alt), Some(0.254), "the board-wide rule, not a net's");+        assert_eq!(width_style(&[json!({"descriptor": "Width Constraint (Min=0.15mm) (Max=1mm) (All)", "scope1": "All"})]), Some(0.15));+        assert_eq!(width_style(&[]), None);+    } }+--- a/engine/src/route.rs+++ b/engine/src/route.rs@@ -1,323 +1,343 @@⋯ 82 unchanged lines ⋯ }  /// plan_route: input {board, rules?, args: {net, from, to, widthMm?, layers?, allowVias?, viaSizeMm?, viaDrillMm?, gridMm?,-/// viaCost?, clearanceMm?, edgeClearanceMm?, maxNodes?}}. `from` and `to` are pads as Ref.Pad.+/// viaCost?, clearanceMm?, edgeClearanceMm?, maxNodes?}}. `from` and `to` are pads as Ref.Pad. The width and via come from+/// the args, else the board's width and via rules (`rules`), else 0.25 mm and 0.6/0.3; `planner` says which. pub fn plan_route(input: &Value) -> Value {     let st = match crate::input::board(input) { Ok(b) => b, Err(e) => return e };     let args = &input["args"];⋯ 16 unchanged lines ⋯     if key(&ends[0]).eq_ignore_ascii_case(&key(&ends[1])) { return crate::input::refuse("bad_args", "from and to are the same pad"); }     let layers: Vec<String> = args["layers"].as_array().map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect()).unwrap_or_else(|| vec!["F.Cu".into(), "B.Cu".into()]);     if layers.is_empty() || layers.iter().any(|l| !l.ends_with(".Cu") || l == "*.Cu") { return crate::input::refuse("bad_args", "layers are copper layers by name: [\"F.Cu\", \"In1.Cu\", \"B.Cu\"]"); }-    let width = num("widthMm", 0.25);-    let (via_size, via_drill) = (num("viaSizeMm", 0.6), num("viaDrillMm", 0.3));+    // the board's own width and via rules, so the route passes its DRC (a 0.25 mm default broke a 10 mil Width rule on+    // Altium's lane board, 2026-10-28); args still win+    let rules = crate::input::rules(input);+    let (width_rule, via_rule) = (crate::features::width_style(&rules), crate::features::via_style(&rules));+    let width = num("widthMm", width_rule.unwrap_or(0.25));+    let width_from = if args.get("widthMm").is_some() { "widthMm" } else if width_rule.is_some() { "the board's width rule" } else { "default 0.25" };+    let (via_size, via_drill) = (num("viaSizeMm", via_rule.map(|v| v.0).unwrap_or(0.6)), num("viaDrillMm", via_rule.map(|v| v.1).unwrap_or(0.3)));+    let via_from = if args.get("viaSizeMm").is_some() { "viaSizeMm/viaDrillMm" } else if via_rule.is_some() { "the board's via rule" } else { "default 0.6/0.3" };     if via_drill >= via_size { return crate::input::refuse("bad_args", "viaDrillMm must be smaller than viaSizeMm"); }     let grid = num("gridMm", 0.5);     if !(0.05..=5.0).contains(&grid) { return crate::input::refuse("bad_args", "gridMm is 0.05 to 5"); }⋯ 1 unchanged line ⋯     let via_cost = num("viaCost", 5.0);     let max_nodes = args["maxNodes"].as_u64().unwrap_or(50_000).clamp(1, 500_000) as usize;     // the board's own clearance decides; a caller's clearanceMm can only raise it-    let rule = crate::fab::min_clearance(&crate::input::rules(input));+    let rule = crate::fab::min_clearance(&rules);     let clearance = num("clearanceMm", 0.0).max(rule.unwrap_or(0.2));     let edge = num("edgeClearanceMm", 0.3);     let ring = crate::coppermap::outline_ring(&st);⋯ 130 unchanged lines ⋯     way.push(json!({"pad": key(&ends[1])}));     json!({"success": true, "found": true, "lengthMm": r3(length), "vias": vias.len(), "viaAt": vias,         "routeNet": {"net": net, "layer": layers[path[0].2], "width": width, "viaSize": via_size, "viaDrill": via_drill, "paths": [way]},-        "planner": {"algorithm": "bounded orthogonal A*", "gridMm": grid, "layers": layers, "clearanceMm": clearance, "edgeClearanceMm": edge, "expandedNodes": seen.len()},+        "planner": {"algorithm": "bounded orthogonal A*", "widthFrom": width_from, "viaFrom": via_from, "gridMm": grid, "layers": layers, "clearanceMm": clearance, "edgeClearanceMm": edge, "expandedNodes": seen.len()},         "revision": revision,         "_hint": "A plan only: pass routeNet to route_net unchanged (with expectedRevision: revision). A layer change is two waypoints at the same point, where route_net places a via. Pours (and a hatched pour's net-less pieces) are not obstacles: repour after placing the route. The path is orthogonal on the grid: a finer gridMm finds tighter paths and searches longer."}) }⋯ 61 unchanged lines ⋯         assert_eq!(three["found"], json!(true), "{three}");         assert!(three["routeNet"]["paths"][0].as_array().unwrap().iter().any(|w| w["layer"] == "In1.Cu"));     }+    #[test]+    fn the_track_and_via_follow_the_boards_rules() {+        // Altium's lane board: a 10 mil Width rule and a 0.5/0.3 Routing Via rule+        let rules = json!([{"enabled": true, "scope1": "All", "priority": 1, "descriptor": "Width Constraint (Min=10mil) (Max=10mil) (Preferred=10mil) (All)"},+                           {"enabled": true, "scope1": "All", "priority": 1, "descriptor": "Routing Via (PreferredHoleWidth=0.3mm) (PreferedWidth=0.5mm) (All)"}]);+        let r = plan_route(&json!({"board": board(json!([])), "rules": rules, "args": {"net": "SIG", "from": "U1.1", "to": "J1.1"}}));+        assert_eq!((r["routeNet"]["width"].clone(), r["routeNet"]["viaSize"].clone()), (json!(0.254), json!(0.5)), "{r}");+        assert_eq!(r["planner"]["widthFrom"], "the board's width rule");+        let given = plan_route(&json!({"board": board(json!([])), "rules": rules, "args": {"net": "SIG", "from": "U1.1", "to": "J1.1", "widthMm": 0.4}}));+        assert_eq!(given["routeNet"]["width"], json!(0.4));+        assert_eq!(plan_route(&json!({"board": board(json!([])), "args": {"net": "SIG", "from": "U1.1", "to": "J1.1"}}))["planner"]["widthFrom"], "default 0.25");+    } }+

Reviews

Colby Knox approved · 13d ago

standard-lane review: approved.

  • width_style follows via_style exactly: the standard form first, then Altium's descriptor, board-wide first, highest priority first.
  • Arguments still win, and planner.widthFrom / viaFrom say where each value came from.
  • The rules are now read once, and the clearance uses the same list.
  • I ran it on a copy of the engine: 65 unit tests pass, and the 5 conformance tests pass against the local fixtures.

Merging. I'll release it as engine 0.6.1 / standard 0.9.1.

Comments

No comments yet.

Log in to comment.