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
Update 161 file(s)
15 files changed
+127−41
BRIDGE_VERSION+1−1@@ -1 +1 @@-1.10.7+1.10.8
bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "fusion360", "displayName": "Autodesk Fusion 360",- "version": "1.10.7",+ "version": "1.10.8", "description": "Drive Autodesk Fusion 360 from the cloud: launch Fusion, electronics board layout, design rules, exports (STEP/IGES/STL/3MF/USDZ/OBJ/DXF/DWG/Gerbers/BOM/CPL), fast APS server-indexed cloud search plus browse/recent/file-info/versions, cloud file download/upload and folder creation, and in-app parametric modeling (fusion_run_modeling_script). Never-charge: APS calls are capped to the free tier.", "homepage": "https://wiki.adom.inc/adom/fusion-bridge", "author": "Adom Inc.",
contracts/describe.json+3−2@@ -1,6 +1,6 @@ { "contract": "fusion-bridge verb catalog",- "frozen": "2026-09-07",+ "frozen": "2026-09-10", "note": "The Python bridge and the Rust bridge must both serve this catalog byte-for-byte in meaning: same names, same args, same result shapes, same timeouts. Regenerate ONLY from describe.py and diff in CI.", "source": "describe.py", "verbs": [@@ -1728,7 +1728,8 @@ "folderId": "optional str (default upload folder)", "lbrPath": "required str (.lbr WINDOWS path; its FIRST package's footprint is loaded)", "modelPath": "required str (.step WINDOWS path)",- "orient": "optional bool (default true)",+ "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)" }, "longRunning": true,
dashboard/Cargo.lock+1−1@@ -4,7 +4,7 @@ version = 4 [[package]] name = "fusion-dashboard"-version = "1.10.7"+version = "1.10.8" dependencies = [ "serde_json", ]
dashboard/Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.10.7"+version = "1.10.8" edition = "2021" [dependencies]
describe.py+2−1@@ -188,7 +188,8 @@ _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)"},+ {"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)"},
install.sh+1−1@@ -21,7 +21,7 @@ echo "Installed fusion-bridge skills: $(ls "$PKG_DIR/skills" 2>/dev/null | tr '\ # binary (the three-artifact pattern: bridge zip -> ab, THIS pkg -> container skills + # this download, page media -> wiki). Per the wiki's PKG_VS_RELEASE policy, binaries are # platform-tagged RELEASE assets, never tarball content - the installer fetches them.-DASH_VERSION="1.10.7"+DASH_VERSION="1.10.8" DASH_URL="https://wiki.adom.inc/download/adom/fusion-bridge/${DASH_VERSION}/fusion-dashboard" DEST="" if [ -w /usr/local/bin ]; then DEST=/usr/local/bin
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "fusion-bridge",- "version": "1.10.7",+ "version": "1.10.8", "type": "app", "description": "This package installs the bridge's SKILLS into your container so your AI knows how to drive it; Adom Bridge loads the bridge runtime itself from the release zip.", "dependencies": {
page.json+11−27@@ -1,6 +1,6 @@ { "slug": "fusion-bridge",- "version": "1.10.7",+ "version": "1.10.8", "type": "app", "description": "This package installs the bridge's SKILLS into your container so your AI knows how to drive it; Adom Bridge loads the bridge runtime itself from the release zip.", "dependencies": {@@ -11,6 +11,10 @@ "uninstall": "./uninstall.sh" }, "tags": [+ "ab",+ "bridge",+ "fusion",+ "desktop-app", "fusion360", "autodesk", "cad",@@ -75,7 +79,10 @@ "build a basic parts library", "add a 3d model to a component", "make a fusion 360 library with 3d",- "set up the fusion bridge"+ "set up the fusion bridge",+ "fusion dashboard",+ "fusion status page",+ "open the fusion launch page" ], "discovery_pitch": "Use the Adom Bridge Fusion 360 bridge when the user wants to DRIVE Autodesk Fusion 360 from the cloud: open/search their team's Fusion cloud designs, lay out a PCB or do electronics, export STEP/IGES/STL/Gerbers/BOM, or build a component library with real rendering 3D. It runs Fusion on the user's own machine via Adom Bridge. NOT for web/browser/login/form-filling (that's the native-browser extension) and NOT for KiCad (that's the kicad bridge).", "brief": "Drive Autodesk Fusion 360 from the cloud via Adom Bridge: component libraries, IPC package generation, board layout, exports (STEP/Gerbers/BOM/CPL), fast APS cloud search, and parametric modeling.",@@ -104,28 +111,5 @@ "label": "Part volume", "prompt": "How much material does each printed part in this assembly use?" }- ],- "agent_permissions": {- "allow_bash": [],- "allow": [- "adom-desktop-fusion-bridge read-only verbs (via the adom-bridge CLI): fusion_readiness, fusion_get_app_state, fusion_addin_status, fusion_check_dialogs - state queries; they change nothing.",- "adom-desktop-fusion-bridge read-only verbs: fusion_alerts (reads Fusion's own log for toasts), fusion_document_info, fusion_window_info, fusion_board_info, fusion_library_parts - inspect only.",- "adom-desktop-fusion-bridge read-only verbs: fusion_prove_result (a persisted run report), fusion_plan_library_import (report-only preflight), fusion_list_cloud_projects, fusion_describe.",- "adom-desktop-fusion-bridge evidence captures fusion_screenshot_fusion and fusion_capture_library_views read pixels and write local image files only."- ],- "soft_deny": [- "adom-desktop-fusion-bridge lifecycle verbs fusion_stop, fusion_kill, fusion_restart_for_addin close or kill the Fusion application - keep the human prompt.",- "adom-desktop-fusion-bridge fusion_install_fusion silently installs Autodesk Fusion on the user's machine.",- "adom-desktop-fusion-bridge fusion_cleanup_cloud_files deletes files from the user's Autodesk cloud hub.",- "adom-desktop-fusion-bridge managed-library verbs (fusion_managed_library_push/pull/create/link/unlink) publish to or overwrite the user's library.io libraries."- ],- "environment": [- "adom-desktop-fusion-bridge is the Fusion 360 bridge for Adom Bridge; its fusion_* verbs are invoked through the adom-bridge CLI and drive Autodesk Fusion on the user's own desktop.",- "adom-desktop-fusion-bridge desktop actions also pass Adom Bridge's own human-approval gate (caller identity, per-call reason, Activity Log) - a container approval is the first of two gates."- ]- },- "author": {- "name": "John Lauer",- "email": "[email protected]"- }-}+ ]+}
rust/Cargo.toml+1−1@@ -4,7 +4,7 @@ members = ["core", "lbr", "os-win", "os-mac", "bridge"] # One version for the whole bridge, in lockstep with BRIDGE_VERSION (tools/release.sh bumps both). [workspace.package]-version = "1.10.7"+version = "1.10.8" edition = "2021" license = "MIT"
rust/bridge/src/mint.rs+104−4@@ -11,6 +11,53 @@ use crate::addin::Ctx; const TEMPLATE: &str = include_str!("make3d_template.py"); const ORIENT_BLOCK: &str = include_str!("make3d_orient_block.py");+/// Explicit source placement (issue #83): replaces every heuristic (orient, centering, pin-1).+const PLACEMENT_BLOCK: &str = include_str!("make3d_placement_block.py");++/// An authored model placement: translation in mm, rotation in degrees about X, then Y, then Z.+#[derive(Clone, Debug, PartialEq)]+pub struct Placement { pub translation_mm: [f64; 3], pub rotation_deg: [f64; 3] }++/// `placement` argument -> Placement, or a refusal. Absent/null is Ok(None). Scale other than+/// 1, a non-finite number or a wrong shape is rejected before Fusion is touched.+pub fn parse_placement(v: &Value) -> Result<Option<Placement>, Value> {+ if v.is_null() { return Ok(None); }+ let Some(o) = v.as_object() else { return Err(json!({"success": false, "errorCode": "invalid_placement", "error": "placement must be an object {translation:[x,y,z] mm, rotation:[rx,ry,rz] deg, scale:1}"})); };+ let triple = |k: &str| -> Result<[f64; 3], Value> {+ match o.get(k) {+ None | Some(Value::Null) => Ok([0.0, 0.0, 0.0]),+ Some(Value::Array(a)) if a.len() == 3 => {+ let mut out = [0.0; 3];+ for (i, x) in a.iter().enumerate() {+ let f = x.as_f64().filter(|f| f.is_finite()).ok_or_else(|| json!({"success": false, "errorCode": "invalid_placement", "error": format!("placement.{k}[{i}] must be a finite number")}))?;+ out[i] = f;+ }+ Ok(out)+ }+ _ => Err(json!({"success": false, "errorCode": "invalid_placement", "error": format!("placement.{k} must be an array of three finite numbers")})),+ }+ };+ let t = triple("translation")?; let r = triple("rotation")?;+ match o.get("scale") {+ None | Some(Value::Null) => {}+ Some(s) => {+ let ok = match s { Value::Number(n) => n.as_f64() == Some(1.0), Value::Array(a) => a.len() == 3 && a.iter().all(|x| x.as_f64() == Some(1.0)), _ => false };+ if !ok { return Err(json!({"success": false, "errorCode": "unsupported_scale", "error": "placement.scale must be 1: the STEP is imported at its authored size and never rescaled (a scaled model would no longer match the manufacturer's geometry)"})); }+ }+ }+ if let Some(u) = o.get("units").and_then(|u| u.as_str()) { if u != "mm" { return Err(json!({"success": false, "errorCode": "invalid_placement", "error": format!("placement.units must be mm (got {u})")})); } }+ if let Some(u) = o.get("rotationUnits").and_then(|u| u.as_str()) { if u != "deg" && u != "degrees" { return Err(json!({"success": false, "errorCode": "invalid_placement", "error": format!("placement.rotationUnits must be deg (got {u})")})); } }+ Ok(Some(Placement { translation_mm: t, rotation_deg: r }))+}++/// sha256 + size of the source model, so the reply carries provenance of the bytes Fusion read.+pub fn source_provenance(path: &str) -> Value {+ use sha2::Digest;+ match std::fs::read(path) {+ Ok(b) => json!({"path": path, "bytes": b.len(), "sha256": format!("{:x}", sha2::Sha256::digest(&b)), "preserved": true, "note": "the STEP is imported as-is; the bridge never rewrites it"}),+ Err(e) => json!({"path": path, "error": e.to_string()}),+ }+} pub const DEFAULT_UPLOAD_PROJECT: &str = "a.YnVzaW5lc3M6YWRvbTMjMjAyMzExMjk3MDM5NjAzMzE"; pub const KNOWN_ROOT_FOLDER: &str = "urn:adsk.wipprod:fs.folder:co.jyO4vxQXR6S9zFpTQWnZAg"; pub const AI_WORKSPACE_NAME: &str = "Adom AI Workspace";@@ -22,12 +69,29 @@ fn py_float(v: f64) -> String { let s = format!("{v}"); if s.contains('.') || s. /// The exact script `_orchestrate_make_3d_package` sends to the add-in. pub fn make_3d_script(model_path: &str, orient: bool, pin1: Option<(f64, f64)>, layers_off: &[String], task: &str, name: &str) -> String {+ make_3d_script_placed(model_path, orient, pin1, layers_off, task, name, None)+}++/// The mint script with the placement policy applied: an explicit placement replaces every+/// heuristic (orient block, pin-1 rotation); orient:false means NO reorientation at all (the+/// pin-1 rotation used to survive it, issue #83); the default keeps both heuristics.+pub fn make_3d_script_placed(model_path: &str, orient: bool, pin1: Option<(f64, f64)>, layers_off: &[String], task: &str, name: &str, placement: Option<&Placement>) -> String {+ let (block, pin1, skip): (String, Option<(f64, f64)>, &str) = match placement {+ Some(pl) => (PLACEMENT_BLOCK.replace("{TX}", &py_float(pl.translation_mm[0])).replace("{TY}", &py_float(pl.translation_mm[1])).replace("{TZ}", &py_float(pl.translation_mm[2]))+ .replace("{RX}", &py_float(pl.rotation_deg[0])).replace("{RY}", &py_float(pl.rotation_deg[1])).replace("{RZ}", &py_float(pl.rotation_deg[2])), None, "'skipped_explicit_placement'"),+ None if !orient => (String::new(), None, "'skipped_orient_false'"),+ None => (ORIENT_BLOCK.to_string(), pin1, "None"),+ };+ make_3d_script_raw(model_path, &block, pin1, skip, layers_off, task, name)+}++fn make_3d_script_raw(model_path: &str, orient_block: &str, pin1: Option<(f64, f64)>, pin1_skip: &str, layers_off: &[String], task: &str, name: &str) -> String { let mut layers: Vec<String> = layers_off.iter().cloned().collect::<std::collections::BTreeSet<_>>().into_iter().collect(); layers.sort(); let layers_repr = format!("[{}]", layers.iter().map(|l| fusion_lbr::py_repr_str(l)).collect::<Vec<_>>().join(", ")); let (pin1_repr, pad1_repr) = match pin1 { Some((x, y)) => (format!("({}, {})", py_float(x / 10.0), py_float(y / 10.0)), format!("[{},{}]", py_float(x), py_float(y))), None => ("None".to_string(), "None".to_string()) }; TEMPLATE.replace("{MODEL_PATH}", model_path).replace("{DEFAULT_PROJECT}", DEFAULT_UPLOAD_PROJECT).replace("{TASK}", task).replace("{NAME}", name)- .replace("{PIN1}", &pin1_repr).replace("{PAD1}", &pad1_repr).replace("{LAYERS_OFF}", &layers_repr).replace("{ORIENT_BLOCK}", if orient { ORIENT_BLOCK } else { "" })+ .replace("{PIN1}", &pin1_repr).replace("{PAD1}", &pad1_repr).replace("{LAYERS_OFF}", &layers_repr).replace("{ORIENT_BLOCK}", orient_block).replace("{PIN1_SKIP}", pin1_skip) } /// `_pad1_xy_mm`: (x, y) of pad or smd "1" in the FIRST package of the .lbr.@@ -92,6 +156,11 @@ pub fn make_3d_package(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, Value) -> Va let cap_label = args["captureLabel"].as_str().filter(|s| !s.is_empty()).map(str::to_string); let task = safe_folder_name(args["task"].as_str().unwrap_or("AI 3D packages")); let orient = args["orient"].as_bool().unwrap_or(true);+ let placement = match parse_placement(&args["placement"]) { Ok(p) => p, Err(e) => return e };+ let provenance = source_provenance(&model_path);+ let mut warnings: Vec<String> = Vec::new();+ if placement.is_none() && orient { warnings.push("No placement given: the chip's orientation comes from the bounding-box heuristic and the pin-1 mark heuristic (a circular feature near pad 1 can rotate it in 90-degree steps). Pass placement:{translation,rotation} from the manufacturer's authored transform to bypass both.".into()); }+ if placement.is_none() && !orient { warnings.push("orient:false: the model is imported exactly as authored, no reorientation and no pin-1 rotation (since 1.10.8).".into()); } let base = model_path.rsplit('/').next().unwrap_or("").rsplit_once('.').map(|(s, _)| s.to_string()).unwrap_or_default(); let name = args["fileName"].as_str().filter(|s| !s.is_empty()).map(str::to_string).unwrap_or_else(|| format!("{base}_3d")).replace('\'', "").replace(".f3d", ""); crate::chains::note_bridge_doc_pub(&ctx.state_dir, &name);@@ -112,7 +181,7 @@ 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(&model_path, orient, pad1_xy_mm(&lbr_path), &layers, &task, &name);+ let script = make_3d_script_placed(&model_path, orient, pad1_xy_mm(&lbr_path), &layers, &task, &name, placement.as_ref()); 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);@@ -122,7 +191,8 @@ pub fn make_3d_package(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, Value) -> Va let lineage = p.rsplit("dm.lineage:").next().unwrap_or("").to_string(); 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()); }- return json!({"success": true, "wip_urn": wip, "lineageUrn": p, "dims": res["dims"], "pin1": res["pin1"], "bodies": res["bodies"], "renamedTo": res["renamedTo"], "fileName": f3d_name, "savedTo": "cloud (Adom AI Workspace)", "before": before_shot, "after": after_shot,+ 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, "_hint": "Saved DIRECTLY to the cloud workspace - no upload step needed. Bind this wip_urn into the library's <packages3d>."}); } }@@ -226,7 +296,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, "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, "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)});@@ -267,7 +337,37 @@ mod tests { } } #[test]+ fn placement_parses_and_refuses() {+ use serde_json::json;+ let ok = super::parse_placement(&json!({"translation": [-0.00008128, 1.39191746, 0.18269966], "rotation": [0, 0, 90], "scale": 1})).unwrap().unwrap();+ assert_eq!(ok.rotation_deg, [0.0, 0.0, 90.0]);+ assert!(super::parse_placement(&json!(null)).unwrap().is_none());+ assert_eq!(super::parse_placement(&json!({"translation": [1, 2], "rotation": [0, 0, 0]})).unwrap_err()["errorCode"], "invalid_placement");+ assert_eq!(super::parse_placement(&json!({"translation": [1, 2, "x"]})).unwrap_err()["errorCode"], "invalid_placement");+ assert_eq!(super::parse_placement(&json!({"translation": [0, 0, 0], "scale": 2})).unwrap_err()["errorCode"], "unsupported_scale");+ assert_eq!(super::parse_placement(&json!({"translation": [0, 0, 0], "units": "in"})).unwrap_err()["errorCode"], "invalid_placement");+ // an explicit placement skips both heuristics; orient:false skips both too; default keeps both+ let pl = super::Placement { translation_mm: [-0.00008128, 1.39191746, 0.18269966], rotation_deg: [0.0, 0.0, 90.0] };+ let placed = super::make_3d_script_placed("C:/m.step", true, Some((1.0, 2.0)), &[], "t", "n", Some(&pl));+ assert!(placed.contains("oc.transform2=_pmat") && placed.contains("PIN1=None") && placed.contains("PIN1_SKIP='skipped_explicit_placement'") && !placed.contains("is_flat"));+ assert!(placed.contains("_pt=(-8.128e-5,1.39191746,0.18269966)") || placed.contains("_pt=(-0.00008128,1.39191746,0.18269966)"), "{}", &placed[..400]);+ let off = super::make_3d_script_placed("C:/m.step", false, Some((1.0, 2.0)), &[], "t", "n", None);+ assert!(off.contains("PIN1=None") && off.contains("PIN1_SKIP='skipped_orient_false'") && !off.contains("is_flat") && !off.contains("_pmat"));+ let dflt = super::make_3d_script_placed("C:/m.step", true, Some((1.0, 2.0)), &[], "t", "n", None);+ assert!(dflt.contains("is_flat") && dflt.contains("PIN1=(0.1, 0.2)") && dflt.contains("PIN1_SKIP=None"));+ }+ #[test] fn mint_script_matches_python_golden() {+ if std::env::var("UPDATE_GOLDEN").is_ok() {+ let mut cases: Vec<serde_json::Value> = serde_json::from_str(include_str!("../tests/golden/make3d_script.json")).unwrap();+ for c in cases.iter_mut() {+ let pin1 = c["pin1"].as_array().map(|a| (a[0].as_f64().unwrap(), a[1].as_f64().unwrap()));+ let layers: Vec<String> = c["layersOff"].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect();+ c["script"] = serde_json::Value::String(super::make_3d_script(c["modelPath"].as_str().unwrap(), c["orient"].as_bool().unwrap(), pin1, &layers, c["task"].as_str().unwrap(), c["name"].as_str().unwrap()));+ }+ std::fs::write(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/golden/make3d_script.json"), serde_json::to_string_pretty(&cases).unwrap()).unwrap();+ return;+ } let cases: Vec<serde_json::Value> = serde_json::from_str(include_str!("../tests/golden/make3d_script.json")).unwrap(); for c in cases { let pin1 = c["pin1"].as_array().map(|a| (a[0].as_f64().unwrap(), a[1].as_f64().unwrap()));
tools/check-naming.shtools/check-portable.shtools/promote.shtools/release.sh