← Commit history
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.13.20\ No newline at end of file+1.13.21\ No newline at end of file
bridge.json+7−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "fusion360",   "displayName": "Autodesk Fusion 360",-  "version": "1.13.20",+  "version": "1.13.21",   "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.",@@ -256,6 +256,7 @@     "fusion_rename_symbol",     "fusion_render_board_3d",     "fusion_render_schematic",+    "fusion_replicate_layout",     "fusion_repour",     "fusion_reset_workspace",     "fusion_resolve_seat_dialog",@@ -321,6 +322,7 @@     "fusion_set_preference",     "fusion_set_seat_takeover",     "fusion_set_track_width",+    "fusion_set_visibility",     "fusion_set_window_sizing",     "fusion_sheet_params",     "fusion_show_2d_board",@@ -344,6 +346,7 @@     "fusion_status",     "fusion_stop",     "fusion_swap_footprint",+    "fusion_symbol_prims",     "fusion_take_screenshot",     "fusion_teardrops",     "fusion_test_board",@@ -516,6 +519,9 @@       "project_snapshot": 300,       "project_create": 180,       "test_board": 180,+      "symbol_prims": 30,+      "set_visibility": 30,+      "replicate_layout": 180,       "teardrops": 300,       "panelize": 300,       "swap_footprint": 180,
contracts/describe.json+527−1
@@ -4740,6 +4740,162 @@             ],             "type": "object"           },+          "exploded_view_render": {+            "description": "Render an exploded-view animation for operators: play the same steps as exploded_view_plan on the real occurrences, save one viewport image per frame from a fixed camera (fitted to the most exploded state), then revert every move and verify each instance is back home and the document state unchanged. Refuses a design with an uncaptured move. Journaled; follow a long render with fusion_mech_operation_status. Returns the frame folder, timed captions from the steps' titles and instructions, and the ffmpeg line that joins the frames into MP4 (Fusion's API has no video export).",+            "properties": {+              "fit": {+                "description": "Fit the camera to the most exploded state first (default true); false keeps the current view.",+                "type": "boolean"+              },+              "folder": {+                "description": "Absolute folder on the PC for frame_00000.png ... (created if missing).",+                "type": "string"+              },+              "fps": {+                "description": "Frames per second (default 24).",+                "maximum": 60,+                "minimum": 1,+                "type": "integer"+              },+              "height": {+                "description": "Frame height in pixels (default 720).",+                "maximum": 2160,+                "minimum": 240,+                "type": "integer"+              },+              "holdSeconds": {+                "description": "Hold the last pose this long (default 1).",+                "maximum": 30,+                "minimum": 0,+                "type": "number"+              },+              "operationId": {+                "type": "string"+              },+              "overwrite": {+                "description": "Replace frames already in folder (default false).",+                "type": "boolean"+              },+              "playback": {+                "enum": [+                  "explode",+                  "assemble"+                ],+                "type": "string"+              },+              "sessionId": {+                "type": "string"+              },+              "steps": {+                "items": {+                  "additionalProperties": false,+                  "properties": {+                    "durationSeconds": {+                      "exclusiveMinimum": 0,+                      "maximum": 3600,+                      "type": "number"+                    },+                    "instruction": {+                      "type": "string"+                    },+                    "moves": {+                      "items": {+                        "additionalProperties": false,+                        "properties": {+                          "direction": {+                            "items": {+                              "maximum": 1000000000.0,+                              "minimum": -1000000000.0,+                              "type": "number"+                            },+                            "maxItems": 3,+                            "minItems": 3,+                            "type": "array"+                          },+                          "distance": {+                            "exclusiveMinimum": 0,+                            "maximum": 1000000.0,+                            "type": "number"+                          },+                          "frame": {+                            "enum": [+                              "world",+                              "parent",+                              "local"+                            ],+                            "type": "string"+                          },+                          "occurrence": {+                            "description": "Exact guarded occurrence reference from inspect; every repeated/nested instance is distinct.",+                            "type": "object"+                          }+                        },+                        "required": [+                          "occurrence",+                          "direction",+                          "distance",+                          "frame"+                        ],+                        "type": "object"+                      },+                      "maxItems": 128,+                      "minItems": 1,+                      "type": "array"+                    },+                    "title": {+                      "type": "string"+                    }+                  },+                  "required": [+                    "durationSeconds",+                    "moves"+                  ],+                  "type": "object"+                },+                "maxItems": 32,+                "minItems": 1,+                "type": "array"+              },+              "target": {+                "additionalProperties": false,+                "properties": {+                  "documentId": {+                    "type": "string"+                  },+                  "revision": {+                    "type": "string"+                  }+                },+                "required": [+                  "documentId",+                  "revision"+                ],+                "type": "object"+              },+              "unit": {+                "enum": [+                  "mm"+                ],+                "type": "string"+              },+              "width": {+                "description": "Frame width in pixels (default 1280).",+                "maximum": 3840,+                "minimum": 320,+                "type": "integer"+              }+            },+            "required": [+              "op",+              "sessionId",+              "operationId",+              "target",+              "unit",+              "steps",+              "folder"+            ],+            "type": "object"+          },           "export": {             "description": "Export entire design using runtime-checked API. Preflight and unique same-filesystem staging preserve the destination on failure; publish only nonempty verified output. Availability differs by Fusion build. STL numeric coordinates use mm.",             "properties": {@@ -10048,6 +10204,70 @@             ],             "type": "object"           },+          "render_image": {+            "description": "Render a photoreal image with Fusion's local renderer: from a standard view fitted to the design (iso by default) or the current view, at a size and quality (0 to 100; 75 by default), optionally on a transparent background or without the ground. Waits for the render, then restores the camera and every render setting it changed. Journaled: a large, high-quality render runs for minutes, so follow it with fusion_mech_operation_status. Fusion's API cannot choose the render environment; the document's own is used.",+            "properties": {+              "filename": {+                "description": "Absolute .png or .jpg path on the PC.",+                "type": "string"+              },+              "ground": {+                "description": "Show the ground plane (shadows and reflections); default leaves the document's setting.",+                "type": "boolean"+              },+              "height": {+                "maximum": 4320,+                "minimum": 64,+                "type": "integer"+              },+              "operationId": {+                "type": "string"+              },+              "overwrite": {+                "type": "boolean"+              },+              "quality": {+                "maximum": 100,+                "minimum": 0,+                "type": "integer"+              },+              "timeoutSeconds": {+                "maximum": 3600,+                "minimum": 10,+                "type": "number"+              },+              "transparent": {+                "type": "boolean"+              },+              "view": {+                "enum": [+                  "iso",+                  "iso-left",+                  "front",+                  "back",+                  "top",+                  "bottom",+                  "left",+                  "right",+                  "current"+                ],+                "type": "string"+              },+              "width": {+                "maximum": 7680,+                "minimum": 64,+                "type": "integer"+              }+            },+            "required": [+              "op",+              "sessionId",+              "operationId",+              "target",+              "filename"+            ],+            "type": "object"+          },           "render_scene_inspect": {             "description": "Guarded main-thread read of current viewport camera, render settings and up to 32 environments. Optional detached world-mm camera is constructed and read back without viewport assignment. Missing fields return PARTIAL; observationComplete covers only bounded available scope. sceneWitnessComplete and renderQualified remain false. No rendering, workspace activation or scene writes.",             "properties": {@@ -11090,7 +11310,7 @@         "op": "inspect"       },       "input": {-        "op": "required enum: inspect|inspect_entity|new_design|parameter_set|parameter_create|health|export|transaction_probe|sketch_create|sketch_geometry|sketch_constraint|sketch_dimension|sketch_project|sketch_edit|plane_create|feature_create|feature_edit|entity_delete|parameter_batch|component_create|occurrence_insert|occurrence_update|occurrence_delete|component_metadata|joint_create|joint_edit|joint_delete|interference|bom|bom_export|cad_import|mcmaster_discover|mcmaster_open|material_list|material_assign|rigid_group_create|rigid_group_edit|rigid_group_delete|assembly_properties|geometry_query|measure|sketch_curve_edit|sketch_offset|construction_axis|sketch_diagnostics|joint_origin_create|joint_origin_edit|joint_origin_delete|regular_joint_create|regular_joint_edit|motion_link_create|motion_link_edit|motion_link_delete|configuration_inspect|render_scene_inspect|exploded_view_plan|exploded_preview_create|exploded_preview_sample|exploded_preview_show|exploded_preview_dispose|exploded_preview_inspect|efficiency_report",+        "op": "required enum: inspect|inspect_entity|new_design|parameter_set|parameter_create|health|export|transaction_probe|sketch_create|sketch_geometry|sketch_constraint|sketch_dimension|sketch_project|sketch_edit|plane_create|feature_create|feature_edit|entity_delete|parameter_batch|component_create|occurrence_insert|occurrence_update|occurrence_delete|component_metadata|joint_create|joint_edit|joint_delete|interference|bom|bom_export|cad_import|mcmaster_discover|mcmaster_open|material_list|material_assign|rigid_group_create|rigid_group_edit|rigid_group_delete|assembly_properties|geometry_query|measure|sketch_curve_edit|sketch_offset|construction_axis|sketch_diagnostics|joint_origin_create|joint_origin_edit|joint_origin_delete|regular_joint_create|regular_joint_edit|motion_link_create|motion_link_edit|motion_link_delete|configuration_inspect|render_scene_inspect|exploded_view_plan|exploded_preview_create|exploded_preview_sample|exploded_preview_show|exploded_preview_dispose|exploded_preview_inspect|efficiency_report|exploded_view_render|render_image",         "operationId": "required unique write ID; reuse unchanged for retry",         "operationSchemas": {           "assembly_properties": {@@ -12077,6 +12297,162 @@             ],             "type": "object"           },+          "exploded_view_render": {+            "description": "Render an exploded-view animation for operators: play the same steps as exploded_view_plan on the real occurrences, save one viewport image per frame from a fixed camera (fitted to the most exploded state), then revert every move and verify each instance is back home and the document state unchanged. Refuses a design with an uncaptured move. Journaled; follow a long render with fusion_mech_operation_status. Returns the frame folder, timed captions from the steps' titles and instructions, and the ffmpeg line that joins the frames into MP4 (Fusion's API has no video export).",+            "properties": {+              "fit": {+                "description": "Fit the camera to the most exploded state first (default true); false keeps the current view.",+                "type": "boolean"+              },+              "folder": {+                "description": "Absolute folder on the PC for frame_00000.png ... (created if missing).",+                "type": "string"+              },+              "fps": {+                "description": "Frames per second (default 24).",+                "maximum": 60,+                "minimum": 1,+                "type": "integer"+              },+              "height": {+                "description": "Frame height in pixels (default 720).",+                "maximum": 2160,+                "minimum": 240,+                "type": "integer"+              },+              "holdSeconds": {+                "description": "Hold the last pose this long (default 1).",+                "maximum": 30,+                "minimum": 0,+                "type": "number"+              },+              "operationId": {+                "type": "string"+              },+              "overwrite": {+                "description": "Replace frames already in folder (default false).",+                "type": "boolean"+              },+              "playback": {+                "enum": [+                  "explode",+                  "assemble"+                ],+                "type": "string"+              },+              "sessionId": {+                "type": "string"+              },+              "steps": {+                "items": {+                  "additionalProperties": false,+                  "properties": {+                    "durationSeconds": {+                      "exclusiveMinimum": 0,+                      "maximum": 3600,+                      "type": "number"+                    },+                    "instruction": {+                      "type": "string"+                    },+                    "moves": {+                      "items": {+                        "additionalProperties": false,+                        "properties": {+                          "direction": {+                            "items": {+                              "maximum": 1000000000.0,+                              "minimum": -1000000000.0,+                              "type": "number"+                            },+                            "maxItems": 3,+                            "minItems": 3,+                            "type": "array"+                          },+                          "distance": {+                            "exclusiveMinimum": 0,+                            "maximum": 1000000.0,+                            "type": "number"+                          },+                          "frame": {+                            "enum": [+                              "world",+                              "parent",+                              "local"+                            ],+                            "type": "string"+                          },+                          "occurrence": {+                            "description": "Exact guarded occurrence reference from inspect; every repeated/nested instance is distinct.",+                            "type": "object"+                          }+                        },+                        "required": [+                          "occurrence",+                          "direction",+                          "distance",+                          "frame"+                        ],+                        "type": "object"+                      },+                      "maxItems": 128,+                      "minItems": 1,+                      "type": "array"+                    },+                    "title": {+                      "type": "string"+                    }+                  },+                  "required": [+                    "durationSeconds",+                    "moves"+                  ],+                  "type": "object"+                },+                "maxItems": 32,+                "minItems": 1,+                "type": "array"+              },+              "target": {+                "additionalProperties": false,+                "properties": {+                  "documentId": {+                    "type": "string"+                  },+                  "revision": {+                    "type": "string"+                  }+                },+                "required": [+                  "documentId",+                  "revision"+                ],+                "type": "object"+              },+              "unit": {+                "enum": [+                  "mm"+                ],+                "type": "string"+              },+              "width": {+                "description": "Frame width in pixels (default 1280).",+                "maximum": 3840,+                "minimum": 320,+                "type": "integer"+              }+            },+            "required": [+              "op",+              "sessionId",+              "operationId",+              "target",+              "unit",+              "steps",+              "folder"+            ],+            "type": "object"+          },           "export": {             "description": "Export entire design using runtime-checked API. Preflight and unique same-filesystem staging preserve the destination on failure; publish only nonempty verified output. Availability differs by Fusion build. STL numeric coordinates use mm.",             "properties": {@@ -17385,6 +17761,70 @@             ],             "type": "object"           },+          "render_image": {+            "description": "Render a photoreal image with Fusion's local renderer: from a standard view fitted to the design (iso by default) or the current view, at a size and quality (0 to 100; 75 by default), optionally on a transparent background or without the ground. Waits for the render, then restores the camera and every render setting it changed. Journaled: a large, high-quality render runs for minutes, so follow it with fusion_mech_operation_status. Fusion's API cannot choose the render environment; the document's own is used.",+            "properties": {+              "filename": {+                "description": "Absolute .png or .jpg path on the PC.",+                "type": "string"+              },+              "ground": {+                "description": "Show the ground plane (shadows and reflections); default leaves the document's setting.",+                "type": "boolean"+              },+              "height": {+                "maximum": 4320,+                "minimum": 64,+                "type": "integer"+              },+              "operationId": {+                "type": "string"+              },+              "overwrite": {+                "type": "boolean"+              },+              "quality": {+                "maximum": 100,+                "minimum": 0,+                "type": "integer"+              },+              "timeoutSeconds": {+                "maximum": 3600,+                "minimum": 10,+                "type": "number"+              },+              "transparent": {+                "type": "boolean"+              },+              "view": {+                "enum": [+                  "iso",+                  "iso-left",+                  "front",+                  "back",+                  "top",+                  "bottom",+                  "left",+                  "right",+                  "current"+                ],+                "type": "string"+              },+              "width": {+                "maximum": 7680,+                "minimum": 64,+                "type": "integer"+              }+            },+            "required": [+              "op",+              "sessionId",+              "operationId",+              "target",+              "filename"+            ],+            "type": "object"+          },           "render_scene_inspect": {             "description": "Guarded main-thread read of current viewport camera, render settings and up to 32 environments. Optional detached world-mm camera is constructed and read back without viewport assignment. Missing fields return PARTIAL; observationComplete covers only bounded available scope. sceneWitnessComplete and renderQualified remain false. No rendering, workspace activation or scene writes.",             "properties": {@@ -19613,6 +20053,41 @@       "summary": "Render a schematic sheet to a PNG without opening a window, through Fusion's own EXPORT IMAGE, and keep visual baselines: baseline set stores the render, baseline compare renders at the baseline's width and reports the pixel drift, the changed region (mm on the sheet) and a diff image with the changed pixels in red.",       "timeoutSeconds": 180     },+    {+      "example": {+        "dryRun": true,+        "source": [+          "U1",+          "R1",+          "C1"+        ],+        "targets": [+          [+            "U2",+            "R2",+            "C2"+          ]+        ]+      },+      "input": {+        "anchor": "a ref from source (default the first)",+        "dryRun": "bool",+        "expectDocument": "required",+        "source": "[refs] of the placed channel (2 or more)",+        "targets": "[[refs], ...] each in the same order as source"+      },+      "longRunning": false,+      "name": "fusion_replicate_layout",+      "output": {+        "footprintMismatches": "[{ref, footprint, source, sourceFootprint}]",+        "moved": "int",+        "moves": "[{ref, x, y, rotation}]",+        "revision": "str"+      },+      "statusVerb": null,+      "summary": "Copy one channel's placement to matching channels (the standard's replicate_layout): each target keeps its anchor part where it is and every other part takes the source's offset and turn from the anchor, turned by the anchors' rotation difference; a bottom part stays mirrored. One guarded move (fusion_move_parts: read back, undone on a mismatch); footprintMismatches names parts whose footprint differs from their source part. dryRun returns the moves.",+      "timeoutSeconds": 180+    },     {       "example": {         "expectDocument": "fusion-routing"@@ -21186,6 +21661,33 @@       "summary": "Change the width of the existing tracks on the given nets or net class, optionally one copper layer. Each track is picked with only its layer displayed (the display is restored), then the copper is read back: only the asked tracks may change, and only their width, or every change is undone. Arcs are left alone.",       "timeoutSeconds": 180     },+    {+      "example": {+        "dryRun": true,+        "libraryPath": "C:/libs/parts.lbr",+        "parameter": "MPN",+        "symbolName": "LDO",+        "visible": true+      },+      "input": {+        "dryRun": "bool",+        "libraryPath": ".lbr on the Fusion machine (required)",+        "parameter": "the attribute, e.g. MPN (required)",+        "symbolName": "the component (required)",+        "visible": "bool, default true"+      },+      "longRunning": false,+      "name": "fusion_set_visibility",+      "output": {+        "component": "str",+        "parameter": "str",+        "symbolsChanged": "[str]",+        "visible": "bool"+      },+      "statusVerb": null,+      "summary": "Show or hide one parameter of a component on the sheet (the standard's set_visibility), offline in a .lbr: EAGLE shows a parameter when the symbol carries a >NAME text for it, so showing adds that text (Info layer, below the body) to each symbol the component draws and hiding removes it. Placed parts follow when the library is updated in the design. Preflighted before writing.",+      "timeoutSeconds": 30+    },     {       "example": {},       "input": {@@ -21687,6 +22189,30 @@       "summary": "A part on a board kept as EAGLE source (fusion_test_board's .brd) takes another footprint from a .lbr at the same place and turn (the standard's swap_footprint); its pads leave their nets and take assigns (pad number -> net, every pad of that number); tracks stay. designator picks the part (default: the first). The board reopens in Fusion as a local design and is read back.",       "timeoutSeconds": 180     },+    {+      "example": {+        "libraryPath": "C:/libs/parts.lbr",+        "symbolName": "LDO"+      },+      "input": {+        "libraryPath": ".lbr on the Fusion machine (required)",+        "symbolName": "the component (required)"+      },+      "longRunning": false,+      "name": "fusion_symbol_prims",+      "output": {+        "bodyRectMil": "[x1, y1, x2, y2]",+        "lines": "int",+        "models": "[{type, name, device}]",+        "pins": "int",+        "polyBboxMil": "[x1, y1, x2, y2] | null",+        "polylines": "int",+        "rectangles": "int"+      },+      "statusVerb": null,+      "summary": "A component's drawing (the standard's symbol_prims), offline from a .lbr: primitive counts by kind (pins, lines, rectangles, polygons, circles, arcs, texts) over the symbols its gates draw, the body rectangle and the polygons' box in mil, and its footprints (the component's devices). Read-only: verifies import_part, set_models and add_models.",+      "timeoutSeconds": 30+    },     {       "example": {},       "input": {
dashboard/Cargo.lock+1−1
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "fusion-dashboard"-version = "1.13.20"+version = "1.13.21" dependencies = [  "serde_json", ]
dashboard/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.13.20"+version = "1.13.21" edition = "2021"  [dependencies]
describe.py+12
@@ -410,6 +410,18 @@ _T = [      {"libraryPath": ".lbr holding the footprint (required)", "footprintName": "str (required)", "pcbPath": ".brd (required)", "assigns": "pad=NET;...", "designator": "the part (default: the first)", "open": "bool, default true"},      {"swap": "{designator, from, to, padsWired, unknownPads, has3d}", "data": "{placed, padsOnComp, bodiesOnComp}", "documentName": "str"},      180, None, False, {"libraryPath": "C:/libs/sensors.lbr", "footprintName": "VL53L5CX", "pcbPath": "C:/Boards/tof-test.brd", "assigns": "1=GND;2=VDD"}),+    ('fusion_replicate_layout', "Copy one channel's placement to matching channels (the standard's replicate_layout): each target keeps its anchor part where it is and every other part takes the source's offset and turn from the anchor, turned by the anchors' rotation difference; a bottom part stays mirrored. One guarded move (fusion_move_parts: read back, undone on a mismatch); footprintMismatches names parts whose footprint differs from their source part. dryRun returns the moves.",+     {"expectDocument": "required", "source": "[refs] of the placed channel (2 or more)", "targets": "[[refs], ...] each in the same order as source", "anchor": "a ref from source (default the first)", "dryRun": "bool"},+     {"moved": "int", "moves": "[{ref, x, y, rotation}]", "footprintMismatches": "[{ref, footprint, source, sourceFootprint}]", "revision": "str"},+     180, None, False, {"source": ["U1", "R1", "C1"], "targets": [["U2", "R2", "C2"]], "dryRun": True}),+    ('fusion_symbol_prims', "A component's drawing (the standard's symbol_prims), offline from a .lbr: primitive counts by kind (pins, lines, rectangles, polygons, circles, arcs, texts) over the symbols its gates draw, the body rectangle and the polygons' box in mil, and its footprints (the component's devices). Read-only: verifies import_part, set_models and add_models.",+     {"libraryPath": ".lbr on the Fusion machine (required)", "symbolName": "the component (required)"},+     {"pins": "int", "lines": "int", "rectangles": "int", "polylines": "int", "bodyRectMil": "[x1, y1, x2, y2]", "polyBboxMil": "[x1, y1, x2, y2] | null", "models": "[{type, name, device}]"},+     30, None, False, {"libraryPath": "C:/libs/parts.lbr", "symbolName": "LDO"}),+    ('fusion_set_visibility', "Show or hide one parameter of a component on the sheet (the standard's set_visibility), offline in a .lbr: EAGLE shows a parameter when the symbol carries a >NAME text for it, so showing adds that text (Info layer, below the body) to each symbol the component draws and hiding removes it. Placed parts follow when the library is updated in the design. Preflighted before writing.",+     {"libraryPath": ".lbr on the Fusion machine (required)", "symbolName": "the component (required)", "parameter": "the attribute, e.g. MPN (required)", "visible": "bool, default true", "dryRun": "bool"},+     {"component": "str", "parameter": "str", "visible": "bool", "symbolsChanged": "[str]"},+     30, None, False, {"libraryPath": "C:/libs/parts.lbr", "symbolName": "LDO", "parameter": "MPN", "visible": True, "dryRun": True}),     ('fusion_test_board', "Footprints from a .lbr on ONE board (the standard's test_board): a NEW board (wMm x hMm outline, or one that fits the row) when pcbPath is new or omitted, APPENDED right of the current parts when pcbPath is an existing .brd, designators continuing (U4...). Written as EAGLE source, opened in Fusion as a local design dialog-free, and read back. assigns and stubs apply to the LAST part placed.",      {"libraryPath": "a .lbr on the Fusion machine (required)", "footprintName": "str | 'A;B;C' (required)", "pcbPath": "a .brd: existing = append, new = create (default %TEMP%/adom-test-boards/<name>.brd)", "wMm": "number (new board only)", "hMm": "number (new board only)", "assigns": "pad=NET;... (last part)", "stubs": "pad=x,y;... mm from the part's origin (last part): a 0.2 mm top track", "prefix": "designator prefix, default U", "layers": "2|4|6|8 (new board)", "open": "bool, default true"},      {"appended": "bool", "pcb": "path", "placed": "'U1;U2'", "count": "int (parts on the board)", "existingParts": "int", "padsOnComp": "int", "bodiesOnComp": "int", "saved": "bool (checked on disk)", "documentName": "str", "unknownPins": "[]"},
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.20"+DASH_VERSION="1.13.21" 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.20",+  "version": "1.13.21",   "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+2−2
@@ -1,6 +1,6 @@ {   "slug": "fusion-bridge",-  "version": "1.13.20",+  "version": "1.13.21",   "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": {@@ -152,4 +152,4 @@   "confirmed_user_skills": [     "fusion-design-efficiency"   ]-}\ No newline at end of file+}
rust/Cargo.lock+5−5
@@ -19,7 +19,7 @@ dependencies = [  [[package]] name = "adom-fusion-bridge"-version = "1.13.20"+version = "1.13.21" dependencies = [  "adom-eda-checks",  "base64",@@ -299,7 +299,7 @@ dependencies = [  [[package]] name = "fusion-core"-version = "1.13.20"+version = "1.13.21" dependencies = [  "serde",  "serde_json",@@ -310,7 +310,7 @@ dependencies = [  [[package]] name = "fusion-lbr"-version = "1.13.20"+version = "1.13.21" dependencies = [  "regex",  "roxmltree",@@ -322,14 +322,14 @@ dependencies = [  [[package]] name = "fusion-os-mac"-version = "1.13.20"+version = "1.13.21" dependencies = [  "fusion-core", ]  [[package]] name = "fusion-os-win"-version = "1.13.20"+version = "1.13.21" 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.20"+version = "1.13.21" edition = "2021" license = "MIT" 
rust/bridge/src/main.rs+12−3
@@ -38,6 +38,7 @@ mod testproject; mod boardgen; mod partimport; mod unsupported;+mod replicate; mod livefeed; mod chainexec; mod epg;@@ -502,13 +503,13 @@ fn main() {                     "fusion_generate_body_engine" => { let mut r = body::generate(&parsed["args"]); r["bridgeVersion"] = json!(VERSION); r["shell"] = json!("rust"); r },                     "fusion_read_sheet_file" | "read_sheet_file" => { let mut r = sheet::read_sheet_file(&parsed["args"]); r["bridgeVersion"] = json!(VERSION); r["shell"] = json!("rust"); r },                     "fusion_netlist" | "netlist" if parsed["args"]["schPath"].is_string() => { let mut r = sheet::netlist_from_sch(&parsed["args"]); r["bridgeVersion"] = json!(VERSION); r["shell"] = json!("rust"); r },-                    v if routing::owns(v.strip_prefix("fusion_").unwrap_or(v)) || sch::owns(v.strip_prefix("fusion_").unwrap_or(v)) || docs::owns(v.strip_prefix("fusion_").unwrap_or(v)) || checks::owns(v.strip_prefix("fusion_").unwrap_or(v)) => {+                    v if routing::owns(v.strip_prefix("fusion_").unwrap_or(v)) || v.strip_prefix("fusion_").unwrap_or(v) == "replicate_layout" || sch::owns(v.strip_prefix("fusion_").unwrap_or(v)) || docs::owns(v.strip_prefix("fusion_").unwrap_or(v)) || checks::owns(v.strip_prefix("fusion_").unwrap_or(v)) => {                         let (sc, ca) = (sidecar.clone(), caller.clone());                         let side = |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command":verb,"args":a}).to_string(), &ca, t);                         let dlg = |r: Value| r;                         let ctx = addin::Ctx { host: &**h, fusion_exe:h.fusion_executable(), short_path:|p|host().short_path(p), state_dir:h.bridge_state_dir(), caller:caller.clone(), pids:h.fusion_processes().iter().map(|p|p.pid).collect(), sidecar:&side, dialogs:&dlg };                         let 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)), "simulate" => spice::simulate(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), "jlcpcb_order" => order::jlcpcb_order(&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)), "replicate_layout" => replicate::replicate_layout(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), "simulate" => spice::simulate(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), "jlcpcb_order" => order::jlcpcb_order(&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                     },@@ -685,7 +686,7 @@ fn main() {                     "fusion_edit_footprint" | "edit_footprint" => native_edit_footprint(&parsed["args"]),                     v @ ("fusion_create_footprint" | "create_footprint" | "fusion_create_symbol" | "create_symbol" | "fusion_read_symbol" | "read_symbol" | "fusion_find_symbol" | "find_symbol"                         | "fusion_copy_footprint" | "copy_footprint" | "fusion_rename_footprint" | "rename_footprint" | "fusion_remove_footprint" | "remove_footprint" | "fusion_xcopy_footprint" | "xcopy_footprint"-                        | "fusion_clone_symbol" | "clone_symbol" | "fusion_rename_symbol" | "rename_symbol" | "fusion_remove_symbol" | "remove_symbol" | "fusion_set_params" | "set_params" | "fusion_footprint_from_land_pattern" | "footprint_from_land_pattern" | "fusion_set_models" | "set_models" | "fusion_add_models" | "add_models") => native_lib_authoring(v.strip_prefix("fusion_").unwrap_or(v), &parsed["args"]),+                        | "fusion_clone_symbol" | "clone_symbol" | "fusion_rename_symbol" | "rename_symbol" | "fusion_remove_symbol" | "remove_symbol" | "fusion_set_params" | "set_params" | "fusion_footprint_from_land_pattern" | "footprint_from_land_pattern" | "fusion_set_models" | "set_models" | "fusion_add_models" | "add_models" | "fusion_symbol_prims" | "symbol_prims" | "fusion_set_visibility" | "set_visibility") => native_lib_authoring(v.strip_prefix("fusion_").unwrap_or(v), &parsed["args"]),                     "fusion_preflight_lbr" | "preflight_lbr" => native_preflight_lbr(&parsed["args"]),                     "fusion_not_supported" | "not_supported" => unsupported::answer(&parsed["args"]),                     // Native since Phase 2: the raw authenticated APS GET (never-charge guarded).@@ -1026,6 +1027,10 @@ fn native_lib_authoring(verb: &str, args: &Value) -> Value {     let existing = std::fs::read_to_string(&path).ok();     let creates = matches!(verb, "create_footprint" | "create_symbol" | "xcopy_footprint" | "footprint_from_land_pattern");     if existing.is_none() && !creates { return json!({"success": false, "errorCode": "library_unreadable", "error": format!("cannot read {path}"), "libraryPath": path, "shell": "rust"}); }+    if verb == "symbol_prims" {+        let name = s("symbolName").or(s("name")).unwrap_or_default();+        return match a::symbol_prims(existing.as_deref().unwrap(), &name) { Ok(mut v) => { v["success"] = json!(true); v["mutated"] = json!(false); v["libraryPath"] = json!(path); v["shell"] = json!("rust"); v }, Err(e) => json!({"success": false, "errorCode": "symbol_not_found", "error": e, "shell": "rust"}) };+    }     if verb == "read_symbol" {         let name = s("symbolName").or(s("name")).unwrap_or_default();         return match a::read_symbol(existing.as_deref().unwrap(), &name) { Ok(mut v) => { v["success"] = json!(true); v["libraryPath"] = json!(path); v["shell"] = json!("rust"); v }, Err(e) => json!({"success": false, "errorCode": "symbol_not_found", "error": e, "shell": "rust"}) };@@ -1052,6 +1057,10 @@ fn native_lib_authoring(verb: &str, args: &Value) -> Value {         "clone_symbol" => a::clone_symbol(text.as_deref().unwrap(), &s("from").unwrap_or_default(), &s("to").unwrap_or_default()),         "rename_symbol" => a::rename_symbol(text.as_deref().unwrap(), &s("from").unwrap_or_default(), &s("to").unwrap_or_default()),         "remove_symbol" => a::remove_symbol(text.as_deref().unwrap(), &s("symbolName").or(s("name")).unwrap_or_default()),+        "set_visibility" => match s("parameter").or(s("paramName")) {+            Some(prm) => a::set_visibility(text.as_deref().unwrap(), &s("symbolName").or(s("name")).unwrap_or_default(), &prm, args["visible"].as_bool().unwrap_or(true)),+            None => Err("parameter is required (the attribute to show or hide, e.g. MPN)".to_string()),+        },         "set_models" => a::set_models(text.as_deref().unwrap(), &s("symbolName").unwrap_or_default(), &s("footprint").unwrap_or_default()),         "add_models" => {             // a model's footprint from another .lbr is copied in; its pcblib is that library
rust/bridge/src/replicate.rsadded+80
@@ -0,0 +1,80 @@+//! `fusion_replicate_layout`: the standard's replicate_layout. One placed channel's arrangement+//! copied to matching channels: each target keeps its anchor part where it is, and every other part+//! takes the source's offset and turn from the anchor, turned by the anchors' rotation difference.+//! A bottom-side part stays mirrored. Applied with one guarded move_parts (read back, undone on a+//! mismatch).++use serde_json::{json, Value};++fn f(v: &Value) -> f64 { v.as_f64().unwrap_or(0.0) }+fn r3(v: f64) -> f64 { (v * 1000.0).round() / 1000.0 }++/// The moves: `parts` are routing_state's components ({ref, x, y, rotation, side, pattern}).+pub fn plan(parts: &[Value], source: &[String], targets: &[Vec<String>], anchor: Option<&str>) -> Result<(Vec<Value>, Vec<Value>), String> {+    if source.len() < 2 { return Err("source is the placed channel's parts, two or more".into()); }+    let a = match anchor { Some(r) => source.iter().position(|s| s.eq_ignore_ascii_case(r)).ok_or(format!("anchor {r} is not in source"))?, None => 0 };+    let find = |r: &str| parts.iter().find(|p| p["ref"].as_str().map(|x| x.eq_ignore_ascii_case(r)).unwrap_or(false));+    let missing: Vec<&String> = source.iter().chain(targets.iter().flatten()).filter(|r| find(r).is_none()).collect();+    if !missing.is_empty() { return Err(format!("not on the board: {}", missing.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", "))); }+    let src = find(&source[a]).unwrap();+    let (mut moves, mut mismatches) = (Vec::new(), Vec::new());+    for t in targets {+        if t.len() != source.len() { return Err(format!("target {:?} has {} parts; source has {} (same order as source)", t, t.len(), source.len())); }+        let anc = find(&t[a]).unwrap();+        let d = f(&anc["rotation"]) - f(&src["rotation"]);+        let (s, c) = d.to_radians().sin_cos();+        for (i, r) in t.iter().enumerate() {+            let (sp, tp) = (find(&source[i]).unwrap(), find(r).unwrap());+            if sp["pattern"] != tp["pattern"] { mismatches.push(json!({"ref": r, "footprint": tp["pattern"], "source": source[i], "sourceFootprint": sp["pattern"]})); }+            if i == a { continue; }+            let (dx, dy) = (f(&sp["x"]) - f(&src["x"]), f(&sp["y"]) - f(&src["y"]));+            let x = f(&anc["x"]) + dx * c - dy * s;+            let y = f(&anc["y"]) + dx * s + dy * c;+            let rot = (f(&sp["rotation"]) + d).rem_euclid(360.0);+            let rot = r3(rot) % 360.0;+            // a bottom part keeps its mirror: EAGLE's MR turn+            let rotation = if tp["side"] == "bottom" { json!(format!("MR{}", rot)) } else { json!(rot) };+            moves.push(json!({"ref": r, "x": r3(x), "y": r3(y), "rotation": rotation}));+        }+    }+    Ok((moves, mismatches))+}++fn refs(v: &Value) -> Vec<String> { v.as_array().into_iter().flatten().filter_map(|x| x.as_str().map(str::to_string)).collect() }++pub fn replicate_layout(args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+    let source = refs(&args["source"]);+    let targets: Vec<Vec<String>> = args["targets"].as_array().into_iter().flatten().map(refs).collect();+    if targets.is_empty() { return json!({"success": false, "errorCode": "bad_args", "error": "targets is [[refs], ...], each in the same order as source"}); }+    let st = crate::routing::call("routing_state", args, pass);+    if st["success"].as_bool() != Some(true) { return st; }+    let parts = st["components"].as_array().cloned().unwrap_or_default();+    let (moves, mismatches) = match plan(&parts, &source, &targets, args["anchor"].as_str()) { Ok(x) => x, Err(e) => return json!({"success": false, "errorCode": "bad_args", "error": e}) };+    if args["dryRun"].as_bool() == Some(true) || moves.is_empty() {+        return json!({"success": true, "mutated": false, "moved": 0, "moves": moves, "footprintMismatches": mismatches, "revision": st["revision"], "boardId": st["boardId"]});+    }+    let mv = crate::routing::call("move_parts", &json!({"boardId": st["boardId"], "expectDocument": args["expectDocument"], "expectedRevision": st["revision"], "parts": moves}), pass);+    if mv["success"].as_bool() != Some(true) { let mut e = mv; e["moves"] = json!(moves); return e; }+    json!({"success": true, "mutated": true, "moved": moves.len(), "moves": moves, "footprintMismatches": mismatches, "revision": mv["revision"], "boardId": mv["boardId"], "documentName": mv["documentName"],+           "_hint": if mismatches.is_empty() { "Each target channel now sits like the source around its anchor. Route it the same way with fusion_copy_routing." }+                    else { "Moved, but footprintMismatches lists parts whose footprint differs from their source part: their pads will not line up with the source's routing." }})+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn a_channel_copies_round_its_turned_anchor() {+        let p = |r: &str, x: f64, y: f64, rot: f64, side: &str| json!({"ref": r, "x": x, "y": y, "rotation": rot, "side": side, "pattern": "R0603"});+        let parts = vec![p("U1", 10.0, 10.0, 0.0, "top"), p("R1", 13.0, 10.0, 90.0, "top"), p("C1", 10.0, 12.0, 0.0, "top"),+                         p("U2", 40.0, 10.0, 90.0, "top"), p("R2", 0.0, 0.0, 0.0, "top"), p("C2", 0.0, 0.0, 0.0, "bottom")];+        let (m, mm) = plan(&parts, &["U1".into(), "R1".into(), "C1".into()], &[vec!["U2".into(), "R2".into(), "C2".into()]], None).unwrap();+        assert!(mm.is_empty());+        // turned 90 degrees round U2: R1's +3 in x becomes +3 in y; C1's +2 in y becomes -2 in x+        assert_eq!(m[0], json!({"ref": "R2", "x": 40.0, "y": 13.0, "rotation": 180.0}));+        assert_eq!(m[1], json!({"ref": "C2", "x": 38.0, "y": 10.0, "rotation": "MR90"}));+        assert!(plan(&parts, &["U1".into(), "R1".into()], &[vec!["U2".into()]], None).is_err());+        assert!(plan(&parts, &["U1".into(), "R1".into()], &[vec!["U2".into(), "R9".into()]], None).unwrap_err().contains("R9"));+    }+}
rust/bridge/src/unsupported.rs+1
@@ -27,6 +27,7 @@ pub const TABLE: &[(&str, &str)] = &[     ("symbol_mode", "an EAGLE symbol has one look: there are no display modes to show"),     ("cleanup_libs", "a new Fusion library starts empty: there is no factory-default part to remove"),     ("plate_vias", "EAGLE pads are always plated: an unplated hole is a separate hole object"),+    ("component_classes", "an EAGLE board has net classes (fusion_net_classes, fusion_add_rule) but no component classes or rooms: group a channel by its designators (fusion_replicate_layout takes them as lists)"), ];  pub fn note(name: &str) -> Option<&'static str> { TABLE.iter().find(|(n, _)| *n == name).map(|(_, v)| *v) }
rust/lbr/src/authoring.rs+92
@@ -507,6 +507,76 @@ pub fn set_models(xml: &str, symbol: &str, footprint: &str) -> Result<(String, V     Ok((root.to_xml_document(), json!({"component": n, "footprint": fp, "removed": removed}))) } +/// The component (deviceset) and the symbols its gates draw, by name.+fn component_symbols(root: &mut Element, name: &str) -> Result<(String, Vec<String>), String> {+    let lib = lib_mut(root)?;+    let ds = section(lib, "devicesets").children.iter().find(|d| d.tag == "deviceset" && d.attr("name").map(|n| n.eq_ignore_ascii_case(name)).unwrap_or(false)).ok_or_else(|| format!("no symbol {name}"))?;+    let syms: Vec<String> = ds.find_child("gates").map(|g| g.children.iter().filter_map(|x| x.attr("symbol").map(str::to_string)).collect()).unwrap_or_default();+    Ok((ds.attr("name").unwrap_or(name).to_string(), syms))+}++/// The standard's symbol_prims: a component's drawing counted by kind, the body rectangle (the+/// symbol layer's lines and rectangles) and the polygons' box, in mil, and its footprints.+pub fn symbol_prims(xml: &str, name: &str) -> Result<Value, String> {+    let mut root = Element::parse(xml).map_err(|e| e.to_string())?;+    let (comp, syms) = component_symbols(&mut root, name)?;+    let lib = lib_mut(&mut root)?;+    let mil = |v: f64| (v / MIL * 10.0).round() / 10.0;+    let num = |e: &Element, k: &str| e.attr(k).and_then(|v| v.parse::<f64>().ok()).unwrap_or(0.0);+    let (mut lines, mut rects, mut polys, mut circles, mut arcs, mut texts, mut pins) = (0, 0, 0, 0, 0, 0, 0);+    let (mut body, mut poly) = ((f64::MAX, f64::MAX, f64::MIN, f64::MIN), (f64::MAX, f64::MAX, f64::MIN, f64::MIN));+    let grow = |b: &mut (f64, f64, f64, f64), x: f64, y: f64| { b.0 = b.0.min(x); b.1 = b.1.min(y); b.2 = b.2.max(x); b.3 = b.3.max(y); };+    for sn in &syms {+        let Some(sym) = section(lib, "symbols").children.iter().find(|x| x.tag == "symbol" && x.attr("name") == Some(sn.as_str())) else { continue };+        for c in &sym.children {+            match c.tag.as_str() {+                "wire" if c.attr("curve").is_some() => arcs += 1,+                "wire" => { lines += 1; if c.attr("layer") == Some("94") { grow(&mut body, num(c, "x1"), num(c, "y1")); grow(&mut body, num(c, "x2"), num(c, "y2")); } }+                "rectangle" => { rects += 1; if c.attr("layer") == Some("94") { grow(&mut body, num(c, "x1"), num(c, "y1")); grow(&mut body, num(c, "x2"), num(c, "y2")); } }+                "polygon" => { polys += 1; for v in c.children.iter().filter(|v| v.tag == "vertex") { grow(&mut poly, num(v, "x"), num(v, "y")); } }+                "circle" => circles += 1,+                "text" => texts += 1,+                "pin" => pins += 1,+                _ => {}+            }+        }+    }+    let bx = |b: (f64, f64, f64, f64)| if b.0 == f64::MAX { Value::Null } else { json!([mil(b.0), mil(b.1), mil(b.2), mil(b.3)]) };+    let ds = section(lib, "devicesets").children.iter().find(|d| d.attr("name") == Some(comp.as_str())).unwrap();+    let models: Vec<Value> = ds.find_child("devices").map(|d| d.children.iter().filter_map(|x| x.attr("package").filter(|p| !p.is_empty()).map(|p| json!({"type": "PCBLIB", "name": p, "device": x.attr("name").unwrap_or("")}))).collect()).unwrap_or_default();+    Ok(json!({"symbol": comp, "symbols": syms, "pins": pins, "lines": lines, "rectangles": rects, "polylines": polys, "circles": circles, "arcs": arcs, "texts": texts,+              "bodyRectMil": bx(body), "polyBboxMil": bx(poly), "displayModes": 1, "models": models}))+}++/// The standard's set_visibility: an EAGLE component shows a parameter on the sheet when its symbol+/// carries a >NAME text for it, so showing one adds that text (Info layer, below the body) to each+/// symbol the component's gates draw, and hiding one removes it.+pub fn set_visibility(xml: &str, name: &str, parameter: &str, visible: bool) -> Result<(String, Value), String> {+    let mut root = Element::parse(xml).map_err(|e| e.to_string())?;+    let (comp, syms) = component_symbols(&mut root, name)?;+    let key = format!(">{}", parameter.trim_start_matches('>').to_uppercase());+    let lib = lib_mut(&mut root)?;+    let mut changed = Vec::new();+    for sn in &syms {+        let Some(sym) = section(lib, "symbols").children.iter_mut().find(|x| x.tag == "symbol" && x.attr("name") == Some(sn.as_str())) else { continue };+        let has = sym.children.iter().any(|c| c.tag == "text" && c.text.eq_ignore_ascii_case(&key));+        if visible && !has {+            let (mut x0, mut y0) = (f64::MAX, f64::MAX);+            for c in &sym.children { for (kx, ky) in [("x1", "y1"), ("x2", "y2"), ("x", "y")] { if let (Some(x), Some(y)) = (c.attr(kx).and_then(|v| v.parse::<f64>().ok()), c.attr(ky).and_then(|v| v.parse::<f64>().ok())) { x0 = x0.min(x); y0 = y0.min(y); } } }+            // below whatever the symbol already writes there (>VALUE and other shown parameters)+            let shown = sym.children.iter().filter(|c| c.tag == "text" && c.text.starts_with('>')).count() as f64;+            let mut t = el("text", &[("x", f(if x0 == f64::MAX { 0.0 } else { x0 })), ("y", f(if y0 == f64::MAX { 0.0 } else { y0 } - 2.54 * shown)), ("size", "1.778".into()), ("layer", "97".into())]);+            t.text = key.clone();+            sym.children.push(t);+            changed.push(sn.clone());+        } else if !visible && has {+            sym.children.retain(|c| !(c.tag == "text" && c.text.eq_ignore_ascii_case(&key)));+            changed.push(sn.clone());+        }+    }+    Ok((root.to_xml_document(), json!({"component": comp, "parameter": key.trim_start_matches('>'), "visible": visible, "symbolsChanged": changed})))+}+ /// The standard's add_models: each footprint becomes another device of the component (EAGLE's /// several-packages-per-component), pins joined by pad number; a footprint already attached is /// skipped, since a device names it once. `sources` holds other .lbr texts a model's footprint is@@ -614,6 +684,28 @@ pub fn preflight_text(path: &std::path::Path) -> (bool, Vec<String>) {     (errs.is_empty(), errs) } +#[cfg(test)]+mod prims_tests {+    use super::*;++    #[test]+    fn prims_count_the_drawing_and_visibility_toggles_a_text() {+        let sym = "META|LDO-X|reg|U\nMODE|0|n\nRECT|0|0|-300|600|0\nLINE|0|0|0|100|100\nPIN|0|1|IN|0|-100|left|300||power_in\nPIN|0|2|OUT|600|-100|right|300||power_out\n";+        let fp = "META|SOT-X|x\nPAD|1|-1|0|1|1|rect|0|top|0\nPAD|2|1|0|1|1|rect|0|top|0\n";+        let (xml, _) = crate::import::import_part(None, Some(fp), Some(sym), None, &json!({})).unwrap();+        let p = symbol_prims(&xml, "ldo-x").unwrap();+        assert_eq!((p["pins"].as_u64(), p["lines"].as_u64(), p["models"][0]["name"].as_str()), (Some(2), Some(5), Some("SOT-X")));+        assert_eq!(p["bodyRectMil"], json!([0.0, -300.0, 600.0, 100.0]));+        let (shown, r) = set_visibility(&xml, "LDO-X", "mpn", true).unwrap();+        assert_eq!(r["symbolsChanged"][0], "LDO-X");+        assert!(shown.contains(">MPN</text>") || shown.contains("&gt;MPN</text>"), "{shown}");+        let (again, r2) = set_visibility(&shown, "LDO-X", "MPN", true).unwrap();+        assert_eq!(r2["symbolsChanged"].as_array().unwrap().len(), 0, "already shown");+        let (hidden, _) = set_visibility(&again, "LDO-X", "MPN", false).unwrap();+        assert!(!hidden.contains("MPN</text>"));+    }+}+ #[cfg(test)] mod model_tests {     use super::*;
tests/run_verb_tests.py+3
@@ -457,6 +457,9 @@ t("fusion_add_models", "daily", {"libraryPath": "C:/Users/olive/Documents/fusion t("fusion_swap_footprint", "daily", {"libraryPath": BOARDLIB, "footprintName": "SOT23", "pcbPath": BOARDDIR + "/test.brd", "designator": "U1", "assigns": "1=IN;2=GND;3=OUT", "open": False}, {"ok": True, "fields": ["swap"]}, "U1 on the battery's test board takes SOT23 (offline)") t("fusion_teardrops", "daily", dict(B, dryRun=True), {"ok": True, "fields": ["teardrops", "skipped", "engine"]}, "the engine's teardrops for BQ25792 (dry run: 87 planned, 5 skipped with why)") t("fusion_panelize", "daily", dict(B, rows=2, cols=2, separation="vscore", dryRun=True), {"ok": True, "fields": ["panelMm", "copyShift", "jlcpcb", "engine"]}, "a 2 x 2 V-score panel plan with JLCPCB's rules (dry run)")+t("fusion_replicate_layout", "daily", dict(B, source=["C11", "C12"], targets=[["C13", "C14"]], dryRun=True), {"ok": True, "fields": ["moves", "footprintMismatches"]}, "a channel's placement planned onto another (dry run on BQ25792)")+t("fusion_symbol_prims", "daily", {"libraryPath": "C:/Users/olive/Documents/fusion-lane-tests/import/lane-import.lbr", "symbolName": "LDO-LANE"}, {"ok": True, "fields": ["pins", "bodyRectMil", "models"]}, "the imported LDO's drawing counted")+t("fusion_set_visibility", "daily", {"libraryPath": "C:/Users/olive/Documents/fusion-lane-tests/import/lane-import.lbr", "symbolName": "LDO-LANE", "parameter": "MPN", "visible": True, "dryRun": True}, {"ok": True, "fields": ["data"]}, "show MPN on the sheet (dry run)") t("fusion_build_board", "daily", {"libraryPath": BOARDLIB, "pcbPath": BOARDDIR + "/build.brd", "parts": [{"ref": "R1", "footprint": "R0603", "x": 5, "y": 5}, {"ref": "Q1", "footprint": "SOT23", "x": 12, "y": 5}], "nets": {"GND": ["R1.2", "Q1.2"]}, "outline": {"rect": [0, 0, 18, 10], "cornerRadius": 1}, "open": False}, {"ok": True, "fields": ["pcbPath", "unknownPins"]}, "written offline (open:false keeps the battery from opening a design each run)") t("fusion_build_board#missing", "negative", {"libraryPath": BOARDLIB, "pcbPath": BOARDDIR + "/bad.brd", "parts": [{"ref": "U1", "footprint": "NO_SUCH_FP", "x": 0, "y": 0}], "open": False}, {"pred": "must_refuse"}, "a footprint missing from the library refuses the whole build") t("fusion_test_board", "daily", {"libraryPath": BOARDLIB, "footprintName": "R0603;SOT23", "pcbPath": BOARDDIR + "/test.brd", "open": False}, {"ok": True, "fields": ["pcb", "placed", "saved"]}, "test.brd is made on the first run and appended to after (designators continue); written offline")