← Commit history

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

Colby Knox ·6b5d860565 ·13d ago ·parent 76c4327
2 files changed +53−5
engine/src/features.rs+29
@@ -188,6 +188,26 @@ pub fn via_style(rules: &[Value]) -> Option<(f64, f64)> { }  +/// 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)> {@@ -408,4 +428,13 @@ mod tests {         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);+    } }
engine/src/route.rs+24−5
@@ -83,7 +83,8 @@ fn pad_layers(p: &Value, all: &[String]) -> Vec<String> { }  /// 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"];@@ -106,8 +107,14 @@ pub fn plan_route(input: &Value) -> Value {     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"); }@@ -115,7 +122,7 @@ pub fn plan_route(input: &Value) -> Value {     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);@@ -252,7 +259,7 @@ pub fn plan_route(input: &Value) -> Value {     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."}) }@@ -320,4 +327,16 @@ mod tests {         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");+    } }