app
Fusion - the Fusion 360 Bridge
Public Made by Adomby adom
Drive Autodesk Fusion 360 from the cloud via Adom Bridge: component libraries, IPC package generation, board layout, exports (STEP/Gerbers/BOM/CPL), fast APS cloud search, and parametric modeling.
← Commit history
Update 24 file(s)
24 files changed
+765−34
BRIDGE_VERSION+1−1CHANGELOG.md+11bridge.json+5−1contracts/describe.json+62dashboard/Cargo.lock+1−1dashboard/Cargo.toml+1−1describe.py+8install.sh+1−1package.json+1−1page.json+5−11rust/Cargo.lock+6−6rust/Cargo.toml+1−1rust/bridge/src/boardgen.rs+109rust/bridge/src/checks.rs+19−1rust/bridge/src/main.rs+1−1rust/bridge/src/routing.rs+2−2rust/bridge/src/routing_script.py+34rust/vendor/adom-eda-checks/Cargo.toml+1−1rust/vendor/adom-eda-checks/README.md+3−1rust/vendor/adom-eda-checks/VENDOR.md+3−3rust/vendor/adom-eda-checks/src/lib.rs+5−1rust/vendor/adom-eda-checks/src/panel.rs+377rust/vendor/adom-eda-checks/src/teardrops.rs+106tests/run_verb_tests.py+2BRIDGE_VERSION+1−1@@ -1 +1 @@-1.13.15+1.13.16
CHANGELOG.md+11@@ -3,6 +3,17 @@ All notable changes to the Adom Bridge Fusion 360 bridge, newest first. Canonical: https://wiki.adom.inc/adom/fusion-bridge ยท GitHub backup: `adom-inc/fusion-bridge` +## 1.13.16 (2026-10-29)++- Engine 0.19.0 (standard 0.22.1): review_board's rule classes from standard-lane's fix.+- `fusion_teardrops` (the standard's teardrops): the engine plans each teardrop to hold the board's clearance; Fusion+ draws them as named solid polygons (rank 1, no thermals, so Fusion never asks for a signal) and reads them back+ (Fusion writes a drawn polygon as <polygonpour>), undoing all on a mismatch.+- `fusion_panelize` (the standard's panelize): the engine plans the panel (V-score or mouse-bite tabs, rails, tooling+ holes, fiducials, JLCPCB's rules); Fusion builds it as a new EAGLE board from the open board's source, copy k's+ parts and nets renamed _k, the copies' outline left to the panel's rout path and the board shape recomputed, and+ opens it as a local design. 336 verbs.+ ## 1.13.15 (2026-10-29) - `fusion_not_supported`: the standard verbs that belong to Altium's own machinery (Altium 365, its script agent and
bridge.json+5−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "fusion360", "displayName": "Autodesk Fusion 360",- "version": "1.13.15",+ "version": "1.13.16", "description": "Drive Autodesk Fusion 360 from the cloud: launch Fusion, electronics board layout, design rules, exports (STEP/IGES/STL/3MF/USDZ/OBJ/DXF/DWG/Gerbers/BOM/CPL), fast APS server-indexed cloud search plus browse/recent/file-info/versions, cloud file download/upload and folder creation, and in-app parametric modeling (fusion_run_modeling_script). Never-charge: APS calls are capped to the free tier.", "homepage": "https://wiki.adom.inc/adom/fusion-bridge", "author": "Adom Inc.",@@ -219,6 +219,7 @@ "fusion_open_electronics", "fusion_open_lbr", "fusion_open_schematic",+ "fusion_panelize", "fusion_part_parameters", "fusion_physical_properties", "fusion_pin_pad_check",@@ -344,6 +345,7 @@ "fusion_stop", "fusion_swap_footprint", "fusion_take_screenshot",+ "fusion_teardrops", "fusion_test_board", "fusion_thermal_vias", "fusion_uninstall",@@ -514,6 +516,8 @@ "project_snapshot": 300, "project_create": 180, "test_board": 180,+ "teardrops": 300,+ "panelize": 300, "swap_footprint": 180, "set_models": 30, "add_models": 30,
contracts/describe.json+62@@ -18142,6 +18142,42 @@ "summary": "Open a LOCAL legacy EAGLE .sch in Fusion's Schematic Editor (dialog-free Document.newDesignFromLocal; a background open). NAV RULE: the schematic, 2D board and 3D board are VIEWS of ONE electronics design, not standalone files. For a team design open the DESIGN document (fusion_open_cloud_file / fusion_open_by_urn) and switch views with fusion_show_schematic; this verb is for a .sch that exists on the Fusion machine (push it there first). Drive the opened sheet with fusion_electron_run (WINDOW FIT, DISPLAY ALL). The bridge captures a post-open screenshot because Fusion can raise a blocking dialog the API cannot see.", "timeoutSeconds": 60 },+ {+ "example": {+ "cols": 3,+ "dryRun": true,+ "rows": 2,+ "separation": "vscore"+ },+ "input": {+ "cols": "int (required)",+ "dryRun": "bool",+ "expectDocument": "required",+ "gapMm": "tabs: the routed gap",+ "open": "bool, default true",+ "outputPath": "the panel .brd (default %TEMP%/adom-panels)",+ "railMm": "default 5",+ "rows": "int (required)",+ "separation": "none | vscore | tabs",+ "toolingHoles": "bool, default true"+ },+ "longRunning": false,+ "name": "fusion_panelize",+ "output": {+ "boardsAt": "[[x, y]]",+ "built": "{copies, parts, holes, fiducials}",+ "copyShift": "[[dx, dy]]",+ "documentName": "str",+ "jlcpcb": "{rules, fits}",+ "panelMm": "[w, h]",+ "pcbPath": "path",+ "readBack": "{components, expected, unconnected, outlineClosed}",+ "separation": "{kind, cutsX?, cutsY?, tabs?}"+ },+ "statusVerb": null,+ "summary": "A manufacturing panel (the standard's panelize), planned by the shared checks engine: rows x cols copies, V-score lines or mouse-bite tabs drawn the way JLCPCB reads them, rails with tooling holes and fiducials, JLCPCB's panel rules checked. Fusion builds it as a NEW EAGLE board from the open board's source (copy k's parts and nets renamed _k, the copies' own outline left to the panel's rout path) and opens it as a local design; the board you panelized is untouched. dryRun returns the plan.",+ "timeoutSeconds": 300+ }, { "example": { "name": "MPN"@@ -21049,6 +21085,32 @@ "summary": "Screenshot the Fusion window in the BACKGROUND (no focus steal, reads through occlusion). Use it to VERIFY your own work: geometry at the wrong Z, an empty view, or a Placeholder 3D body all render successfully and only a look at the pixels catches them.", "timeoutSeconds": 60 },+ {+ "example": {+ "dryRun": true,+ "targets": [+ "via"+ ]+ },+ "input": {+ "dryRun": "bool",+ "expectDocument": "required",+ "lengthRatio": "default 0.5",+ "targets": "[via, tht, smd] (default all)",+ "widthRatio": "default 1.0"+ },+ "longRunning": false,+ "name": "fusion_teardrops",+ "output": {+ "applied": "int",+ "engine": "str",+ "skipped": "[{at, why}]",+ "teardrops": "[{at, net, layer, points}]"+ },+ "statusVerb": null,+ "summary": "Teardrops where tracks meet vias and pads (the standard's teardrops), planned by the shared checks engine to hold the board's clearance: each a filled polygon on the track's layer and net; the ones that would not fit are skipped with why. Fusion draws them as named solid polygons (rank 1, no thermals), reads them back and undoes all on a mismatch; dryRun returns the plan.",+ "timeoutSeconds": 300+ }, { "example": { "footprintName": "VL53L5CX;VL53L7CX",
dashboard/Cargo.lock+1−1@@ -4,7 +4,7 @@ version = 4 [[package]] name = "fusion-dashboard"-version = "1.13.15"+version = "1.13.16" dependencies = [ "serde_json", ]
dashboard/Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.13.15"+version = "1.13.16" edition = "2021" [dependencies]
describe.py+8@@ -546,6 +546,14 @@ _T = [ {"expectDocument": "required", "ref": "the part (required)", "pad": "default its largest pad", "size": "via diameter mm", "drill": "mm", "pitchMm": "default size + 0.5, at least 1", "insetMm": "default 0.1", "dryRun": "bool"}, {"applied": "int", "failed": "int", "planned": "{part, pad, net, padMm, vias, pitchMm, via: {sizeMm, drillMm, from}, points}", "apply": "dryRun: {verb: fusion_place_vias, args: {vias}}", "engine": "str"}, 180, None, False, {'ref': 'U3', 'dryRun': True}),+ ('fusion_teardrops', "Teardrops where tracks meet vias and pads (the standard's teardrops), planned by the shared checks engine to hold the board's clearance: each a filled polygon on the track's layer and net; the ones that would not fit are skipped with why. Fusion draws them as named solid polygons (rank 1, no thermals), reads them back and undoes all on a mismatch; dryRun returns the plan.",+ {"expectDocument": "required", "targets": "[via, tht, smd] (default all)", "lengthRatio": "default 0.5", "widthRatio": "default 1.0", "dryRun": "bool"},+ {"applied": "int", "teardrops": "[{at, net, layer, points}]", "skipped": "[{at, why}]", "engine": "str"},+ 300, None, False, {"targets": ["via"], "dryRun": True}),+ ('fusion_panelize', "A manufacturing panel (the standard's panelize), planned by the shared checks engine: rows x cols copies, V-score lines or mouse-bite tabs drawn the way JLCPCB reads them, rails with tooling holes and fiducials, JLCPCB's panel rules checked. Fusion builds it as a NEW EAGLE board from the open board's source (copy k's parts and nets renamed _k, the copies' own outline left to the panel's rout path) and opens it as a local design; the board you panelized is untouched. dryRun returns the plan.",+ {"expectDocument": "required", "rows": "int (required)", "cols": "int (required)", "separation": "none | vscore | tabs", "gapMm": "tabs: the routed gap", "railMm": "default 5", "toolingHoles": "bool, default true", "outputPath": "the panel .brd (default %TEMP%/adom-panels)", "open": "bool, default true", "dryRun": "bool"},+ {"panelMm": "[w, h]", "boardsAt": "[[x, y]]", "copyShift": "[[dx, dy]]", "separation": "{kind, cutsX?, cutsY?, tabs?}", "jlcpcb": "{rules, fits}", "pcbPath": "path", "built": "{copies, parts, holes, fiducials}", "readBack": "{components, expected, unconnected, outlineClosed}", "documentName": "str"},+ 300, None, False, {"rows": 2, "cols": 3, "separation": "vscore", "dryRun": True}), ('fusion_via_fence', "A via fence for EMC (the standard's via_fence), planned by the shared checks engine: vias on viaNet both sides of the nets' tracks and/or round the board edge, at lambda/20 for freqGHz or a pitch; each clear of other copper, pads and the edge. Fusion places them, reads them back and undoes them on a mismatch; dryRun returns the plan.", {"expectDocument": "required", "viaNet": "usually GND (required)", "nets": "[net]", "edge": "bool", "freqGHz": "number", "pitchMm": "default 2", "offsetMm": "track to via centre", "size": "default 0.6", "drill": "default 0.3", "clearance": "default 0.2", "edgeInsetMm": "number", "dryRun": "bool"}, {"applied": "int", "failed": "int", "planned": "{vias, candidates, via, pitchMm, offsetMm, points}", "apply": "dryRun: {verb: fusion_place_vias, args: {vias}}", "engine": "str"},
install.sh+1−1@@ -21,7 +21,7 @@ echo "Installed fusion-bridge skills: $(ls "$PKG_DIR/skills" 2>/dev/null | tr '\ # binary (the three-artifact pattern: bridge zip -> ab, THIS pkg -> container skills + # this download, page media -> wiki). Per the wiki's PKG_VS_RELEASE policy, binaries are # platform-tagged RELEASE assets, never tarball content - the installer fetches them.-DASH_VERSION="1.13.15"+DASH_VERSION="1.13.16" DASH_URL="https://wiki.adom.inc/download/adom/fusion-bridge/${DASH_VERSION}/fusion-dashboard" DEST="" if [ -w /usr/local/bin ]; then DEST=/usr/local/bin
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "fusion-bridge",- "version": "1.13.15",+ "version": "1.13.16", "type": "app", "description": "This package installs the bridge's SKILLS into your container so your AI knows how to drive it; Adom Bridge loads the bridge runtime itself from the release zip.", "dependencies": {
page.json+5−11@@ -1,6 +1,6 @@ { "slug": "fusion-bridge",- "version": "1.13.15",+ "version": "1.13.16", "type": "app", "description": "This package installs the bridge's SKILLS into your container so your AI knows how to drive it; Adom Bridge loads the bridge runtime itself from the release zip.", "dependencies": {@@ -17,9 +17,7 @@ "pcb", "exports", "aps",- "modeling",- "mechanical",- "assemblies"+ "modeling" ], "org": "adom", "title": "Fusion - the Fusion 360 Bridge",@@ -92,13 +90,9 @@ "pin electrical types from a schematic", "part heights on my fusion board", "which fusion parts have no 3d model",- "will my fusion board fit the enclosure",- "build a parametric part in fusion",- "assemble mechanical parts in fusion",- "insert a McMaster part in fusion",- "generate a mechanical assembly BOM"+ "will my fusion board fit the enclosure" ],- "discovery_pitch": "Use the Adom Bridge Fusion 360 bridge when the user wants to DRIVE Autodesk Fusion 360 from the cloud: open/search their team's Fusion cloud designs, author parametric mechanical parts and internal assemblies, ingest McMaster CAD and generate mechanical BOMs, lay out a PCB or do electronics, export STEP/IGES/STL/Gerbers/BOM, or build a component library with real rendering 3D. It runs Fusion on the user's own machine via Adom Bridge. NOT for web/browser/login/form-filling (that's the native-browser extension) and NOT for KiCad (that's the kicad bridge).",+ "discovery_pitch": "Use the Adom Bridge Fusion 360 bridge when the user wants to DRIVE Autodesk Fusion 360 from the cloud: open/search their team's Fusion cloud designs, lay out a PCB or do electronics, export STEP/IGES/STL/Gerbers/BOM, or build a component library with real rendering 3D. It runs Fusion on the user's own machine via Adom Bridge. NOT for web/browser/login/form-filling (that's the native-browser extension) and NOT for KiCad (that's the kicad bridge).", "brief": "Drive Autodesk Fusion 360 from the cloud via Adom Bridge: component libraries, IPC package generation, board layout, exports (STEP/Gerbers/BOM/CPL), fast APS cloud search, and parametric modeling.", "sample_prompts": [ {@@ -149,4 +143,4 @@ "name": "John Lauer", "email": "[email protected]" }-}+}
rust/Cargo.lock+6−6@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "adom-eda-checks"-version = "0.18.0"+version = "0.19.0" dependencies = [ "roxmltree", "serde_json",@@ -19,7 +19,7 @@ dependencies = [ [[package]] name = "adom-fusion-bridge"-version = "1.13.15"+version = "1.13.16" dependencies = [ "adom-eda-checks", "base64",@@ -299,7 +299,7 @@ dependencies = [ [[package]] name = "fusion-core"-version = "1.13.15"+version = "1.13.16" dependencies = [ "serde", "serde_json",@@ -310,7 +310,7 @@ dependencies = [ [[package]] name = "fusion-lbr"-version = "1.13.15"+version = "1.13.16" dependencies = [ "regex", "roxmltree",@@ -322,14 +322,14 @@ dependencies = [ [[package]] name = "fusion-os-mac"-version = "1.13.15"+version = "1.13.16" dependencies = [ "fusion-core", ] [[package]] name = "fusion-os-win"-version = "1.13.15"+version = "1.13.16" dependencies = [ "fusion-core", "windows-sys 0.59.0",
rust/Cargo.toml+1−1@@ -4,7 +4,7 @@ members = ["core", "lbr", "os-win", "os-mac", "bridge"] # One version for the whole bridge, in lockstep with BRIDGE_VERSION (tools/release.sh bumps both). [workspace.package]-version = "1.13.15"+version = "1.13.16" edition = "2021" license = "MIT"
rust/bridge/src/boardgen.rs+109@@ -304,6 +304,97 @@ pub fn swap_footprint(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, Value) -> Val } } +/// Every coordinate of an element and its children moved by (dx, dy).+fn shift(e: &mut Element, dx: f64, dy: f64) {+ for (k, v) in e.attrs.iter_mut() {+ let d = match k.as_str() { "x" | "x1" | "x2" | "x3" => dx, "y" | "y1" | "y2" | "y3" => dy, _ => continue };+ if let Ok(n) = v.parse::<f64>() { *v = f(n + d); }+ }+ for c in e.children.iter_mut() { shift(c, dx, dy); }+}++/// The panel: the board's copies at the plan's copyShift (copy 1 keeps its names; copy k renames+/// parts and nets with _k so the copies stay apart), each copy's own outline left out (layer 20),+/// and the panel's outline, cut lines and texts, holes, tooling holes and fiducials from the plan.+pub fn compose_panel(source: &str, plan: &Value) -> Result<(String, Value), String> {+ let shifts: Vec<(f64, f64)> = plan["copyShift"].as_array().ok_or("the plan has no copyShift")?.iter().filter_map(|s| Some((s.get(0)?.as_f64()?, s.get(1)?.as_f64()?))).collect();+ if shifts.is_empty() { return Err("the plan places no copies".into()); }+ let mut root = Element::parse(source).map_err(|e| e.to_string())?;+ let board = root.find_deep_mut("board").ok_or("not a board: no <board>")?;+ for t in ["plain", "elements", "signals"] { if board.find_child(t).is_none() { board.children.push(Element::new(t)); } }+ // Fusion's source carries the board's computed shape; the panel's comes from its own outline+ board.children.retain(|c| c.tag != "boardshape");+ let outline = |c: &Element| c.attr("layer") == Some("20");+ let plain0: Vec<Element> = board.find_child("plain").unwrap().children.iter().filter(|c| !outline(c)).cloned().collect();+ let els0: Vec<Element> = board.find_child("elements").unwrap().children.clone();+ let sigs0: Vec<Element> = board.find_child("signals").unwrap().children.clone();+ let suffix = |k: usize, n: &str| if k == 0 { n.to_string() } else { format!("{n}_{}", k + 1) };+ let (mut plain, mut els, mut sigs) = (Vec::new(), Vec::new(), Vec::new());+ for (k, (dx, dy)) in shifts.iter().enumerate() {+ for c in &plain0 { let mut c = c.clone(); shift(&mut c, *dx, *dy); plain.push(c); }+ for e in &els0 {+ let mut e = e.clone(); shift(&mut e, *dx, *dy);+ if let Some(a) = e.attrs.iter_mut().find(|a| a.0 == "name") { a.1 = suffix(k, &a.1); }+ els.push(e);+ }+ for sg in &sigs0 {+ let mut sg = sg.clone(); shift(&mut sg, *dx, *dy);+ if let Some(a) = sg.attrs.iter_mut().find(|a| a.0 == "name") { a.1 = suffix(k, &a.1); }+ for c in sg.children.iter_mut().filter(|c| c.tag == "contactref") { if let Some(a) = c.attrs.iter_mut().find(|a| a.0 == "element") { a.1 = suffix(k, &a.1); } }+ sigs.push(sg);+ }+ }+ let w = |x1: f64, y1: f64, x2: f64, y2: f64, wd: f64| el("wire", &[("x1", f(x1)), ("y1", f(y1)), ("x2", f(x2)), ("y2", f(y2)), ("width", f(wd)), ("layer", "20".into())]);+ let pm = plan["panelMm"].as_array().and_then(|a| Some((a.first()?.as_f64()?, a.get(1)?.as_f64()?))).ok_or("the plan has no panelMm")?;+ for (a, b, c, d) in [(0.0, 0.0, pm.0, 0.0), (pm.0, 0.0, pm.0, pm.1), (pm.0, pm.1, 0.0, pm.1), (0.0, pm.1, 0.0, 0.0)] { plain.push(w(a, b, c, d, 0.1)); }+ let g = |v: &Value, k: &str| v[k].as_f64().unwrap_or(0.0);+ let ol = &plan["outlineLayer"];+ for l in ol["lines"].as_array().into_iter().flatten() { plain.push(w(g(l, "x1"), g(l, "y1"), g(l, "x2"), g(l, "y2"), l["width"].as_f64().unwrap_or(0.1))); }+ for t in ol["texts"].as_array().into_iter().flatten() {+ let mut e = el("text", &[("x", f(g(t, "x"))), ("y", f(g(t, "y"))), ("size", f(t["size"].as_f64().unwrap_or(1.0))), ("layer", "20".into())]);+ let r = g(t, "rotation"); if r.abs() > 1e-9 { e.attrs.push(("rot".into(), format!("R{}", f(r)))); }+ e.text = t["text"].as_str().unwrap_or("").into(); plain.push(e);+ }+ let holes: Vec<&Value> = plan["holes"].as_array().into_iter().flatten().chain(plan["toolingHoles"].as_array().into_iter().flatten()).collect();+ for h in &holes { plain.push(el("hole", &[("x", f(g(h, "x"))), ("y", f(g(h, "y"))), ("drill", f(h["drill"].as_f64().unwrap_or(0.5)))])); }+ // a fiducial: a 1 mm copper dot in a 2 mm mask opening, as fusion_board_features places them+ let fids = plan["fiducials"].as_array().cloned().unwrap_or_default();+ for fd in &fids {+ for (layer, r) in [("1", 0.5), ("29", 1.0)] { plain.push(el("circle", &[("x", f(g(fd, "x"))), ("y", f(g(fd, "y"))), ("radius", f(r)), ("width", "0".into()), ("layer", layer.into())])); }+ }+ let parts = els.len();+ board.find_child_mut("plain").unwrap().children = plain;+ board.find_child_mut("elements").unwrap().children = els;+ board.find_child_mut("signals").unwrap().children = sigs;+ Ok((root.to_xml_document(), json!({"copies": shifts.len(), "parts": parts, "holes": holes.len(), "fiducials": fids.len()})))+}++/// `fusion_panelize`: the engine's panel plan for the open board, built as a new EAGLE board (the+/// board's source with its copies placed) and opened in Fusion as a local design, then read back.+pub fn panelize(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ let plan = crate::checks::call("panelize", args, pass);+ if plan["success"].as_bool() != Some(true) || args["dryRun"].as_bool() == Some(true) { return plan; }+ let src = crate::routing::call("board_source", &json!({"expectDocument": args["expectDocument"]}), pass);+ let Some(source) = src["source"].as_str().or(src["data"]["source"].as_str()) else { return json!({"success": false, "errorCode": "board_unreadable", "error": src["error"], "plan": plan}) };+ let doc = src["documentName"].as_str().or(args["expectDocument"].as_str()).unwrap_or("board").to_string();+ let safe: String = doc.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect();+ let out = args["outputPath"].as_str().map(|s| s.replace('\\', "/")).unwrap_or_else(|| std::env::temp_dir().join("adom-panels").join(format!("{safe}-panel-{}x{}.brd", plan["rows"], plan["cols"])).to_string_lossy().replace('\\', "/"));+ let (xml, rep) = match compose_panel(source, &plan) { Ok(x) => x, Err(e) => return json!({"success": false, "errorCode": "panel_refused", "error": e, "plan": plan}) };+ if let Err(e) = write(&out, &xml) { return e; }+ let mut r = plan.clone();+ r["mutated"] = json!(false); r["pcbPath"] = json!(out); r["built"] = rep.clone();+ if args["open"].as_bool() == Some(false) { r["_hint"] = json!("The panel is written (open:false); the board you panelized is untouched."); return r; }+ match open_and_read(ctx, &out, pass) {+ Err(e) => { let mut e = e; e["plan"] = plan; e }+ Ok((pdoc, st)) => {+ r["documentName"] = json!(pdoc);+ r["readBack"] = json!({"components": st["components"].as_array().map(|a| a.len()), "expected": rep["parts"], "unconnected": st["unconnected"], "outlineClosed": st["outlineClosed"]});+ r["_hint"] = json!(format!("The panel is open as {pdoc}, a new local design; the board you panelized is untouched. Copy k's parts and nets carry _k. Export it with fusion_export_gerbers (the outline layer carries the rout path, V-cut lines and texts); jlcpcb.rules says what JLCPCB will make of it."));+ r+ }+ }+}+ /// "A;B;C" or a list. fn list(v: &Value, sep: char) -> Vec<String> { match v { Value::Array(a) => a.iter().filter_map(|x| x.as_str().map(|s| s.trim().to_string())).filter(|s| !s.is_empty()).collect(),@@ -564,6 +655,24 @@ mod tests { assert!(swap(&first, LBR, "demo", Some("Q7"), "SOT23", &[]).is_err()); } + #[test]+ fn a_panel_repeats_the_board_renamed_without_its_own_outline() {+ let (board, _) = compose(&plan(None, vec![part("R1", "R0603", 5.0, 5.0, 0.0)], &[("GND", &[("R1", "1")])])).unwrap();+ let board = board.replace("<elements>", "<boardshape><polygonoutlinesegments><vertex x=\"0\" y=\"1\" /></polygonoutlinesegments></boardshape><elements>");+ let p = json!({"copyShift": [[5, 5], [30, 5]], "panelMm": [55, 20], "outlineLayer": {"lines": [{"x1": 25, "y1": 0, "x2": 25, "y2": 20, "width": 0.1}], "texts": [{"text": "V-CUT", "x": 25, "y": 1, "size": 1, "rotation": 90}]},+ "holes": [{"x": 25, "y": 10, "drill": 0.5}], "toolingHoles": [{"x": 2, "y": 2, "drill": 2.0}], "fiducials": [{"x": 2, "y": 18}]});+ let (xml, r) = compose_panel(&board, &p).unwrap();+ assert_eq!((r["copies"].as_u64(), r["parts"].as_u64(), r["holes"].as_u64()), (Some(2), Some(2), Some(2)));+ assert!(xml.contains(r#"<element name="R1" library="demo" package="R0603" value="R0603" x="10" y="10" />"#), "{xml}");+ assert!(xml.contains(r#"<element name="R1_2" library="demo" package="R0603" value="R0603" x="35" y="10" />"#));+ assert!(xml.contains(r#"<signal name="GND_2"><contactref element="R1_2" pad="1" /></signal>"#));+ // the board's rounded outline is not copied: the panel's 4 edges and one cut line are the outline layer+ assert_eq!(xml.matches(r#"curve="90""#).count(), 0);+ assert_eq!(xml.matches(r#"layer="20""#).count(), 6);+ assert!(xml.contains(r#"<text x="25" y="1" size="1" layer="20" rot="R90">V-CUT</text>"#));+ assert!(!xml.contains("boardshape"), "Fusion recomputes the panel's shape");+ }+ #[test] fn outlines_refuse_bad_shapes() { assert!(outline_wires(&json!({"rect": [0, 0, 0, 5]})).is_err());
rust/bridge/src/checks.rs+19−1@@ -6,7 +6,8 @@ use serde_json::{json, Value}; // footprint_from_land_pattern needs no board: it writes a .lbr offline (main.rs native_lib_authoring)-pub fn owns(cmd: &str) -> bool { cmd != "footprint_from_land_pattern" && adom_eda_checks::CHECKS.contains(&cmd) }+// panelize builds a new board from the plan and opens it: boardgen::panelize, which has the Fusion context+pub fn owns(cmd: &str) -> bool { !matches!(cmd, "footprint_from_land_pattern" | "panelize") && adom_eda_checks::CHECKS.contains(&cmd) } /// A pcb-design-rules record for a fab (adom/pcb-design-rules rules/records/<vendor>.json): the /// record for the board's copper layer count, or the asked process.@@ -101,6 +102,7 @@ pub fn call(cmd: &str, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Val } for k in ["boardId", "documentName", "revision"] { if !inputs[k].is_null() { r[k] = inputs[k].clone(); } } if matches!(cmd, "thermal_vias" | "via_fence") { return place(r, args, pass); }+ if cmd == "teardrops" { return place_teardrops(r, args, pass); } if cmd == "plan_fanout" { // each call is one fusion_route_net; every route changes the revision, so the next call // takes the revision the previous reply answered@@ -203,6 +205,22 @@ fn place(mut r: Value, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Val r } +/// The engine's teardrops drawn as named solid polygons (routing_script place_teardrops), read back.+fn place_teardrops(mut r: Value, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ r["mutated"] = json!(false);+ let n = r["teardrops"].as_array().map(|a| a.len()).unwrap_or(0);+ if r["success"].as_bool() == Some(false) || args["dryRun"].as_bool() == Some(true) || n == 0 { return r; }+ let mut a = args.clone();+ a["teardrops"] = r["teardrops"].clone();+ if !r["revision"].is_null() && a["expectedRevision"].is_null() { a["expectedRevision"] = r["revision"].clone(); }+ let placed = crate::routing::call("place_teardrops", &a, pass);+ if placed["success"].as_bool() != Some(true) { let mut e = placed; e["planned"] = json!(n); e["engine"] = r["engine"].clone(); return e; }+ for k in ["applied", "revision", "boardId", "documentName"] { if !placed[k].is_null() { r[k] = placed[k].clone(); } }+ r["mutated"] = json!(true);+ r["_hint"] = json!("Teardrops are named solid polygons (rank 1, no thermals) of each track's net: fusion_repour, then fusion_routing_drc. Remove them with fusion_remove_pours on their nets, or UNDO.");+ r+}+ /// Fusion's silk_state gives each designator's origin, size and turn; the shared engine's silk /// planner reads a box. The box of EAGLE's vector text from its origin (lower left before the turn): /// characters about 0.8 of the size wide, mirrored on the bottom side.
rust/bridge/src/main.rs+1−1@@ -559,7 +559,7 @@ fn main() { r["shell"] = json!("rust"); r }, "fusion_start" | "start" | "launch" if addin_owner_is_exe(&**h) => { let (sc, ca) = (sidecar.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let sd = h.bridge_state_dir(); let dlg = |r: Value| r; let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: sd.clone(), caller: caller.clone(), pids: h.fusion_processes().iter().map(|p| p.pid).collect(), sidecar: &side, dialogs: &dlg }; let t0 = Instant::now(); let ind = timings::Indicator::start(&sd, "start", &caller); let mut r = launch::start(&ctx, &parsed["args"]); ind.stop(r["success"].as_bool() == Some(false)); timings::log_run(&sd, "start", t0.elapsed().as_secs_f64(), r["success"].as_bool() != Some(false)); r["shell"] = json!("rust"); r }, "fusion_open_lbr" | "open_lbr" if addin_owner_is_exe(&**h) => { let (sc, ca, ca2) = (sidecar.clone(), caller.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let sd = h.bridge_state_dir(); let pids: Vec<u32> = h.fusion_processes().iter().map(|p| p.pid).collect(); let dlg = |r: Value| small_verbs::merge_dialog_array(r, &ca2, &pids); let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: sd.clone(), caller: caller.clone(), pids: pids.clone(), sidecar: &side, dialogs: &dlg }; let t0 = Instant::now(); let ind = timings::Indicator::start(&sd, "open_lbr", &caller); let mut r = orchestrations::open_lbr(&ctx, &parsed["args"], &|v, a| addin::pass_through(v, &a, &ctx)); ind.stop(r["success"].as_bool() == Some(false)); timings::log_run(&sd, "open_lbr", t0.elapsed().as_secs_f64(), r["success"].as_bool() != Some(false)); r["shell"] = json!("rust"); r },- v @ ("fusion_show_symbol" | "show_symbol" | "fusion_show_footprint" | "show_footprint" | "fusion_show_library" | "show_library" | "fusion_show_project" | "show_project" | "fusion_show_schematic" | "show_schematic" | "fusion_show_2d_board" | "show_2d_board" | "fusion_show_3d_board" | "show_3d_board" | "fusion_capture_library_views" | "capture_library_views" | "fusion_library_views" | "library_views" | "fusion_open_cloud_file" | "open_cloud_file" | "fusion_save_lbr" | "save_lbr" | "fusion_cleanup_cloud_files" | "cleanup_cloud_files" | "fusion_inspect_bodies" | "inspect_bodies" | "fusion_board_stackup" | "board_stackup" | "fusion_export_optimized_glb" | "export_optimized_glb" | "fusion_fetch_optimized_glb" | "fetch_optimized_glb" | "fusion_set_design_rules" | "set_design_rules" | "fusion_make_test_project" | "make_test_project" | "fusion_project_create" | "project_create" | "fusion_test_board" | "test_board" | "fusion_build_board" | "build_board" | "fusion_swap_footprint" | "swap_footprint" | "fusion_make_showcase_project" | "make_showcase_project" | "fusion_open_design" | "open_design" | "fusion_new_electronics_from_eagle" | "new_electronics_from_eagle" | "fusion_aps_open" | "aps_open" | "fusion_schematic" | "schematic" | "fusion_eagle_import_status" | "eagle_import_status" | "fusion_show_attributes" | "show_attributes") if addin_owner_is_exe(&**h) => { let (sc, ca, ca2) = (sidecar.clone(), caller.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let sd = h.bridge_state_dir(); let pids: Vec<u32> = h.fusion_processes().iter().map(|p| p.pid).collect(); let dlg = |r: Value| small_verbs::merge_dialog_array(r, &ca2, &pids); let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: sd.clone(), caller: caller.clone(), pids: pids.clone(), sidecar: &side, dialogs: &dlg }; let cmd = v.strip_prefix("fusion_").unwrap_or(v); let t0 = Instant::now(); let ind = timings::Indicator::start(&sd, cmd, &caller); let pass = |verb: &str, a: Value| addin::pass_through(verb, &a, &ctx); let r0 = match cmd { "show_symbol" => orchestrations::show_symbol(&ctx, &parsed["args"], &pass), "show_footprint" => orchestrations::show_footprint(&ctx, &parsed["args"], &pass), "show_library" => orchestrations::show_library(&ctx, &parsed["args"], &pass), "show_project" => match orchestrations::show_project(&ctx, &parsed["args"], &pass) { Some(r) => r, None => side("show_project", parsed["args"].clone(), 240) }, "capture_library_views" => small_verbs::apply_failure_dialogs(orchestrations::capture_library_views(&ctx, &parsed["args"], &pass), &caller, &pids), "library_views" => { let mut r = small_verbs::apply_failure_dialogs(orchestrations::capture_library_views(&ctx, &docs::library_views_args(&parsed["args"]), &pass), &caller, &pids); r["aliasOf"] = json!("fusion_capture_library_views"); r }, "open_cloud_file" => orchestrations::open_cloud_file(&ctx, &parsed["args"], &pass), "save_lbr" => small_verbs::merge_dialog_array(orchestrations::save_lbr(&ctx, &parsed["args"], &pass), &caller, &pids), "cleanup_cloud_files" => orchestrations::cleanup_cloud_files(&parsed["args"], &pass), "inspect_bodies" => orchestrations::inspect_bodies(&parsed["args"], &pass), "board_stackup" => services::board_stackup(&parsed["args"], &pass), "export_optimized_glb" => if parsed["args"]["async"].as_bool() == Some(true) { services::start_glb_export_async(&ctx, &parsed["args"]) } else { services::export_optimized_glb(&parsed["args"], &pass) }, "fetch_optimized_glb" => if parsed["args"]["exportId"].is_string() { services::glb_export_async_status(&parsed["args"]) } else { services::fetch_optimized_glb(&parsed["args"]) }, "set_design_rules" => services::set_design_rules(&ctx, &parsed["args"], &pass), "make_test_project" => small_verbs::merge_dialog_array(testproject::make_test_project(&ctx, &parsed["args"], &pass), &caller, &pids), "project_create" => small_verbs::merge_dialog_array(testproject::project_create(&ctx, &parsed["args"], &pass), &caller, &pids), "test_board" => small_verbs::merge_dialog_array(boardgen::test_board(&ctx, &parsed["args"], &pass), &caller, &pids), "build_board" => small_verbs::merge_dialog_array(boardgen::build_board(&ctx, &parsed["args"], &pass), &caller, &pids), "swap_footprint" => small_verbs::merge_dialog_array(boardgen::swap_footprint(&ctx, &parsed["args"], &pass), &caller, &pids), "make_showcase_project" => small_verbs::merge_dialog_array(testproject::make_showcase_project(&ctx, &parsed["args"], &pass), &caller, &pids), "open_design" => orchestrations::open_design(&ctx, &parsed["args"], &pass), "new_electronics_from_eagle" => orchestrations::new_electronics_from_eagle(&ctx, &parsed["args"], &pass), "schematic" => orchestrations::schematic(&ctx, &parsed["args"], &pass), "eagle_import_status" => orchestrations::eagle_import_status(&ctx, &parsed["args"]), "show_attributes" => orchestrations::show_attributes(&ctx, &parsed["args"], &pass), "aps_open" => orchestrations::aps_open(&ctx, &parsed["args"], &pass), other => orchestrations::open_with_screenshot(&ctx, other, &parsed["args"], &pass) }; let mut r = small_verbs::merge_dialog_array(r0, &caller, &pids); small_verbs::add_image_delivery(&mut r); ind.stop(r["success"].as_bool() == Some(false)); timings::log_run(&sd, cmd, t0.elapsed().as_secs_f64(), r["success"].as_bool() != Some(false)); r["shell"] = json!("rust"); r },+ v @ ("fusion_show_symbol" | "show_symbol" | "fusion_show_footprint" | "show_footprint" | "fusion_show_library" | "show_library" | "fusion_show_project" | "show_project" | "fusion_show_schematic" | "show_schematic" | "fusion_show_2d_board" | "show_2d_board" | "fusion_show_3d_board" | "show_3d_board" | "fusion_capture_library_views" | "capture_library_views" | "fusion_library_views" | "library_views" | "fusion_open_cloud_file" | "open_cloud_file" | "fusion_save_lbr" | "save_lbr" | "fusion_cleanup_cloud_files" | "cleanup_cloud_files" | "fusion_inspect_bodies" | "inspect_bodies" | "fusion_board_stackup" | "board_stackup" | "fusion_export_optimized_glb" | "export_optimized_glb" | "fusion_fetch_optimized_glb" | "fetch_optimized_glb" | "fusion_set_design_rules" | "set_design_rules" | "fusion_make_test_project" | "make_test_project" | "fusion_project_create" | "project_create" | "fusion_test_board" | "test_board" | "fusion_build_board" | "build_board" | "fusion_swap_footprint" | "swap_footprint" | "fusion_panelize" | "panelize" | "fusion_make_showcase_project" | "make_showcase_project" | "fusion_open_design" | "open_design" | "fusion_new_electronics_from_eagle" | "new_electronics_from_eagle" | "fusion_aps_open" | "aps_open" | "fusion_schematic" | "schematic" | "fusion_eagle_import_status" | "eagle_import_status" | "fusion_show_attributes" | "show_attributes") if addin_owner_is_exe(&**h) => { let (sc, ca, ca2) = (sidecar.clone(), caller.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let sd = h.bridge_state_dir(); let pids: Vec<u32> = h.fusion_processes().iter().map(|p| p.pid).collect(); let dlg = |r: Value| small_verbs::merge_dialog_array(r, &ca2, &pids); let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: sd.clone(), caller: caller.clone(), pids: pids.clone(), sidecar: &side, dialogs: &dlg }; let cmd = v.strip_prefix("fusion_").unwrap_or(v); let t0 = Instant::now(); let ind = timings::Indicator::start(&sd, cmd, &caller); let pass = |verb: &str, a: Value| addin::pass_through(verb, &a, &ctx); let r0 = match cmd { "show_symbol" => orchestrations::show_symbol(&ctx, &parsed["args"], &pass), "show_footprint" => orchestrations::show_footprint(&ctx, &parsed["args"], &pass), "show_library" => orchestrations::show_library(&ctx, &parsed["args"], &pass), "show_project" => match orchestrations::show_project(&ctx, &parsed["args"], &pass) { Some(r) => r, None => side("show_project", parsed["args"].clone(), 240) }, "capture_library_views" => small_verbs::apply_failure_dialogs(orchestrations::capture_library_views(&ctx, &parsed["args"], &pass), &caller, &pids), "library_views" => { let mut r = small_verbs::apply_failure_dialogs(orchestrations::capture_library_views(&ctx, &docs::library_views_args(&parsed["args"]), &pass), &caller, &pids); r["aliasOf"] = json!("fusion_capture_library_views"); r }, "open_cloud_file" => orchestrations::open_cloud_file(&ctx, &parsed["args"], &pass), "save_lbr" => small_verbs::merge_dialog_array(orchestrations::save_lbr(&ctx, &parsed["args"], &pass), &caller, &pids), "cleanup_cloud_files" => orchestrations::cleanup_cloud_files(&parsed["args"], &pass), "inspect_bodies" => orchestrations::inspect_bodies(&parsed["args"], &pass), "board_stackup" => services::board_stackup(&parsed["args"], &pass), "export_optimized_glb" => if parsed["args"]["async"].as_bool() == Some(true) { services::start_glb_export_async(&ctx, &parsed["args"]) } else { services::export_optimized_glb(&parsed["args"], &pass) }, "fetch_optimized_glb" => if parsed["args"]["exportId"].is_string() { services::glb_export_async_status(&parsed["args"]) } else { services::fetch_optimized_glb(&parsed["args"]) }, "set_design_rules" => services::set_design_rules(&ctx, &parsed["args"], &pass), "make_test_project" => small_verbs::merge_dialog_array(testproject::make_test_project(&ctx, &parsed["args"], &pass), &caller, &pids), "project_create" => small_verbs::merge_dialog_array(testproject::project_create(&ctx, &parsed["args"], &pass), &caller, &pids), "test_board" => small_verbs::merge_dialog_array(boardgen::test_board(&ctx, &parsed["args"], &pass), &caller, &pids), "build_board" => small_verbs::merge_dialog_array(boardgen::build_board(&ctx, &parsed["args"], &pass), &caller, &pids), "swap_footprint" => small_verbs::merge_dialog_array(boardgen::swap_footprint(&ctx, &parsed["args"], &pass), &caller, &pids), "panelize" => small_verbs::merge_dialog_array(boardgen::panelize(&ctx, &parsed["args"], &pass), &caller, &pids), "make_showcase_project" => small_verbs::merge_dialog_array(testproject::make_showcase_project(&ctx, &parsed["args"], &pass), &caller, &pids), "open_design" => orchestrations::open_design(&ctx, &parsed["args"], &pass), "new_electronics_from_eagle" => orchestrations::new_electronics_from_eagle(&ctx, &parsed["args"], &pass), "schematic" => orchestrations::schematic(&ctx, &parsed["args"], &pass), "eagle_import_status" => orchestrations::eagle_import_status(&ctx, &parsed["args"]), "show_attributes" => orchestrations::show_attributes(&ctx, &parsed["args"], &pass), "aps_open" => orchestrations::aps_open(&ctx, &parsed["args"], &pass), other => orchestrations::open_with_screenshot(&ctx, other, &parsed["args"], &pass) }; let mut r = small_verbs::merge_dialog_array(r0, &caller, &pids); small_verbs::add_image_delivery(&mut r); ind.stop(r["success"].as_bool() == Some(false)); timings::log_run(&sd, cmd, t0.elapsed().as_secs_f64(), r["success"].as_bool() != Some(false)); r["shell"] = json!("rust"); r }, "fusion_state" | "state" if addin_owner_is_exe(&**h) => { let (sc, ca) = (sidecar.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let dlg = |r: Value| r; let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: h.bridge_state_dir(), caller: caller.clone(), pids: h.fusion_processes().iter().map(|p| p.pid).collect(), sidecar: &side, dialogs: &dlg }; let mut r = orchestrations::fusion_state(&ctx, &parsed["args"]); r["shell"] = json!("rust"); r }, v @ ("fusion_make_3d_package" | "make_3d_package" | "fusion_build_library_3d" | "build_library_3d" | "fusion_bind_library_3d" | "bind_library_3d" | "fusion_prove_library" | "prove_library" | "fusion_attach_3d_package" | "attach_3d_package" | "fusion_show_3d_package" | "show_3d_package" | "fusion_import_part" | "import_part") if addin_owner_is_exe(&**h) => { let (sc, ca, ca2) = (sidecar.clone(), caller.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let sd = h.bridge_state_dir(); let pids: Vec<u32> = h.fusion_processes().iter().map(|p| p.pid).collect(); let dlg = |r: Value| small_verbs::merge_dialog_array(r, &ca2, &pids); let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: sd.clone(), caller: caller.clone(), pids: pids.clone(), sidecar: &side, dialogs: &dlg }; let cmd = v.strip_prefix("fusion_").unwrap_or(v); let t0 = Instant::now(); *LONG_OP.lock().unwrap() = Some(cmd.to_string()); *LONG_OP_SINCE.lock().unwrap() = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs_f64()).unwrap_or(0.0); let pass = |verb: &str, a: Value| addin::pass_through(verb, &a, &ctx); let r0 = match cmd { "make_3d_package" => mint::make_3d_package(&ctx, &parsed["args"], &pass), "bind_library_3d" => mint::bind_library_3d(&ctx, &parsed["args"], &pass), "prove_library" => prove::prove_library(&ctx, &parsed["args"], &pass), "attach_3d_package" => mint::attach_3d_package(&ctx, &parsed["args"], &pass), "show_3d_package" => mint::show_3d_package(&ctx, &parsed["args"], &pass), "import_part" => partimport::import_part(&ctx, &parsed["args"], &pass), _ => mint::build_library_3d(&ctx, &parsed["args"], &pass) }; *LONG_OP.lock().unwrap() = None; let mut r = small_verbs::apply_failure_dialogs(r0, &caller, &pids); timings::log_run(&sd, cmd, t0.elapsed().as_secs_f64(), r["success"].as_bool() != Some(false)); r["shell"] = json!("rust"); r }, v @ ("fusion_apply_fusion_update" | "apply_fusion_update" | "fusion_install_fusion" | "install_fusion" | "fusion_uninstall" | "uninstall" | "fusion_prefs_open" | "prefs_open" | "fusion_prefs_close" | "prefs_close" | "fusion_generate_package" | "generate_package" | "fusion_mcp_status" | "mcp_status" | "fusion_mcp_tools" | "mcp_tools" | "fusion_mcp_call" | "mcp_call" | "fusion_mcp_resources" | "mcp_resources" | "fusion_mcp_enable" | "mcp_enable" | "fusion_library_manager" | "library_manager" | "fusion_managed_library_create" | "managed_library_create" | "fusion_managed_library_link" | "managed_library_link" | "fusion_managed_library_pull" | "managed_library_pull" | "fusion_managed_library_push" | "managed_library_push" | "fusion_managed_library_unlink" | "managed_library_unlink" | "fusion_signin" | "signin" | "fusion_signin_2fa" | "signin_2fa" | "fusion_web_signin" | "web_signin" | "fusion_aps_signin" | "aps_signin" | "fusion_demo" | "demo" | "fusion_demo_progress" | "demo_progress" | "fusion_demo_control" | "demo_control" | "fusion_electronics_query" | "electronics_query" | "fusion_view" | "view" | "fusion_interactive" | "interactive" | "fusion_narrate" | "narrate") if addin_owner_is_exe(&**h) => { let (sc, ca) = (sidecar.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let dlg = |r: Value| r; let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: h.bridge_state_dir(), caller: caller.clone(), pids: h.fusion_processes().iter().map(|p| p.pid).collect(), sidecar: &side, dialogs: &dlg }; let pass = |verb: &str, a: Value| addin::pass_through(verb, &a, &ctx); let cmd = v.strip_prefix("fusion_").unwrap_or(v); let mut r = match cmd { "apply_fusion_update" => maintenance::apply_fusion_update(&ctx, &pass), "install_fusion" => maintenance::install_fusion(&ctx), "prefs_open" => maintenance::prefs_open(&ctx, &parsed["args"], &pass), "prefs_close" => maintenance::prefs_close(&ctx, &parsed["args"]), "generate_package" => epg::generate_package(&parsed["args"], VERSION, &pass),
rust/bridge/src/routing.rs+2−2@@ -3,7 +3,7 @@ use serde_json::{json, Value}; const SOURCE: &str = concat!(include_str!("routing_script.py"), "\n", include_str!("routing_planner.py"), "\n", include_str!("board_model.py"), "\nFUSION_DEFAULT_RULES = r'''", include_str!("fusion_default_designrules.xml"), "'''\n");-pub fn owns(cmd: &str) -> bool { matches!(cmd, "routing_state" | "route_net" | "routing_drc" | "move_parts" | "place_vias" | "apply_pours" | "pour_state" | "board_source" | "ripup_nets" | "schematic_run" | "board_bom" | "change_pours" | "schematic_plan" | "board_seed" | "remove_pours" | "layer_stack" | "netlist" | "bodies" | "component_data" | "component_pins" | "list_nets" | "net_connections" | "list_designators" | "layers" | "copper_layers" | "layers_in_use" | "read_rules" | "rules_by_kind" | "silk_state" | "net_classes" | "diff_pairs" | "run_drc" | "repour" | "zoom_to" | "export_bom" | "save_document" | "save_to_cloud" | "apply_silkscreen" | "set_layer_visibility" | "select_net" | "set_track_width" | "remove_items" | "set_outline" | "board_shape" | "board_outline" | "run_erc" | "autoroute" | "update_board_from_eagle" | "board_dxf" | "edit_parts" | "align_parts" | "remove_graphics" | "remove_parts" | "check_inputs" | "export_odb" | "export_ipc356" | "export_gencad" | "export_ipc2581" | "import_svg" | "copy_routing" | "set_layer_count" | "board_info_compat" | "footprint_primitives" | "place_planned_vias" | "board_features" | "add_rule" | "read_board") }+pub fn owns(cmd: &str) -> bool { matches!(cmd, "routing_state" | "route_net" | "routing_drc" | "move_parts" | "place_vias" | "apply_pours" | "pour_state" | "board_source" | "ripup_nets" | "schematic_run" | "board_bom" | "change_pours" | "schematic_plan" | "board_seed" | "remove_pours" | "layer_stack" | "netlist" | "bodies" | "component_data" | "component_pins" | "list_nets" | "net_connections" | "list_designators" | "layers" | "copper_layers" | "layers_in_use" | "read_rules" | "rules_by_kind" | "silk_state" | "net_classes" | "diff_pairs" | "run_drc" | "repour" | "zoom_to" | "export_bom" | "save_document" | "save_to_cloud" | "apply_silkscreen" | "set_layer_visibility" | "select_net" | "set_track_width" | "remove_items" | "set_outline" | "board_shape" | "board_outline" | "run_erc" | "autoroute" | "update_board_from_eagle" | "board_dxf" | "edit_parts" | "align_parts" | "remove_graphics" | "remove_parts" | "check_inputs" | "export_odb" | "export_ipc356" | "export_gencad" | "export_ipc2581" | "import_svg" | "copy_routing" | "set_layer_count" | "board_info_compat" | "footprint_primitives" | "place_planned_vias" | "place_teardrops" | "board_features" | "add_rule" | "read_board") } pub fn call(cmd: &str, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value { let mut args_v = args.clone(); if args_v.is_object() { args_v["_bridgeVersion"] = json!(env!("CARGO_PKG_VERSION")); }@@ -32,7 +32,7 @@ pub fn call(cmd: &str, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Val } out }- None => json!({"success":false,"errorCode":"invalid_routing_response","error":"Fusion did not return a structured routing result", "mutationMayHaveOccurred":matches!(cmd, "route_net" | "move_parts" | "place_vias" | "apply_pours" | "apply_silkscreen" | "set_track_width" | "remove_items" | "set_outline" | "board_shape" | "autoroute" | "update_board_from_eagle" | "edit_parts" | "align_parts" | "remove_graphics" | "remove_parts" | "import_svg" | "copy_routing" | "set_layer_count" | "place_planned_vias" | "board_features" | "add_rule")}),+ None => json!({"success":false,"errorCode":"invalid_routing_response","error":"Fusion did not return a structured routing result", "mutationMayHaveOccurred":matches!(cmd, "route_net" | "move_parts" | "place_vias" | "apply_pours" | "apply_silkscreen" | "set_track_width" | "remove_items" | "set_outline" | "board_shape" | "autoroute" | "update_board_from_eagle" | "edit_parts" | "align_parts" | "remove_graphics" | "remove_parts" | "import_svg" | "copy_routing" | "set_layer_count" | "place_planned_vias" | "place_teardrops" | "board_features" | "add_rule")}), } }
rust/bridge/src/routing_script.py+34@@ -3705,6 +3705,11 @@ def _place_and_check(app, args, board, items, label): elif it['kind'] == 'rect': x1, y1, x2, y2 = it['rect'] cmds.append("LAYER %d; RECT R0 (%.6fmm %.6fmm) (%.6fmm %.6fmm);" % (it['layer'], x1, y1, x2, y2))+ elif it['kind'] == 'polygon':+ # named, so Fusion never asks for the signal in its modal; solid, rank 1 and no thermals:+ # a teardrop is copper of its own net that nothing else's pour may cut+ path = ' '.join('(%.6fmm %.6fmm)' % (x, y) for x, y in it['points'] + it['points'][:1])+ cmds.append("CHANGE ISOLATE 0mm; CHANGE RANK 1; CHANGE POUR SOLID; CHANGE ORPHANS ON; CHANGE THERMALS OFF; LAYER %d; POLYGON '%s' %.6fmm %s;" % (it['layer'], identifier(it['net']), it.get('width', 0.0254), path)) done = 0 for k in range(0, len(cmds), 25): app.executeTextCommand('Electron.run ' + ' '.join(cmds[k:k + 25]))@@ -3719,6 +3724,13 @@ def _place_and_check(app, args, board, items, label): missing.append(it) if it['kind'] == 'hole' and not any(abs(h[0] - it['at'][0]) < 0.002 and abs(h[1] - it['at'][1]) < 0.002 for h in holes2): missing.append(it)+ if it['kind'] == 'polygon':+ x0, y0 = it['points'][0]+ sig = after.find("signals/signal[@name='%s']" % identifier(it['net']))+ # Fusion writes a drawn polygon as <polygonpour> with its outline under polygonoutlineobjects+ pgs = [] if sig is None else sig.findall('polygon') + sig.findall('polygonpour')+ if not any(int(pg.get('layer', 0)) == it['layer'] and any(abs(_f(v, 'x') - x0) < 0.002 and abs(_f(v, 'y') - y0) < 0.002 for v in pg.findall('vertex') + pg.findall('polygonoutlineobjects/vertex')) for pg in pgs):+ missing.append(dict(it, at=(x0, y0))) if missing: for _ in range(done): app.executeTextCommand('Electron.run UNDO;')@@ -3745,6 +3757,27 @@ def place_planned_vias(app, root, board, meta, args): return {'success': True, 'mutated': True, **meta2, 'applied': len(items), 'failed': 0} +def place_teardrops(app, root, board, meta, args):+ """The teardrops of an engine plan ({net, layer, points}: filled polygons where a track meets a+ pad or via), drawn as named solid polygons and read back; one that does not read back undoes all."""+ _guard(meta, args)+ want = args.get('teardrops')+ if not isinstance(want, list) or not want:+ fail('invalid_args', 'teardrops must be a non-empty list of {net, layer, points}')+ nets = {s.get('name') for s in board.findall('signals/signal')}+ items = []+ for t in want:+ net = identifier(t.get('net'))+ if net not in nets:+ fail('unknown_net', 'No net named %s on the board' % t.get('net'))+ pts = [(number(p[0]), number(p[1])) for p in (t.get('points') or [])]+ if len(pts) < 3:+ fail('invalid_args', 'a teardrop needs three points at least')+ items.append({'kind': 'polygon', 'net': net, 'layer': _layer_number(board, t.get('layer')), 'points': pts})+ after, meta2, _ = _place_and_check(app, args, board, items, 'teardrops')+ return {'success': True, 'mutated': True, **meta2, 'applied': len(items), 'failed': 0}++ def board_features(app, root, board, meta, args): _guard(meta, args) env = _copper_env(board)@@ -3827,6 +3860,7 @@ def handle(app,args,action): if action=='board_info_compat': return board_info_compat(board,meta) if action=='footprint_primitives': return footprint_primitives(board,meta,args) if action=='place_planned_vias': return place_planned_vias(app,root,board,meta,args)+ if action=='place_teardrops': return place_teardrops(app,root,board,meta,args) if action=='board_features': return board_features(app,root,board,meta,args) if action=='routing_state': st=state(board,meta); n,fa=st['unconnected'],st['fusionAirwires']
rust/vendor/adom-eda-checks/Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-eda-checks"-version = "0.18.0"+version = "0.19.0" edition = "2021" description = "The EDA Bridge Standard's checks and board-file writers, computed from the board model JSON: no bridge calls, no tool." license = "MIT"
rust/vendor/adom-eda-checks/README.md+3−1@@ -25,6 +25,7 @@ boards the engine gives the same answers the Altium bridge 0.2.76 gives. | `current_capacity` | board, stack? | per net and layer, the narrowest copper against the width the current needs (IPC-2221), and the vias a layer change needs | | `footprint_from_land_pattern` | args only | a footprint laid out from a datasheet's recommended land pattern (`family` chip, dual, sot, quad or bga, with pitch, span, pad size, `exposedPad`, `body`): pads numbered the standard way (pin 1 top-left, counterclockwise; BGA rows without I, O, Q, S, X, Z), silkscreen (`overlay`) kept `silkClearance` from copper with a pin-1 dot, a `fab` outline with a pin-1 chamfer and the `courtyard` grown by its margin, in mm, Y up, origin at the centre. A missing number is refused by name. Each bridge writes it in its own library format. From the Altium bridge's footprint.rs | | `height_check` | bodies, board? | each part's height on its side, parts over `maxTopMm`/`maxBottomMm` or under a lid region (`regions`), body collisions and near misses (`minGapMm`); `noBody` and `complete: false` when a part has no 3D body |+| `panelize` | board, stack? | a panel plan in panel mm from its lower-left corner: `rows` x `cols` copies (each with its `copyShift`), rails (`railMm`) with three tooling holes and fiducials, and `separation` none, `vscore` (V-CUT lines edge to edge with their labels) or `tabs` (mouse-bite holes and the rout path), with JLCPCB's V-cut or mouse-bite rules checked and copper near the edge or outside the outline named. Refuses tabs when the board draws its outline on a mechanical layer. From the Altium bridge's panel.rs | | `pin_pad_check` | board, sheetParts | each symbol's pins against its footprint's pads: pins with no pad (a net that can never reach copper), pads with no pin (mounting and exposed pads excepted), parts only on the sheets or only on the board. From the Altium bridge's schreview.rs | | `place_decoupling` | board, bodies? | positions for an IC's decoupling caps (`ic`, `caps?`, `gapMm` 0.3): each just outside the IC's box on the side of the power pin it serves, turned so its power pad faces the pin and level with it, sliding along when the spot is taken (by a part on the same side, or a through-hole pad from the other). Without `caps` it takes caps across ground and a supply of the IC, leaving out (`leftOut`) a signal net with one cap (a crystal's load) and a cap nearer another IC's pin on that supply. Each cap's pads keep clear of the stubs and vias plan_fanout would give the IC's other pins (`leaveFanout`, default true), so the fanout still fits after. A cap it cannot place stays where it is and keeps its spot. `icPowerNets` lists the IC's supplies. `moveArgs` is move_parts' arguments; `validation` is placement_check on the moved board. From the KiCad bridge's place_tools | | `length_tune` | board, rules? | a serpentine on the net's longest straight track with room beside it, its bumps sized to add exactly what `targetMm` (or `matchTo`, another net's length) asks, every bump `clearance` (the board's strictest rule) from other nets' pads, routed tracks and vias and inside the board; answered as the track to `remove` and the `routeNet` that replaces it. From the Altium bridge's lengthtune.rs |@@ -36,6 +37,7 @@ boards the engine gives the same answers the Altium bridge 0.2.76 gives. | `route_pair` | board, stack?, rules? | a differential pair planned as one centreline (pad midpoints through `via` waypoints) with each net offset half a pitch to its own side, mitred corners and a `fanMm` fan-out; width and gap given, or from `diffOhms` and the stack. `problems` names a crossing or other copper (pads by outline, routed tracks, vias) closer than the clearance; `routeNet` holds both nets' route_net arguments and `widthRule` the pair's add_rule. From the Altium bridge's pairroute.rs | | `schematic_review` | sheetParts, netlist | every placed pin, with its electrical type, against the compiled netlist: power pins on no net and outputs driving each other (errors); unconnected pins, one-pin nets, inputs nothing drives and IC supplies with no capacitor to ground (warnings; a regulator's switch node on an inductor is no missing capacitor). From the Altium bridge's schreview.rs | | `silk_designators` | board, silk | a place for each reference designator: one size (`size`, stepping down to `minSize`), beside its own part, clear of every pad by `padClearance`, of other parts' bodies and of other labels, inside the board. `applyArgs` is apply_silkscreen's arguments; parts with no clear spot are listed, not forced. From the Altium bridge's silkplace.rs |+| `teardrops` | board, rules? | a tapered fill where a track meets a via or pad of its own net (`targets` via, tht, smd), as wide as the pad at its centre (`widthRatio`) and narrowing to the track `lengthRatio` x the pad's size past its edge; each a polygon on the track's layer kept clear of other nets' copper by the board's clearance, the joints with no room skipped and named | | `thermal_vias` | board, rules? | a grid of vias in a part's exposed pad (`ref`, `pad?`), clear of vias already there; `apply` is `place_vias` arguments | | `via_fence` | board, rules? | vias of `viaNet` both sides of the named nets' tracks and/or round the board edge (`edge`), at `pitchMm` or lambda/20 at `freqGHz`, clear of other copper; `apply` is `place_vias` arguments | @@ -113,7 +115,7 @@ adom-eda-checks import_svg --svg logo.svg --args '{"at": [10, 10], "widthMm": 20 Replies keep the standard envelope (`success`, `errorCode`, `error`, `_hint`) and name verbs without a prefix (`apply_pours`, `add_rule`, `place_vias`): a bridge adds its own. Every reply also carries `engine`-(`"adom-eda-checks 0.18.0"`), so a bridge reports the engine it runs without writing the version by hand.+(`"adom-eda-checks 0.19.0"`), so a bridge reports the engine it runs without writing the version by hand. ## Tests
rust/vendor/adom-eda-checks/VENDOR.md+3−3@@ -3,9 +3,9 @@ The EDA Bridge Standard's shared checks engine, copied unchanged from adom/eda-bridge-standard `engine/` (the wiki has no git protocol, so Cargo cannot fetch it). -- Standard version: 0.21.0-- Standard commit: 6c5850f3303941197eaa1d2d147d33691e01bcaa-- Crate version: 0.18.0+- Standard version: 0.22.1+- Standard commit: b9da2c63e0fffbf6d5e184c81f6db0baa111ff1d+- Crate version: 0.19.0 - Copied: 2026-10-29, by fusion-lane To update: clone the standard fresh (`adom-wiki repo clone adom/eda-bridge-standard`), copy `engine/src`,
rust/vendor/adom-eda-checks/src/lib.rs+5−1@@ -22,6 +22,7 @@ pub mod landpattern; pub mod lengths; pub mod lengthtune; pub mod pairroute;+pub mod panel; pub mod report; pub mod review; pub mod placement;@@ -30,6 +31,7 @@ pub mod route; pub mod schreview; pub mod silkplace; pub mod space;+pub mod teardrops; pub mod writers; /// This engine's version. Every reply carries it as `engine` ("adom-eda-checks 0.3.0"), so a bridge never writes it by hand@@ -37,7 +39,7 @@ pub mod writers; pub const VERSION: &str = env!("CARGO_PKG_VERSION"); /// The checks this build answers, by standard verb name.-pub const CHECKS: &[&str] = &["assembly_check", "auto_classes", "board_report", "check_clearance", "check_orientation", "copper_balance", "current_capacity", "estimate_cost", "fab_check", "find_space", "footprint_from_land_pattern", "height_check", "impedance", "length_tune", "net_lengths", "pin_pad_check", "place_decoupling", "placement_check", "plan_fanout", "plan_route", "return_path", "review_board", "route_pair", "schematic_review", "silk_designators", "thermal_vias", "via_fence"];+pub const CHECKS: &[&str] = &["assembly_check", "auto_classes", "board_report", "check_clearance", "check_orientation", "copper_balance", "current_capacity", "estimate_cost", "fab_check", "find_space", "footprint_from_land_pattern", "height_check", "impedance", "length_tune", "net_lengths", "panelize", "pin_pad_check", "place_decoupling", "placement_check", "plan_fanout", "plan_route", "return_path", "review_board", "route_pair", "schematic_review", "silk_designators", "teardrops", "thermal_vias", "via_fence"]; /// Run one check or writer by its standard name. The reply carries `engine`. pub fn run(check: &str, input: &Value) -> Value {@@ -64,6 +66,7 @@ fn answer(check: &str, input: &Value) -> Value { "impedance" => impedance::impedance(input), "length_tune" => lengthtune::length_tune(input), "net_lengths" => lengths::net_lengths(input),+ "panelize" => panel::panelize(input), "pin_pad_check" => schreview::pin_pad_check(input), "place_decoupling" => placetools::place_decoupling(input), "plan_fanout" => placetools::plan_fanout(input),@@ -74,6 +77,7 @@ fn answer(check: &str, input: &Value) -> Value { "route_pair" => pairroute::route_pair(input), "schematic_review" => schreview::schematic_review(input), "silk_designators" => silkplace::silk_designators(input),+ "teardrops" => teardrops::teardrops(input), "thermal_vias" => features::thermal_vias(input), "via_fence" => features::via_fence(input), _ => input::refuse("not_supported", &format!("{check} is not a verb this engine answers (checks: {}; writers: {})", CHECKS.join(", "), writers::WRITERS.join(", "))),
rust/vendor/adom-eda-checks/src/panel.rsadded+377@@ -0,0 +1,377 @@+//! Manufacturing panels: a step-and-repeat of a board 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). From the Altium+//! bridge's panel.rs: the engine answers the plan in panel mm from the panel's lower-left corner, and each bridge builds+//! the panel in its own way (Altium an embedded board array, Fusion an EAGLE board with the copies placed).+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 }++/// 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)+}++/// panelize: input {board, stack?, args: {rows, cols, separation?: none|vscore|tabs, gapMm?, railMm?, toolingHoles?}}. The+/// plan in panel mm from its lower-left corner: its size, where each copy's outline corner goes (and the shift that takes+/// the board's own coordinates there), the outline-layer lines (the V-CUT lines and their labels, or the tab rout path),+/// the mouse-bite and tooling holes, the fiducials, and JLCPCB's rules against it.+pub fn panelize(input: &Value) -> Value {+ let st = match crate::input::board(input) { Ok(b) => b, Err(e) => return e };+ let args = &input["args"];+ let bad = |e: &str| crate::input::refuse("bad_args", e);+ let int = |k: &str, d: i64| args[k].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 = args["separation"].as_str().unwrap_or("none").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[k].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)"); }+ let Some(bb) = crate::geom::outline_bbox(&st) else { return crate::input::refuse("no_outline", "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\", or panelize without separation and order the fab's own panel with mouse bites, or move the outline to a 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 thick = crate::input::stack(input).and_then(|s| s["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 mut sep_out = json!({"kind": sep});+ let (mut lines, mut texts, mut holes, mut jlc): (Vec<[f64; 4]>, Vec<Value>, Vec<Value>, Vec<Value>) = (Vec::new(), Vec::new(), Vec::new(), Vec::new());+ match sep.as_str() {+ "vscore" => {+ let (xs, ys) = vcut_lines(w, h, rows, cols, rail);+ let (ls, ts) = vcut_marks(&xs, &ys, pw, ph, rail);+ lines = ls;+ texts = ts.iter().map(|(x, y, rot)| json!({"text": "V-CUT", "x": x, "y": y, "size": 1.0, "rotation": rot})).collect();+ 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 (ls, hs, tabs) = tab_plan(w, h, rows, cols, gap, rail);+ lines = ls;+ holes = hs.iter().map(|p| json!({"x": p[0], "y": p[1], "drill": BITE_HOLE, "plated": false})).collect();+ jlc = jlc_tabs(gap, pw, ph, clear.as_ref().map(|c| c.0));+ sep_out["tabs"] = json!(tabs);+ }+ _ => {}+ }+ let tooling = args["toolingHoles"].as_bool().unwrap_or(true);+ let at = |k: &str| -> Vec<Value> { lay[k].as_array().cloned().unwrap_or_default() };+ let shifts: Vec<Value> = at("boards").iter().map(|b| json!([r3(f(&b[0]) - x0), r3(f(&b[1]) - y0)])).collect();+ 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 = jlc.iter().filter(|r| r["ok"] == json!(false)).count();+ json!({"success": true, "rows": rows, "cols": cols, "boardMm": [r3(w), r3(h)], "panelMm": lay["size"], "gapMm": gap, "railMm": rail,+ "boardsAt": lay["boards"], "copyShift": shifts,+ "outlineLayer": {"lines": lines.iter().map(|l| json!({"x1": l[0], "y1": l[1], "x2": l[2], "y2": l[3], "width": 0.1})).collect::<Vec<_>>(), "texts": texts},+ "holes": holes, "toolingHoles": if tooling { json!(at("toolingHoles").iter().map(|p| json!({"x": p[0], "y": p[1], "drill": 2.0})).collect::<Vec<_>>()) } else { json!([]) },+ "fiducials": if tooling { json!(at("fiducials").iter().map(|p| json!({"x": p[0], "y": p[1]})).collect::<Vec<_>>()) } else { json!([]) },+ "separation": sep_out, "jlcpcb": if jlc.is_empty() { Value::Null } else { json!({"rules": jlc, "fits": fails == 0}) },+ "nearestCopperToEdge": clear.map(|(mm, mut what)| { what["mm"] = json!(mm); what }), "warnings": warnings,+ "_hint": "A plan in panel mm from its lower-left corner. Place each copy of the board shifted by copyShift (board coordinates plus the shift land it at boardsAt), draw the panel's outline (0, 0, panelMm) and outlineLayer's lines and texts on the outline layer, drill holes (non-plated mouse bites) and toolingHoles, and place the fiducials. jlcpcb.rules lists each 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);+ }+}
rust/vendor/adom-eda-checks/src/teardrops.rsadded+106@@ -0,0 +1,106 @@+//! Teardrops: where a track meets a via or a pad of its own net, a tapered fill from the pad out along the track, so a+//! drill wander or an etch nick does not cut the joint. Each is a polygon on the track's layer, planned clear of every+//! other net's copper; a joint with no room is skipped and named. The shape is the straight teardrop KiCad draws: as+//! wide as the pad at the pad's centre (`widthRatio`), narrowing to the track `lengthRatio` x the pad's size beyond its+//! edge.+use crate::route::{pad_shape, Shape};+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 }+type P = (f64, f64);++/// The teardrop polygon from a joint at `c` (pad or via of size `s` across) along a track toward `toward`, `w` wide.+/// None when the track is too short or already as wide as the pad. Pure.+pub fn shape(c: P, toward: P, s: f64, w: f64, length_ratio: f64, width_ratio: f64) -> Option<Vec<P>> {+ let (dx, dy) = (toward.0 - c.0, toward.1 - c.1);+ let len = (dx * dx + dy * dy).sqrt();+ let half = (s / 2.0 * width_ratio).min(s / 2.0);+ if len < 1e-6 || w >= 2.0 * half * 0.9 { return None; }+ let (u, n) = ((dx / len, dy / len), (-dy / len, dx / len));+ // the taper ends lengthRatio x the pad's size past its edge, or before the track's far end+ let reach = (s / 2.0 + length_ratio * s).min(len * 0.8);+ if reach <= s / 2.0 { return None; }+ let at = |along: f64, side: f64| (c.0 + u.0 * along + n.0 * side, c.1 + u.1 * along + n.1 * side);+ Some(vec![at(0.0, half), at(reach, w / 2.0), at(reach, -w / 2.0), at(0.0, -half)])+}++/// teardrops: input {board, rules?, args: {targets?: ["via", "tht", "smd"] (default via and tht), lengthRatio? (0.5),+/// widthRatio? (1.0), clearanceMm?}}. The teardrops as polygons on their track's layer, and the joints skipped with why.+pub fn teardrops(input: &Value) -> Value {+ let st = match crate::input::board(input) { Ok(b) => b, Err(e) => return e };+ let args = &input["args"];+ let targets: Vec<String> = args["targets"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(|s| s.to_lowercase())).collect()).unwrap_or_else(|| vec!["via".into(), "tht".into()]);+ let (lr, wr) = (args["lengthRatio"].as_f64().unwrap_or(0.5), args["widthRatio"].as_f64().unwrap_or(1.0));+ let clearance = args["clearanceMm"].as_f64().unwrap_or(0.0).max(crate::fab::routing_clearance(&crate::input::rules(input)).unwrap_or(0.2));+ let net = |v: &Value| v["net"].as_str().unwrap_or("").to_string();+ let tracks: Vec<Value> = st["tracks"].as_array().cloned().unwrap_or_default().into_iter().filter(|t| !net(t).is_empty() && t["layer"].as_str().map(|l| l.ends_with(".Cu")).unwrap_or(false)).collect();+ let vias = st["vias"].as_array().cloned().unwrap_or_default();+ let pads = st["pads"].as_array().cloned().unwrap_or_default();+ // the joints: (name, net, centre, size across, the layers it is on: empty for every layer)+ let mut joints: Vec<(String, String, P, f64, Option<String>)> = Vec::new();+ if targets.iter().any(|t| t == "via") {+ for v in &vias { joints.push(("via".into(), net(v), (f(&v["position"]["x"]), f(&v["position"]["y"])), f(&v["size"]), None)); }+ }+ for p in &pads {+ let through = f(&p["drill"]) > 0.0 || p["layer"] == "*.Cu";+ if (through && !targets.iter().any(|t| t == "tht")) || (!through && !targets.iter().any(|t| t == "smd")) { continue; }+ let s = f(&p["sizeX"]).min(f(&p["sizeY"]));+ let name = format!("{}.{}", p["ref"].as_str().unwrap_or(""), p["name"].as_str().unwrap_or(""));+ joints.push((name, net(p), (f(&p["x"]), f(&p["y"])), s, (!through).then(|| p["layer"].as_str().unwrap_or("").to_string())));+ }+ // other nets' copper, by layer ("" every layer)+ let mut copper: Vec<(String, String, Shape)> = Vec::new();+ for t in &tracks { copper.push((net(t), t["layer"].as_str().unwrap_or("").into(), Shape::Cap((f(&t["start"]["x"]), f(&t["start"]["y"])), (f(&t["end"]["x"]), f(&t["end"]["y"])), f(&t["width"]) / 2.0))); }+ for v in &vias { let c = (f(&v["position"]["x"]), f(&v["position"]["y"])); copper.push((net(v), String::new(), Shape::Cap(c, c, f(&v["size"]) / 2.0))); }+ for p in &pads { let l = if f(&p["drill"]) > 0.0 || p["layer"] == "*.Cu" { String::new() } else { p["layer"].as_str().unwrap_or("").to_string() }; copper.push((net(p), l, pad_shape(p))); }+ let (mut out, mut skipped) = (Vec::new(), Vec::new());+ for t in &tracks {+ let layer = t["layer"].as_str().unwrap_or("").to_string();+ let (a, b) = ((f(&t["start"]["x"]), f(&t["start"]["y"])), (f(&t["end"]["x"]), f(&t["end"]["y"])));+ for (end, other) in [(a, b), (b, a)] {+ let Some(j) = joints.iter().find(|j| j.1 == net(t) && j.4.as_ref().map(|l| *l == layer).unwrap_or(true) && ((j.2 .0 - end.0).powi(2) + (j.2 .1 - end.1).powi(2)).sqrt() < 0.01) else { continue };+ let Some(poly) = shape(j.2, other, j.3, f(&t["width"]), lr, wr) else { continue };+ let s = Shape::Poly(poly.clone());+ if let Some((on, _, _)) = copper.iter().find(|(on, l, c)| *on != j.1 && (l.is_empty() || *l == layer) && s.gap(c) < clearance - 1e-9) {+ skipped.push(json!({"at": j.0, "net": j.1, "layer": layer, "why": format!("no room: it would come within {clearance} mm of {on}")}));+ continue;+ }+ out.push(json!({"at": j.0, "net": j.1, "layer": layer, "points": poly.iter().map(|p| json!([r3(p.0), r3(p.1)])).collect::<Vec<_>>()}));+ }+ }+ json!({"success": true, "teardrops": out, "count": out.len(), "skipped": skipped, "clearanceMm": clearance, "targets": targets, "lengthRatio": lr, "widthRatio": wr, "revision": st["revision"],+ "_hint": "A plan: draw each teardrop as a filled copper polygon on its layer and net (the tool's own teardrop command where it has one), with expectedRevision: revision, then repour. Each keeps clearanceMm from other nets' copper; skipped names the joints with no room."})+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn a_teardrop_tapers_from_the_pad_to_the_track() {+ let p = shape((0.0, 0.0), (5.0, 0.0), 0.6, 0.2, 0.5, 1.0).unwrap();+ // as wide as the via at its centre, the track's width 0.3 + 0.3 = 0.6 mm out+ assert_eq!(p, vec![(0.0, 0.3), (0.6, 0.1), (0.6, -0.1), (0.0, -0.3)]);+ // a track already as wide as the pad, or a stub too short to taper, takes none+ assert!(shape((0.0, 0.0), (5.0, 0.0), 0.6, 0.6, 0.5, 1.0).is_none());+ assert!(shape((0.0, 0.0), (0.3, 0.0), 0.6, 0.2, 0.5, 1.0).is_none());+ }++ #[test]+ fn teardrops_go_on_vias_and_skip_a_joint_with_no_room() {+ let b = json!({"revision": "r9",+ "tracks": [{"net": "A", "layer": "F.Cu", "width": 0.2, "start": {"x": 0.0, "y": 0.0}, "end": {"x": 5.0, "y": 0.0}},+ {"net": "B", "layer": "F.Cu", "width": 0.2, "start": {"x": 10.0, "y": 0.0}, "end": {"x": 15.0, "y": 0.0}},+ {"net": "C", "layer": "F.Cu", "width": 0.2, "start": {"x": 10.4, "y": -3.0}, "end": {"x": 10.4, "y": -0.45}}],+ "vias": [{"net": "A", "size": 0.6, "drill": 0.3, "position": {"x": 0.0, "y": 0.0}}, {"net": "B", "size": 0.6, "drill": 0.3, "position": {"x": 10.0, "y": 0.0}}],+ "pads": []});+ let r = teardrops(&json!({"board": b, "args": {}}));+ assert_eq!(r["count"], json!(1), "{r}");+ assert_eq!(r["teardrops"][0]["at"], "via");+ assert_eq!(r["teardrops"][0]["net"], "A");+ // B's teardrop would come too near C's track+ assert_eq!(r["skipped"][0]["net"], "B");+ assert!(r["skipped"][0]["why"].as_str().unwrap().contains("C"));+ }+}
tests/run_verb_tests.py+2@@ -452,6 +452,8 @@ t("fusion_not_supported#a365", "negative", {"verb": "a365_status"}, {"pred": "mu t("fusion_set_models", "daily", {"libraryPath": "C:/Users/olive/Documents/fusion-lane-tests/import/lane-import.lbr", "symbolName": "LDO-LANE", "footprint": "SOT-23-5-LANE", "dryRun": True}, {"ok": True, "fields": ["data"]}, "one footprint kept (dry run)") t("fusion_add_models", "daily", {"libraryPath": "C:/Users/olive/Documents/fusion-lane-tests/import/lane-import.lbr", "symbolName": "LDO-LANE", "models": [{"name": "SOT-23-5-LANE"}], "dryRun": True}, {"ok": True, "fields": ["data"]}, "an attached footprint is skipped, not stacked (dry run)") t("fusion_swap_footprint", "daily", {"libraryPath": BOARDLIB, "footprintName": "SOT23", "pcbPath": BOARDDIR + "/test.brd", "designator": "U1", "assigns": "1=IN;2=GND;3=OUT", "open": False}, {"ok": True, "fields": ["swap"]}, "U1 on the battery's test board takes SOT23 (offline)")+t("fusion_teardrops", "daily", dict(B, dryRun=True), {"ok": True, "fields": ["teardrops", "skipped", "engine"]}, "the engine's teardrops for BQ25792 (dry run: 87 planned, 5 skipped with why)")+t("fusion_panelize", "daily", dict(B, rows=2, cols=2, separation="vscore", dryRun=True), {"ok": True, "fields": ["panelMm", "copyShift", "jlcpcb", "engine"]}, "a 2 x 2 V-score panel plan with JLCPCB's rules (dry run)") t("fusion_build_board", "daily", {"libraryPath": BOARDLIB, "pcbPath": BOARDDIR + "/build.brd", "parts": [{"ref": "R1", "footprint": "R0603", "x": 5, "y": 5}, {"ref": "Q1", "footprint": "SOT23", "x": 12, "y": 5}], "nets": {"GND": ["R1.2", "Q1.2"]}, "outline": {"rect": [0, 0, 18, 10], "cornerRadius": 1}, "open": False}, {"ok": True, "fields": ["pcbPath", "unknownPins"]}, "written offline (open:false keeps the battery from opening a design each run)") t("fusion_build_board#missing", "negative", {"libraryPath": BOARDLIB, "pcbPath": BOARDDIR + "/bad.brd", "parts": [{"ref": "U1", "footprint": "NO_SUCH_FP", "x": 0, "y": 0}], "open": False}, {"pred": "must_refuse"}, "a footprint missing from the library refuses the whole build") t("fusion_test_board", "daily", {"libraryPath": BOARDLIB, "footprintName": "R0603;SOT23", "pcbPath": BOARDDIR + "/test.brd", "open": False}, {"ok": True, "fields": ["pcb", "placed", "saved"]}, "test.brd is made on the first run and appended to after (designators continue); written offline")