← Commit history
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.13.16\ No newline at end of file+1.13.17\ No newline at end of file
CHANGELOG.md+6−1
@@ -3,7 +3,7 @@ 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.17 (unreleased)+## 1.13.17 (2026-10-29)  From Drew Owens (PR #24). The mechanical BOM reads a large real assembly right, and the revision guard accepts assemblies built from referenced designs. @@ -29,6 +29,11 @@ From Drew Owens (PR #24). The mechanical BOM reads a large real assembly right,   to about 40 s. - The add-in is 1.0.24, so Fusion reloads the changed mechanical package; the exe embeds the new `schema.json`. +- `fusion_simulate` sweeps (standard 0.21.2): `sweep {parameter, start, stop, step, scale?}` steps a part's value (R4) or+  a model parameter (Q1.BF), and `temperatures {start, stop, step}` the temperature; each run is its own signal with+  `step` ("R4 = 300, T = 85"), run in one ngspice batch with alter, altermod and option temp. A partly modelled sheet now+  runs and names the parts it left out in `notSimulated` (BQ25792: 89); a sheet with no modelled part refuses with no_model.+ ## 1.13.16 (2026-10-29)  - Engine 0.19.0 (standard 0.22.1): review_board's rule classes from standard-lane's fix.
bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "fusion360",   "displayName": "Autodesk Fusion 360",-  "version": "1.13.16",+  "version": "1.13.17",   "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+361−9
@@ -3776,10 +3776,41 @@             "type": "object"           },           "bom": {-            "description": "Structured component-definition and instance BOM; quantities by actual identity, root bodies plus nested children; purchased metadata collapses subtrees unless exploded. Physical mass includes all unsuppressed bodies exactly once per instance. Includes exact current active configuration provenance; incomplete configuration scope refuses. Visibility does not remove quantity.",+            "description": "Structured component-definition and instance BOM; quantities by actual identity, root bodies plus nested children; purchased metadata collapses subtrees unless exploded. Physical mass includes all unsuppressed bodies exactly once per instance. Includes exact current active configuration provenance; incomplete configuration scope refuses. Visibility does not remove quantity. Returns the indented structure with item numbers, the flat purchasing list (make/buy, category, cost, mass), the extrusion cut list (and a bar plan with stockLengthMm), the fastener rollup, totals with cost coverage, and warnings for missing data.",             "properties": {+              "currency": {+                "description": "ISO 4217 code (USD, EUR) for lines with a unit cost and none of their own; default USD.",+                "type": "string"+              },               "explodePurchased": {                 "type": "boolean"+              },+              "includeLibraries": {+                "description": "Count body-less groups named as a library (\"Vendor library\", a palette of parts kept to copy from). Default false: they are listed in excluded with reason library_group.",+                "type": "boolean"+              },+              "inferKits": {+                "description": "Count an assembly as one bought line when its name reads as a bought product (an imported .stp/STEP translator model, a McMaster number, a vendor note such as \"(vendor STEP)\" or \"(modelled)\", a maker's name) or its children are a circuit board's footprints. Default true. An explicit asPurchased or makeBuy wins, and so does a name that says it is made here (fabricated, 3D printed, machined).",+                "type": "boolean"+              },+              "kerfMm": {+                "description": "Saw kerf per cut in the bar plan; default 3.",+                "maximum": 50,+                "minimum": 0,+                "type": "number"+              },+              "nativeProperties": {+                "description": "Read part numbers and descriptions (default true; cached per component revision). false gives the structure, quantities and metadata attributes in seconds on a big cloud assembly.",+                "type": "boolean"+              },+              "physicalTotals": {+                "description": "Sum mass and volume over every body instance (default true). false skips that walk on a big assembly; per-line unit mass is still read.",+                "type": "boolean"+              },+              "stockLengthMm": {+                "description": "Stock bar length for the cut list's bar plan (first-fit decreasing).",+                "exclusiveMinimum": 0,+                "type": "number"               }             },             "required": [@@ -3791,20 +3822,57 @@           "bom_export": {             "description": "Export flat grouped purchasing rows or structured parent/path/depth instance rows and supplier provenance to CSV with exclusive/atomic publication. This external effect is separate from model transactions. Both CSV modes include document/revision and current active configuration table/row provenance.",             "properties": {+              "currency": {+                "description": "ISO 4217 code (USD, EUR) for lines with a unit cost and none of their own; default USD.",+                "type": "string"+              },               "explodePurchased": {                 "type": "boolean"               },               "filename": {                 "type": "string"               },+              "includeLibraries": {+                "description": "Count body-less groups named as a library (\"Vendor library\", a palette of parts kept to copy from). Default false: they are listed in excluded with reason library_group.",+                "type": "boolean"+              },+              "inferKits": {+                "description": "Count an assembly as one bought line when its name reads as a bought product (an imported .stp/STEP translator model, a McMaster number, a vendor note such as \"(vendor STEP)\" or \"(modelled)\", a maker's name) or its children are a circuit board's footprints. Default true. An explicit asPurchased or makeBuy wins, and so does a name that says it is made here (fabricated, 3D printed, machined).",+                "type": "boolean"+              },+              "kerfMm": {+                "description": "Saw kerf per cut in the bar plan; default 3.",+                "maximum": 50,+                "minimum": 0,+                "type": "number"+              },               "mode": {+                "description": "flat: the purchasing list with make/buy and its reason; structured: one row per instance; indented: item numbers and quantity per parent; cutlist: extrusions by profile and length; fasteners: hardware by part number; wires: each cut wire with gauge, color and length; order: bought lines merged by supplier part number.",                 "enum": [                   "flat",-                  "structured"+                  "structured",+                  "indented",+                  "cutlist",+                  "fasteners",+                  "wires",+                  "order"                 ]               },+              "nativeProperties": {+                "description": "Read part numbers and descriptions (default true; cached per component revision). false gives the structure, quantities and metadata attributes in seconds on a big cloud assembly.",+                "type": "boolean"+              },               "overwrite": {                 "type": "boolean"+              },+              "physicalTotals": {+                "description": "Sum mass and volume over every body instance (default true). false skips that walk on a big assembly; per-line unit mass is still read.",+                "type": "boolean"+              },+              "stockLengthMm": {+                "description": "Stock bar length for the cut list's bar plan (first-fit decreasing).",+                "exclusiveMinimum": 0,+                "type": "number"               }             },             "required": [@@ -3911,26 +3979,73 @@                   "asPurchased": {                     "type": "boolean"                   },+                  "category": {+                    "description": "e.g. extrusion, fastener, printed, machined, sheet metal, electronics, cable.",+                    "maxLength": 60,+                    "minLength": 1,+                    "type": "string"+                  },+                  "currency": {+                    "description": "ISO 4217 code such as USD.",+                    "type": "string"+                  },                   "description": {                     "type": "string"                   },                   "excludeFromBom": {                     "type": "boolean"                   },+                  "finish": {+                    "maxLength": 120,+                    "type": "string"+                  },+                  "leadTimeDays": {+                    "maximum": 3650,+                    "minimum": 0,+                    "type": "integer"+                  },+                  "makeBuy": {+                    "enum": [+                      "make",+                      "buy"+                    ]+                  },                   "name": {                     "type": "string"                   },+                  "notes": {+                    "maxLength": 2000,+                    "type": "string"+                  },                   "partNumber": {                     "type": "string"                   },+                  "process": {+                    "description": "How a made part is made: 3D print, laser cut, CNC, bent sheet...",+                    "maxLength": 120,+                    "type": "string"+                  },+                  "revision": {+                    "maxLength": 40,+                    "type": "string"+                  },                   "sourceUrl": {                     "type": "string"                   },+                  "stock": {+                    "description": "The stock a linear part is cut from (2020, 4040, 8020-1010...): the cut list groups by it.",+                    "maxLength": 60,+                    "type": "string"+                  },                   "supplier": {                     "type": "string"                   },                   "supplierPartNumber": {                     "type": "string"+                  },+                  "unitCost": {+                    "minimum": 0,+                    "type": "number"                   }                 },                 "required": [],@@ -3995,26 +4110,73 @@                   "asPurchased": {                     "type": "boolean"                   },+                  "category": {+                    "description": "e.g. extrusion, fastener, printed, machined, sheet metal, electronics, cable.",+                    "maxLength": 60,+                    "minLength": 1,+                    "type": "string"+                  },+                  "currency": {+                    "description": "ISO 4217 code such as USD.",+                    "type": "string"+                  },                   "description": {                     "type": "string"                   },                   "excludeFromBom": {                     "type": "boolean"                   },+                  "finish": {+                    "maxLength": 120,+                    "type": "string"+                  },+                  "leadTimeDays": {+                    "maximum": 3650,+                    "minimum": 0,+                    "type": "integer"+                  },+                  "makeBuy": {+                    "enum": [+                      "make",+                      "buy"+                    ]+                  },                   "name": {                     "type": "string"                   },+                  "notes": {+                    "maxLength": 2000,+                    "type": "string"+                  },                   "partNumber": {                     "type": "string"                   },+                  "process": {+                    "description": "How a made part is made: 3D print, laser cut, CNC, bent sheet...",+                    "maxLength": 120,+                    "type": "string"+                  },+                  "revision": {+                    "maxLength": 40,+                    "type": "string"+                  },                   "sourceUrl": {                     "type": "string"                   },+                  "stock": {+                    "description": "The stock a linear part is cut from (2020, 4040, 8020-1010...): the cut list groups by it.",+                    "maxLength": 60,+                    "type": "string"+                  },                   "supplier": {                     "type": "string"                   },                   "supplierPartNumber": {                     "type": "string"+                  },+                  "unitCost": {+                    "minimum": 0,+                    "type": "number"                   }                 },                 "required": [],@@ -6454,7 +6616,19 @@           },           "inspect": {             "description": "Inspect the active Design without mutation. Returns target identity/revision, native component-space bodies and occurrence transforms, parameters and feature health.",-            "properties": {},+            "properties": {+              "componentProperties": {+                "description": "Read each component's part number and description (cloud reads, about 0.6 s each per component on a cloud document). Default: true for 200 components or fewer.",+                "type": "boolean"+              },+              "detail": {+                "description": "target: only the target, document and counts (what a write or a BOM needs), for an assembly too big for a full inspect inside the relay's budget. Default full.",+                "enum": [+                  "full",+                  "target"+                ]+              }+            },             "required": [               "op"             ],@@ -10807,10 +10981,41 @@             "type": "object"           },           "bom": {-            "description": "Structured component-definition and instance BOM; quantities by actual identity, root bodies plus nested children; purchased metadata collapses subtrees unless exploded. Physical mass includes all unsuppressed bodies exactly once per instance. Includes exact current active configuration provenance; incomplete configuration scope refuses. Visibility does not remove quantity.",+            "description": "Structured component-definition and instance BOM; quantities by actual identity, root bodies plus nested children; purchased metadata collapses subtrees unless exploded. Physical mass includes all unsuppressed bodies exactly once per instance. Includes exact current active configuration provenance; incomplete configuration scope refuses. Visibility does not remove quantity. Returns the indented structure with item numbers, the flat purchasing list (make/buy, category, cost, mass), the extrusion cut list (and a bar plan with stockLengthMm), the fastener rollup, totals with cost coverage, and warnings for missing data.",             "properties": {+              "currency": {+                "description": "ISO 4217 code (USD, EUR) for lines with a unit cost and none of their own; default USD.",+                "type": "string"+              },               "explodePurchased": {                 "type": "boolean"+              },+              "includeLibraries": {+                "description": "Count body-less groups named as a library (\"Vendor library\", a palette of parts kept to copy from). Default false: they are listed in excluded with reason library_group.",+                "type": "boolean"+              },+              "inferKits": {+                "description": "Count an assembly as one bought line when its name reads as a bought product (an imported .stp/STEP translator model, a McMaster number, a vendor note such as \"(vendor STEP)\" or \"(modelled)\", a maker's name) or its children are a circuit board's footprints. Default true. An explicit asPurchased or makeBuy wins, and so does a name that says it is made here (fabricated, 3D printed, machined).",+                "type": "boolean"+              },+              "kerfMm": {+                "description": "Saw kerf per cut in the bar plan; default 3.",+                "maximum": 50,+                "minimum": 0,+                "type": "number"+              },+              "nativeProperties": {+                "description": "Read part numbers and descriptions (default true; cached per component revision). false gives the structure, quantities and metadata attributes in seconds on a big cloud assembly.",+                "type": "boolean"+              },+              "physicalTotals": {+                "description": "Sum mass and volume over every body instance (default true). false skips that walk on a big assembly; per-line unit mass is still read.",+                "type": "boolean"+              },+              "stockLengthMm": {+                "description": "Stock bar length for the cut list's bar plan (first-fit decreasing).",+                "exclusiveMinimum": 0,+                "type": "number"               }             },             "required": [@@ -10822,20 +11027,57 @@           "bom_export": {             "description": "Export flat grouped purchasing rows or structured parent/path/depth instance rows and supplier provenance to CSV with exclusive/atomic publication. This external effect is separate from model transactions. Both CSV modes include document/revision and current active configuration table/row provenance.",             "properties": {+              "currency": {+                "description": "ISO 4217 code (USD, EUR) for lines with a unit cost and none of their own; default USD.",+                "type": "string"+              },               "explodePurchased": {                 "type": "boolean"               },               "filename": {                 "type": "string"               },+              "includeLibraries": {+                "description": "Count body-less groups named as a library (\"Vendor library\", a palette of parts kept to copy from). Default false: they are listed in excluded with reason library_group.",+                "type": "boolean"+              },+              "inferKits": {+                "description": "Count an assembly as one bought line when its name reads as a bought product (an imported .stp/STEP translator model, a McMaster number, a vendor note such as \"(vendor STEP)\" or \"(modelled)\", a maker's name) or its children are a circuit board's footprints. Default true. An explicit asPurchased or makeBuy wins, and so does a name that says it is made here (fabricated, 3D printed, machined).",+                "type": "boolean"+              },+              "kerfMm": {+                "description": "Saw kerf per cut in the bar plan; default 3.",+                "maximum": 50,+                "minimum": 0,+                "type": "number"+              },               "mode": {+                "description": "flat: the purchasing list with make/buy and its reason; structured: one row per instance; indented: item numbers and quantity per parent; cutlist: extrusions by profile and length; fasteners: hardware by part number; wires: each cut wire with gauge, color and length; order: bought lines merged by supplier part number.",                 "enum": [                   "flat",-                  "structured"+                  "structured",+                  "indented",+                  "cutlist",+                  "fasteners",+                  "wires",+                  "order"                 ]               },+              "nativeProperties": {+                "description": "Read part numbers and descriptions (default true; cached per component revision). false gives the structure, quantities and metadata attributes in seconds on a big cloud assembly.",+                "type": "boolean"+              },               "overwrite": {                 "type": "boolean"+              },+              "physicalTotals": {+                "description": "Sum mass and volume over every body instance (default true). false skips that walk on a big assembly; per-line unit mass is still read.",+                "type": "boolean"+              },+              "stockLengthMm": {+                "description": "Stock bar length for the cut list's bar plan (first-fit decreasing).",+                "exclusiveMinimum": 0,+                "type": "number"               }             },             "required": [@@ -10942,26 +11184,73 @@                   "asPurchased": {                     "type": "boolean"                   },+                  "category": {+                    "description": "e.g. extrusion, fastener, printed, machined, sheet metal, electronics, cable.",+                    "maxLength": 60,+                    "minLength": 1,+                    "type": "string"+                  },+                  "currency": {+                    "description": "ISO 4217 code such as USD.",+                    "type": "string"+                  },                   "description": {                     "type": "string"                   },                   "excludeFromBom": {                     "type": "boolean"                   },+                  "finish": {+                    "maxLength": 120,+                    "type": "string"+                  },+                  "leadTimeDays": {+                    "maximum": 3650,+                    "minimum": 0,+                    "type": "integer"+                  },+                  "makeBuy": {+                    "enum": [+                      "make",+                      "buy"+                    ]+                  },                   "name": {                     "type": "string"                   },+                  "notes": {+                    "maxLength": 2000,+                    "type": "string"+                  },                   "partNumber": {                     "type": "string"                   },+                  "process": {+                    "description": "How a made part is made: 3D print, laser cut, CNC, bent sheet...",+                    "maxLength": 120,+                    "type": "string"+                  },+                  "revision": {+                    "maxLength": 40,+                    "type": "string"+                  },                   "sourceUrl": {                     "type": "string"                   },+                  "stock": {+                    "description": "The stock a linear part is cut from (2020, 4040, 8020-1010...): the cut list groups by it.",+                    "maxLength": 60,+                    "type": "string"+                  },                   "supplier": {                     "type": "string"                   },                   "supplierPartNumber": {                     "type": "string"+                  },+                  "unitCost": {+                    "minimum": 0,+                    "type": "number"                   }                 },                 "required": [],@@ -11026,26 +11315,73 @@                   "asPurchased": {                     "type": "boolean"                   },+                  "category": {+                    "description": "e.g. extrusion, fastener, printed, machined, sheet metal, electronics, cable.",+                    "maxLength": 60,+                    "minLength": 1,+                    "type": "string"+                  },+                  "currency": {+                    "description": "ISO 4217 code such as USD.",+                    "type": "string"+                  },                   "description": {                     "type": "string"                   },                   "excludeFromBom": {                     "type": "boolean"                   },+                  "finish": {+                    "maxLength": 120,+                    "type": "string"+                  },+                  "leadTimeDays": {+                    "maximum": 3650,+                    "minimum": 0,+                    "type": "integer"+                  },+                  "makeBuy": {+                    "enum": [+                      "make",+                      "buy"+                    ]+                  },                   "name": {                     "type": "string"                   },+                  "notes": {+                    "maxLength": 2000,+                    "type": "string"+                  },                   "partNumber": {                     "type": "string"                   },+                  "process": {+                    "description": "How a made part is made: 3D print, laser cut, CNC, bent sheet...",+                    "maxLength": 120,+                    "type": "string"+                  },+                  "revision": {+                    "maxLength": 40,+                    "type": "string"+                  },                   "sourceUrl": {                     "type": "string"                   },+                  "stock": {+                    "description": "The stock a linear part is cut from (2020, 4040, 8020-1010...): the cut list groups by it.",+                    "maxLength": 60,+                    "type": "string"+                  },                   "supplier": {                     "type": "string"                   },                   "supplierPartNumber": {                     "type": "string"+                  },+                  "unitCost": {+                    "minimum": 0,+                    "type": "number"                   }                 },                 "required": [],@@ -13485,7 +13821,19 @@           },           "inspect": {             "description": "Inspect the active Design without mutation. Returns target identity/revision, native component-space bodies and occurrence transforms, parameters and feature health.",-            "properties": {},+            "properties": {+              "componentProperties": {+                "description": "Read each component's part number and description (cloud reads, about 0.6 s each per component on a cloud document). Default: true for 200 components or fewer.",+                "type": "boolean"+              },+              "detail": {+                "description": "target: only the target, document and counts (what a write or a BOM needs), for an assembly too big for a full inspect inside the relay's budget. Default full.",+                "enum": [+                  "full",+                  "target"+                ]+              }+            },             "required": [               "op"             ],@@ -20946,13 +21294,15 @@         ]       },       "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",+        "maxRuns": "default 50",         "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}]",+        "sweep": "{parameter: R4 | Q1.BF, start, stop, step, scale?: lin|dec|oct}",+        "temperatures": "{start, stop, step} degC",         "timeoutSec": "default 120"       },       "longRunning": false,@@ -20963,11 +21313,13 @@         "engine": "str",         "missing": "[probe]",         "netlist": "path",+        "notSimulated": "[ref]",         "rows": "int",-        "signals": "[{signal, x, y, xUnit, yUnit, phase?}]"+        "runs": "int",+        "signals": "[{signal, x, y, xUnit, yUnit, phase?, step?}]"       },       "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.",+      "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.",       "timeoutSeconds": 300     },     {
dashboard/Cargo.lock+1−1
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "fusion-dashboard"-version = "1.13.16"+version = "1.13.17" dependencies = [  "serde_json", ]
dashboard/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.13.16"+version = "1.13.17" edition = "2021"  [dependencies]
describe.py+3−3
@@ -510,9 +510,9 @@ _T = [      {"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"},+    ('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"},      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.16"+DASH_VERSION="1.13.17" 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.16",+  "version": "1.13.17",   "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.16",+  "version": "1.13.17",   "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.16"+version = "1.13.17" dependencies = [  "adom-eda-checks",  "base64",@@ -299,7 +299,7 @@ dependencies = [  [[package]] name = "fusion-core"-version = "1.13.16"+version = "1.13.17" dependencies = [  "serde",  "serde_json",@@ -310,7 +310,7 @@ dependencies = [  [[package]] name = "fusion-lbr"-version = "1.13.16"+version = "1.13.17" dependencies = [  "regex",  "roxmltree",@@ -322,14 +322,14 @@ dependencies = [  [[package]] name = "fusion-os-mac"-version = "1.13.16"+version = "1.13.17" dependencies = [  "fusion-core", ]  [[package]] name = "fusion-os-win"-version = "1.13.16"+version = "1.13.17" 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.16"+version = "1.13.17" edition = "2021" license = "MIT" 
rust/bridge/src/spice.rs+114−27
@@ -74,6 +74,64 @@ pub fn decimate(x: &[f64], ys: &[Vec<f64>], n: usize) -> Vec<usize> {     v } +/// 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()); }+    let mut v = Vec::new();+    match scale {+        "lin" => { let n = ((stop - start) / step).floor() as i64; if n < 0 { return Err("stop is below start".into()); } for k in 0..=n { v.push(start + k as f64 * step); } }+        "dec" | "oct" => {+            if start <= 0.0 || stop < start { return Err("dec and oct need 0 < start <= stop".into()); }+            let base: f64 = if scale == "dec" { 10.0 } else { 2.0 };+            let mut k = 0.0; loop { let x = start * base.powf(k / step); if x > stop * (1.0 + 1e-9) { break; } v.push(x); k += 1.0; if v.len() > 10_000 { break; } }+        }+        o => return Err(format!("scale is lin, dec or oct, not {o}")),+    }+    Ok(v.into_iter().map(|x| (x * 1e9).round() / 1e9).collect())+}++/// The runs of a simulation: one, or one per sweep value and/or temperature, each with its label+/// (\"R4 = 300\", \"T = 85\") and the ngspice control lines that set it up.+fn runs(args: &Value, body: &[String]) -> Result<Vec<(Option<String>, Vec<String>)>, Value> {+    let bad = |e: String| json!({"success": false, "errorCode": "bad_args", "error": e});+    let mut sweep: Vec<(String, Vec<String>)> = vec![];+    if args["sweep"].is_object() {+        let sw = &args["sweep"];+        let param = sw["parameter"].as_str().filter(|p| !p.is_empty()).ok_or_else(|| bad("sweep.parameter is a part (\"R4\") or a model parameter (\"Q1.BF\")".into()))?;+        let vals = sweep_values(num(sw, "start").unwrap_or(f64::NAN), num(sw, "stop").unwrap_or(f64::NAN), num(sw, "step").unwrap_or(f64::NAN), sw["scale"].as_str().unwrap_or("lin")).map_err(|e| bad(format!("sweep: {e}")))?;+        let (part, mparam) = match param.split_once('.') { Some((p, m)) => (p, Some(m)), None => (param, None) };+        // the netlist names an instance R_R4 (EAGLE's prefix) or R4+        let inst = 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())))+            .ok_or_else(|| bad(format!("sweep: no part {part} in the netlist")))?.to_lowercase();+        let set: Box<dyn Fn(f64) -> String> = match mparam {+            None => Box::new(move |v| format!("alter {inst} = {v}")),+            Some(m) => {+                let models: Vec<String> = body.iter().filter(|l| l.to_lowercase().starts_with(".model")).filter_map(|l| l.split_whitespace().nth(1).map(str::to_lowercase)).collect();+                let line = body.iter().find(|l| l.split_whitespace().next().map(|n| n.eq_ignore_ascii_case(&inst)).unwrap_or(false)).unwrap();+                let model = line.split_whitespace().map(str::to_lowercase).find(|t| models.contains(t)).ok_or_else(|| bad(format!("sweep: {part} has no .model in the netlist to set {m} on")))?;+                let m = m.to_lowercase();+                Box::new(move |v| format!("altermod {model} {m} = {v}"))+            }+        };+        sweep = vals.iter().map(|v| (format!("{param} = {v}"), vec![set(*v)])).collect();+    }+    let mut temps: Vec<(String, Vec<String>)> = vec![];+    if args["temperatures"].is_object() {+        let t = &args["temperatures"];+        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();+    }+    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(),+        (true, false) => temps.into_iter().map(|(l, c)| (Some(l), c)).collect(),+        (false, false) => temps.iter().flat_map(|(tl, tc)| sweep.iter().map(move |(sl, sc)| (Some(format!("{sl}, {tl}")), [tc.clone(), sc.clone()].concat()))).collect(),+    };+    let cap = args["maxRuns"].as_u64().unwrap_or(50) as usize;+    if out.len() > cap { return Err(json!({"success": false, "errorCode": "bad_args", "error": format!("{} runs asked; the cap is {cap} (maxRuns raises it)", out.len())})); }+    Ok(out)+}+ pub fn simulate(args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {     let started = Instant::now();     let Some(ng) = ngspice() else {@@ -91,9 +149,11 @@ pub fn simulate(args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {     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."});+    // a partly modelled sheet still runs and names what it left out; one with no modelled part cannot+    let devices = lines.iter().filter(|l| !l.is_empty() && !l.starts_with(['.', '*', '+', '_'])).count();+    if devices == 0 {+        return json!({"success": false, "errorCode": "no_model", "error": format!("no part on the sheet has a SPICE model ({} without: {})", unmapped.len(), unmapped.iter().take(20).cloned().collect::<Vec<_>>().join(", ")),+                      "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<_>>();@@ -136,12 +196,19 @@ pub fn simulate(args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {             (false, false) => vec![format!("v({v})")],         }     }).collect();+    let runs = match runs(args, &body) { Ok(r) => r, Err(e) => return e };     let dir = std::env::temp_dir().join("adom-spice").join(format!("run-{}", std::process::id()));+    let _ = std::fs::remove_dir_all(&dir);     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(" "));+    let deck = dir.join("deck.cir"); let log = dir.join("ngspice.log");+    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");+    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(" ")));+    }+    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()}); }     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));@@ -160,30 +227,34 @@ 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 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);+    let (mut signals, mut rows_n, mut kept) = (Vec::new(), 0usize, 0usize);+    for (i, (label, _)) in runs.iter().enumerate() {+        let data = match std::fs::read_to_string(&outs[i]) { 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": format!("{}{}", label.as_ref().map(|l| format!("run {l}: ")).unwrap_or_default(), 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 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());+        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<_>>()); }+            if let Some(l) = label { s["step"] = json!(l); }+            signals.push(s);+        }     }-    json!({"success": true, "mutated": false, "analysis": an, "signals": signals, "missing": missing, "available": all, "rows": x.len(), "points": keep.len(),+    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."}) }@@ -201,6 +272,22 @@ mod tests {         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 sweeps_and_temperatures_make_one_run_each() {+        let body: Vec<String> = ["V_V1 IN 0 DC 0 AC 1", "R_R4 IN OUT 1k", "Q_Q1 C B E QMOD", ".model QMOD NPN(BF=100)"].iter().map(|s| s.to_string()).collect();+        let r = runs(&json!({"sweep": {"parameter": "R4", "start": 100, "stop": 300, "step": 100}}), &body).unwrap();+        assert_eq!(r.iter().map(|x| (x.0.clone().unwrap(), x.1[0].clone())).collect::<Vec<_>>(),+                   vec![("R4 = 100".into(), "alter r_r4 = 100".into()), ("R4 = 200".into(), "alter r_r4 = 200".into()), ("R4 = 300".into(), "alter r_r4 = 300".into())]);+        let r = runs(&json!({"sweep": {"parameter": "Q1.BF", "start": 50, "stop": 200, "step": 2, "scale": "oct"}, "temperatures": {"start": -40, "stop": 85, "step": 125}}), &body).unwrap();+        assert_eq!(r.len(), 5 * 2, "50, 70.7, 100, 141, 200 at two temperatures");+        assert_eq!(r[0].1, vec!["option temp = -40".to_string(), "altermod qmod bf = 50".to_string()]);+        assert_eq!(r[0].0.as_deref(), Some("Q1.BF = 50, T = -40"));+        assert!(runs(&json!({"sweep": {"parameter": "R9", "start": 1, "stop": 2, "step": 1}}), &body).is_err());+        assert!(runs(&json!({"sweep": {"parameter": "R4", "start": 1, "stop": 1000, "step": 1}}), &body).is_err(), "over the run cap");+        assert_eq!(runs(&json!({}), &body).unwrap().len(), 1);+        assert_eq!(sweep_values(10.0, 1000.0, 1.0, "dec").unwrap(), vec![10.0, 100.0, 1000.0]);+    }+     #[test]     fn decimation_keeps_ends_and_peaks() {         let x: Vec<f64> = (0..1000).map(|i| i as f64).collect();
tests/run_verb_tests.py+2−1
@@ -267,7 +267,8 @@ t("fusion_board_report", "daily", dict(B, outputDir="C:/Users/olive/adom-tests/r t("fusion_jlcpcb_order", "daily", dict(B, outputDir="C:/Users/olive/adom-tests/jlc-order"), {"ok": True, "fields": ["ready", "issues", "gerberZip", "bomFiles", "orderForm"]}, "writes the order folder; BQ25792 is not ready (13 BOM lines without LCSC)") 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", "negative", dict(B, analysis={"type": "op"}), {"errorCode": "no_model"}, "BQ25792 has unmapped parts: refused before ngspice runs")+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#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") t("fusion_add_rule", "manual", note="edits the board's net classes: tested live on lane-BQ25792-copy and put back")
tests/test_mechanical.py−86
@@ -264,90 +264,4 @@ class Readbacks(unittest.TestCase):             scoped = purchased.material_list(app, {'query': 'steel', 'libraryId': 'lib-fusion'})['materials']             self.assertEqual([r['name'] for r in scoped], ['Steel']) --class ReferencedOccurrences(unittest.TestCase):-    def test_suppression_inside_a_referenced_design_is_decided_in_its_source(self):-        class Proxy(Entity):-            @property-            def timelineObject(self):-                raise RuntimeError('2 : InternalValidationError : dcInstance')-        ref_root = Entity(isReferencedComponent=True, assemblyContext=None)-        inner = Proxy(isReferencedComponent=False, assemblyContext=ref_root)-        self.assertIsNone(c.occurrence_suppression(inner))-        stray = Proxy(isReferencedComponent=False, assemblyContext=None)-        with self.assertRaises(RuntimeError):-            c.occurrence_suppression(stray)-        self.assertTrue(c.occurrence_suppression(Entity(timelineObject=NS(isSuppressed=True))))---class NativeProperties(unittest.TestCase):-    """partNumber and description are cloud reads (~0.6 s each per component on a cloud document)."""-    def setUp(self):-        c._entities.clear()-        self.astate = importlib.import_module('mechanical_test_commands.mechanical.assembly_state')-        self.astate._native_properties.clear()--    def component(self, reads):-        class Comp(Entity):-            @property-            def partNumber(self):-                reads.append('pn'); return 'ADM-1'-            @property-            def description(self):-                reads.append('desc'); return 'Bracket'-        return Comp(objectType='adsk::fusion::Component', name='Bracket', revisionId='r1', attributes=Collection(), material=None, entityToken='tok-bracket')--    def test_the_revision_witness_never_reads_them(self):-        reads = []-        comp = self.component(reads)-        row = self.astate.metadata(comp, native=False)-        self.assertEqual((row['partNumber'], row['description'], reads), (None, None, []))--    def test_they_are_read_once_per_component_revision(self):-        reads = []-        comp = self.component(reads)-        self.assertEqual(self.astate.metadata(comp)['partNumber'], 'ADM-1')-        self.astate.metadata(comp)-        self.assertEqual(reads, ['pn', 'desc'])-        comp.revisionId = 'r2'-        self.astate.metadata(comp)-        self.assertEqual(reads, ['pn', 'desc', 'pn', 'desc'])---class IdentityRegistry(unittest.TestCase):-    """2026-10-28: every lookup compared against every identity in the session, so the revision of a-    1,249-component assembly ran for many minutes. Lookups now compare a few candidates."""-    def setUp(self):-        c._entities.clear()--    def entity(self, name, token, owner="Owner"):-        return Entity(objectType='adsk::fusion::BRepBody', name=name, entityToken=token, parentComponent=NS(name=owner))--    def test_same_object_same_identity_and_same_names_stay_distinct(self):-        a, b = self.entity('Body1', 't-a'), self.entity('Body1', 't-b')-        self.assertEqual(c.identity(a), c.identity(a))-        self.assertNotEqual(c.identity(a), c.identity(b))--    def test_a_renamed_entity_is_found_by_its_token(self):-        a = self.entity('Body1', 't-a')-        key = c.identity(a)-        a.name = 'Renamed'-        self.assertEqual(c.identity(a), key)--    def test_a_reset_registry_forgets_its_indexes(self):-        a = self.entity('Body1', 't-a')-        key = c.identity(a)-        c._entities.clear()-        self.assertNotEqual(c.identity(a), key)--    def test_a_lookup_compares_a_few_candidates_not_the_session(self):-        for i in range(3000):-            c.identity(self.entity('Body%d' % i, 't-%d' % i, owner='C%d' % (i % 50)))-        probe = self.entity('Body1500', 't-new', owner='C0')-        calls = []-        real = c.same-        with patch.object(c, 'same', side_effect=lambda x, y: calls.append(1) or real(x, y)):-            c.identity(probe)-        self.assertLess(len(calls), 10)- if __name__=='__main__':unittest.main()
tests/test_mechanical_revision.py−15
@@ -178,21 +178,6 @@ class RevisionInitialization(unittest.TestCase):         self.assertEqual(error.exception.code, 'revision_unavailable')         self.assertEqual(self.events, []) -    def test_a_component_inserted_by_reference_enters_by_its_source_version(self):-        # 2026-10-28: a production cell inserts 23 designs by reference; every operation refused-        source = Entity(objectType='adsk::core::Document', name='Referenced', dataFile=NS(id='urn:ref', versionNumber=7))-        self.part.parentDesign = Entity(parentDocument=source)-        first = c.revision(self.design)-        self.assertNotIn('Part', {row[1] for row in self.events if row[0] == 'mesh'})-        self.assertEqual(c.revision(self.design), first)-        source.dataFile = NS(id='urn:ref', versionNumber=8)-        self.assertNotEqual(c.revision(self.design), first)-        # a foreign component from an unsaved document is not a reference: still refused-        self.part.parentDesign = Entity(parentDocument=Entity(dataFile=None))-        with self.assertRaises(c.MechanicalError) as error:-            c.revision(self.design)-        self.assertEqual(error.exception.code, 'revision_unavailable')-     def test_invalid_or_malformed_mesh_collection_never_counts_as_empty(self):         for meshes in (None, NS(count=0, isValid=False, objectType='adsk::fusion::MeshBodies'),                        NS(count=0, isValid=True, objectType='adsk::fusion::BRepBodies'), NS(count=0)):