← Commit history

Library 3D mints save into a named project and folder (issue #126)

Colby Knox ·b6fde859e7 ·16d ago ·parent 65a7ad5
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\"""#));+    }+}