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
Library 3D mints save into a named project and folder (issue #126)
4 files changed
+86−7
CHANGELOG.md+7@@ -83,6 +83,13 @@ The board model, to the [EDA Bridge Standard](https://wiki.adom.inc/adom/eda-bri - Verified live: first save with the board in front (fprj, fsch, fbrd v1, linked), route a net, save in place (board v1 to v2), a second Save As refused; closed and reopened from the cloud, the board shows the routed net and the folder holds exactly one .fprj, .fsch and .fbrd.+- **Library 3D models go where the run says** (page issue #126): `fusion_make_3d_package` and+ `fusion_build_library_3d` take `projectName` and `folderPath`; each .f3d is saved into+ `<projectName>/<folderPath or "Adom AI Workspace">/<task>` (folders created), found by name in the hub, never+ the active project. A missing project is refused (`project_not_found`), and a failed save with a named+ destination is reported, not uploaded to the fixed APS folder of another project. The reply's `savedTo` names+ the project and folder. Without `projectName` the mint is unchanged. Verified live: with no project open,+ ADS8588SIPM minted into Default Project/fusion-lane-tests/lib3d/issue-126, nothing new in Main. - **DisplayLink warning** (page issue #121): when Fusion has detected an indirect display adapter (`IndirectDisplayAdapterDetected=1` in its `NMachineSpecificOptions.xml`), Fusion Electronics can die in its D3D12 canvas on any monitor. `fusion_readiness` now carries `displayWarning`, and the guided tour and
contracts/describe.json+6−2@@ -618,11 +618,13 @@ "input": { "capture": "optional bool (default true)", "folderId": "optional str",+ "folderPath": "optional str under that project, created if missing (default Adom AI Workspace); the task subfolder goes under it", "lbrPath": "required str (combined .lbr WINDOWS path to bind + open)", "openWhenDone": "optional bool (default true)", "outLbrPath": "optional str (default: overwrite lbrPath)", "parts": "required [{package, lbrPath (per-part footprint .lbr), modelPath (.step)}]",- "projectId": "optional str"+ "projectId": "optional str",+ "projectName": "optional str: save the .f3d into this project by name (never the active one; a missing name is refused)" }, "longRunning": true, "name": "fusion_build_library_3d",@@ -2139,11 +2141,13 @@ "captureLabel": "optional str (capture before/after)", "fileName": "optional str", "folderId": "optional str (default upload folder)",+ "folderPath": "optional str under that project, created if missing (default Adom AI Workspace); the task subfolder goes under it", "lbrPath": "required str (.lbr WINDOWS path; its FIRST package's footprint is loaded)", "modelPath": "required str (.step WINDOWS path)", "orient": "optional bool (default true). false = import exactly as authored: NO bounding-box reorientation and NO pin-1 rotation (1.10.8)", "placement": "optional {translation:[x,y,z] mm, rotation:[rx,ry,rz] deg (about X, then Y, then Z, extrinsic, about the origin), scale:1} from the manufacturer's authored transform. Bypasses every heuristic; the reply returns the applied 4x4 matrix (mm), verification (verified|mismatch), source {path, sha256, bytes} and warnings. Non-finite values, wrong shapes and any scale other than 1 are refused before Fusion is touched (invalid_placement / unsupported_scale)",- "projectId": "optional str (default MAIN-project)"+ "projectId": "optional str (default MAIN-project)",+ "projectName": "optional str: save the .f3d into this project by name (never the active one; a missing name is refused)" }, "longRunning": true, "name": "fusion_make_3d_package",
describe.py+2−2@@ -289,11 +289,11 @@ _T = [ {"type": "required str (EPG family, e.g. chip|soic|qfn|qfp|bga|sot23|dfn2|melf|ecap|crystal|header_straight - see supportedTypes in the error for the full list)", "params": "optional dict (generator dims in MM by default, e.g. chip: {D,E,A,L,L1}; soic: {A,A1,b,D,E,E1,e,L,DPins}; omitted keys use EPG defaults)", "unitsCm": "optional bool (params already EPG-native cm)", "etch": "optional str (marking text on the chip top: MPN / '103'; multi-line with newline, e.g. MPN+variant - laid along the LONGEST face axis)", "etchStyle": "optional str raised|engraved (default raised = thin WHITE positive extrude, silkscreen-style contrast; engraved = sunken cut)", "etchDepthMm": "optional float (marking height/depth, default 0.03)", "etchHeightMm": "optional float (text height; default auto-fit: top-5%-band face, 10% margin, width-aware)", "outputStep": "optional str (WINDOWS path; exports STEP incl. the marking)"}, {"success": "bool", "bodies": "[{name,vol}]", "etched": "str|null", "stepPath": "str|null", "manifest": "obj (settings+why+bboxes; also written as <step>.manifest.json sidecar for marking refreshes)", "manifestPath": "str|null", "epgDir": "str"}, 120, _STATUS, True, {"type": "chip", "params": {"D": 1.6, "E": 0.8, "A": 0.45, "L": 0.3, "L1": 0.3}, "etch": "103", "outputStep": "C:/tmp/ADOM-R0603-103.step"}), ("fusion_make_3d_package", "Create a RENDERING component 3D package (FOOTPRINT + chip, aligned) for ONE part, fully programmatically with ZERO GUI dialogs. Opens the .lbr, runs Electron.Create3DPackage to load the package's footprint into a generator doc, imports the STEP onto it, orients it flat, saveAs an .f3d (skips the FINISH Save dialog + the two unbeatable 'Fusion360' CEF modals), uploads the f3d via APS, and returns an fs.file:vf wip_urn to hand-write into the library's <packages3d>. projectId/folderId default to the MAIN-project upload folder. Pass captureLabel to also get a BEFORE (footprint) + AFTER (chip placed) screenshot. PITFALL: a RAW STEP upload binds but renders NOTHING ('Thumbnail download failed') - this verb makes an f3d, which renders. Prefer fusion_build_library_3d for many parts.",- {"lbrPath": "required str (.lbr WINDOWS path; its FIRST package's footprint is loaded)", "modelPath": "required str (.step WINDOWS path)", "projectId": "optional str (default MAIN-project)", "folderId": "optional str (default upload folder)", "fileName": "optional str", "captureLabel": "optional str (capture before/after)", "orient": "optional bool (default true). false = import exactly as authored: NO bounding-box reorientation and NO pin-1 rotation (1.10.8)",+ {"projectName": "optional str: save the .f3d into this project by name (never the active one; a missing name is refused)", "folderPath": "optional str under that project, created if missing (default Adom AI Workspace); the task subfolder goes under it", "lbrPath": "required str (.lbr WINDOWS path; its FIRST package's footprint is loaded)", "modelPath": "required str (.step WINDOWS path)", "projectId": "optional str (default MAIN-project)", "folderId": "optional str (default upload folder)", "fileName": "optional str", "captureLabel": "optional str (capture before/after)", "orient": "optional bool (default true). false = import exactly as authored: NO bounding-box reorientation and NO pin-1 rotation (1.10.8)", "placement": "optional {translation:[x,y,z] mm, rotation:[rx,ry,rz] deg (about X, then Y, then Z, extrinsic, about the origin), scale:1} from the manufacturer's authored transform. Bypasses every heuristic; the reply returns the applied 4x4 matrix (mm), verification (verified|mismatch), source {path, sha256, bytes} and warnings. Non-finite values, wrong shapes and any scale other than 1 are refused before Fusion is touched (invalid_placement / unsupported_scale)"}, {"success": "bool", "wip_urn": "str", "dims_mm": "[x,y,z]", "f3d": "str", "before": "str|null", "after": "str|null"}, 200, _STATUS, True, {"lbrPath": "C:/tmp/newlib/R_4k7.lbr", "modelPath": "C:/tmp/newlib/R_4k7.step", "captureLabel": "R_4k7"}), ("fusion_build_library_3d", "Build a RENDERING multi-part 3D library in ONE call - the whole programmatic pipeline. For each part it makes the footprint+chip f3d package (no GUI dialogs, optional before/after screenshots) and collects the wip_urn, then injects ALL bindings into the combined .lbr in one pass (no hand XML surgery) and opens the finished library once. This is the verb for a basic-parts sampler / any many-part library; it replaces the per-part make_3d_package loop + manual binding. projectId/folderId default to the MAIN-project upload folder. Each part is independent, so re-run just the failed parts if Fusion resets mid-run.",- {"lbrPath": "required str (combined .lbr WINDOWS path to bind + open)", "parts": "required [{package, lbrPath (per-part footprint .lbr), modelPath (.step)}]", "outLbrPath": "optional str (default: overwrite lbrPath)", "capture": "optional bool (default true)", "projectId": "optional str", "folderId": "optional str", "openWhenDone": "optional bool (default true)"},+ {"projectName": "optional str: save the .f3d into this project by name (never the active one; a missing name is refused)", "folderPath": "optional str under that project, created if missing (default Adom AI Workspace); the task subfolder goes under it", "lbrPath": "required str (combined .lbr WINDOWS path to bind + open)", "parts": "required [{package, lbrPath (per-part footprint .lbr), modelPath (.step)}]", "outLbrPath": "optional str (default: overwrite lbrPath)", "capture": "optional bool (default true)", "projectId": "optional str", "folderId": "optional str", "openWhenDone": "optional bool (default true)"}, {"success": "bool", "boundLbr": "str", "partsBound": "int", "partsTotal": "int", "parts": "[{package,success,wip_urn,dims_mm,error}]", "screenshots": "[{package,stage,path}]", "opened": "bool"}, 1800, _STATUS, True, {"lbrPath": "C:/tmp/newlib/AdomBasicParts2.lbr", "parts": [{"package": "R_4k7", "lbrPath": "C:/tmp/newlib/R_4k7.lbr", "modelPath": "C:/tmp/newlib/R_4k7.step"}]}), ("fusion_capture_library_views", "Capture the LIBRARY-EDITOR views EEs trust: the schematic SYMBOL (full pinout), the FOOTPRINT (pads + layer stack), and the COMPONENT/device view (Content Manager: symbol + the package table with the footprint<->package Mapped check + pin/pad counts). The 3D before/after shots don't show these. Requires the .lbr OPEN in the Electronics Library workspace (fusion_open_lbr first). Runs EDIT <pkg>.sym/.pac/.dev + WINDOW FIT + a background hwnd screenshot per view; returns the PNG paths on the box (C:/tmp/conduit-screenshots - pull with desktop_pull_file). EDIT uses the DEVICESET name (e.g. ESP32-S3FN8).", {"packages": "required [<deviceset name>] (or 'package': single name)", "views": "optional subset of ['symbol','footprint','3d','component'] (default ALL FOUR, in that assembly order - 3d opens the part's BOUND f3d (footprint + chip) by its urn, frames it, captures it and closes it unsaved; needs libraryPath; component is the ganged view last)", "libraryPath": "optional str (.lbr on the box; required for the 3d view, defaults to the last library opened)", "settle": "optional float secs after each EDIT before capture (default 1.5; raise if a shot shows the PREVIOUS part)"},
rust/bridge/src/mint.rs+71−3@@ -94,6 +94,40 @@ fn make_3d_script_raw(model_path: &str, orient_block: &str, pin1: Option<(f64, f .replace("{PIN1}", &pin1_repr).replace("{PAD1}", &pad1_repr).replace("{LAYERS_OFF}", &layers_repr).replace("{ORIENT_BLOCK}", orient_block).replace("{PIN1_SKIP}", pin1_skip) } +/// Point a rendered mint script at a named project and folder (page issue #126): the template+/// saves into Fusion's ACTIVE project (else "Main") under "Adom AI Workspace/<task>", which put a+/// run's models in an unrelated project. With projectName the project is found by name in the hub+/// (never the active one; a missing name is refused, not substituted) and the .f3d goes to+/// <project>/<folderPath or "Adom AI Workspace">/<task>, folders created as needed. The template+/// itself is unchanged, so a call without projectName renders exactly as before.+pub fn with_destination(script: &str, project: &str, folder_path: &str, task: &str) -> Option<String> {+ let start = script.find("proj=None\n")?;+ let marker = format!("wsf=_sub(_sub(rf,'Adom AI Workspace'),'{task}')\n");+ let end = script.find(&marker)? + marker.len();+ let lit = |s: &str| serde_json::to_string(s).unwrap();+ let block = format!(r#"proj=None+_PN={pn}; _FP={fp}; _TK={tk}+try:+ hubs=app.data.dataHubs+ for hi in range(hubs.count):+ ps=hubs.item(hi).dataProjects+ for pj2 in range(ps.count):+ p2=ps.item(pj2)+ if str(p2.name).lower()==_PN.lower(): proj=p2; break+ if proj: break+except Exception as e: res['projErr']=str(e)[:60]+if not proj: res['errorCode']='project_not_found'; res['projectName']=_PN+try:+ if proj is None: raise RuntimeError('no project named %s in this hub' % _PN)+ rf=proj.rootFolder+ wsf=rf+ for _part in ([x for x in _FP.replace('\\','/').split('/') if x.strip()] or ['Adom AI Workspace']): wsf=_sub(wsf,_part)+ wsf=_sub(wsf,_TK)+ res['savedTo']={{'project':proj.name,'folder':'/'.join(([x for x in _FP.replace('\\','/').split('/') if x.strip()] or ['Adom AI Workspace'])+[_TK])}}+"#, pn = lit(project), fp = lit(folder_path), tk = lit(task));+ Some(format!("{}{}{}", &script[..start], block, &script[end..]))+}+ /// `_pad1_xy_mm`: (x, y) of pad or smd "1" in the FIRST package of the .lbr. pub fn pad1_xy_mm(lbr_path: &str) -> Option<(f64, f64)> { let t = std::fs::read_to_string(lbr_path).ok()?;@@ -181,7 +215,14 @@ pub fn make_3d_package(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, Value) -> Va let mut before_shot = Value::Null; if let Some(l) = &cap_label { let _ = pass("run_modeling_script", json!({"script": "app.activeViewport.fit()\nresult={}"})); std::thread::sleep(Duration::from_millis(400)); before_shot = json!(crate::orchestrations::capture_labeled(ctx, &format!("{l}_before"))); } let layers: Vec<String> = args["layersOff"].as_array().map(|a| a.iter().map(|v| match v { Value::String(s) => s.clone(), o => o.to_string() }).collect()).unwrap_or_default();- let script = make_3d_script_placed(&model_path, orient, pad1_xy_mm(&lbr_path), &layers, &task, &name, placement.as_ref());+ let mut script = make_3d_script_placed(&model_path, orient, pad1_xy_mm(&lbr_path), &layers, &task, &name, placement.as_ref());+ let dest_project = args["projectName"].as_str().map(str::trim).filter(|s| !s.is_empty()).map(str::to_string);+ if let Some(pn) = &dest_project {+ match with_destination(&script, pn, args["folderPath"].as_str().unwrap_or(""), &task) {+ Some(s2) => script = s2,+ None => return json!({"success": false, "errorCode": "destination_unsupported", "error": "this mint script has no destination block to point at projectName (a bridge defect: the template changed)"}),+ }+ } let r = pass("run_modeling_script", json!({"script": script})); let res = r["data"]["result"].clone(); let f3d_path = res["path"].as_str().map(str::to_string);@@ -192,10 +233,17 @@ pub fn make_3d_package(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, Value) -> Va let wip = format!("urn:adsk.wipprod:fs.file:vf.{lineage}?version=1"); if lbr_path.contains("0603WAF3300T5E") { let _ = std::fs::write(ctx.state_dir.join("r3d-urn.json"), json!({"0603WAF3300T5E": wip}).to_string()); } let placement_out = if res["placement"].is_object() { res["placement"].clone() } else { json!({"requested": null, "heuristics": {"orient": orient, "centering": false, "pin1": res["pin1"]["status"]}, "verification": "heuristic (no explicit placement to verify against)"}) };- return json!({"success": true, "wip_urn": wip, "lineageUrn": p, "dims": res["dims"], "pin1": res["pin1"], "placement": placement_out, "source": provenance, "warnings": warnings, "bodies": res["bodies"], "renamedTo": res["renamedTo"], "fileName": f3d_name, "savedTo": "cloud (Adom AI Workspace)", "before": before_shot, "after": after_shot,+ return json!({"success": true, "wip_urn": wip, "lineageUrn": p, "dims": res["dims"], "pin1": res["pin1"], "placement": placement_out, "source": provenance, "warnings": warnings, "bodies": res["bodies"], "renamedTo": res["renamedTo"], "fileName": f3d_name, "savedTo": if res["savedTo"].is_object() { res["savedTo"].clone() } else { json!("cloud (Adom AI Workspace)") }, "before": before_shot, "after": after_shot, "_hint": "Saved DIRECTLY to the cloud workspace - no upload step needed. Bind this wip_urn into the library's <packages3d>."}); } }+ if let Some(pn) = &dest_project {+ // a named destination is never swapped for the fixed APS upload folder (another project)+ let code = res["errorCode"].as_str().unwrap_or("cloud_save_failed").to_string();+ return json!({"success": false, "errorCode": code, "projectName": pn, "folderPath": args["folderPath"], "detail": res,+ "error": if code == "project_not_found" { format!("No project named {pn} in this Autodesk hub, so nothing was saved (the bridge never falls back to another project)") } else { format!("The model could not be saved into {pn}: {}", res["err"].as_str().or(res["projErr"].as_str()).unwrap_or("Fusion's in-app cloud save failed")) },+ "_hint": "Pass the exact project name as Fusion's data panel shows it (fusion_list_cloud_projects); folderPath is created if missing. Without projectName the models go to the active project, as before."});+ } if res["errorCode"].as_str() == Some("no_cloud_project") { let detail: serde_json::Map<String, Value> = ["projErr", "projErr2", "createErr"].iter().filter_map(|k| res.get(*k).filter(|v| !v.is_null()).map(|v| (k.to_string(), v.clone()))).collect(); return json!({"success": false, "errorCode": "no_cloud_project", "error": format!("3D minting uploads an .f3d to Autodesk cloud storage, and this account has no usable cloud project (typical of a brand-new Autodesk account). The bridge searched every hub for any healthy project and also tried to CREATE an 'Adom' project; all failed: {}", if detail.is_empty() { "(no per-step detail)".to_string() } else { crate::fusion_prefs::py_str(&Value::Object(detail)) }),@@ -296,7 +344,7 @@ pub fn build_library_3d_core(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, Value) if let Some(key) = args["_cancelKey"].as_str() { if crate::chains::cancel_requested_pub(key) { cancelled = true; break; } } let docs_before: Vec<String> = pass("document_info", json!({}))["data"]["openDocuments"].as_array().map(|a| a.iter().filter_map(|d| d["name"].as_str().map(str::to_string)).collect()).unwrap_or_default(); if pkg.is_empty() || per_lbr.is_empty() || step.is_empty() { results.push(json!({"package": pkg, "success": false, "error": "package, lbrPath (per-part .lbr) and modelPath (.step) all required"})); on_part(results.last().unwrap(), results.len(), total); continue; }- let mk_args = json!({"lbrPath": per_lbr, "modelPath": step, "projectId": project_id, "folderId": folder_id, "fileName": format!("{pkg}_3d"), "task": task, "placement": p["placement"].clone(), "orient": p["orient"].clone(), "captureLabel": if capture { json!(pkg) } else { Value::Null }, "orient": p["orient"].as_bool().unwrap_or(true)});+ let mk_args = json!({"lbrPath": per_lbr, "modelPath": step, "projectId": project_id, "folderId": folder_id, "projectName": args["projectName"], "folderPath": args["folderPath"], "fileName": format!("{pkg}_3d"), "task": task, "placement": p["placement"].clone(), "orient": p["orient"].clone(), "captureLabel": if capture { json!(pkg) } else { Value::Null }, "orient": p["orient"].as_bool().unwrap_or(true)}); let mut mk = make_3d_package(ctx, &mk_args, pass); if mk["success"].as_bool() != Some(true) { std::thread::sleep(Duration::from_secs(2)); mk = make_3d_package(ctx, &mk_args, pass); } let mut entry = json!({"package": pkg, "success": mk["success"].as_bool().unwrap_or(false)});@@ -579,3 +627,23 @@ pub fn show_3d_package(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, Value) -> Va if let Some(w) = &wrapped { out["wrappedLibrary"] = json!(w); } out }++#[cfg(test)]+mod destination_tests {+ #[test]+ fn named_project_replaces_the_active_project_block() {+ let plain = super::make_3d_script("C:/m.step", true, None, &[], "Run 7", "U1_3d");+ assert!(plain.contains("proj=app.data.activeProject"));+ let named = super::with_destination(&plain, "Claude AI", "Adom AI Flow", "Run 7").expect("block found");+ assert!(!named.contains("proj=app.data.activeProject"), "the active project is never consulted");+ assert!(named.contains("_PN=\"Claude AI\"; _FP=\"Adom AI Flow\"; _TK=\"Run 7\""));+ assert!(named.contains("res['errorCode']='project_not_found'"));+ assert!(named.contains("doc.saveAs('U1_3d', wsf"), "the save itself is untouched");+ // the rest of the script is unchanged+ let (a, b) = (plain.split("proj=None").next().unwrap(), named.split("proj=None").next().unwrap());+ assert_eq!(a, b);+ // quotes in names cannot break out of the string+ let q = super::with_destination(&plain, "Bob's \"project\"", "", "Run 7").unwrap();+ assert!(q.contains(r#"_PN="Bob's \"project\"""#));+ }+}