app
Fusion - the Fusion 360 Bridge
Public Made by Adomby adom
Drive Autodesk Fusion 360 from the cloud via Adom Bridge: component libraries, IPC package generation, board layout, exports (STEP/Gerbers/BOM/CPL), fast APS cloud search, and parametric modeling.
← Commit history
render_board, render_board_layers, bom exact
8 files changed
+305−17
CHANGELOG.md+12@@ -3,6 +3,18 @@ All notable changes to the Adom Bridge Fusion 360 bridge, newest first. Canonical: https://wiki.adom.inc/adom/fusion-bridge ยท GitHub backup: `adom-inc/fusion-bridge` +## 1.13.35 (2026-10-30)++- `fusion_render_board_3d` (the standard's render_board and render_board_3d): `refs` frames the named parts (the union+ of their 3D boxes, `marginMm` round them), `rotate` turns the view ('x,y,z' degrees about its centre), and+ `quality:'high'` is Fusion's local ray-tracer from the same camera, with every render setting put back and the render+ cancelled after `renderSeconds`. Checked live on lane-BQ25792-copy: IC1 and C11 framed and turned '-45,0,45'; an iso+ ray-traced render at 800x450.+- `fusion_render_board_layers`: the standard's presets (outline, drill, assembly_top/bottom, fabrication added) and+ copper layer names over Fusion's EXPORT IMAGE, with widthPx/heightPx; svg refused by name (Fusion writes none).+- `fusion_bom`: the standard's bom from board_bom, schematic_data and the variants (DNP left out, checks for+ missing and inconsistent part numbers, schematic-board differences).+ ## 1.13.34 (2026-10-30) Partial standard verbs answered exactly, in the standard's own shapes (stdshapes.rs):
bridge.json+2@@ -67,6 +67,7 @@ "fusion_board_stackup", "fusion_board_summary", "fusion_bodies",+ "fusion_bom", "fusion_build_board", "fusion_build_library_3d", "fusion_cancel_run",@@ -271,6 +272,7 @@ "fusion_rename_footprint", "fusion_rename_symbol", "fusion_render_board_3d",+ "fusion_render_board_layers", "fusion_render_schematic", "fusion_replicate_layout", "fusion_repour",
contracts/describe.json+65−3@@ -953,6 +953,29 @@ "summary": "Each part's 3D body as a box, from the design's 3D PCB: footprint rectangle in the board frame (x0, y0, x1, y1 mm) and height range above the surface it sits on (z0 standoff, z1 top; below the board for bottom parts). Names the parts with no 3D model (noBody) and the ones whose body is not where the board puts the part (suspect: off its footprint, floating or sunk, from a wrong library model or a 3D PCB not yet updated). The tallest part per side. What a height or enclosure check reads. Switches the design to its 3D PCB and back to the 2D board inside Fusion; takes no window or mouse.", "timeoutSeconds": 120 },+ {+ "example": {},+ "input": {+ "boards": "int, default 1: multiplies quantities",+ "expectDocument": "the board's name",+ "outputDir": "folder for bom.csv and bom-jlcpcb.csv",+ "variant": "a variant's name (default the current one)",+ "write": "bool, default true"+ },+ "longRunning": false,+ "name": "fusion_bom",+ "output": {+ "alternates": "[{designator, alternatePart}]",+ "checks": "{missingMpn, missingManufacturer, missingPartNumbers, inconsistent, dnp, excludedKinds, sheetNotOnBoard, boardNotOnSheet}",+ "files": "[path]",+ "fittedParts": "int",+ "lines": "[{quantity, designators, value, comment, description, footprint, package, mpn, manufacturer, mouser, digikey, lcsc, libReference}]",+ "variants": "[str]"+ },+ "statusVerb": null,+ "summary": "The bill of materials in the standard's shape (the standard's bom), from Fusion's own reads: the board's lines grouped by value, footprint and part numbers (fusion_board_bom, which writes bom.csv and bom-jlcpcb.csv), each part's description and library reference from the schematic, and the variant's not-fitted parts left out (variant names one; default the current). checks lists missing MPN, manufacturer and distributor numbers, the same value and footprint under different part numbers, DNP parts, parts on the schematic but not the board and the reverse.",+ "timeoutSeconds": 180+ }, { "example": { "libraryPath": "C:/libs/buck.lbr",@@ -20692,7 +20715,10 @@ }, { "example": {- "background": "transparent",+ "refs": [+ "U1"+ ],+ "rotate": "-45,0,45", "side": "iso" }, "input": {@@ -20700,9 +20726,14 @@ "expectDocument": "optional: the design's name", "format": "png|jpg", "height": "int px, default 900",+ "marginMm": "mm round framed parts, default 2", "outputPath": "the image on the Fusion machine (default %TEMP%/adom-render/board-<side>.<format>)", "perspective": "bool, default false",- "quality": "basic (high, a ray-traced render, is refused)",+ "quality": "basic (default) | high (ray-traced)",+ "refs": "[ref]: frame these parts",+ "renderQuality": "int 25-100 for quality high, default 25",+ "renderSeconds": "seconds before a high render is cancelled, default 45",+ "rotate": "'x,y,z' degrees", "side": "top|bottom|left|right|front|back|iso, default top", "width": "int px, default 1600", "zoom": "number, default 1.0"@@ -20711,14 +20742,45 @@ "name": "fusion_render_board_3d", "output": { "design": "str",+ "framed": "{refs, missing, pivot, zoom} when refs was given", "height": "int", "path": "str",+ "quality": "basic|high",+ "rotate": "[x,y,z]", "side": "str", "success": "bool", "width": "int" }, "statusVerb": null,- "summary": "A 3D picture of the board from one side (the standard's render_board_3d): Fusion's own view of the design's 3D PCB, fitted to the board, saved as an image without the user's tab changing (it is put back). Orthographic by default; transparent background available for png.",+ "summary": "A 3D picture of the board from one side (the standard's render_board_3d and render_board): Fusion's own view of the design's 3D PCB, saved as an image without the user's tab changing (it is put back). Fitted to the board, or framed on parts (refs, with marginMm), turned by rotate ('x,y,z' degrees about the view's centre, e.g. '-45,0,45'). quality high is Fusion's local ray-tracer from the same camera (every render setting put back; cancelled after renderSeconds). Orthographic by default; transparent background for png.",+ "timeoutSeconds": 120+ },+ {+ "example": {+ "preset": "top_copper"+ },+ "input": {+ "dpi": "int, default 300 (max 600)",+ "expectDocument": "the board's name",+ "format": "png (svg refused)",+ "layers": "[layer] overriding preset",+ "listPresets": "bool",+ "mirror": "bool",+ "monochrome": "bool",+ "outputPath": "the png on the Fusion machine",+ "preset": "a preset name, default all"+ },+ "longRunning": false,+ "name": "fusion_render_board_layers",+ "output": {+ "format": "png",+ "heightPx": "int",+ "path": "str",+ "preset": "str or custom",+ "widthPx": "int"+ },+ "statusVerb": null,+ "summary": "Chosen board layers as an image without a window (the standard's render_board_layers), through Fusion's EXPORT IMAGE: a preset (all, top_copper, bottom_copper, top_silkscreen, bottom_silkscreen, top_soldermask, bottom_soldermask, top_paste, bottom_paste, outline, drill, assembly_top, assembly_bottom, fabrication) or layers by the standard's copper names (F.Cu, In1.Cu .. In14.Cu, B.Cu) or Fusion's numbers; dpi, mirror, monochrome. png only: Fusion writes no svg, so format svg is refused by name.", "timeoutSeconds": 120 }, {
describe.py+10−4@@ -230,10 +230,16 @@ _T = [ {"libraryPath": "the .lbr on the Fusion machine", "parts": "[device set name]", "views": "[symbol, footprint, 3d, device] (default all)"}, {"aliasOf": "fusion_capture_library_views", "captured": "[{package, view, path, ok}]"}, 600, None, True, {"libraryPath": "C:/libs/power.lbr", "parts": ["TPS54202"]}),- ("fusion_render_board_3d", "A 3D picture of the board from one side (the standard's render_board_3d): Fusion's own view of the design's 3D PCB, fitted to the board, saved as an image without the user's tab changing (it is put back). Orthographic by default; transparent background available for png.",- {"side": "top|bottom|left|right|front|back|iso, default top", "width": "int px, default 1600", "height": "int px, default 900", "background": "default|transparent", "zoom": "number, default 1.0", "perspective": "bool, default false", "format": "png|jpg", "outputPath": "the image on the Fusion machine (default %TEMP%/adom-render/board-<side>.<format>)", "quality": "basic (high, a ray-traced render, is refused)", "expectDocument": "optional: the design's name"},- {"success": "bool", "path": "str", "side": "str", "width": "int", "height": "int", "design": "str"},- 120, None, False, {"side": "iso", "background": "transparent"}),+ ("fusion_render_board_3d", "A 3D picture of the board from one side (the standard's render_board_3d and render_board): Fusion's own view of the design's 3D PCB, saved as an image without the user's tab changing (it is put back). Fitted to the board, or framed on parts (refs, with marginMm), turned by rotate ('x,y,z' degrees about the view's centre, e.g. '-45,0,45'). quality high is Fusion's local ray-tracer from the same camera (every render setting put back; cancelled after renderSeconds). Orthographic by default; transparent background for png.",+ {"side": "top|bottom|left|right|front|back|iso, default top", "width": "int px, default 1600", "height": "int px, default 900", "background": "default|transparent", "zoom": "number, default 1.0", "perspective": "bool, default false", "format": "png|jpg", "outputPath": "the image on the Fusion machine (default %TEMP%/adom-render/board-<side>.<format>)", "quality": "basic (default) | high (ray-traced)", "refs": "[ref]: frame these parts", "marginMm": "mm round framed parts, default 2", "rotate": "'x,y,z' degrees", "renderQuality": "int 25-100 for quality high, default 25", "renderSeconds": "seconds before a high render is cancelled, default 45", "expectDocument": "optional: the design's name"},+ {"success": "bool", "path": "str", "side": "str", "width": "int", "height": "int", "quality": "basic|high", "framed": "{refs, missing, pivot, zoom} when refs was given", "rotate": "[x,y,z]", "design": "str"},+ 120, None, False, {"side": "iso", "refs": ["U1"], "rotate": "-45,0,45"}),+ ("fusion_render_board_layers", "Chosen board layers as an image without a window (the standard's render_board_layers), through Fusion's EXPORT IMAGE: a preset (all, top_copper, bottom_copper, top_silkscreen, bottom_silkscreen, top_soldermask, bottom_soldermask, top_paste, bottom_paste, outline, drill, assembly_top, assembly_bottom, fabrication) or layers by the standard's copper names (F.Cu, In1.Cu .. In14.Cu, B.Cu) or Fusion's numbers; dpi, mirror, monochrome. png only: Fusion writes no svg, so format svg is refused by name.",+ {"preset": "a preset name, default all", "layers": "[layer] overriding preset", "format": "png (svg refused)", "dpi": "int, default 300 (max 600)", "mirror": "bool", "monochrome": "bool", "outputPath": "the png on the Fusion machine", "listPresets": "bool", "expectDocument": "the board's name"},+ {"path": "str", "format": "png", "widthPx": "int", "heightPx": "int", "preset": "str or custom"}, 120, None, False, {"preset": "top_copper"}),+ ("fusion_bom", "The bill of materials in the standard's shape (the standard's bom), from Fusion's own reads: the board's lines grouped by value, footprint and part numbers (fusion_board_bom, which writes bom.csv and bom-jlcpcb.csv), each part's description and library reference from the schematic, and the variant's not-fitted parts left out (variant names one; default the current). checks lists missing MPN, manufacturer and distributor numbers, the same value and footprint under different part numbers, DNP parts, parts on the schematic but not the board and the reverse.",+ {"expectDocument": "the board's name", "variant": "a variant's name (default the current one)", "boards": "int, default 1: multiplies quantities", "outputDir": "folder for bom.csv and bom-jlcpcb.csv", "write": "bool, default true"},+ {"lines": "[{quantity, designators, value, comment, description, footprint, package, mpn, manufacturer, mouser, digikey, lcsc, libReference}]", "fittedParts": "int", "checks": "{missingMpn, missingManufacturer, missingPartNumbers, inconsistent, dnp, excludedKinds, sheetNotOnBoard, boardNotOnSheet}", "variants": "[str]", "alternates": "[{designator, alternatePart}]", "files": "[path]"}, 180, None, False, {}), ("fusion_edit_parts", "Edit placed parts (the standard's edit_parts): value, designator (newRef), any field (attribute; null removes it) and the lock, each read back. Fusion keeps board and schematic in step, so the edit is in the schematic too. dnp, excludeFromBom, excludeFromPos and mount are refused per part (Fusion keeps them in assembly variants).", {"edits": "[{ref, value?, newRef?, fields?: {name: value | null}, locked?: bool}] (required)", "expectDocument": "required: the board's name", "expectedRevision": "optional", "dryRun": "bool"}, {"edited": "[{ref, after: {ref, value, fields, locked}}]", "failed": "[{ref, error}]", "revision": "str"},
rust/bridge/src/docs_script.py+94−9@@ -105,12 +105,22 @@ SIDES = {'top': 'TopViewOrientation', 'bottom': 'BottomViewOrientation', 'left': def render_board_3d(app, args): """A 3D picture of the board from one side, through Fusion's own viewport, with the tab the user had put back afterwards."""- import adsk.core+ import adsk.core, adsk.fusion side = (args.get('side') or 'top').lower() if side not in SIDES: return {'success': False, 'errorCode': 'bad_args', 'error': 'side is top, bottom, left, right, front, back or iso'}- if (args.get('quality') or 'basic') == 'high':- return {'success': False, 'errorCode': 'unsupported', 'error': 'quality high (a ray-traced render) is not offered: Fusion renders through its Render workspace, which this verb does not drive'}+ high = (args.get('quality') or 'basic') == 'high'+ rotate = args.get('rotate')+ if isinstance(rotate, str):+ try:+ rotate = [float(v) for v in rotate.split(',')]+ except ValueError:+ return {'success': False, 'errorCode': 'bad_args', 'error': "rotate is 'x,y,z' degrees, e.g. '-45,0,45'"}+ if rotate is not None and len(rotate) != 3:+ return {'success': False, 'errorCode': 'bad_args', 'error': "rotate is 'x,y,z' degrees"}+ refs = args.get('refs') or []+ if isinstance(refs, str):+ refs = [refs] fmt = (args.get('format') or 'png').lower() out = args.get('outputPath') if not out:@@ -132,6 +142,52 @@ def render_board_3d(app, args): cam.isFitView = True cam.isSmoothTransition = False vp.camera = cam+ framed = None+ if refs:+ # frame the named parts: the union of their 3D boxes (Packages' child occurrences are+ # PACKAGE:REF), the camera kept on its side and moved onto their centre+ d = adsk.fusion.Design.cast(app.activeProduct)+ want = {str(r).upper() for r in refs}+ boxes, found = [], set()+ for o in d.rootComponent.occurrences:+ if o.component.name != 'Packages':+ continue+ for c in o.childOccurrences:+ r = c.name.rsplit(':', 1)[-1].upper()+ if r in want and c.boundingBox:+ boxes.append(c.boundingBox)+ found.add(r)+ missing = sorted(want - found)+ if not boxes:+ return {'success': False, 'errorCode': 'unknown_parts', 'error': 'No 3D part named %s on the board' % ', '.join(missing)}+ lo = [min(b.minPoint.x for b in boxes), min(b.minPoint.y for b in boxes), min(b.minPoint.z for b in boxes)]+ hi = [max(b.maxPoint.x for b in boxes), max(b.maxPoint.y for b in boxes), max(b.maxPoint.z for b in boxes)]+ margin = float(args.get('marginMm', 2.0)) / 10.0+ centre = adsk.core.Point3D.create(*[(a + b) / 2 for a, b in zip(lo, hi)])+ cam = vp.camera+ off = cam.target.vectorTo(cam.eye)+ eye = centre.copy(); eye.translateBy(off)+ cam.target, cam.eye = centre, eye+ cam.isFitView = False+ span = max(hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]) + 2 * margin+ cam.viewExtents = span+ cam.isSmoothTransition = False+ vp.camera = cam+ framed = {'refs': sorted(found), 'missing': missing, 'pivot': [round(v * 10, 3) for v in (centre.x, centre.y, centre.z)], 'zoom': round(span * 10, 3)}+ if rotate:+ # turn the eye (and the up vector) about the target: x, then y, then z, in degrees+ import math+ cam = vp.camera+ off = cam.target.vectorTo(cam.eye); up = cam.upVector+ for axis, deg in zip(((1, 0, 0), (0, 1, 0), (0, 0, 1)), rotate):+ if deg:+ m = adsk.core.Matrix3D.create()+ m.setToRotation(math.radians(deg), adsk.core.Vector3D.create(*axis), adsk.core.Point3D.create(0, 0, 0))+ off.transformBy(m); up.transformBy(m)+ eye = cam.target.copy(); eye.translateBy(off)+ cam.eye, cam.upVector = eye, up+ cam.isSmoothTransition = False+ vp.camera = cam zoom = float(args.get('zoom', 1.0)) if zoom and abs(zoom - 1.0) > 1e-6: cam = vp.camera@@ -139,18 +195,47 @@ def render_board_3d(app, args): cam.isSmoothTransition = False vp.camera = cam w, h = int(args.get('width', 1600)), int(args.get('height', 900))- opts = adsk.core.SaveImageFileOptions.create(out)- opts.width, opts.height = w, h- opts.isAntiAliased = True- opts.isBackgroundTransparent = (args.get('background') == 'transparent') and fmt == 'png'- ok = vp.saveAsImageFileWithOptions(opts)+ transparent = (args.get('background') == 'transparent') and fmt == 'png'+ if high:+ # Fusion's local raytracer from this camera (the mechanical layer's render_image does+ # the same from a named view); every render setting it touches is put back+ import time+ d = adsk.fusion.Design.cast(app.activeProduct)+ rendering = d.renderManager.rendering+ keep = (rendering.aspectRatio, rendering.resolutionWidth, rendering.resolutionHeight, rendering.renderQuality, rendering.isBackgroundTransparent)+ if os.path.exists(out):+ os.remove(out)+ try:+ rendering.aspectRatio = adsk.fusion.RenderAspectRatios.CustomRenderAspectRatio+ rendering.resolutionWidth, rendering.resolutionHeight = w, h+ rendering.renderQuality = max(25, int(args.get('renderQuality', 25)))+ rendering.isBackgroundTransparent = transparent+ future = rendering.startLocalRender(out)+ deadline = time.time() + float(args.get('renderSeconds', 45))+ while future is not None and future.renderState != adsk.fusion.LocalRenderStates.FinishedLocalRenderState and time.time() < deadline:+ adsk.doEvents(); time.sleep(0.25)+ done = future is not None and future.renderState == adsk.fusion.LocalRenderStates.FinishedLocalRenderState+ if not done and future is not None:+ try: future.cancel()+ except Exception: pass+ finally:+ rendering.aspectRatio, rendering.resolutionWidth, rendering.resolutionHeight, rendering.renderQuality, rendering.isBackgroundTransparent = keep+ if not done:+ return {'success': False, 'errorCode': 'render_timeout', 'error': 'the ray-traced render did not finish in renderSeconds; it was cancelled (a smaller width/height or a larger renderSeconds helps)'}+ ok = os.path.exists(out)+ else:+ opts = adsk.core.SaveImageFileOptions.create(out)+ opts.width, opts.height = w, h+ opts.isAntiAliased = True+ opts.isBackgroundTransparent = transparent+ ok = vp.saveAsImageFileWithOptions(opts) finally: if front is not None and front != i: app.executeTextCommand('UI.SwitchToTab %d' % front) if not ok or not os.path.exists(out): return {'success': False, 'errorCode': 'render_failed', 'error': 'Fusion did not write %s' % out} return {'success': True, 'path': out, 'side': side, 'width': w, 'height': h, 'format': fmt, 'design': doc.name,- 'background': 'transparent' if opts.isBackgroundTransparent else 'default',+ 'background': 'transparent' if transparent else 'default', 'quality': 'high' if high else 'basic', 'framed': framed, 'rotate': rotate, '_hint': 'Fusion\'s own 3D view of the design\'s 3D PCB, fitted to the board from that side; the tab you had is back in front. A 3D PCB out of date with the board shows the old placement: fusion_show_3d_board updates it.'}
rust/bridge/src/main.rs+1−1@@ -567,7 +567,7 @@ fn main() { r["shell"] = json!("rust"); r }, "fusion_start" | "start" | "launch" if addin_owner_is_exe(&**h) => { let (sc, ca) = (sidecar.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let sd = h.bridge_state_dir(); let dlg = |r: Value| r; let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: sd.clone(), caller: caller.clone(), pids: h.fusion_processes().iter().map(|p| p.pid).collect(), sidecar: &side, dialogs: &dlg }; let t0 = Instant::now(); let ind = timings::Indicator::start(&sd, "start", &caller); let mut r = launch::start(&ctx, &parsed["args"]); ind.stop(r["success"].as_bool() == Some(false)); timings::log_run(&sd, "start", t0.elapsed().as_secs_f64(), r["success"].as_bool() != Some(false)); r["shell"] = json!("rust"); r }, "fusion_open_lbr" | "open_lbr" if addin_owner_is_exe(&**h) => { let (sc, ca, ca2) = (sidecar.clone(), caller.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let sd = h.bridge_state_dir(); let pids: Vec<u32> = h.fusion_processes().iter().map(|p| p.pid).collect(); let dlg = |r: Value| small_verbs::merge_dialog_array(r, &ca2, &pids); let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: sd.clone(), caller: caller.clone(), pids: pids.clone(), sidecar: &side, dialogs: &dlg }; let t0 = Instant::now(); let ind = timings::Indicator::start(&sd, "open_lbr", &caller); let mut r = orchestrations::open_lbr(&ctx, &parsed["args"], &|v, a| addin::pass_through(v, &a, &ctx)); ind.stop(r["success"].as_bool() == Some(false)); timings::log_run(&sd, "open_lbr", t0.elapsed().as_secs_f64(), r["success"].as_bool() != Some(false)); r["shell"] = json!("rust"); r },- v @ ("fusion_show_symbol" | "show_symbol" | "fusion_show_footprint" | "show_footprint" | "fusion_show_library" | "show_library" | "fusion_show_project" | "show_project" | "fusion_show_schematic" | "show_schematic" | "fusion_show_2d_board" | "show_2d_board" | "fusion_show_3d_board" | "show_3d_board" | "fusion_capture_library_views" | "capture_library_views" | "fusion_library_views" | "library_views" | "fusion_open_cloud_file" | "open_cloud_file" | "fusion_save_lbr" | "save_lbr" | "fusion_cleanup_cloud_files" | "cleanup_cloud_files" | "fusion_inspect_bodies" | "inspect_bodies" | "fusion_board_stackup" | "board_stackup" | "fusion_export_optimized_glb" | "export_optimized_glb" | "fusion_export_3d" | "export_3d" | "fusion_board_summary" | "board_summary" | "fusion_read_schematic" | "read_schematic" | "fusion_export_pos" | "export_pos" | "fusion_import_step" | "import_step" | "fusion_fetch_optimized_glb" | "fetch_optimized_glb" | "fusion_set_design_rules" | "set_design_rules" | "fusion_make_test_project" | "make_test_project" | "fusion_project_create" | "project_create" | "fusion_test_board" | "test_board" | "fusion_build_board" | "build_board" | "fusion_swap_footprint" | "swap_footprint" | "fusion_panelize" | "panelize" | "fusion_make_showcase_project" | "make_showcase_project" | "fusion_open_design" | "open_design" | "fusion_new_electronics_from_eagle" | "new_electronics_from_eagle" | "fusion_aps_open" | "aps_open" | "fusion_schematic" | "schematic" | "fusion_eagle_import_status" | "eagle_import_status" | "fusion_show_attributes" | "show_attributes") if addin_owner_is_exe(&**h) => { let (sc, ca, ca2) = (sidecar.clone(), caller.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let sd = h.bridge_state_dir(); let pids: Vec<u32> = h.fusion_processes().iter().map(|p| p.pid).collect(); let dlg = |r: Value| small_verbs::merge_dialog_array(r, &ca2, &pids); let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: sd.clone(), caller: caller.clone(), pids: pids.clone(), sidecar: &side, dialogs: &dlg }; let cmd = v.strip_prefix("fusion_").unwrap_or(v); let t0 = Instant::now(); let ind = timings::Indicator::start(&sd, cmd, &caller); let pass = |verb: &str, a: Value| addin::pass_through(verb, &a, &ctx); let r0 = match cmd { "show_symbol" => orchestrations::show_symbol(&ctx, &parsed["args"], &pass), "show_footprint" => orchestrations::show_footprint(&ctx, &parsed["args"], &pass), "show_library" => orchestrations::show_library(&ctx, &parsed["args"], &pass), "show_project" => match orchestrations::show_project(&ctx, &parsed["args"], &pass) { Some(r) => r, None => side("show_project", parsed["args"].clone(), 240) }, "capture_library_views" => small_verbs::apply_failure_dialogs(orchestrations::capture_library_views(&ctx, &parsed["args"], &pass), &caller, &pids), "library_views" => { let mut r = small_verbs::apply_failure_dialogs(orchestrations::capture_library_views(&ctx, &docs::library_views_args(&parsed["args"]), &pass), &caller, &pids); r["aliasOf"] = json!("fusion_capture_library_views"); r }, "open_cloud_file" => orchestrations::open_cloud_file(&ctx, &parsed["args"], &pass), "save_lbr" => small_verbs::merge_dialog_array(orchestrations::save_lbr(&ctx, &parsed["args"], &pass), &caller, &pids), "cleanup_cloud_files" => orchestrations::cleanup_cloud_files(&parsed["args"], &pass), "inspect_bodies" => orchestrations::inspect_bodies(&parsed["args"], &pass), "board_stackup" => services::board_stackup(&parsed["args"], &pass), "board_summary" => stdshapes::board_summary(&parsed["args"], &pass), "read_schematic" => stdshapes::read_schematic(&parsed["args"], &pass), "export_pos" => stdshapes::export_pos(&parsed["args"], &pass), "export_3d" => services::export_3d(&parsed["args"], &pass), "import_step" => if parsed["args"]["into"].as_str() == Some("new") { pass("import_step", parsed["args"].clone()) } else { services::import_step_with_board(&parsed["args"], &pass) }, "export_optimized_glb" => if parsed["args"]["async"].as_bool() == Some(true) { services::start_glb_export_async(&ctx, &parsed["args"]) } else { services::export_optimized_glb(&parsed["args"], &pass) }, "fetch_optimized_glb" => if parsed["args"]["exportId"].is_string() { services::glb_export_async_status(&parsed["args"]) } else { services::fetch_optimized_glb(&parsed["args"]) }, "set_design_rules" => services::set_design_rules(&ctx, &parsed["args"], &pass), "make_test_project" => small_verbs::merge_dialog_array(testproject::make_test_project(&ctx, &parsed["args"], &pass), &caller, &pids), "project_create" => small_verbs::merge_dialog_array(testproject::project_create(&ctx, &parsed["args"], &pass), &caller, &pids), "test_board" => small_verbs::merge_dialog_array(boardgen::test_board(&ctx, &parsed["args"], &pass), &caller, &pids), "build_board" => small_verbs::merge_dialog_array(boardgen::build_board(&ctx, &parsed["args"], &pass), &caller, &pids), "swap_footprint" => small_verbs::merge_dialog_array(boardgen::swap_footprint(&ctx, &parsed["args"], &pass), &caller, &pids), "panelize" => small_verbs::merge_dialog_array(boardgen::panelize(&ctx, &parsed["args"], &pass), &caller, &pids), "make_showcase_project" => small_verbs::merge_dialog_array(testproject::make_showcase_project(&ctx, &parsed["args"], &pass), &caller, &pids), "open_design" => orchestrations::open_design(&ctx, &parsed["args"], &pass), "new_electronics_from_eagle" => orchestrations::new_electronics_from_eagle(&ctx, &parsed["args"], &pass), "schematic" => orchestrations::schematic(&ctx, &parsed["args"], &pass), "eagle_import_status" => orchestrations::eagle_import_status(&ctx, &parsed["args"]), "show_attributes" => orchestrations::show_attributes(&ctx, &parsed["args"], &pass), "aps_open" => orchestrations::aps_open(&ctx, &parsed["args"], &pass), other => orchestrations::open_with_screenshot(&ctx, other, &parsed["args"], &pass) }; let mut r = small_verbs::merge_dialog_array(r0, &caller, &pids); small_verbs::add_image_delivery(&mut r); ind.stop(r["success"].as_bool() == Some(false)); timings::log_run(&sd, cmd, t0.elapsed().as_secs_f64(), r["success"].as_bool() != Some(false)); r["shell"] = json!("rust"); r },+ v @ ("fusion_show_symbol" | "show_symbol" | "fusion_show_footprint" | "show_footprint" | "fusion_show_library" | "show_library" | "fusion_show_project" | "show_project" | "fusion_show_schematic" | "show_schematic" | "fusion_show_2d_board" | "show_2d_board" | "fusion_show_3d_board" | "show_3d_board" | "fusion_capture_library_views" | "capture_library_views" | "fusion_library_views" | "library_views" | "fusion_open_cloud_file" | "open_cloud_file" | "fusion_save_lbr" | "save_lbr" | "fusion_cleanup_cloud_files" | "cleanup_cloud_files" | "fusion_inspect_bodies" | "inspect_bodies" | "fusion_board_stackup" | "board_stackup" | "fusion_export_optimized_glb" | "export_optimized_glb" | "fusion_export_3d" | "export_3d" | "fusion_board_summary" | "board_summary" | "fusion_read_schematic" | "read_schematic" | "fusion_export_pos" | "export_pos" | "fusion_render_board_layers" | "render_board_layers" | "fusion_bom" | "bom" | "fusion_import_step" | "import_step" | "fusion_fetch_optimized_glb" | "fetch_optimized_glb" | "fusion_set_design_rules" | "set_design_rules" | "fusion_make_test_project" | "make_test_project" | "fusion_project_create" | "project_create" | "fusion_test_board" | "test_board" | "fusion_build_board" | "build_board" | "fusion_swap_footprint" | "swap_footprint" | "fusion_panelize" | "panelize" | "fusion_make_showcase_project" | "make_showcase_project" | "fusion_open_design" | "open_design" | "fusion_new_electronics_from_eagle" | "new_electronics_from_eagle" | "fusion_aps_open" | "aps_open" | "fusion_schematic" | "schematic" | "fusion_eagle_import_status" | "eagle_import_status" | "fusion_show_attributes" | "show_attributes") if addin_owner_is_exe(&**h) => { let (sc, ca, ca2) = (sidecar.clone(), caller.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let sd = h.bridge_state_dir(); let pids: Vec<u32> = h.fusion_processes().iter().map(|p| p.pid).collect(); let dlg = |r: Value| small_verbs::merge_dialog_array(r, &ca2, &pids); let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: sd.clone(), caller: caller.clone(), pids: pids.clone(), sidecar: &side, dialogs: &dlg }; let cmd = v.strip_prefix("fusion_").unwrap_or(v); let t0 = Instant::now(); let ind = timings::Indicator::start(&sd, cmd, &caller); let pass = |verb: &str, a: Value| addin::pass_through(verb, &a, &ctx); let r0 = match cmd { "show_symbol" => orchestrations::show_symbol(&ctx, &parsed["args"], &pass), "show_footprint" => orchestrations::show_footprint(&ctx, &parsed["args"], &pass), "show_library" => orchestrations::show_library(&ctx, &parsed["args"], &pass), "show_project" => match orchestrations::show_project(&ctx, &parsed["args"], &pass) { Some(r) => r, None => side("show_project", parsed["args"].clone(), 240) }, "capture_library_views" => small_verbs::apply_failure_dialogs(orchestrations::capture_library_views(&ctx, &parsed["args"], &pass), &caller, &pids), "library_views" => { let mut r = small_verbs::apply_failure_dialogs(orchestrations::capture_library_views(&ctx, &docs::library_views_args(&parsed["args"]), &pass), &caller, &pids); r["aliasOf"] = json!("fusion_capture_library_views"); r }, "open_cloud_file" => orchestrations::open_cloud_file(&ctx, &parsed["args"], &pass), "save_lbr" => small_verbs::merge_dialog_array(orchestrations::save_lbr(&ctx, &parsed["args"], &pass), &caller, &pids), "cleanup_cloud_files" => orchestrations::cleanup_cloud_files(&parsed["args"], &pass), "inspect_bodies" => orchestrations::inspect_bodies(&parsed["args"], &pass), "board_stackup" => services::board_stackup(&parsed["args"], &pass), "board_summary" => stdshapes::board_summary(&parsed["args"], &pass), "read_schematic" => stdshapes::read_schematic(&parsed["args"], &pass), "export_pos" => stdshapes::export_pos(&parsed["args"], &pass), "render_board_layers" => stdshapes::render_board_layers(&parsed["args"], &pass), "bom" => stdshapes::bom(&parsed["args"], &pass), "export_3d" => services::export_3d(&parsed["args"], &pass), "import_step" => if parsed["args"]["into"].as_str() == Some("new") { pass("import_step", parsed["args"].clone()) } else { services::import_step_with_board(&parsed["args"], &pass) }, "export_optimized_glb" => if parsed["args"]["async"].as_bool() == Some(true) { services::start_glb_export_async(&ctx, &parsed["args"]) } else { services::export_optimized_glb(&parsed["args"], &pass) }, "fetch_optimized_glb" => if parsed["args"]["exportId"].is_string() { services::glb_export_async_status(&parsed["args"]) } else { services::fetch_optimized_glb(&parsed["args"]) }, "set_design_rules" => services::set_design_rules(&ctx, &parsed["args"], &pass), "make_test_project" => small_verbs::merge_dialog_array(testproject::make_test_project(&ctx, &parsed["args"], &pass), &caller, &pids), "project_create" => small_verbs::merge_dialog_array(testproject::project_create(&ctx, &parsed["args"], &pass), &caller, &pids), "test_board" => small_verbs::merge_dialog_array(boardgen::test_board(&ctx, &parsed["args"], &pass), &caller, &pids), "build_board" => small_verbs::merge_dialog_array(boardgen::build_board(&ctx, &parsed["args"], &pass), &caller, &pids), "swap_footprint" => small_verbs::merge_dialog_array(boardgen::swap_footprint(&ctx, &parsed["args"], &pass), &caller, &pids), "panelize" => small_verbs::merge_dialog_array(boardgen::panelize(&ctx, &parsed["args"], &pass), &caller, &pids), "make_showcase_project" => small_verbs::merge_dialog_array(testproject::make_showcase_project(&ctx, &parsed["args"], &pass), &caller, &pids), "open_design" => orchestrations::open_design(&ctx, &parsed["args"], &pass), "new_electronics_from_eagle" => orchestrations::new_electronics_from_eagle(&ctx, &parsed["args"], &pass), "schematic" => orchestrations::schematic(&ctx, &parsed["args"], &pass), "eagle_import_status" => orchestrations::eagle_import_status(&ctx, &parsed["args"]), "show_attributes" => orchestrations::show_attributes(&ctx, &parsed["args"], &pass), "aps_open" => orchestrations::aps_open(&ctx, &parsed["args"], &pass), other => orchestrations::open_with_screenshot(&ctx, other, &parsed["args"], &pass) }; let mut r = small_verbs::merge_dialog_array(r0, &caller, &pids); small_verbs::add_image_delivery(&mut r); ind.stop(r["success"].as_bool() == Some(false)); timings::log_run(&sd, cmd, t0.elapsed().as_secs_f64(), r["success"].as_bool() != Some(false)); r["shell"] = json!("rust"); r }, "fusion_state" | "state" if addin_owner_is_exe(&**h) => { let (sc, ca) = (sidecar.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let dlg = |r: Value| r; let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: h.bridge_state_dir(), caller: caller.clone(), pids: h.fusion_processes().iter().map(|p| p.pid).collect(), sidecar: &side, dialogs: &dlg }; let mut r = orchestrations::fusion_state(&ctx, &parsed["args"]); r["shell"] = json!("rust"); r }, v @ ("fusion_make_3d_package" | "make_3d_package" | "fusion_build_library_3d" | "build_library_3d" | "fusion_bind_library_3d" | "bind_library_3d" | "fusion_prove_library" | "prove_library" | "fusion_attach_3d_package" | "attach_3d_package" | "fusion_show_3d_package" | "show_3d_package" | "fusion_import_part" | "import_part") if addin_owner_is_exe(&**h) => { let (sc, ca, ca2) = (sidecar.clone(), caller.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let sd = h.bridge_state_dir(); let pids: Vec<u32> = h.fusion_processes().iter().map(|p| p.pid).collect(); let dlg = |r: Value| small_verbs::merge_dialog_array(r, &ca2, &pids); let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: sd.clone(), caller: caller.clone(), pids: pids.clone(), sidecar: &side, dialogs: &dlg }; let cmd = v.strip_prefix("fusion_").unwrap_or(v); let t0 = Instant::now(); *LONG_OP.lock().unwrap() = Some(cmd.to_string()); *LONG_OP_SINCE.lock().unwrap() = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs_f64()).unwrap_or(0.0); let pass = |verb: &str, a: Value| addin::pass_through(verb, &a, &ctx); let r0 = match cmd { "make_3d_package" => mint::make_3d_package(&ctx, &parsed["args"], &pass), "bind_library_3d" => mint::bind_library_3d(&ctx, &parsed["args"], &pass), "prove_library" => prove::prove_library(&ctx, &parsed["args"], &pass), "attach_3d_package" => mint::attach_3d_package(&ctx, &parsed["args"], &pass), "show_3d_package" => mint::show_3d_package(&ctx, &parsed["args"], &pass), "import_part" => partimport::import_part(&ctx, &parsed["args"], &pass), _ => mint::build_library_3d(&ctx, &parsed["args"], &pass) }; *LONG_OP.lock().unwrap() = None; let mut r = small_verbs::apply_failure_dialogs(r0, &caller, &pids); timings::log_run(&sd, cmd, t0.elapsed().as_secs_f64(), r["success"].as_bool() != Some(false)); r["shell"] = json!("rust"); r }, v @ ("fusion_apply_fusion_update" | "apply_fusion_update" | "fusion_tool_update" | "tool_update" | "fusion_window_info" | "window_info" | "fusion_install_fusion" | "install_fusion" | "fusion_uninstall" | "uninstall" | "fusion_prefs_open" | "prefs_open" | "fusion_prefs_close" | "prefs_close" | "fusion_generate_package" | "generate_package" | "fusion_mcp_status" | "mcp_status" | "fusion_mcp_tools" | "mcp_tools" | "fusion_mcp_call" | "mcp_call" | "fusion_mcp_resources" | "mcp_resources" | "fusion_mcp_enable" | "mcp_enable" | "fusion_library_manager" | "library_manager" | "fusion_managed_library_create" | "managed_library_create" | "fusion_managed_library_link" | "managed_library_link" | "fusion_managed_library_pull" | "managed_library_pull" | "fusion_managed_library_push" | "managed_library_push" | "fusion_managed_library_unlink" | "managed_library_unlink" | "fusion_signin" | "signin" | "fusion_signin_2fa" | "signin_2fa" | "fusion_web_signin" | "web_signin" | "fusion_aps_signin" | "aps_signin" | "fusion_demo" | "demo" | "fusion_demo_progress" | "demo_progress" | "fusion_demo_control" | "demo_control" | "fusion_electronics_query" | "electronics_query" | "fusion_view" | "view" | "fusion_interactive" | "interactive" | "fusion_narrate" | "narrate") if addin_owner_is_exe(&**h) => { let (sc, ca) = (sidecar.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let dlg = |r: Value| r; let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: h.bridge_state_dir(), caller: caller.clone(), pids: h.fusion_processes().iter().map(|p| p.pid).collect(), sidecar: &side, dialogs: &dlg }; let pass = |verb: &str, a: Value| addin::pass_through(verb, &a, &ctx); let cmd = v.strip_prefix("fusion_").unwrap_or(v); let mut r = match cmd { "apply_fusion_update" => maintenance::apply_fusion_update(&ctx, &pass), "tool_update" => maintenance::tool_update(&ctx, &parsed["args"], &pass), "window_info" => stdshapes::window_info(&ctx), "install_fusion" => maintenance::install_fusion(&ctx), "prefs_open" => maintenance::prefs_open(&ctx, &parsed["args"], &pass), "prefs_close" => maintenance::prefs_close(&ctx, &parsed["args"]), "generate_package" => epg::generate_package(&parsed["args"], VERSION, &pass),
rust/bridge/src/stdshapes.rs+117@@ -156,10 +156,127 @@ pub fn window_info(ctx: &Ctx) -> Value { json!({"success": true, "mutated": false, "windows": windows, "dialogs": dialogs, "hasModalDialogs": modal, "mainHwnd": main, "mainTitle": facts.main_title}) } +/// EAGLE layer numbers for the standard's render_board_layers presets the add-in's export does not name.+fn preset_layers(preset: &str) -> Option<Vec<i64>> {+ Some(match preset {+ "drill" => vec![20, 44, 45],+ "assembly_top" => vec![20, 21, 25, 27, 51],+ "assembly_bottom" => vec![20, 22, 26, 28, 52],+ "fabrication" => vec![20, 44, 45, 46, 47, 51, 52],+ _ => return None,+ })+}++/// A layer as the standard names it (copper F.Cu, In1.Cu .. In14.Cu, B.Cu) or Fusion's own number.+fn layer_number(v: &Value) -> Option<i64> {+ if let Some(n) = v.as_i64() { return Some(n); }+ let s = v.as_str()?.trim();+ if let Ok(n) = s.parse::<i64>() { return Some(n); }+ match s { "F.Cu" => Some(1), "B.Cu" => Some(16), _ => s.strip_prefix("In").and_then(|r| r.strip_suffix(".Cu")).and_then(|k| k.parse::<i64>().ok()).filter(|k| (1..=14).contains(k)).map(|k| k + 1) }+}++fn png_size(path: &str) -> Option<(u32, u32)> {+ let b = std::fs::read(path).ok()?;+ if b.len() < 24 || &b[1..4] != b"PNG" { return None; }+ Some((u32::from_be_bytes([b[16], b[17], b[18], b[19]]), u32::from_be_bytes([b[20], b[21], b[22], b[23]])))+}++/// `fusion_render_board_layers`: the standard's render_board_layers over the add-in's EXPORT IMAGE:+/// the standard's presets and layer names, png only (Fusion's EXPORT IMAGE writes no svg).+pub fn render_board_layers(args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ let mut a = args.clone();+ if args["listPresets"].as_bool() == Some(true) {+ let mut r = pass("export_board_image", json!({"listPresets": true}));+ for p in ["drill", "assembly_top", "assembly_bottom", "fabrication", "outline"] { r["data"]["presets"][p] = json!(format!("layers {:?}", preset_layers(p).unwrap_or(vec![20]))); }+ return r;+ }+ let format = args["format"].as_str().unwrap_or("png").to_lowercase();+ if format != "png" { return json!({"success": false, "errorCode": "not_supported", "error": format!("Fusion's EXPORT IMAGE writes no {format}: format png")}); }+ let preset = args["preset"].as_str().unwrap_or("all").to_string();+ if let Some(ls) = args["layers"].as_array() {+ let nums: Vec<i64> = ls.iter().filter_map(layer_number).collect();+ if nums.len() != ls.len() { return json!({"success": false, "errorCode": "bad_args", "error": "layers are copper names (F.Cu, In1.Cu .. In14.Cu, B.Cu) or Fusion layer numbers"}); }+ a["layers"] = json!(nums);+ } else if preset == "outline" { a["preset"] = json!("board_outline"); }+ else if let Some(l) = preset_layers(&preset) { a["layers"] = json!(l); }+ if a["outputPath"].is_null() {+ a["outputPath"] = json!(format!("{}/adom-board-layers/{}.png", std::env::temp_dir().to_string_lossy().replace('\\', "/"), preset));+ }+ let _ = pass("show_2d_board", json!({"expectDocument": args["expectDocument"]}));+ let mut r = pass("export_board_image", a.clone());+ if r["success"].as_bool() == Some(false) { return r; }+ let path = a["outputPath"].as_str().unwrap_or("").to_string();+ let (w, h) = png_size(&path).map(|(w, h)| (json!(w), json!(h))).unwrap_or((Value::Null, Value::Null));+ r["path"] = json!(path); r["format"] = json!("png"); r["widthPx"] = w; r["heightPx"] = h;+ r["preset"] = json!(if args["layers"].is_array() { "custom".to_string() } else { preset });+ r+}++/// `fusion_bom`: the standard's bom over Fusion's own reads: board_bom (the board's lines and their+/// part numbers, written as bom.csv and bom-jlcpcb.csv), schematic_data (each part's description and+/// device) and variants (not-fitted parts and alternates). `variant` names one; default the current.+pub fn bom(args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ let mut ba = json!({"expectDocument": args["expectDocument"]});+ if args["write"].as_bool() != Some(false) { if let Some(d) = args["outputDir"].as_str() { ba["outputDir"] = json!(d); } }+ let bb = crate::routing::call("board_bom", &ba, pass);+ if bb["success"].as_bool() == Some(false) { return bb; }+ let mut sa = json!({"expectDocument": args["expectDocument"], "params": true});+ if let Some(l) = args["limit"].as_u64() { sa["limit"] = json!(l); }+ let sd = crate::sch::call("schematic_data", &sa, pass);+ let sheet: std::collections::HashMap<String, Value> = sd["components"].as_array().cloned().unwrap_or_default().into_iter()+ .filter_map(|c| c["designator"].as_str().map(|d| (d.to_string(), c.clone()))).collect();+ let vr = crate::sch::call("variants", &json!({"expectDocument": args["expectDocument"]}), pass);+ let variants = vr["variants"].as_array().cloned().unwrap_or_default();+ let chosen = match args["variant"].as_str() {+ Some(n) => match variants.iter().find(|v| v["name"].as_str() == Some(n)) { Some(v) => Some(v.clone()), None => return json!({"success": false, "errorCode": "unknown_variant", "error": format!("No variant {n}"), "variants": variants.iter().map(|v| v["name"].clone()).collect::<Vec<_>>()}) },+ None => variants.iter().find(|v| v["current"] == true).cloned(),+ };+ let dnp: Vec<String> = chosen.as_ref().map(|v| v["notFitted"].as_array().cloned().unwrap_or_default().iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default();+ let boards = args["boards"].as_u64().unwrap_or(1).max(1);+ let param = |c: &Value, keys: &[&str]| -> Value { for k in keys { if let Some(s) = c["params"][*k].as_str().filter(|s| !s.trim().is_empty()) { return json!(s); } } Value::Null };+ let mut lines = Vec::new();+ let (mut fitted, mut no_mpn, mut no_mfr) = (0u64, Vec::new(), Vec::new());+ for l in bb["lines"].as_array().into_iter().flatten() {+ let des: Vec<String> = l["designators"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect())+ .or_else(|| l["designators"].as_str().map(|s| s.split(|c: char| c == ',' || c.is_whitespace()).filter(|x| !x.is_empty()).map(str::to_string).collect())).unwrap_or_default();+ let fit: Vec<String> = des.iter().filter(|d| !dnp.contains(d)).cloned().collect();+ if fit.is_empty() { continue; }+ let first = sheet.get(&fit[0]).cloned().unwrap_or(json!({}));+ fitted += fit.len() as u64;+ if l["mpn"].as_str().map(|s| s.trim().is_empty()).unwrap_or(true) { no_mpn.extend(fit.iter().cloned()); }+ if l["manufacturer"].as_str().map(|s| s.trim().is_empty()).unwrap_or(true) { no_mfr.extend(fit.iter().cloned()); }+ lines.push(json!({"quantity": fit.len() as u64 * boards, "designators": fit, "value": l["value"], "comment": first["comment"].clone(),+ "description": param(&first, &["DESCRIPTION", "DESC", "Description"]), "footprint": l["package"], "package": l["package"],+ "mpn": l["mpn"], "manufacturer": l["manufacturer"], "mouser": l["mouser"], "digikey": l["digikey"], "lcsc": l["lcsc"], "libReference": first["libReference"].clone()}));+ }+ // the same value and footprint ordered under different part numbers+ let mut inconsistent = Vec::new();+ for field in ["mpn", "manufacturer"] {+ let mut by: std::collections::BTreeMap<(String, String), Vec<(String, Vec<Value>)>> = Default::default();+ for l in &lines { let k = (l["value"].as_str().unwrap_or("").to_string(), l["package"].as_str().unwrap_or("").to_string()); by.entry(k).or_default().push((l[field].as_str().unwrap_or("").to_string(), l["designators"].as_array().cloned().unwrap_or_default())); }+ for ((_v, _p), rows) in by { let vals: std::collections::BTreeSet<&String> = rows.iter().map(|r| &r.0).collect(); if vals.len() > 1 {+ inconsistent.push(json!({"field": field, "designators": rows.iter().flat_map(|r| r.1.clone()).collect::<Vec<_>>(), "values": vals.into_iter().collect::<Vec<_>>()})); } }+ }+ let board_refs: std::collections::HashSet<String> = bb["lines"].as_array().into_iter().flatten().flat_map(|l| l["designators"].as_array().cloned().unwrap_or_default()).filter_map(|x| x.as_str().map(str::to_string)).collect();+ let sheet_not_on_board: Vec<&String> = sheet.iter().filter(|(d, c)| !board_refs.contains(*d) && c["device"].as_str().map(|s| !s.is_empty()).unwrap_or(false)).map(|(d, _)| d).collect();+ let files: Vec<Value> = bb["files"].as_object().map(|m| m.values().cloned().collect()).unwrap_or_default();+ let alternates: Vec<Value> = chosen.as_ref().map(|v| v["alternates"].as_array().cloned().unwrap_or_default().into_iter().map(|a| json!({"designator": a["designator"], "alternatePart": a["value"]})).collect()).unwrap_or_default();+ json!({"success": true, "mutated": false, "lines": lines, "fittedParts": fitted * boards, "files": files, "alternates": alternates,+ "variants": variants.iter().map(|v| v["name"].clone()).collect::<Vec<_>>(), "variant": chosen.as_ref().map(|v| v["name"].clone()),+ "checks": {"missingMpn": no_mpn, "missingManufacturer": no_mfr, "missingPartNumbers": bb["missingPartNumbers"], "inconsistent": inconsistent,+ "dnp": dnp, "excludedKinds": [], "sheetNotOnBoard": sheet_not_on_board, "boardNotOnSheet": bb["boardOnly"]},+ "_hint": "Lines are the board's parts grouped by value, footprint and part numbers; DNP parts of the variant are left out. bom.csv and bom-jlcpcb.csv are in files."})+}+ #[cfg(test)] mod tests { use super::*; #[test]+ fn standard_layer_names_become_eagle_numbers() {+ assert_eq!([layer_number(&json!("F.Cu")), layer_number(&json!("In2.Cu")), layer_number(&json!("B.Cu")), layer_number(&json!(21)), layer_number(&json!("In15.Cu"))], [Some(1), Some(3), Some(16), Some(21), None]);+ assert_eq!(preset_layers("drill"), Some(vec![20, 44, 45]));+ }+ #[test] fn a_library_lists_its_components_or_its_footprints() { let p = std::env::temp_dir().join(format!("adom-ll-{}.lbr", std::process::id())); std::fs::write(&p, r#"<eagle><drawing><library><packages><package name="R0603"/><package name="SOT23"/></packages><symbols/><devicesets><deviceset name="R"/></devicesets></library></drawing></eagle>"#).unwrap();
tests/run_verb_tests.py+4@@ -446,6 +446,10 @@ t("fusion_list_library", "daily", {"libraryPath": "C:/Users/olive/Documents/fusi t("fusion_export_pos", "daily", dict(B, fab="jlcpcb", outputDir=f"{SCRATCH}/pos"), {"ok": True, "fields": ["files", "parts"]}, "pick-and-place per side plus JLCPCB's CPL") t("fusion_export_pos#gerber", "negative", dict(B, format="gerber"), {"errorCode": "not_supported"}, "no Gerber position file in Fusion: refused by name") t("fusion_window_info", "daily", {}, {"ok": True, "fields": ["windows", "dialogs", "hasModalDialogs"]}, "read-only: windows and dialogs, no screenshots")+t("fusion_bom", "daily", B, {"ok": True, "fields": ["lines", "checks", "fittedParts"]}, "the standard's bom from board_bom, schematic_data and variants")+t("fusion_render_board_layers", "daily", dict(B, preset="top_copper", outputPath=f"{SCRATCH}/layers/top_copper.png"), {"ok": True, "fields": ["path", "widthPx"]}, "EXPORT IMAGE with the standard's preset")+t("fusion_render_board_layers#svg", "negative", dict(B, format="svg"), {"errorCode": "not_supported"}, "Fusion writes no svg: refused by name")+t("fusion_render_board_3d#framed", "daily", dict(B, side="top", refs=["IC1"], rotate="-45,0,45", outputPath=f"{SCRATCH}/render/framed.png"), {"ok": True, "fields": ["path", "framed"]}, "framed on a part and turned") t("fusion_log_tail", "daily", {"sinceOffset": 0, "maxBytes": 20000, "errorsOnly": True}, {"ok": True, "fields": ["entries", "next", "truncated"]}, "read-only: Fusion's own log since an offset") t("fusion_verb_times", "daily", {"limit": 20}, {"ok": True, "fields": ["verbs", "since"]}) t("fusion_live_feed", "daily", {"action": "poll"}, {"ok": True}, "one poll of the feed; no waiting")