← Commit history
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.10.7\ No newline at end of file+1.10.8\ No newline at end of file
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]"-  }-}\ No newline at end of file+  ]+}
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.sh

  
tools/check-portable.sh

  
tools/promote.sh

  
tools/release.sh