← Commit history
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.13.8\ No newline at end of file+1.13.9\ No newline at end of file
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.9 (2026-10-29)++Circuit simulation with the ngspice Fusion bundles (the standard's #23). 312 verbs.++- `fusion_export_spice`: the design's SPICE netlist from EAGLE's own EXPORT SPICENETLIST (no dialog), ngspice dialect,+  with its elements, the parts that have no SPICE mapping, analyses and includes.+- `fusion_simulate`: op, dc, ac (dec, oct, lin), tran or raw SPICE text, probes by net or source current, signals as+  x/y data (AC in dB with the unwrapped phase in degrees). The netlist gets the analysis and a .control block and+  Fusion's bundled ngspice 41 runs it in batch mode in a child process, so Fusion's simulation dialog never opens. A+  part with no SPICE mapping refuses with `no_model`. Checked on an RC low-pass (1k, 100n): -2.99 dB and -44.9+  degrees at 1.585 kHz.+ ## 1.13.8 (2026-10-29)  Schematic placing and wiring, and engine 0.11.0 (standard 0.14.0). 310 verbs.
bridge.json+6−2
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "fusion360",   "displayName": "Autodesk Fusion 360",-  "version": "1.13.8",+  "version": "1.13.9",   "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.",@@ -134,6 +134,7 @@     "fusion_export_sat",     "fusion_export_skp",     "fusion_export_source",+    "fusion_export_spice",     "fusion_export_step",     "fusion_export_stl",     "fusion_export_usdz",@@ -316,6 +317,7 @@     "fusion_signin_2fa",     "fusion_signin_status",     "fusion_silk_state",+    "fusion_simulate",     "fusion_start",     "fusion_state",     "fusion_status",@@ -509,7 +511,9 @@       "diff_pairs": 60,       "sch_place_part": 120,       "sch_connect": 120,-      "sch_swap_part": 120+      "sch_swap_part": 120,+      "export_spice": 120,+      "simulate": 300     },     "prefixes": [       {
contracts/describe.json+60
@@ -2349,6 +2349,26 @@       "summary": "Export the ACTIVE electronics document in Fusion's native source format via Document.CopyToDesktop: .fbrd (board), .fsch (schematic) or .flbr (library); the outputPath extension picks the format. The container holds whichever editor is ACTIVE, so since 1.10.35 the bridge switches to it first: the Schematic Editor for a .fsch, the PCB Editor for a .fbrd (reply carries switchedView). A .flbr still needs the library open (fusion_open_lbr). For plain EAGLE XML use fusion_export_eagle_source.",       "timeoutSeconds": 60     },+    {+      "example": {},+      "input": {+        "expectDocument": "the design",+        "outputPath": "the .net to write (default %TEMP%/adom-spice/<design>.net)"+      },+      "longRunning": false,+      "name": "fusion_export_spice",+      "output": {+        "analyses": "[line]",+        "dialect": "ngspice",+        "elements": "int",+        "includes": "[line]",+        "path": "str",+        "unmapped": "[part]"+      },+      "statusVerb": null,+      "summary": "The design's SPICE netlist (the standard's export_spice): EAGLE's own EXPORT SPICENETLIST of the open design's schematic, written on the Fusion machine with no dialog, in ngspice's dialect. Parts with no SPICE mapping come out as comments (unmapped); analyses and includes (.include/.lib/.model/.subckt) are listed.",+      "timeoutSeconds": 120+    },     {       "example": {         "outputPath": "C:/out/board.step"@@ -20312,6 +20332,46 @@       "summary": "The silkscreen as it stands: every part's body box, its designator and value texts (position, size, rotation, side, shown; a smashed part's own text, else the footprint's placeholder), and the board's free silkscreen lines and texts.",       "timeoutSeconds": 60     },+    {+      "example": {+        "analysis": {+          "points": 20,+          "start": 10,+          "stop": 1000000,+          "sweep": "dec",+          "type": "ac"+        },+        "probes": [+          {+            "net": "OUT"+          }+        ]+      },+      "input": {+        "allowUnmapped": "bool",+        "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}",+        "expectDocument": "the design",+        "maxProbes": "default 32",+        "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}]",+        "timeoutSec": "default 120"+      },+      "longRunning": false,+      "name": "fusion_simulate",+      "output": {+        "analysis": "str",+        "available": "[net]",+        "engine": "str",+        "missing": "[probe]",+        "netlist": "path",+        "rows": "int",+        "signals": "[{signal, x, y, xUnit, yUnit, phase?}]"+      },+      "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. A part with no SPICE mapping refuses with no_model (allowUnmapped skips them). netlistPath simulates a netlist file instead.",+      "timeoutSeconds": 300+    },     {       "example": {},       "input": {
dashboard/Cargo.lock+1−1
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "fusion-dashboard"-version = "1.13.8"+version = "1.13.9" dependencies = [  "serde_json", ]
dashboard/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.13.8"+version = "1.13.9" edition = "2021"  [dependencies]
describe.py+8
@@ -418,6 +418,14 @@ _T = [      {"expectDocument": "the design", "designators": "['R1','R2'] or 'R1,R2' (required)", "params": "{MPN: ...} or 'Key=Value;...' (required)", "dryRun": "bool"},      {"updated": "[ref]", "notFound": "[ref]"},      120, None, False, {'designators': ['R1'], 'params': {'MPN': '0603WAF1002T5E'}, 'dryRun': True}),+    ('fusion_export_spice', "The design's SPICE netlist (the standard's export_spice): EAGLE's own EXPORT SPICENETLIST of the open design's schematic, written on the Fusion machine with no dialog, in ngspice's dialect. Parts with no SPICE mapping come out as comments (unmapped); analyses and includes (.include/.lib/.model/.subckt) are listed.",+     {"expectDocument": "the design", "outputPath": "the .net to write (default %TEMP%/adom-spice/<design>.net)"},+     {"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. A part with no SPICE mapping refuses with no_model (allowUnmapped skips them). 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", "allowUnmapped": "bool", "netlistPath": "a .net/.cir on the Fusion machine instead of the design", "timeoutSec": "default 120"},+     {"signals": "[{signal, x, y, xUnit, yUnit, phase?}]", "missing": "[probe]", "available": "[net]", "engine": "str", "analysis": "str", "rows": "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"},      {"designator": "str", "libref": "dev@lib", "atMil": "[x, y]", "pins": "int", "footprints": "[package]", "saved": "false"},
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.8"+DASH_VERSION="1.13.9" 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.8",+  "version": "1.13.9",   "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.8",+  "version": "1.13.9",   "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]"   }-}\ No newline at end of file+}
rust/Cargo.lock+5−5
@@ -19,7 +19,7 @@ dependencies = [  [[package]] name = "adom-fusion-bridge"-version = "1.13.8"+version = "1.13.9" dependencies = [  "adom-eda-checks",  "base64",@@ -299,7 +299,7 @@ dependencies = [  [[package]] name = "fusion-core"-version = "1.13.8"+version = "1.13.9" dependencies = [  "serde",  "serde_json",@@ -310,7 +310,7 @@ dependencies = [  [[package]] name = "fusion-lbr"-version = "1.13.8"+version = "1.13.9" dependencies = [  "regex",  "roxmltree",@@ -322,14 +322,14 @@ dependencies = [  [[package]] name = "fusion-os-mac"-version = "1.13.8"+version = "1.13.9" dependencies = [  "fusion-core", ]  [[package]] name = "fusion-os-win"-version = "1.13.8"+version = "1.13.9" 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.8"+version = "1.13.9" edition = "2021" license = "MIT" 
rust/bridge/src/main.rs+2−1
@@ -15,6 +15,7 @@ mod chains; mod writers; mod body;+mod spice; mod pdf; mod small_verbs; mod addin;@@ -501,7 +502,7 @@ fn main() {                         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 cmd=v.strip_prefix("fusion_").unwrap_or(v);-                        let mut r=match cmd { "bodies" => bodies::bodies(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), "export_bom" => routing::export_bom(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), "save_document" => savedoc::save_document(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), "save_to_cloud" => savedoc::save_to_cloud(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), c if sch::owns(c) => sch::call(c,&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), c if docs::owns(c) => docs::call(c,&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), c if writers::owns(c) => writers::call(c,&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), c if checks::owns(c) => checks::call(c,&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), _ => routing::call(cmd,&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)) };+                        let mut r=match cmd { "bodies" => bodies::bodies(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), "simulate" => spice::simulate(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), "export_bom" => routing::export_bom(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), "save_document" => savedoc::save_document(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), "save_to_cloud" => savedoc::save_to_cloud(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), c if sch::owns(c) => sch::call(c,&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), c if docs::owns(c) => docs::call(c,&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), c if writers::owns(c) => writers::call(c,&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), c if checks::owns(c) => checks::call(c,&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), _ => routing::call(cmd,&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)) };                         if cmd=="netlist" { r=sheet::shape_board_netlist(r,&parsed["args"]); }                         r["bridgeVersion"]=json!(VERSION); r["shell"]=json!("rust"); r                     },
rust/bridge/src/sch.rs+1−1
@@ -2,7 +2,7 @@ //! thread through the script transport, as the routing verbs do. use serde_json::{json, Value}; const SOURCE: &str = include_str!("sch_script.py");-pub fn owns(cmd: &str) -> bool { matches!(cmd, "sch_items" | "sch_pins" | "sch_trace_point" | "sch_page" | "sch_hierarchy" | "sch_text" | "sch_junction" | "sch_delete_wire" | "sch_connect_pins" | "sch_split_wire" | "sch_bus" | "sch_edit_label" | "sch_move_region" | "sch_fix_connectivity" | "sch_bus_entry" | "sch_connect_to_bus" | "render_schematic" | "sch_reset_fields" | "sch_create" | "sch_sheet_pins" | "sch_update_from_library" | "cross_probe" | "cross_check" | "schematic_data" | "part_parameters" | "doc_params" | "sheet_params" | "sch_edit" | "annotate" | "sch_connect" | "sch_place_part" | "sch_swap_part") }+pub fn owns(cmd: &str) -> bool { matches!(cmd, "sch_items" | "sch_pins" | "sch_trace_point" | "sch_page" | "sch_hierarchy" | "sch_text" | "sch_junction" | "sch_delete_wire" | "sch_connect_pins" | "sch_split_wire" | "sch_bus" | "sch_edit_label" | "sch_move_region" | "sch_fix_connectivity" | "sch_bus_entry" | "sch_connect_to_bus" | "render_schematic" | "sch_reset_fields" | "sch_create" | "sch_sheet_pins" | "sch_update_from_library" | "cross_probe" | "cross_check" | "schematic_data" | "part_parameters" | "doc_params" | "sheet_params" | "sch_edit" | "annotate" | "sch_connect" | "sch_place_part" | "sch_swap_part" | "export_spice" | "simulate") } pub fn call(cmd: &str, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {     if cmd == "render_schematic" { return render_schematic(args, pass); }     let script = format!("import json\nexec({})\nresult=handle(app,json.loads({}),{})\n",
rust/bridge/src/sch_script.py+39
@@ -2098,10 +2098,49 @@ def sch_render(app, args):             'extentMm': [_r4(x0), _r4(y0), _r4(x1), _r4(y1)], 'fusionVersion': app.version}  +def export_spice(app, args):+    """The standard's export_spice: EAGLE's own SPICE netlist (EXPORT SPICENETLIST, no dialog) of+    the design's schematic, written on this machine and parsed: device lines, the parts with no+    SPICE mapping (EAGLE writes them with a leading _), analyses, includes and models."""+    import os+    i, doc = sch_doc(app, args)+    out = args.get('outputPath') or os.path.join(tempfile.gettempdir(), 'adom-spice', re.sub(r'[^A-Za-z0-9_.-]', '_', doc.name) + '.net')+    out = out.replace('\\', '/')+    os.makedirs(os.path.dirname(out), exist_ok=True)+    front = app.activeDocument+    docs = [app.documents.item(k) for k in range(app.documents.count)]+    j = docs.index(front) if front in docs else None+    # EAGLE asks "already exists. Overwrite?" for a file that is there: remove it first, and answer+    # yes on the same command line in case it could not be removed (live 2026-10-29: the question was drawn)+    if os.path.exists(out):+        try: os.remove(out)+        except OSError: pass+    try:+        app.executeTextCommand('UI.SwitchToTab %d' % i)+        app.executeTextCommand("Electron.run SET CONFIRM YES; EXPORT SPICENETLIST '%s'; SET CONFIRM OFF;" % out)+    finally:+        if j is not None and j != i:+            app.executeTextCommand('UI.SwitchToTab %d' % j)+    if not os.path.isfile(out):+        sfail('export_failed', 'Fusion did not write the netlist %s' % out)+    text = open(out, 'rb').read().decode('utf-8', 'replace')+    lines = [l.strip() for l in text.splitlines()]+    devices = [l for l in lines if l and l[0] not in '*.+' and not l.startswith('_')]+    unmapped = sorted({l.split()[0][1:] for l in lines if l.startswith('_')})+    analyses = [l for l in lines if re.match(r'\.(op|dc|ac|tran|noise|tf|pz|sens)\b', l, re.I)]+    includes = [l for l in lines if re.match(r'\.(include|lib|model|subckt)\b', l, re.I)]+    return {'success': True, 'mutated': False, 'documentName': doc.name, 'path': out, 'dialect': 'ngspice', 'elements': len(devices),+            'unmapped': unmapped, 'analyses': analyses, 'includes': includes, 'sizeBytes': len(text.encode()),+            '_hint': 'EAGLE writes parts with no SPICE mapping as comments starting with _ (unmapped): give them a model (Fusion\'s ngspice-simulation library, or MAPTOMODEL) before fusion_simulate.'+            if unmapped else 'Every part has a SPICE mapping: fusion_simulate runs it with Fusion\'s bundled ngspice.'}++ def handle(app, args, action):     try:         if action in EDITS:             return EDITS[action](app, args)+        if action == 'export_spice':+            return export_spice(app, args)         if action == 'sch_render':             return sch_render(app, args)         if action == 'cross_probe':
rust/bridge/src/spice.rsadded+211
@@ -0,0 +1,211 @@+//! `fusion_simulate`: the standard's simulate (#23) without Fusion's SIM dialog. The design's own+//! SPICE netlist (export_spice: EAGLE's EXPORT SPICENETLIST) gets the analysis and a .control block+//! that writes the probes, and the ngspice Fusion bundles (LibEagle/ngspice/bin/ngspice_con.exe, the+//! engine Fusion's SIM runs) runs it in batch mode in a child process. Signals come back as x/y data.+use serde_json::{json, Value};+use std::path::{Path, PathBuf};+use std::time::{Duration, Instant};++/// Fusion's bundled ngspice: the newest webdeploy build that has one.+fn ngspice() -> Option<PathBuf> {+    let base = PathBuf::from(std::env::var("LOCALAPPDATA").ok()?).join("Autodesk").join("webdeploy").join("production");+    let mut found: Vec<(std::time::SystemTime, PathBuf)> = std::fs::read_dir(&base).ok()?.flatten()+        .map(|d| d.path().join("Applications").join("Electron").join("LibEagle").join("ngspice").join("bin").join("ngspice_con.exe"))+        .filter(|p| p.is_file())+        .map(|p| (std::fs::metadata(&p).and_then(|m| m.modified()).unwrap_or(std::time::UNIX_EPOCH), p))+        .collect();+    found.sort();+    found.pop().map(|(_, p)| p)+}++fn num(v: &Value, k: &str) -> Option<f64> { v[k].as_f64().or_else(|| v[k].as_str().and_then(|s| s.parse().ok())) }++/// The analysis line, or a refusal.+fn analysis_line(a: &Value) -> Result<String, Value> {+    let bad = |m: &str| json!({"success": false, "errorCode": "bad_args", "error": m});+    let g = |k: &str| num(a, k).map(|v| format!("{v}")).unwrap_or_default();+    match a["type"].as_str().unwrap_or("op") {+        "op" => Ok(".op".into()),+        "dc" => match (a["source"].as_str(), num(a, "start"), num(a, "stop"), num(a, "step")) {+            (Some(s), Some(_), Some(_), Some(_)) => Ok(format!(".dc {s} {} {} {}", g("start"), g("stop"), g("step"))),+            _ => Err(bad("dc needs source, start, stop and step")),+        },+        "ac" => {+            let sweep = a["sweep"].as_str().unwrap_or("dec");+            if !matches!(sweep, "dec" | "oct" | "lin") { return Err(bad("ac sweep is dec, oct or lin")); }+            match (num(a, "points"), num(a, "start"), num(a, "stop")) {+                (Some(p), Some(_), Some(_)) => Ok(format!(".ac {sweep} {} {} {}", p as u64, g("start"), g("stop"))),+                _ => Err(bad("ac needs points, start and stop (Hz)")),+            }+        }+        "tran" => match (num(a, "step"), num(a, "stop")) {+            (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)")),+        },+        "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"))),+    }+}++/// The nodes the devices connect (their pins), as ngspice knows them (lowercase).+fn nodes(lines: &[String]) -> Vec<String> {+    let mut out: Vec<String> = Vec::new();+    for l in lines {+        let t: Vec<&str> = l.split_whitespace().collect();+        if t.is_empty() || "*.+_".contains(&t[0][..1]) { continue; }+        // two nodes for two-terminal elements; a subcircuit (X) every token but its name and model+        let k = match t[0].chars().next().unwrap_or(' ').to_ascii_uppercase() { 'X' => t.len().saturating_sub(1), 'Q' | 'J' => 4.min(t.len()), 'M' => 5.min(t.len()), 'E' | 'G' => 5.min(t.len()), _ => 3.min(t.len()) };+        for n in &t[1..k.max(1)] { let n = n.to_lowercase(); if n != "0" && !out.contains(&n) { out.push(n); } }+    }+    out+}++/// At most `n` points: every step-th one, plus the first, the last and each local extremum.+pub fn decimate(x: &[f64], ys: &[Vec<f64>], n: usize) -> Vec<usize> {+    let len = x.len();+    if len <= n || n < 3 { return (0..len).collect(); }+    let mut keep = std::collections::BTreeSet::new();+    keep.insert(0); keep.insert(len - 1);+    for y in ys { for i in 1..len - 1 { if (y[i] > y[i - 1] && y[i] >= y[i + 1]) || (y[i] < y[i - 1] && y[i] <= y[i + 1]) { keep.insert(i); } } }+    let step = ((len as f64) / (n.saturating_sub(keep.len().min(n - 2)).max(2) as f64)).ceil().max(1.0) as usize;+    let mut i = 0; while i < len { keep.insert(i); i += step; }+    let mut v: Vec<usize> = keep.into_iter().collect();+    while v.len() > n { let drop = v.len() / 2; v.remove(drop.max(1).min(v.len() - 2)); }+    v+}++pub fn simulate(args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+    let started = Instant::now();+    let Some(ng) = ngspice() else {+        return json!({"success": false, "errorCode": "no_simulator", "error": "Fusion's ngspice (LibEagle/ngspice/bin/ngspice_con.exe) was not found under %LOCALAPPDATA%/Autodesk/webdeploy"});+    };+    // the netlist: a file given (netlistPath), else the design's own (export_spice)+    let (path, export) = match args["netlistPath"].as_str() {+        Some(p) => (p.to_string(), Value::Null),+        None => {+            let e = crate::sch::call("export_spice", args, pass);+            if e["success"].as_bool() != Some(true) { return e; }+            (e["path"].as_str().unwrap_or("").to_string(), e)+        }+    };+    let text = match std::fs::read_to_string(&path) { Ok(t) => t, Err(e) => return json!({"success": false, "errorCode": "netlist_unreadable", "error": format!("{path}: {e}")}) };+    let lines: Vec<String> = text.lines().map(|l| l.trim().to_string()).collect();+    let unmapped: Vec<String> = lines.iter().filter(|l| l.starts_with('_')).filter_map(|l| l.split_whitespace().next().map(|s| s[1..].to_string())).collect();+    if !unmapped.is_empty() && args["allowUnmapped"].as_bool() != Some(true) {+        return json!({"success": false, "errorCode": "no_model", "error": format!("{} part(s) have no SPICE mapping: {}", unmapped.len(), unmapped.iter().take(20).cloned().collect::<Vec<_>>().join(", ")),+                      "unmapped": unmapped, "netlist": path, "_hint": "Use simulation-capable parts (Fusion's ngspice-simulation library) or map them (MAPTOMODEL); allowUnmapped:true simulates without them."});+    }+    // 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 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;+    for l in &lines {+        let ll = l.to_lowercase();+        if ll.starts_with(".control") { in_ctl = true; continue; }+        if ll.starts_with(".endc") { in_ctl = false; continue; }+        if in_ctl || ll == ".end" || l.starts_with('_') || given.contains(l) { continue; }+        body.push(l.clone());+    }+    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)+    let mut missing: Vec<Value> = Vec::new();+    match args["probes"].as_array() {+        Some(ps) if !ps.is_empty() => for p in ps {+            if let Some(n) = p["net"].as_str() {+                let k = n.to_lowercase();+                if all.contains(&k) { probes.push((format!("V({n})"), k)); } else { missing.push(p.clone()); }+            } else if let Some(c) = p["current"].as_str() {+                probes.push((format!("I({c})"), format!("i({})", c.to_lowercase())));+            }+        },+        _ => for n in all.iter().take(cap) { probes.push((format!("V({})", n.to_uppercase()), n.clone())); },+    }+    if probes.is_empty() {+        return json!({"success": false, "errorCode": "no_such_probe", "error": "none of the probes is a net of the netlist", "missing": missing, "available": all});+    }+    let ac = kind == "ac";+    // AC: magnitude in dB and the continuous (unwrapped) phase; otherwise the value itself+    let vecs: Vec<String> = probes.iter().flat_map(|(_, v)| {+        let current = v.starts_with("i(");+        match (ac, current) {+            (true, true) => vec![format!("db({v})"), format!("cph({v})")],+            (true, false) => vec![format!("vdb({v})"), format!("cph({v})")],+            (false, true) => vec![v.clone()],+            (false, false) => vec![format!("v({v})")],+        }+    }).collect();+    let dir = std::env::temp_dir().join("adom-spice").join(format!("run-{}", std::process::id()));+    let _ = std::fs::create_dir_all(&dir);+    let out = dir.join("signals.txt"); let deck = dir.join("deck.cir"); let log = dir.join("ngspice.log");+    let _ = std::fs::remove_file(&out);+    let deck_text = format!("{}\n{an}\n.control\nset wr_singlescale\nset wr_vecnames\nset numdgt=10\nrun\nwrdata {} {}\n.endc\n.end\n",+        body.join("\n"), out.to_string_lossy().replace('\\', "/"), vecs.join(" "));+    if let Err(e) = std::fs::write(&deck, &deck_text) { return json!({"success": false, "errorCode": "write_failed", "error": e.to_string()}); }+    let mut cmd = std::process::Command::new(&ng);+    cmd.arg("-b").arg(&deck).arg("-o").arg(&log).current_dir(Path::new(&path).parent().unwrap_or(&dir));+    #[cfg(windows)] { use std::os::windows::process::CommandExt; cmd.creation_flags(0x0800_0000); }+    let mut child = match cmd.spawn() { Ok(c) => c, Err(e) => return json!({"success": false, "errorCode": "simulation_failed", "error": format!("ngspice did not start: {e}")}) };+    let limit = Duration::from_secs(args["timeoutSec"].as_u64().unwrap_or(120).clamp(5, 600));+    loop {+        match child.try_wait() {+            Ok(Some(_)) => break,+            Ok(None) if started.elapsed() > limit => { let _ = child.kill(); return json!({"success": false, "errorCode": "simulation_failed", "error": format!("ngspice ran past {}s and was stopped", limit.as_secs()), "deck": deck}); }+            Ok(None) => std::thread::sleep(Duration::from_millis(100)),+            Err(e) => return json!({"success": false, "errorCode": "simulation_failed", "error": e.to_string()}),+        }+    }+    let logtext = std::fs::read_to_string(&log).unwrap_or_default();+    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 data = match std::fs::read_to_string(&out) { Ok(d) => d, Err(_) => {+        let msg: Vec<&str> = logtext.lines().filter(|l| { let l = l.to_lowercase(); l.contains("error") || l.contains("fatal") || l.contains("no such vector") }).take(8).collect();+        return json!({"success": false, "errorCode": "simulation_failed", "error": if msg.is_empty() { "ngspice wrote no data".to_string() } else { msg.join(" | ") }, "engine": engine, "deck": deck, "log": log});+    } };+    let mut rows = data.lines().filter(|l| !l.trim().is_empty());+    let header: Vec<String> = rows.next().unwrap_or("").split_whitespace().map(str::to_string).collect();+    let cols: Vec<Vec<f64>> = rows.map(|l| l.split_whitespace().filter_map(|t| t.parse::<f64>().ok()).collect()).filter(|r: &Vec<f64>| r.len() == header.len()).collect();+    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);+    let (x_unit, y_unit) = match kind.as_str() { "ac" => ("Hz", "dB"), "tran" => ("s", "V"), "dc" => ("V", "V"), _ => ("", "V") };+    let n = args["points"].as_u64().unwrap_or(200) as usize;+    let mut signals = Vec::new();+    let per = if ac { 2 } else { 1 };+    let ys: Vec<Vec<f64>> = (0..probes.len()).map(|k| col(1 + k * per)).collect();+    let keep = decimate(&x, &ys, n);+    for (k, (name, _)) in probes.iter().enumerate() {+        let y = &ys[k];+        let mut s = json!({"signal": name, "x": keep.iter().map(|&i| x[i]).collect::<Vec<_>>(), "y": keep.iter().map(|&i| y[i]).collect::<Vec<_>>(), "xUnit": x_unit,+                           "yUnit": if name.starts_with("I(") && !ac { "A" } else { y_unit }});+        if ac { let ph = col(2 + k * 2); s["phase"] = json!(keep.iter().map(|&i| ph[i].to_degrees()).collect::<Vec<_>>()); }+        signals.push(s);+    }+    json!({"success": true, "mutated": false, "analysis": an, "signals": signals, "missing": missing, "available": all, "rows": x.len(), "points": keep.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."})+}++#[cfg(test)]+mod tests {+    use super::*;+    #[test]+    fn analyses_and_nodes() {+        assert_eq!(analysis_line(&json!({"type": "ac", "sweep": "dec", "points": 20, "start": 10, "stop": 1e6})).unwrap(), ".ac dec 20 10 1000000");+        assert_eq!(analysis_line(&json!({"type": "tran", "step": 1e-6, "stop": 1e-3})).unwrap(), ".tran 0.000001 0.001");+        assert_eq!(analysis_line(&json!({"type": "dc", "source": "V1", "start": 0, "stop": 5, "step": 0.1})).unwrap(), ".dc V1 0 5 0.1");+        assert!(analysis_line(&json!({"type": "ac", "sweep": "dec"})).is_err());+        assert_eq!(analysis_line(&json!({"type": "raw", "text": ".ac dec 5 1 1k"})).unwrap(), ".ac dec 5 1 1k");+        let l: Vec<String> = ["* x", "V_V1 IN 0 DC 0 AC 1", "R_R1 IN OUT 1k", "C_C1 OUT 0 100n", "_U1 N_3 PART", ".end"].iter().map(|s| s.to_string()).collect();+        assert_eq!(nodes(&l), vec!["in", "out"]);+    }+    #[test]+    fn decimation_keeps_ends_and_peaks() {+        let x: Vec<f64> = (0..1000).map(|i| i as f64).collect();+        let y: Vec<f64> = x.iter().map(|v| if *v == 500.0 { 10.0 } else { 0.0 }).collect();+        let k = decimate(&x, &[y], 50);+        assert!(k.len() <= 50 && k.contains(&0) && k.contains(&999) && k.contains(&500));+    }+}
tests/run_verb_tests.py+3−1
@@ -256,6 +256,8 @@ t("fusion_fab_check", "daily", dict(B, vendor="jlcpcb"), {"ok": True, "fields": t("fusion_estimate_cost", "daily", dict(B, quantities=[5, 30], assembly=True), {"ok": True, "fields": ["prices", "source", "engine"]}) t("fusion_read_board", "daily", dict(B, sections="components,nets,rules,outline", limit=5), {"ok": True, "fields": ["data", "counts"]}) t("fusion_diff_pairs", "daily", dict(B), {"ok": True, "fields": ["pairs"]}, "BQ25792 pairs D_P/D_N by name")+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", "negative", dict(B, analysis={"type": "op"}), {"errorCode": "no_model"}, "BQ25792 has unmapped parts: refused before ngspice runs") 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") t("fusion_add_rule", "manual", note="edits the board's net classes: tested live on lane-BQ25792-copy and put back")@@ -501,7 +503,7 @@ _SECTION_BY_VERB = {     "fusion_verb_times": "bridge-lifecycle", "fusion_live_feed": "bridge-lifecycle", "fusion_account": "auth-tokens", "fusion_claim_seat": "auth-tokens", "fusion_release_seat": "auth-tokens", "fusion_prewarm": "bridge-lifecycle", "fusion_prove_result": "cad-3d-bom",     "fusion_set_driving_badge": "bridge-lifecycle", "fusion_set_window_sizing": "gui-driving", "fusion_cancel_run": "cad-3d-bom",     "fusion_preflight_lbr": "cad-3d-bom", "fusion_library_parts": "cad-3d-bom", "fusion_export_library_part": "cad-3d-bom", "fusion_edit_footprint": "cad-3d-bom", "fusion_create_footprint": "cad-3d-bom", "fusion_create_symbol": "cad-3d-bom", "fusion_read_symbol": "cad-3d-bom", "fusion_find_symbol": "cad-3d-bom", "fusion_copy_footprint": "cad-3d-bom", "fusion_rename_footprint": "cad-3d-bom", "fusion_remove_footprint": "cad-3d-bom", "fusion_xcopy_footprint": "cad-3d-bom", "fusion_clone_symbol": "cad-3d-bom", "fusion_rename_symbol": "cad-3d-bom", "fusion_remove_symbol": "cad-3d-bom", "fusion_set_params": "cad-3d-bom", "fusion_read_footprint": "cad-3d-bom", "fusion_cross_probe": "routing", "fusion_project_info": "routing", "fusion_schematic_data": "routing", "fusion_part_parameters": "routing", "fusion_cross_check": "routing", "fusion_doc_params": "routing", "fusion_sheet_params": "routing", "fusion_sch_edit": "routing", "fusion_annotate": "routing", "fusion_footprint_primitives": "routing", "fusion_thermal_vias": "routing", "fusion_via_fence": "routing", "fusion_board_features": "routing", "fusion_export_pdf": "routing", "fusion_project_create": "routing", "fusion_project_rename": "routing", "fusion_project_snapshot": "routing", "fusion_import_svg": "routing", "fusion_copy_routing": "routing", "fusion_set_layer_count": "routing", "fusion_take_screenshot": "gui-driving",-    "fusion_schematic": "gui-driving", "fusion_show_attributes": "cad-3d-bom", "fusion_make_showcase_project": "cad-3d-bom", "fusion_eagle_import_status": "cad-3d-bom", "fusion_routing_state": "routing", "fusion_route_net": "routing", "fusion_routing_drc": "routing", "fusion_plan_route": "routing", "fusion_move_parts": "routing", "fusion_place_vias": "routing", "fusion_apply_pours": "routing", "fusion_pour_state": "routing", "fusion_board_source": "routing", "fusion_ripup_nets": "routing", "fusion_schematic_run": "routing", "fusion_board_bom": "routing", "fusion_change_pours": "routing", "fusion_board_seed": "routing", "fusion_schematic_plan": "routing", "fusion_remove_pours": "routing", "fusion_layer_stack": "routing", "fusion_netlist": "routing", "fusion_read_sheet_file": "routing", "fusion_bodies": "routing", "fusion_save_document": "aps", "fusion_apply_silkscreen": "routing", "fusion_component_data": "routing", "fusion_component_pins": "routing", "fusion_list_nets": "routing", "fusion_net_connections": "routing", "fusion_list_designators": "routing", "fusion_layers": "routing", "fusion_copper_layers": "routing", "fusion_layers_in_use": "routing", "fusion_read_rules": "routing", "fusion_rules_by_kind": "routing", "fusion_silk_state": "routing", "fusion_net_classes": "routing", "fusion_run_drc": "routing", "fusion_repour": "routing", "fusion_zoom_to": "routing", "fusion_launch": "bridge-lifecycle", "fusion_export_gerber": "exports", "fusion_dismiss_dialogs": "gui-driving", "fusion_board_summary": "routing", "fusion_install": "bridge-lifecycle", "fusion_sch_items": "routing", "fusion_sch_pins": "routing", "fusion_sch_trace_point": "routing", "fusion_sch_page": "routing", "fusion_sch_hierarchy": "routing", "fusion_sch_sheet_pins": "routing", "fusion_sch_connect_pins": "routing", "fusion_sch_fix_connectivity": "routing", "fusion_render_schematic": "routing", "fusion_sch_update_from_library": "routing", "fusion_sch_text": "routing", "fusion_sch_junction": "routing", "fusion_sch_delete_wire": "routing", "fusion_sch_split_wire": "routing", "fusion_sch_bus": "routing", "fusion_sch_edit_label": "routing", "fusion_sch_move_region": "routing", "fusion_sch_bus_entry": "routing", "fusion_sch_connect_to_bus": "routing", "fusion_sch_reset_fields": "routing", "fusion_sch_create": "routing", "fusion_update_board_from_eagle": "routing", "fusion_render_board_3d": "cad-3d-bom", "fusion_generate_body": "cad-3d-bom", "fusion_library_views": "gui-driving", "fusion_edit_parts": "routing", "fusion_align_parts": "routing", "fusion_remove_graphics": "routing", "fusion_remove_parts": "routing", "fusion_copper_balance": "routing", "fusion_return_path": "routing", "fusion_impedance": "routing", "fusion_current_capacity": "routing", "fusion_fab_check": "routing", "fusion_estimate_cost": "routing", "fusion_read_board": "routing", "fusion_diff_pairs": "routing", "fusion_sch_place_part": "routing", "fusion_sch_connect": "routing", "fusion_sch_swap_part": "routing", "fusion_add_rule": "routing", "fusion_height_check": "routing", "fusion_placement_check": "routing", "fusion_check_clearance": "routing", "fusion_place_decoupling": "routing", "fusion_plan_fanout": "routing", "fusion_export_odb": "exports", "fusion_export_ipc356": "exports", "fusion_export_gencad": "exports", "fusion_export_ipc2581": "exports", "fusion_select_net": "routing", "fusion_set_layer_visibility": "routing", "fusion_board_outline": "routing", "fusion_run_erc": "routing", "fusion_set_track_width": "routing", "fusion_remove_items": "routing", "fusion_set_outline": "routing", "fusion_board_shape": "routing", "fusion_autoroute": "routing", "fusion_check_dialogs": "gui-driving", "fusion_status": "bridge-lifecycle", "fusion_save": "aps", "fusion_run_script": "gui-driving", "fusion_run_command": "gui-driving", "fusion_list_commands": "gui-driving", "fusion_dialog_log": "gui-driving", "fusion_signin_status": "bridge-lifecycle", "fusion_license_status": "bridge-lifecycle", "fusion_open_document": "cad-3d-bom", "fusion_show_3d_chip": "cad-3d-bom",+    "fusion_schematic": "gui-driving", "fusion_show_attributes": "cad-3d-bom", "fusion_make_showcase_project": "cad-3d-bom", "fusion_eagle_import_status": "cad-3d-bom", "fusion_routing_state": "routing", "fusion_route_net": "routing", "fusion_routing_drc": "routing", "fusion_plan_route": "routing", "fusion_move_parts": "routing", "fusion_place_vias": "routing", "fusion_apply_pours": "routing", "fusion_pour_state": "routing", "fusion_board_source": "routing", "fusion_ripup_nets": "routing", "fusion_schematic_run": "routing", "fusion_board_bom": "routing", "fusion_change_pours": "routing", "fusion_board_seed": "routing", "fusion_schematic_plan": "routing", "fusion_remove_pours": "routing", "fusion_layer_stack": "routing", "fusion_netlist": "routing", "fusion_read_sheet_file": "routing", "fusion_bodies": "routing", "fusion_save_document": "aps", "fusion_apply_silkscreen": "routing", "fusion_component_data": "routing", "fusion_component_pins": "routing", "fusion_list_nets": "routing", "fusion_net_connections": "routing", "fusion_list_designators": "routing", "fusion_layers": "routing", "fusion_copper_layers": "routing", "fusion_layers_in_use": "routing", "fusion_read_rules": "routing", "fusion_rules_by_kind": "routing", "fusion_silk_state": "routing", "fusion_net_classes": "routing", "fusion_run_drc": "routing", "fusion_repour": "routing", "fusion_zoom_to": "routing", "fusion_launch": "bridge-lifecycle", "fusion_export_gerber": "exports", "fusion_dismiss_dialogs": "gui-driving", "fusion_board_summary": "routing", "fusion_install": "bridge-lifecycle", "fusion_sch_items": "routing", "fusion_sch_pins": "routing", "fusion_sch_trace_point": "routing", "fusion_sch_page": "routing", "fusion_sch_hierarchy": "routing", "fusion_sch_sheet_pins": "routing", "fusion_sch_connect_pins": "routing", "fusion_sch_fix_connectivity": "routing", "fusion_render_schematic": "routing", "fusion_sch_update_from_library": "routing", "fusion_sch_text": "routing", "fusion_sch_junction": "routing", "fusion_sch_delete_wire": "routing", "fusion_sch_split_wire": "routing", "fusion_sch_bus": "routing", "fusion_sch_edit_label": "routing", "fusion_sch_move_region": "routing", "fusion_sch_bus_entry": "routing", "fusion_sch_connect_to_bus": "routing", "fusion_sch_reset_fields": "routing", "fusion_sch_create": "routing", "fusion_update_board_from_eagle": "routing", "fusion_render_board_3d": "cad-3d-bom", "fusion_generate_body": "cad-3d-bom", "fusion_library_views": "gui-driving", "fusion_edit_parts": "routing", "fusion_align_parts": "routing", "fusion_remove_graphics": "routing", "fusion_remove_parts": "routing", "fusion_copper_balance": "routing", "fusion_return_path": "routing", "fusion_impedance": "routing", "fusion_current_capacity": "routing", "fusion_fab_check": "routing", "fusion_estimate_cost": "routing", "fusion_read_board": "routing", "fusion_diff_pairs": "routing", "fusion_sch_place_part": "routing", "fusion_export_spice": "routing", "fusion_simulate": "routing", "fusion_sch_connect": "routing", "fusion_sch_swap_part": "routing", "fusion_add_rule": "routing", "fusion_height_check": "routing", "fusion_placement_check": "routing", "fusion_check_clearance": "routing", "fusion_place_decoupling": "routing", "fusion_plan_fanout": "routing", "fusion_export_odb": "exports", "fusion_export_ipc356": "exports", "fusion_export_gencad": "exports", "fusion_export_ipc2581": "exports", "fusion_select_net": "routing", "fusion_set_layer_visibility": "routing", "fusion_board_outline": "routing", "fusion_run_erc": "routing", "fusion_set_track_width": "routing", "fusion_remove_items": "routing", "fusion_set_outline": "routing", "fusion_board_shape": "routing", "fusion_autoroute": "routing", "fusion_check_dialogs": "gui-driving", "fusion_status": "bridge-lifecycle", "fusion_save": "aps", "fusion_run_script": "gui-driving", "fusion_run_command": "gui-driving", "fusion_list_commands": "gui-driving", "fusion_dialog_log": "gui-driving", "fusion_signin_status": "bridge-lifecycle", "fusion_license_status": "bridge-lifecycle", "fusion_open_document": "cad-3d-bom", "fusion_show_3d_chip": "cad-3d-bom",     "fusion_apply_fusion_update": "bridge-lifecycle", "fusion_resolve_seat_dialog": "bridge-lifecycle", "fusion_interactive": "gui-driving", "fusion_narrate": "gui-driving", "fusion_view": "gui-driving", "fusion_electronics_query": "cad-3d-bom",     "fusion_activate_document": "gui-driving", "fusion_library_manager": "gui-driving", "fusion_plan_library_import": "cad-3d-bom",     "fusion_create_library": "cad-3d-bom", "fusion_install_library_bundle": "cad-3d-bom",