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 21 file(s)
21 files changed
+351−62
BRIDGE_VERSION+1−1CHANGELOG.md+12bridge.json+1−1contracts/describe.json+4−2dashboard/Cargo.lock+1−1dashboard/Cargo.toml+1−1describe.py+2−2install.sh+1−1package.json+1−1page.json+3−6rust/Cargo.lock+6−6rust/Cargo.toml+1−1rust/bridge/src/bodies.rs+72−8rust/bridge/src/spice.rs+134−6rust/vendor/adom-eda-checks/Cargo.toml+1−1rust/vendor/adom-eda-checks/README.md+3−3rust/vendor/adom-eda-checks/VENDOR.md+3−3rust/vendor/adom-eda-checks/src/bodies.rs+84−11rust/vendor/adom-eda-checks/src/landpattern.rs+16−6rust/vendor/adom-eda-checks/src/placement.rs+2−1tests/run_verb_tests.py+2BRIDGE_VERSION+1−1@@ -1 +1 @@-1.13.19+1.13.20
CHANGELOG.md+12@@ -3,6 +3,18 @@ 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.20 (2026-10-29)++- Engine 0.20.0 (standard 0.23.0). `fusion_bodies` gives a part turned off the axes its rectangle along its own axes+ (`cx, cy, w, h, rotation`, measured by Fusion's oriented bounding box along the part's own x axis) beside the world box,+ so height_check, placement_check and the other body checks stop counting the box's empty corners. A SOT-23-5 at 45+ degrees: world box 4.07 x 4.07 mm, its own rectangle 2.8 x 2.9.+- `fusion_simulate`: `analysis {type: noise, source, output, reference?, sweep, points, start, stop}` answers the output+ and input noise spectra (`onoise`, `inoise`, V/rtHz) and their integrated `totals` (an RC: sqrt(4kTR) at 10 Hz,+ 2.03e-7 V integrated against kT/C's 2.04e-7). `monteCarlo {runs, distribution, seed, tolerances}` draws every R, C and L+ with a plain value within its tolerance (uniform, or gaussian with sigma a third of it), a signal per run with the drawn+ values as its step; the same seed draws the same values.+ ## 1.13.19 (2026-10-29) - review_board, board_report, pin_pad_check and schematic_review give the engine each part's attributes as
bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "fusion360", "displayName": "Autodesk Fusion 360",- "version": "1.13.19",+ "version": "1.13.20", "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.",
contracts/describe.json+4−2@@ -21558,10 +21558,11 @@ ] }, "input": {- "analysis": "{type: op} | {type: dc, source, start, stop, step} | {type: ac, sweep: dec|oct|lin, points, start, stop} | {type: tran, step, stop, start?} | {type: raw, text}",+ "analysis": "{type: op} | {type: dc, source, start, stop, step} | {type: ac, sweep: dec|oct|lin, points, start, stop} | {type: tran, step, stop, start?} | {type: noise, source, output, reference?, sweep, points, start, stop} | {type: raw, text}", "expectDocument": "the design", "maxProbes": "default 32", "maxRuns": "default 50",+ "monteCarlo": "{runs, distribution: uniform|gaussian, seed, tolerances: {resistor, capacitor, inductor} %}: every R/C/L with a plain value drawn, a signal per run with the drawn values as step", "netlistPath": "a .net/.cir on the Fusion machine instead of the design", "points": "per signal, default 200 (a ceiling: first, last and extrema kept)", "probes": "[{net} | {current: source}]",@@ -21580,7 +21581,8 @@ "notSimulated": "[ref]", "rows": "int", "runs": "int",- "signals": "[{signal, x, y, xUnit, yUnit, phase?, step?}]"+ "signals": "[{signal, x, y, xUnit, yUnit, phase?, step?}]",+ "totals": "noise: {onoise, inoise} V (a list with step when there are several runs)" }, "statusVerb": null, "summary": "Simulate the design (the standard's simulate) with the ngspice Fusion bundles (the engine Fusion's SIM runs), without Fusion's simulation dialog: the design's SPICE netlist (fusion_export_spice) gets the analysis and a .control block, and ngspice runs it in batch mode in a child process. analysis op, dc, ac (dec, oct, lin), tran or raw SPICE text; probes by net or source current (default every net, up to maxProbes); signals come back as x/y data, AC as dB with the unwrapped phase in degrees. sweep steps a part's value (R4) or a model parameter (Q1.BF) and temperatures the temperature, one run each (ngspice alter/altermod/option temp in one batch), each run its own signal with step (\"R4 = 300, T = 85\"). A partly modelled sheet runs and names the parts it left out in notSimulated; one with no modelled part refuses with no_model. netlistPath simulates a netlist file instead.",
dashboard/Cargo.lock+1−1@@ -4,7 +4,7 @@ version = 4 [[package]] name = "fusion-dashboard"-version = "1.13.19"+version = "1.13.20" dependencies = [ "serde_json", ]
dashboard/Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.13.19"+version = "1.13.20" edition = "2021" [dependencies]
describe.py+2−2@@ -511,8 +511,8 @@ _T = [ {"path": "str", "dialect": "ngspice", "elements": "int", "unmapped": "[part]", "analyses": "[line]", "includes": "[line]"}, 120, None, False, {}), ('fusion_simulate', "Simulate the design (the standard's simulate) with the ngspice Fusion bundles (the engine Fusion's SIM runs), without Fusion's simulation dialog: the design's SPICE netlist (fusion_export_spice) gets the analysis and a .control block, and ngspice runs it in batch mode in a child process. analysis op, dc, ac (dec, oct, lin), tran or raw SPICE text; probes by net or source current (default every net, up to maxProbes); signals come back as x/y data, AC as dB with the unwrapped phase in degrees. sweep steps a part's value (R4) or a model parameter (Q1.BF) and temperatures the temperature, one run each (ngspice alter/altermod/option temp in one batch), each run its own signal with step (\"R4 = 300, T = 85\"). A partly modelled sheet runs and names the parts it left out in notSimulated; one with no modelled part refuses with no_model. netlistPath simulates a netlist file instead.",- {"expectDocument": "the design", "analysis": "{type: op} | {type: dc, source, start, stop, step} | {type: ac, sweep: dec|oct|lin, points, start, stop} | {type: tran, step, stop, start?} | {type: raw, text}", "probes": "[{net} | {current: source}]", "points": "per signal, default 200 (a ceiling: first, last and extrema kept)", "maxProbes": "default 32", "sweep": "{parameter: R4 | Q1.BF, start, stop, step, scale?: lin|dec|oct}", "temperatures": "{start, stop, step} degC", "maxRuns": "default 50", "netlistPath": "a .net/.cir on the Fusion machine instead of the design", "timeoutSec": "default 120"},- {"signals": "[{signal, x, y, xUnit, yUnit, phase?, step?}]", "missing": "[probe]", "notSimulated": "[ref]", "available": "[net]", "engine": "str", "analysis": "str", "rows": "int", "runs": "int", "netlist": "path"},+ {"expectDocument": "the design", "analysis": "{type: op} | {type: dc, source, start, stop, step} | {type: ac, sweep: dec|oct|lin, points, start, stop} | {type: tran, step, stop, start?} | {type: noise, source, output, reference?, sweep, points, start, stop} | {type: raw, text}", "probes": "[{net} | {current: source}]", "points": "per signal, default 200 (a ceiling: first, last and extrema kept)", "maxProbes": "default 32", "sweep": "{parameter: R4 | Q1.BF, start, stop, step, scale?: lin|dec|oct}", "temperatures": "{start, stop, step} degC", "monteCarlo": "{runs, distribution: uniform|gaussian, seed, tolerances: {resistor, capacitor, inductor} %}: every R/C/L with a plain value drawn, a signal per run with the drawn values as step", "maxRuns": "default 50", "netlistPath": "a .net/.cir on the Fusion machine instead of the design", "timeoutSec": "default 120"},+ {"signals": "[{signal, x, y, xUnit, yUnit, phase?, step?}]", "missing": "[probe]", "notSimulated": "[ref]", "totals": "noise: {onoise, inoise} V (a list with step when there are several runs)", "available": "[net]", "engine": "str", "analysis": "str", "rows": "int", "runs": "int", "netlist": "path"}, 300, None, False, {'analysis': {'type': 'ac', 'sweep': 'dec', 'points': 20, 'start': 10, 'stop': 1000000}, 'probes': [{'net': 'OUT'}]}), ('fusion_sch_place_part', "Place a part on the schematic (the standard's sch_place_part): a deviceset from a .lbr on the Fusion machine at x, y (mil) and a rotation, with its designator (R? or none: the next free number) and value (comment). footprint picks the variant whose package has that name. The device is proven in the .lbr and the library USEd first, and ADD's questions are answered, so no picker or dialog opens; read back as the one part added, or undone. Fusion adds it to the board too.", {"expectDocument": "the design", "library": ".lbr path on the Fusion machine (required)", "symbol": "the deviceset (required)", "designator": "e.g. R12 or R? (default the deviceset's prefix, next free)", "comment": "the value", "footprint": "a package name of the deviceset's variants", "x": "mil, default 1000", "y": "mil, default 1000", "rotation": "0|90|180|270", "sheet": "int, default the one shown", "dryRun": "bool"},
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.19"+DASH_VERSION="1.13.20" 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.19",+ "version": "1.13.20", "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+3−6@@ -1,6 +1,6 @@ { "slug": "fusion-bridge",- "version": "1.13.19",+ "version": "1.13.20", "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": {@@ -148,8 +148,5 @@ "author": { "name": "John Lauer", "email": "[email protected]"- },- "confirmed_user_skills": [- "fusion-design-efficiency"- ]-}+ }+}
rust/Cargo.lock+6−6@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "adom-eda-checks"-version = "0.19.0"+version = "0.20.0" dependencies = [ "roxmltree", "serde_json",@@ -19,7 +19,7 @@ dependencies = [ [[package]] name = "adom-fusion-bridge"-version = "1.13.19"+version = "1.13.20" dependencies = [ "adom-eda-checks", "base64",@@ -299,7 +299,7 @@ dependencies = [ [[package]] name = "fusion-core"-version = "1.13.19"+version = "1.13.20" dependencies = [ "serde", "serde_json",@@ -310,7 +310,7 @@ dependencies = [ [[package]] name = "fusion-lbr"-version = "1.13.19"+version = "1.13.20" dependencies = [ "regex", "roxmltree",@@ -322,14 +322,14 @@ dependencies = [ [[package]] name = "fusion-os-mac"-version = "1.13.19"+version = "1.13.20" dependencies = [ "fusion-core", ] [[package]] name = "fusion-os-win"-version = "1.13.19"+version = "1.13.20" 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.19"+version = "1.13.20" edition = "2021" license = "MIT"
rust/bridge/src/bodies.rs+72−8@@ -15,15 +15,45 @@ else: rc = des.rootComponent def box(b): return [round(v * 10, 4) for v in (b.minPoint.x, b.minPoint.y, b.minPoint.z, b.maxPoint.x, b.maxPoint.y, b.maxPoint.z)]- def boxes_of(occ):- # one box per occurrence that holds shown bodies: the bodies' union, then its sub-occurrences+ import math+ def turn_of(occ):+ # the part's own x axis in the board plane: its turn in degrees (a bottom part's flip included)+ try:+ m = occ.transform2+ return math.degrees(math.atan2(m.getCell(1, 0), m.getCell(0, 0)))+ except Exception:+ return 0.0+ def turned(bodies, deg):+ # the bodies' union as a rectangle along the part's own axes: [cx, cy, w, h] in mm, or None+ a = math.radians(deg)+ u = adsk.core.Vector3D.create(math.cos(a), math.sin(a), 0)+ v = adsk.core.Vector3D.create(-math.sin(a), math.cos(a), 0)+ lu = lv = 1e9; hu = hv = -1e9+ for b in bodies:+ ob = app.measureManager.getOrientedBoundingBox(b, u, v)+ if ob is None:+ return None+ c = ob.centerPoint+ cu = c.x * u.x + c.y * u.y; cv = c.x * v.x + c.y * v.y+ lu = min(lu, cu - ob.length / 2); hu = max(hu, cu + ob.length / 2)+ lv = min(lv, cv - ob.width / 2); hv = max(hv, cv + ob.width / 2)+ mu, mv = (lu + hu) / 2, (lv + hv) / 2+ return [round((mu * u.x + mv * v.x) * 10, 4), round((mu * u.y + mv * v.y) * 10, 4), round((hu - lu) * 10, 4), round((hv - lv) * 10, 4)]+ def boxes_of(occ, deg):+ # one box per occurrence that holds shown bodies: the bodies' union, then its sub-occurrences;+ # a part turned off the axes adds its rectangle along its own axes, [cx, cy, w, h] out = []- own = [box(b.boundingBox) for b in occ.bRepBodies if b.isLightBulbOn and b.boundingBox]+ shown = [b for b in occ.bRepBodies if b.isLightBulbOn and b.boundingBox]+ own = [box(b.boundingBox) for b in shown] if own:- out.append([min(b[i] for b in own) for i in range(3)] + [max(b[i] for b in own) for i in range(3, 6)])+ row = [min(b[i] for b in own) for i in range(3)] + [max(b[i] for b in own) for i in range(3, 6)]+ if abs(((deg + 45) % 90) - 45) > 0.5:+ t = turned(shown, deg)+ if t: row += t+ out.append(row) for ch in occ.childOccurrences: if ch.isLightBulbOn:- out += boxes_of(ch)+ out += boxes_of(ch, deg) return out board = None parts = []@@ -34,8 +64,20 @@ else: board = box(core[0].boundingBox) if o.component.name == 'Packages': for c in o.childOccurrences:- parts.append({'ref': c.name.rsplit(':', 1)[-1], 'package': c.name.rsplit(':', 1)[0], 'box': box(c.boundingBox) if c.boundingBox else None,- 'boxes': boxes_of(c), 'visible': c.isLightBulbOn})+ deg = turn_of(c)+ bxs = boxes_of(c, deg)+ row = {'ref': c.name.rsplit(':', 1)[-1], 'package': c.name.rsplit(':', 1)[0], 'box': box(c.boundingBox) if c.boundingBox else None,+ 'boxes': bxs, 'visible': c.isLightBulbOn, 'rotation': round(deg, 3)}+ if abs(((deg + 45) % 90) - 45) > 0.5:+ # the whole part along its own axes, over every shown body it holds+ allb = []+ def walk(x):+ allb.extend(b for b in x.bRepBodies if b.isLightBulbOn and b.boundingBox)+ for ch in x.childOccurrences:+ if ch.isLightBulbOn: walk(ch)+ walk(c)+ row['turned'] = turned(allb, deg) if allb else None+ parts.append(row) result = {'board': board, 'parts': parts, 'document': app.activeDocument.name} "#; @@ -62,6 +104,10 @@ pub fn shape(read: &Value, components: &[Value], through: &std::collections::Has let (z0, z1) = if side == "bottom" { (bottom - b[5], bottom - b[2]) } else { (b[2] - top, b[5] - top) }; let r3 = |v: f64| (v * 1000.0).round() / 1000.0; let mut row = json!({"ref": r, "side": side, "x0": r3(b[0]), "y0": r3(b[1]), "x1": r3(b[3]), "y1": r3(b[4]), "z0": r3(z0), "z1": r3(z1), "package": p["package"]});+ // a part turned off the axes: its rectangle along its own axes, so the engine does not count the+ // box's empty corners (standard engine 0.20.0)+ let turned = |row: &mut Value, t: &[f64]| { if t.len() == 4 { for (k, v) in [("cx", t[0]), ("cy", t[1]), ("w", t[2]), ("h", t[3])] { row[k] = json!(r3(v)); } row["rotation"] = json!(r3(f(&p["rotation"]))); } };+ if let Some(t) = p["turned"].as_array() { turned(&mut row, &t.iter().map(f).collect::<Vec<_>>()); } let tht = through.contains(r); if tht { // leads through the board: how far they stand out of the far side matters to an enclosure@@ -70,12 +116,14 @@ pub fn shape(read: &Value, components: &[Value], through: &std::collections::Has row["leadsPastFarSideMm"] = json!(r3(past.max(0.0))); } // an assembly model: one row per sub-box, each with its own height range on the part's side- let subs: Vec<Vec<f64>> = p["boxes"].as_array().map(|a| a.iter().filter_map(|x| x.as_array().map(|v| v.iter().map(f).collect::<Vec<f64>>())).filter(|v| v.len() == 6).collect()).unwrap_or_default();+ let subs: Vec<Vec<f64>> = p["boxes"].as_array().map(|a| a.iter().filter_map(|x| x.as_array().map(|v| v.iter().map(f).collect::<Vec<f64>>())).filter(|v| v.len() == 6 || v.len() == 10).collect()).unwrap_or_default(); if subs.len() > 1 { for s in &subs { let (z0, z1) = if side == "bottom" { (bottom - s[5], bottom - s[2]) } else { (s[2] - top, s[5] - top) }; let mut sr = row.clone();+ for k in ["cx", "cy", "w", "h", "rotation"] { if let Some(o) = sr.as_object_mut() { o.remove(k); } } for (k, v) in [("x0", s[0]), ("y0", s[1]), ("x1", s[3]), ("y1", s[4]), ("z0", z0), ("z1", z1)] { sr[k] = json!(r3(v)); }+ if s.len() == 10 { turned(&mut sr, &s[6..10]); } bodies.push(sr); } } else {@@ -162,6 +210,22 @@ mod tests { assert_eq!(j1["throughHole"], true); assert_eq!(j1["leadsPastFarSideMm"], 1.695); }+ #[test]+ fn a_turned_part_carries_its_own_rectangle() {+ // a 4 x 4 mm QFN turned 45 degrees: its world box is 5.66 square, its own rectangle 4 x 4+ let read = json!({"board": [0, 0, 0, 30, 30, 1.5], "parts": [+ {"ref": "U1", "package": "QFN", "box": [7.17, 7.17, 1.5, 12.83, 12.83, 2.4], "rotation": 45.0, "turned": [10.0, 10.0, 4.0, 4.0]},+ {"ref": "R1", "package": "R", "box": [1, 1, 1.5, 2, 1.5, 1.9], "rotation": 90.0}]});+ let comps = vec![json!({"ref": "U1", "x": 10.0, "y": 10.0, "side": "top"}), json!({"ref": "R1", "x": 1.5, "y": 1.25, "side": "top"})];+ let r = shape(&read, &comps, &Default::default());+ let u1 = &r["bodies"][0];+ assert_eq!((u1["cx"].as_f64(), u1["w"].as_f64(), u1["rotation"].as_f64(), u1["x0"].as_f64()), (Some(10.0), Some(4.0), Some(45.0), Some(7.17)));+ assert!(r["bodies"][1]["cx"].is_null(), "a part on the axes is its box");+ // the engine reads it as the turned rectangle+ let b = adom_eda_checks::run("height_check", &json!({"bodies": r["bodies"], "args": {}}));+ assert_ne!(b["success"], json!(false), "{b}");+ }+ #[test] fn a_molecule_is_a_box_per_sub_assembly() { // the USB-C molecule on BQ25792: its own board rides 1 mm above the main board on pins, its
rust/bridge/src/spice.rs+134−6@@ -42,8 +42,19 @@ fn analysis_line(a: &Value) -> Result<String, Value> { (Some(_), Some(_)) => Ok(format!(".tran {} {}{}", g("step"), g("stop"), num(a, "start").map(|s| format!(" {s}")).unwrap_or_default())), _ => Err(bad("tran needs step and stop (s)")), },+ "noise" => {+ let sweep = a["sweep"].as_str().unwrap_or("dec");+ if !matches!(sweep, "dec" | "oct" | "lin") { return Err(bad("noise sweep is dec, oct or lin")); }+ match (a["source"].as_str(), a["output"].as_str(), num(a, "points"), num(a, "start"), num(a, "stop")) {+ (Some(src), Some(out), Some(p), Some(_), Some(_)) => {+ let node = match a["reference"].as_str() { Some(r) => format!("v({},{})", out.to_lowercase(), r.to_lowercase()), None => format!("v({})", out.to_lowercase()) };+ Ok(format!(".noise {node} {src} {sweep} {} {} {}", p as u64, g("start"), g("stop")))+ }+ _ => Err(bad("noise needs source (the input source), output (a net), points, start and stop (Hz)")),+ }+ } "raw" => a["text"].as_str().filter(|t| t.trim_start().starts_with('.')).map(|t| t.trim().to_string()).ok_or_else(|| bad("raw needs text, a SPICE analysis line such as .ac dec 20 10 1meg")),- t => Err(bad(&format!("analysis type {t} is op, dc, ac, tran or raw"))),+ t => Err(bad(&format!("analysis type {t} is op, dc, ac, tran, noise or raw"))), } } @@ -74,6 +85,65 @@ pub fn decimate(x: &[f64], ys: &[Vec<f64>], n: usize) -> Vec<usize> { v } +/// The netlist's instance for a part as the caller names it: EAGLE's netlist calls R4 R_R4.+fn instance(body: &[String], part: &str) -> Option<String> {+ body.iter().filter_map(|l| l.split_whitespace().next()).find(|n| n.eq_ignore_ascii_case(part) || n.to_uppercase().ends_with(&format!("_{}", part.to_uppercase()))).map(str::to_lowercase)+}++/// A SPICE value: 4.7k, 100n, 1meg, 10 (unit letters after the scale are ignored, as ngspice does).+pub fn spice_value(t: &str) -> Option<f64> {+ let t = t.trim().to_lowercase();+ let end = t.find(|c: char| !(c.is_ascii_digit() || c == '.' || c == '-' || c == '+' || c == 'e')).unwrap_or(t.len());+ // an exponent's e is part of the number only when digits follow it+ let (mut num, mut rest) = (&t[..end], &t[end..]);+ if num.ends_with('e') { num = &num[..num.len() - 1]; rest = &t[end - 1..]; }+ num.parse::<f64>().ok()?;+ // the scale as a decimal exponent, so 100n parses to exactly 1e-7+ let exp = if rest.starts_with("meg") { 6 } else { match rest.chars().next() { Some('f') => -15, Some('p') => -12, Some('n') => -9, Some('u') => -6, Some('m') => -3, Some('k') => 3, Some('g') => 9, Some('t') => 12, _ => 0 } };+ let (mant, e0) = match num.split_once('e') { Some((m, e)) => (m, e.parse::<i32>().ok()?), None => (num, 0) };+ format!("{mant}e{}", e0 + exp).parse().ok()+}++/// splitmix64: the same seed draws the same values on every machine.+struct Rng(u64);+impl Rng {+ fn next(&mut self) -> f64 { self.0 = self.0.wrapping_add(0x9E3779B97F4A7C15); let mut z = self.0; z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9); z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB); ((z ^ (z >> 31)) >> 11) as f64 / (1u64 << 53) as f64 }+}++/// Monte Carlo runs: every R, C and L with a plain value drawn within its tolerance (uniform, or+/// gaussian with sigma a third of it, clipped to it), applied with alter; the step names the values.+fn monte_carlo(mc: &Value, body: &[String]) -> Result<Vec<(String, Vec<String>)>, String> {+ let n = mc["runs"].as_u64().filter(|n| *n > 0).ok_or("monteCarlo.runs is the number of runs (1 or more)")? as usize;+ let dist = mc["distribution"].as_str().unwrap_or("uniform");+ if !matches!(dist, "uniform" | "gaussian") { return Err(format!("monteCarlo.distribution is uniform or gaussian, not {dist}")); }+ let tol = |k: char| -> Option<f64> { let t = &mc["tolerances"]; [("resistor", 'r'), ("capacitor", 'c'), ("inductor", 'l')].iter().find(|(_, c)| *c == k).and_then(|(name, _)| t[*name].as_f64()).map(|p| p / 100.0) };+ let parts: Vec<(String, String, f64, f64)> = body.iter().filter_map(|l| {+ let t: Vec<&str> = l.split_whitespace().collect();+ let k = t.first()?.chars().next()?.to_ascii_lowercase();+ let tl = tol(k)?;+ let v = spice_value(t.get(3)?)?;+ let name = t[0].split_once('_').map(|(_, r)| r).unwrap_or(t[0]).to_string();+ Some((t[0].to_lowercase(), name, v, tl))+ }).collect();+ if parts.is_empty() { return Err("monteCarlo: no resistor, capacitor or inductor with a tolerance and a plain value in the netlist".into()); }+ let mut rng = Rng(mc["seed"].as_u64().unwrap_or(1));+ let mut out = Vec::new();+ for k in 0..n {+ let (mut set, mut drawn) = (Vec::new(), Vec::new());+ for (inst, name, v, tl) in &parts {+ let d = if dist == "uniform" { (rng.next() * 2.0 - 1.0) * tl } else {+ let (u1, u2) = (rng.next().max(1e-300), rng.next());+ ((-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos() * tl / 3.0).clamp(-tl, *tl)+ };+ let x = { let x = v * (1.0 + d); let m = 10f64.powi(5 - x.abs().log10().ceil() as i32); (x * m).round() / m };+ set.push(format!("alter {inst} = {x}"));+ drawn.push(format!("{name} = {x}"));+ }+ out.push((format!("run {}: {}", k + 1, drawn.join(", ")), set));+ }+ Ok(out)+}+ /// The values a sweep steps through: lin by the increment, dec and oct by points per decade or octave. fn sweep_values(start: f64, stop: f64, step: f64, scale: &str) -> Result<Vec<f64>, String> { if !(start.is_finite() && stop.is_finite() && step.is_finite()) || step <= 0.0 { return Err("start, stop and a step above 0 are required".into()); }@@ -121,6 +191,10 @@ fn runs(args: &Value, body: &[String]) -> Result<Vec<(Option<String>, Vec<String let vals = sweep_values(num(t, "start").unwrap_or(f64::NAN), num(t, "stop").unwrap_or(f64::NAN), num(t, "step").unwrap_or(f64::NAN), "lin").map_err(|e| bad(format!("temperatures: {e}")))?; temps = vals.iter().map(|v| (format!("T = {v}"), vec![format!("option temp = {v}")])).collect(); }+ if args["monteCarlo"].is_object() {+ if !sweep.is_empty() { return Err(bad("monteCarlo and sweep together: pick one (a Monte Carlo run already varies every part)".into())); }+ sweep = monte_carlo(&args["monteCarlo"], body).map_err(bad)?;+ } let out: Vec<(Option<String>, Vec<String>)> = match (sweep.is_empty(), temps.is_empty()) { (true, true) => vec![(None, vec![])], (false, true) => sweep.into_iter().map(|(l, c)| (Some(l), c)).collect(),@@ -156,7 +230,7 @@ pub fn simulate(args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value { "notSimulated": unmapped, "netlist": path, "_hint": "Use simulation-capable parts (Fusion's ngspice-simulation library) or map them (MAPTOMODEL)."}); } // the deck: the netlist's devices and models, the analysis, and a .control block that writes the probes- let given = lines.iter().filter(|l| l.to_lowercase().starts_with(".op") || l.to_lowercase().starts_with(".tran") || l.to_lowercase().starts_with(".ac") || l.to_lowercase().starts_with(".dc")).cloned().collect::<Vec<_>>();+ let given = lines.iter().filter(|l| l.to_lowercase().starts_with(".op") || l.to_lowercase().starts_with(".tran") || l.to_lowercase().starts_with(".ac") || l.to_lowercase().starts_with(".dc") || l.to_lowercase().starts_with(".noise")).cloned().collect::<Vec<_>>(); let an = if args["analysis"].is_object() { match analysis_line(&args["analysis"]) { Ok(l) => l, Err(e) => return e } } else { given.first().cloned().unwrap_or_else(|| ".op".into()) }; let kind = an.trim_start_matches('.').split_whitespace().next().unwrap_or("op").to_lowercase(); let mut body: Vec<String> = Vec::new(); let mut in_ctl = false;@@ -167,6 +241,13 @@ pub fn simulate(args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value { if in_ctl || ll == ".end" || l.starts_with('_') || given.contains(l) { continue; } body.push(l.clone()); }+ // a noise analysis names its input source as the caller knows it (V1); the netlist calls it V_V1+ let an = if kind == "noise" {+ let mut t: Vec<String> = an.split_whitespace().map(str::to_string).collect();+ if t.len() > 2 { if let Some(i) = instance(&body, &t[2]) { t[2] = i; } else { return json!({"success": false, "errorCode": "bad_args", "error": format!("noise source {} is not in the netlist", t[2])}); } }+ t.join(" ")+ } else { an };+ let noise = kind == "noise"; let all = nodes(&lines); let cap = args["maxProbes"].as_u64().unwrap_or(32) as usize; let mut probes: Vec<(String, String)> = Vec::new(); // (signal name, ngspice vector)@@ -204,9 +285,16 @@ pub fn simulate(args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value { let outs: Vec<PathBuf> = (0..runs.len()).map(|i| dir.join(format!("signals-{i}.txt"))).collect(); // ngspice has no .step: each run alters the circuit, runs and writes its own file let mut ctl = String::from(".control\nset wr_singlescale\nset wr_vecnames\nset numdgt=10\n");+ let tots: Vec<PathBuf> = (0..runs.len()).map(|i| dir.join(format!("totals-{i}.txt"))).collect(); for (i, (_, set)) in runs.iter().enumerate() { for l in set { ctl.push_str(l); ctl.push('\n'); }- ctl.push_str(&format!("run\nwrdata {} {}\n", outs[i].to_string_lossy().replace('\\', "/"), vecs.join(" ")));+ if noise {+ // each noise run makes two plots, the spectrum and the integrated totals, numbered in order+ ctl.push_str(&format!("run\nsetplot noise{}\nwrdata {} onoise_spectrum inoise_spectrum\nsetplot noise{}\nwrdata {} onoise_total inoise_total\n",+ 2 * i + 1, outs[i].to_string_lossy().replace('\\', "/"), 2 * i + 2, tots[i].to_string_lossy().replace('\\', "/")));+ } else {+ ctl.push_str(&format!("run\nwrdata {} {}\n", outs[i].to_string_lossy().replace('\\', "/"), vecs.join(" ")));+ } } let deck_text = format!("{}\n{an}\n{ctl}.endc\n.end\n", body.join("\n")); if let Err(e) = std::fs::write(&deck, &deck_text) { return json!({"success": false, "errorCode": "write_failed", "error": e.to_string()}); }@@ -227,7 +315,8 @@ pub fn simulate(args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value { let version = logtext.split("ngspice-").nth(1).and_then(|s| s.split(|c: char| !c.is_ascii_digit() && c != '.').next()).filter(|s| !s.is_empty()).map(str::to_string) .or_else(|| ng.parent().and_then(|b| b.parent()).and_then(|r| std::fs::read_dir(r.join("docs")).ok()).and_then(|d| d.flatten().find_map(|f| f.file_name().to_string_lossy().strip_prefix("ngspice-").and_then(|s| s.split('-').next()).map(str::to_string)))); let engine = format!("ngspice {} (Fusion's bundled LibEagle/ngspice)", version.unwrap_or_else(|| "?".into()));- let (x_unit, y_unit) = match kind.as_str() { "ac" => ("Hz", "dB"), "tran" => ("s", "V"), "dc" => ("V", "V"), _ => ("", "V") };+ let (x_unit, y_unit) = match kind.as_str() { "ac" => ("Hz", "dB"), "noise" => ("Hz", "V/rtHz"), "tran" => ("s", "V"), "dc" => ("V", "V"), _ => ("", "V") };+ let mut totals: Vec<Value> = Vec::new(); let n = args["points"].as_u64().unwrap_or(200) as usize; let per = if ac { 2 } else { 1 }; let (mut signals, mut rows_n, mut kept) = (Vec::new(), 0usize, 0usize);@@ -242,6 +331,22 @@ pub fn simulate(args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value { if cols.is_empty() { return json!({"success": false, "errorCode": "simulation_failed", "error": "ngspice wrote no rows", "engine": engine}); } let col = |j: usize| cols.iter().map(|r| r[j]).collect::<Vec<f64>>(); let x = col(0);+ if noise {+ let ys: Vec<Vec<f64>> = (1..=2).map(col).collect();+ let keep = decimate(&x, &ys, n);+ rows_n = rows_n.max(x.len()); kept = kept.max(keep.len());+ // the totals plot: the last numbers written are onoise_total and inoise_total (V)+ let tv: Vec<f64> = std::fs::read_to_string(&tots[i]).unwrap_or_default().lines().last().unwrap_or("").split_whitespace().filter_map(|t| t.parse().ok()).collect();+ let tot = if tv.len() >= 2 { json!({"onoise": tv[tv.len() - 2], "inoise": tv[tv.len() - 1]}) } else { Value::Null };+ for (k, name) in ["onoise", "inoise"].iter().enumerate() {+ let mut s = json!({"signal": name, "x": keep.iter().map(|&j| x[j]).collect::<Vec<_>>(), "y": keep.iter().map(|&j| ys[k][j]).collect::<Vec<_>>(), "xUnit": x_unit, "yUnit": y_unit});+ if let Some(l) = label { s["step"] = json!(l); }+ signals.push(s);+ }+ let mut t = tot; if let (Some(l), true) = (label, t.is_object()) { t["step"] = json!(l); }+ totals.push(t);+ continue;+ } let ys: Vec<Vec<f64>> = (0..probes.len()).map(|k| col(1 + k * per)).collect(); let keep = decimate(&x, &ys, n); rows_n = rows_n.max(x.len()); kept = kept.max(keep.len());@@ -254,9 +359,12 @@ pub fn simulate(args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value { signals.push(s); } }- json!({"success": true, "mutated": false, "analysis": an, "signals": signals, "missing": missing, "notSimulated": unmapped, "available": all, "rows": rows_n, "points": kept, "runs": runs.len(),+ let mut out = json!({"success": true, "mutated": false, "analysis": an, "signals": signals, "missing": missing, "notSimulated": unmapped, "available": all, "rows": rows_n, "points": kept, "runs": runs.len(), "engine": engine, "netlist": path, "deck": deck, "export": export, "elapsedSec": (started.elapsed().as_secs_f64() * 100.0).round() / 100.0,- "_hint": "signals are ngspice's own output for the design's SPICE netlist (EAGLE's EXPORT SPICENETLIST); points is a ceiling (first, last and extrema kept). For AC, y is the magnitude in dB and phase the unwrapped phase in degrees."})+ "_hint": "signals are ngspice's own output for the design's SPICE netlist (EAGLE's EXPORT SPICENETLIST); points is a ceiling (first, last and extrema kept). For AC, y is the magnitude in dB and phase the unwrapped phase in degrees."});+ // noise: the integrated output and input noise (V), one per run when there are several+ if noise { out["totals"] = if totals.len() == 1 { totals.remove(0) } else { json!(totals) }; }+ out } #[cfg(test)]@@ -288,6 +396,26 @@ mod tests { assert_eq!(sweep_values(10.0, 1000.0, 1.0, "dec").unwrap(), vec![10.0, 100.0, 1000.0]); } + #[test]+ fn noise_lines_and_monte_carlo_draws() {+ assert_eq!(analysis_line(&json!({"type": "noise", "source": "V1", "output": "OUT", "sweep": "dec", "points": 10, "start": 10, "stop": 1e6})).unwrap(), ".noise v(out) V1 dec 10 10 1000000");+ assert!(analysis_line(&json!({"type": "noise", "source": "V1"})).is_err());+ assert_eq!((spice_value("4.7k"), spice_value("100n"), spice_value("1meg"), spice_value("10"), spice_value("1e3")), (Some(4700.0), Some(1e-7), Some(1e6), Some(10.0), Some(1000.0)));+ let body: Vec<String> = ["V_V1 IN 0 DC 0 AC 1", "R_R1 IN OUT 1k", "C_C1 OUT 0 100n"].iter().map(|s| s.to_string()).collect();+ let mc = json!({"monteCarlo": {"runs": 3, "distribution": "gaussian", "seed": 7, "tolerances": {"resistor": 1, "capacitor": 10}}});+ let a = runs(&mc, &body).unwrap();+ assert_eq!(a.len(), 3);+ assert_eq!(a, runs(&mc, &body).unwrap(), "the same seed draws the same values");+ for (label, set) in &a {+ assert!(label.as_ref().unwrap().contains("R1 = ") && label.as_ref().unwrap().contains("C1 = "));+ let r: f64 = set[0].rsplit(' ').next().unwrap().parse().unwrap();+ let c: f64 = set[1].rsplit(' ').next().unwrap().parse().unwrap();+ assert!((990.0..=1010.0).contains(&r) && (0.9e-7..=1.1e-7).contains(&c), "{set:?}");+ }+ assert_ne!(runs(&json!({"monteCarlo": {"runs": 1, "seed": 8, "tolerances": {"resistor": 5}}}), &body).unwrap(), runs(&json!({"monteCarlo": {"runs": 1, "seed": 9, "tolerances": {"resistor": 5}}}), &body).unwrap());+ assert!(runs(&json!({"monteCarlo": {"runs": 2, "tolerances": {"resistor": 1}}, "sweep": {"parameter": "R1", "start": 1, "stop": 2, "step": 1}}), &body).is_err());+ }+ #[test] fn decimation_keeps_ends_and_peaks() { let x: Vec<f64> = (0..1000).map(|i| i as f64).collect();
rust/vendor/adom-eda-checks/Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-eda-checks"-version = "0.19.0"+version = "0.20.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−3@@ -23,7 +23,7 @@ boards the engine gives the same answers the Altium bridge 0.2.76 gives. | `fab_check` | board, stack?, rules?, silk?, fab? | the board's smallest features, compared with a fab's limits from adom/pcb-design-rules; each finding names the fab's page and the date it was read | | `impedance` | stack | the track width (and pair gap) for a target impedance on each copper layer; `apply` answers `add_rule` arguments | | `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 |+| `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), a shorter column spread over the longer one's span (SOT-23-5's pins 4 and 5 level with 3 and 1), silkscreen (`overlay`) kept `silkClearance` from copper with a pin-1 dot, a `fab` outline with a pin-1 chamfer and the `courtyard` round the pads and body 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 |@@ -75,7 +75,7 @@ Every check takes one JSON object: | `board` | the board model, `<prefix>_routing_state` (`schema/board.schema.json`) | | `stack` | the stack-up companion, `<prefix>_layer_stack` | | `pours` | poured copper, `<prefix>_pour_state` (its `zones`) |-| `bodies` | the 3D bodies companion, `<prefix>_bodies`: `bodies[{ref, side, x0, y0, x1, y1, z0, z1}]` (one box per body or per part) and `noBody` |+| `bodies` | the 3D bodies companion, `<prefix>_bodies`: `bodies[{ref, side, x0, y0, x1, y1, z0, z1}]` (one box per body or per part) and `noBody`. A turned body may give its footprint as `{cx, cy, w, h, rotation}` or four `corners`; bodies are then compared by their rectangles (separating axes), not their boxes | | `rules` | the board's rules, `<prefix>_read_rules`: a clearance rule as `{kind: "clearance", clearanceMm}` (a `value` with its unit, `"0.1mm"`, works too). Only copper-to-copper clearance counts: a rule whose kind or name names the board edge, mask, silk, paste, holes, a keepout or restrict area, the courtyard or the same net is skipped | | `silk` | `<prefix>_silk_state` | | `fab` | one record from adom/pcb-design-rules `rules/records/<vendor>.json`, with `vendor` |@@ -115,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.19.0"`), so a bridge reports the engine it runs without writing the version by hand.+(`"adom-eda-checks 0.20.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.22.1-- Standard commit: b9da2c63e0fffbf6d5e184c81f6db0baa111ff1d-- Crate version: 0.19.0+- Standard version: 0.23.0+- Standard commit: fd56bbb65298d45c49240c5baae39b3235d36313+- Crate version: 0.20.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/bodies.rs+84−11@@ -9,19 +9,74 @@ use std::collections::BTreeMap; fn r3(v: f64) -> f64 { (v * 1000.0).round() / 1000.0 } fn f(v: &Value) -> f64 { v.as_f64().unwrap_or(0.0) } -/// One body as a box in mm: its part, side (top/bottom), x and y extent, and z from the board surface it sits on.+/// One body as a box in mm: its part, side (top/bottom), x and y extent, and z from the board surface it sits on; a+/// turned body also has its footprint as a rectangle's four corners (`turned`), and x/y extent is their bounding box. #[derive(Debug, Clone)]-pub struct BodyBox { pub part: String, pub top: bool, pub x0: f64, pub y0: f64, pub x1: f64, pub y1: f64, pub z0: f64, pub z1: f64 }+pub struct BodyBox { pub part: String, pub top: bool, pub x0: f64, pub y0: f64, pub x1: f64, pub y1: f64, pub z0: f64, pub z1: f64, pub turned: Option<Vec<(f64, f64)>> } -/// The bodies companion (`bodies[{ref, side, x0, y0, x1, y1, z0, z1}]`, mm) as boxes. Pure.+/// A body row's turned rectangle: `{cx, cy, w, h, rotation}` (degrees counterclockwise) or `corners` (four [x, y]).+fn turned(r: &Value) -> Option<Vec<(f64, f64)>> {+ if let Some(cs) = r["corners"].as_array().filter(|a| a.len() == 4) { return Some(cs.iter().map(|c| (f(&c[0]), f(&c[1]))).collect()); }+ if r["cx"].is_number() && r["w"].is_number() && r["h"].is_number() {+ let (cx, cy, hw, hh, a) = (f(&r["cx"]), f(&r["cy"]), f(&r["w"]) / 2.0, f(&r["h"]) / 2.0, f(&r["rotation"]).to_radians());+ let (c, s) = (a.cos(), a.sin());+ return Some([(-hw, -hh), (hw, -hh), (hw, hh), (-hw, hh)].iter().map(|(u, v)| (cx + u * c - v * s, cy + u * s + v * c)).collect());+ }+ None+}++/// The bodies companion (`bodies[{ref, side, x0, y0, x1, y1, z0, z1}]`, mm, and for a turned body `{cx, cy, w, h,+/// rotation}` or `corners`) as boxes. Pure. pub fn boxes(rows: &[Value]) -> Vec<BodyBox> {- rows.iter().filter(|r| r["ref"].as_str().map(|s| !s.is_empty()).unwrap_or(false)).map(|r| BodyBox {- part: r["ref"].as_str().unwrap_or("").to_string(), top: r["side"].as_str() != Some("bottom"),- x0: f(&r["x0"]).min(f(&r["x1"])), y0: f(&r["y0"]).min(f(&r["y1"])), x1: f(&r["x0"]).max(f(&r["x1"])), y1: f(&r["y0"]).max(f(&r["y1"])),- z0: f(&r["z0"]), z1: f(&r["z1"]),+ rows.iter().filter(|r| r["ref"].as_str().map(|s| !s.is_empty()).unwrap_or(false)).map(|r| {+ let t = turned(r);+ let (x0, y0, x1, y1) = match &t {+ Some(cs) => cs.iter().fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |b, p| (b.0.min(p.0), b.1.min(p.1), b.2.max(p.0), b.3.max(p.1))),+ None => (f(&r["x0"]).min(f(&r["x1"])), f(&r["y0"]).min(f(&r["y1"])), f(&r["x0"]).max(f(&r["x1"])), f(&r["y0"]).max(f(&r["y1"]))),+ };+ BodyBox { part: r["ref"].as_str().unwrap_or("").to_string(), top: r["side"].as_str() != Some("bottom"), x0, y0, x1, y1, z0: f(&r["z0"]), z1: f(&r["z1"]), turned: t } }).collect() } +/// How two bodies' footprints meet in x and y: (true, how deep) when they overlap, else (false, the gap between them).+/// Axis boxes compare as boxes; when either is turned, both are compared as rectangles by separating axes, so a part at+/// 45 degrees no longer counts its box's empty corners (kicad-lane #26: 70 false collisions round a turned QFN). Pure.+pub fn xy_meet(a: &BodyBox, b: &BodyBox) -> (bool, f64) {+ if a.turned.is_none() && b.turned.is_none() {+ let dx = (b.x0 - a.x1).max(a.x0 - b.x1);+ let dy = (b.y0 - a.y1).max(a.y0 - b.y1);+ return if dx < 0.0 && dy < 0.0 { (true, -(dx.max(dy))) } else { (false, (dx.max(0.0).powi(2) + dy.max(0.0).powi(2)).sqrt()) };+ }+ let poly = |x: &BodyBox| x.turned.clone().unwrap_or_else(|| vec![(x.x0, x.y0), (x.x1, x.y0), (x.x1, x.y1), (x.x0, x.y1)]);+ let (pa, pb) = (poly(a), poly(b));+ // separating axes: each edge's normal of either rectangle+ let mut sep = f64::MIN;+ for p in [&pa, &pb] {+ for i in 0..p.len() {+ let (e0, e1) = (p[i], p[(i + 1) % p.len()]);+ let (nx, ny) = (e0.1 - e1.1, e1.0 - e0.0);+ let l = (nx * nx + ny * ny).sqrt();+ if l < 1e-12 { continue; }+ let proj = |q: &Vec<(f64, f64)>| q.iter().map(|v| (v.0 * nx + v.1 * ny) / l).fold((f64::MAX, f64::MIN), |m, d| (m.0.min(d), m.1.max(d)));+ let ((a0, a1), (b0, b1)) = (proj(&pa), proj(&pb));+ sep = sep.max((b0 - a1).max(a0 - b1));+ }+ }+ if sep < 0.0 { return (true, -sep); }+ // apart: the shortest distance between their edges+ let seg = |p: (f64, f64), a: (f64, f64), b: (f64, f64)| {+ let (dx, dy) = (b.0 - a.0, b.1 - a.1);+ let l2 = dx * dx + dy * dy;+ let t = if l2 == 0.0 { 0.0 } else { (((p.0 - a.0) * dx + (p.1 - a.1) * dy) / l2).clamp(0.0, 1.0) };+ ((p.0 - a.0 - t * dx).powi(2) + (p.1 - a.1 - t * dy).powi(2)).sqrt()+ };+ let mut d = f64::MAX;+ for (p, q) in [(&pa, &pb), (&pb, &pa)] {+ for v in p.iter() { for i in 0..q.len() { d = d.min(seg(*v, q[i], q[(i + 1) % q.len()])); } }+ }+ (false, d)+}+ /// The input's bodies: the companion's `bodies`, or the companion given as a bare list. pub fn input_boxes(input: &Value) -> Option<Vec<BodyBox>> { let b = crate::input::part(input, "bodies")?;@@ -69,9 +124,7 @@ pub fn clashes(bs: &[BodyBox], gap: f64) -> (Vec<Value>, Vec<Value>) { if a.part == b.part || a.top != b.top { continue; } let dz = a.z1.min(b.z1) - a.z0.max(b.z0); if dz <= 0.0 { continue; } // one sits above the other (a part under a connector's overhang)- let dx = (b.x0 - a.x1).max(a.x0 - b.x1);- let dy = (b.y0 - a.y1).max(a.y0 - b.y1);- let (hit, d) = if dx < 0.0 && dy < 0.0 { (true, -(dx.max(dy))) } else { (false, (dx.max(0.0).powi(2) + dy.max(0.0).powi(2)).sqrt()) };+ let (hit, d) = xy_meet(a, b); if !hit && d >= gap { continue; } let key = if a.part < b.part { (a.part.clone(), b.part.clone()) } else { (b.part.clone(), a.part.clone()) }; let at = json!([r3((a.x0.max(b.x0) + a.x1.min(b.x1)) / 2.0), r3((a.y0.max(b.y0) + a.y1.min(b.y1)) / 2.0)]);@@ -135,7 +188,7 @@ mod tests { use super::*; fn bx(p: &str, top: bool, x0: f64, y0: f64, x1: f64, y1: f64, z0: f64, z1: f64) -> BodyBox {- BodyBox { part: p.into(), top, x0, y0, x1, y1, z0, z1 }+ BodyBox { part: p.into(), top, x0, y0, x1, y1, z0, z1, turned: None } } #[test]@@ -184,4 +237,24 @@ mod tests { assert_eq!(r["complete"], json!(false)); assert_eq!(height_check(&json!({"args": {}}))["errorCode"], "no_bodies"); }++ #[test]+ fn turned_bodies_meet_by_their_rectangles_not_their_boxes() {+ // a 4 mm QFN at 45 degrees and a cap in its box's empty corner: apart, not a collision (kicad-lane #26)+ let rows = vec![json!({"ref": "U1", "side": "top", "cx": 0.0, "cy": 0.0, "w": 4.0, "h": 4.0, "rotation": 45.0, "z0": 0.0, "z1": 0.9}),+ json!({"ref": "C1", "side": "top", "x0": 2.0, "y0": 2.0, "x1": 2.6, "y1": 2.3, "z0": 0.0, "z1": 0.5})];+ let bs = boxes(&rows);+ assert!((bs[0].x1 - 2.0 * 2f64.sqrt()).abs() < 1e-9, "the box still covers the turned body");+ let (hit, d) = xy_meet(&bs[0], &bs[1]);+ assert!(!hit && d > 0.5, "{hit} {d}");+ let (col, _) = clashes(&bs, 0.2);+ assert!(col.is_empty());+ // the same cap moved onto the QFN's edge collides+ let near = boxes(&[rows[0].clone(), json!({"ref": "C1", "side": "top", "x0": 1.2, "y0": 1.2, "x1": 1.8, "y1": 1.5, "z0": 0.0, "z1": 0.5})]);+ assert!(xy_meet(&near[0], &near[1]).0);+ // corners work too+ let c = boxes(&[json!({"ref": "U2", "corners": [[0, 0], [2, 0], [2, 1], [0, 1]], "z0": 0, "z1": 1})]);+ assert_eq!((c[0].x1, c[0].y1), (2.0, 1.0));+ } }+
rust/vendor/adom-eda-checks/src/landpattern.rs+16−6@@ -41,9 +41,14 @@ pub fn pads(a: &Value) -> Result<Vec<Pad>, String> { if n % 2 != 0 { return Err(format!("dual needs an even pin count, got {n}")); } (n / 2, n / 2) };- // left column top to bottom, then right column bottom to top (counter-clockwise from pin 1 top-left)- for i in 0..left { push((i + 1).to_string(), -span / 2.0, ((left as f64 - 1.0) / 2.0 - i as f64) * pitch, pl, pw); }- for j in 0..right { push((left + j + 1).to_string(), span / 2.0, (j as f64 - (right as f64 - 1.0) / 2.0) * pitch, pl, pw); }+ // left column top to bottom, then right column bottom to top (counter-clockwise from pin 1 top-left). A shorter+ // column spreads over the longer one's span, its middle left empty: SOT-23-5's pins 4 and 5 line up with 3 and+ // 1 (kicad-lane #25); a single pin sits in the middle (SOT-23)+ let n = left.max(right);+ let step = |count: usize| if count > 1 && count < n { (n as f64 - 1.0) * pitch / (count as f64 - 1.0) } else { pitch };+ let (ls, rs) = (step(left), step(right));+ for i in 0..left { push((i + 1).to_string(), -span / 2.0, ((left as f64 - 1.0) / 2.0 - i as f64) * ls, pl, pw); }+ for j in 0..right { push((left + j + 1).to_string(), span / 2.0, (j as f64 - (right as f64 - 1.0) / 2.0) * rs, pl, pw); } } "quad" => { let (pitch, pl, pw) = (need("pitch")?, need("padLength")?, need("padWidth")?);@@ -148,10 +153,9 @@ pub fn layout(a: &Value) -> Result<Fp, String> { let r = 0.15; let (dx, dy) = if family == "bga" { (x0 - clear - r, y1 + clear + r) } else { (p1.x - p1.w / 2.0 - clear - r - silk_w, p1.y) }; fp.discs.push(("overlay", r3(dx), r3(dy), r));- x0 = x0.min(dx - r);- y1 = y1.max(dy + r); }- // courtyard: everything grown by the margin, on a 0.01 mm grid+ // courtyard: the pads and the body grown by the margin, on a 0.01 mm grid. The silkscreen pin-1 dot may sit outside it+ // (IPC-7351; kicad-lane #25: holding the dot pushed a 7 mm BGA's courtyard 0.5 mm out on two sides) let g = |v: f64, up: bool| if up { (v * 100.0).ceil() / 100.0 } else { (v * 100.0).floor() / 100.0 }; let c = (g(x0 - margin, false), g(y0 - margin, false), g(x1 + margin, true), g(y1 + margin, true)); fp.courtyard = c;@@ -219,6 +223,9 @@ mod tests { fn sot23_has_two_pins_left_and_one_right() { let p = pads(&json!({"family": "sot", "leftPins": 2, "rightPins": 1, "pitch": 1.9, "span": 2.2, "padLength": 1.0, "padWidth": 0.6})).unwrap(); assert_eq!(p.iter().map(|q| (q.name.clone(), q.x, q.y)).collect::<Vec<_>>(), vec![("1".into(), -1.1, 0.95), ("2".into(), -1.1, -0.95), ("3".into(), 1.1, 0.0)]);+ // SOT-23-5: pins 4 and 5 line up with 3 and 1 (kicad-lane #25)+ let q = pads(&json!({"family": "sot", "leftPins": 3, "rightPins": 2, "pitch": 0.95, "span": 2.6, "padLength": 1.1, "padWidth": 0.6})).unwrap();+ assert_eq!(q.iter().map(|p| (p.name.clone(), p.y)).collect::<Vec<_>>(), vec![("1".into(), 0.95), ("2".into(), 0.0), ("3".into(), -0.95), ("4".into(), -0.95), ("5".into(), 0.95)]); } #[test]@@ -249,6 +256,9 @@ mod tests { let c = fp.courtyard; assert!(fp.pads.iter().all(|p| { let (a0, b0, a1, b1) = ext(p); a0 >= c.0 && b0 >= c.1 && a1 <= c.2 && b1 <= c.3 })); assert_eq!(fp.discs.len(), 1);+ // the courtyard holds the pads and the body, not the pin-1 dot (kicad-lane #25): a 7 mm BGA is symmetric+ let bga = layout(&json!({"family": "bga", "rows": 8, "cols": 8, "pitch": 0.8, "ballPad": 0.4, "body": {"w": 7, "l": 7}})).unwrap();+ assert_eq!(bga.courtyard, (-3.75, -3.75, 3.75, 3.75)); assert!(fp.discs[0].1 < -2.7 - 0.775); }
rust/vendor/adom-eda-checks/src/placement.rs+2−1@@ -111,7 +111,8 @@ pub fn is_stacked(pads: &[Value], bs: &[crate::bodies::BodyBox], a: &str, b: &st if pa.is_empty() || pb.is_empty() || pa.iter().any(|x| pb.iter().any(|y| hit(grow(*x, margin), grow(*y, margin)))) { return false; } let (ba, bb): (Vec<_>, Vec<_>) = (bs.iter().filter(|x| x.part == a).collect(), bs.iter().filter(|x| x.part == b).collect()); if ba.is_empty() || bb.is_empty() { return false; }- !ba.iter().any(|p| bb.iter().any(|q| hit(grow((p.x0, p.y0, p.x1, p.y1), margin), grow((q.x0, q.y0, q.x1, q.y1), margin)) && p.z0.max(q.z0) < p.z1.min(q.z1)))+ // bodies meet when their footprints come within twice the margin (turned ones by their rectangles) and their heights overlap+ !ba.iter().any(|p| bb.iter().any(|q| { let (h, d) = crate::bodies::xy_meet(p, q); (h || d < 2.0 * margin) && p.z0.max(q.z0) < p.z1.min(q.z1) })) } /// placement_check: input {board, bodies?, args: {marginMm?, edgeMm?}}. Without bodies the parts' boxes are their pads
tests/run_verb_tests.py+2@@ -268,6 +268,8 @@ t("fusion_jlcpcb_order", "daily", dict(B, outputDir="C:/Users/olive/adom-tests/j t("fusion_sch_zoom", "daily", dict(B, refs=["IC1", "C11"]), {"ok": True, "fields": ["found", "rectMm", "image"]}, "SHOW + WINDOW + EXPORT IMAGE WINDOW: nothing changes") t("fusion_export_spice", "daily", dict(B), {"ok": True, "fields": ["path", "elements", "unmapped"]}, "EAGLE's EXPORT SPICENETLIST: BQ25792's ICs have no SPICE mapping") t("fusion_simulate", "daily", dict(B, analysis={"type": "op"}), {"ok": True, "fields": ["signals", "notSimulated", "engine"]}, "BQ25792 is partly modelled: it runs and names the 89 parts it left out (standard 0.21.1)")+t("fusion_simulate#noise", "daily", {"netlistPath": "C:/Users/olive/Documents/fusion-lane-tests/rc.cir", "analysis": {"type": "noise", "source": "V1", "output": "OUT", "sweep": "dec", "points": 10, "start": 10, "stop": 1e6}}, {"ok": True, "fields": ["signals", "totals"]}, "the RC's output noise: sqrt(4kTR) at low frequency, about sqrt(kT/C) integrated")+t("fusion_simulate#montecarlo", "daily", {"netlistPath": "C:/Users/olive/Documents/fusion-lane-tests/rc.cir", "analysis": {"type": "ac", "sweep": "dec", "points": 20, "start": 10, "stop": 1e6}, "probes": [{"net": "OUT"}], "monteCarlo": {"runs": 3, "distribution": "gaussian", "seed": 7, "tolerances": {"resistor": 1, "capacitor": 10}}}, {"ok": True, "fields": ["signals", "runs"]}, "3 seeded Monte Carlo runs, the drawn values in each signal's step") t("fusion_simulate#sweep", "daily", {"netlistPath": "C:/Users/olive/Documents/fusion-lane-tests/rc.cir", "analysis": {"type": "ac", "sweep": "dec", "points": 20, "start": 10, "stop": 1e6}, "probes": [{"net": "OUT"}], "sweep": {"parameter": "R1", "start": 1000, "stop": 4000, "step": 1000}, "temperatures": {"start": 25, "stop": 85, "step": 60}}, {"ok": True, "fields": ["signals", "runs"]}, "an RC low-pass: R1 x temperature, 8 runs, each its own signal with step") for v in ("fusion_sch_place_part", "fusion_sch_connect", "fusion_sch_swap_part"): t(v, "manual", note="edits the schematic (and the board with it): tested live on lane-BQ25792-copy with a part placed and deleted again")