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
generate_body pin counts per generator, exposed pad, STEP folder (unreleased, 1.13.4)
4 files changed
+59−10
CHANGELOG.md+7@@ -5,6 +5,13 @@ Canonical: https://wiki.adom.inc/adom/fusion-bridge ยท GitHub backup: `adom-inc/ ## 1.13.4 (unreleased) +- `fusion_generate_body` gives each package generator its pins the way that generator counts them. SOIC-8 came out+ with 4 leads and QFP-48 with 24: SOIC, SOJ, SON, DIP and the oscillators take the total, QFP and PLCC the two+ opposite sides together, QFN each side (read from Fusion's generator scripts). `D2`/`E2` now draw the exposed pad+ (the generator's thermal pad; QFN calls it `D1`/`E1`).+- `fusion_generate_package {outputStep}` makes the STEP's folder first. Fusion's STEP export writes nothing into a+ missing folder, and the verb then failed writing its manifest. It now also refuses if no STEP was written.+ - Engine 0.4.1 (standard 0.7.1): `fusion_placement_check` lists a part under a raised molecule as `stacked` (pads compared one by one, bodies in 3D) instead of a courtyard overlap. On BQ25792: 39 overlaps and 78 stacked, where 1.13.3 reported 117 overlaps.
rust/bridge/src/epg_template.py+4@@ -157,9 +157,13 @@ if @@ETCH@@: step_path = None if @@OUTPUT_STEP@@:+ # Fusion's STEP export writes nothing into a folder that does not exist: make it first+ os.makedirs(os.path.dirname(os.path.abspath(@@OUTPUT_STEP@@)), exist_ok=True) em = design.exportManager opts = em.createSTEPExportOptions(@@OUTPUT_STEP@@) em.execute(opts)+ if not os.path.isfile(@@OUTPUT_STEP@@):+ raise RuntimeError('Fusion did not write the STEP file %s' % @@OUTPUT_STEP@@) step_path = @@OUTPUT_STEP@@ # SIDECAR MANIFEST: every setting we picked + WHY + the bboxes we calculated,
rust/bridge/src/main.rs+44−6@@ -76,6 +76,29 @@ const STANDARD_ALIASES: &[(&str, &str)] = &[ ]; /// The standard's verbs this bridge serves through its own, with the arguments renamed:+/// The standard's generate_body dimensions as Fusion's package generator (EPG Scripts3d) reads them.+/// `pins` is the part's total; each generator counts it its own way (read from its script, Fusion+/// 2706): qfn per side (DPins, EPins); qfp and plcc the two opposite sides together (it halves+/// them); soic, soj, son, dip and the oscillators the total. An exposed pad (D2/E2) turns on the+/// generator's thermal pad, which qfn names D1/E1. bga and headers take rows and columns, as given.+fn epg_params(family: &str, mut p: serde_json::Map<String, Value>) -> serde_json::Map<String, Value> {+ let qfn = ["qfn", "vqfn", "wqfn"].iter().any(|q| family.starts_with(q));+ let quad_pairs = ["qfp", "lqfp", "tqfp", "pqfp", "plcc"].iter().any(|q| family.starts_with(q));+ let grid = family.starts_with("bga") || family.starts_with("header");+ if let Some(n) = p.remove("pins").and_then(|v| v.as_u64()) {+ if !p.contains_key("DPins") && !grid {+ if qfn { p.insert("DPins".into(), json!(n / 4)); p.insert("EPins".into(), json!(n / 4)); }+ else if quad_pairs { p.insert("DPins".into(), json!(n / 2)); p.insert("EPins".into(), json!(n / 2)); }+ else { p.insert("DPins".into(), json!(n)); }+ }+ }+ if p.contains_key("D2") || p.contains_key("E2") {+ if qfn { for (from, to) in [("D2", "D1"), ("E2", "E1")] { if let Some(v) = p.remove(from) { p.insert(to.into(), v); } } }+ p.entry("thermal").or_insert(json!(1));+ }+ p+}+ /// generate_body -> fusion_generate_package (family -> type, dimensionsMm -> params with pins as /// the generator's per-side DPins/EPins, marking -> etch, markingStyle -> etchStyle, outputPath -> /// outputStep). (library_views is docs.rs: it opens the library first.)@@ -85,12 +108,7 @@ fn standard_adapter(verb: &str, mut parsed: Value) -> (String, Value) { match bare { "generate_body" => { let family = a["family"].as_str().unwrap_or("").to_lowercase();- let mut params = a["dimensionsMm"].as_object().cloned().unwrap_or_default();- if let Some(n) = params.remove("pins").and_then(|v| v.as_u64()) {- let quad = ["qfn", "qfp", "lqfp", "tqfp", "pqfp", "vqfn", "wqfn"].iter().any(|q| family.starts_with(q));- if quad { params.insert("DPins".into(), json!(n / 4)); params.insert("EPins".into(), json!(n / 4)); }- else { params.insert("DPins".into(), json!(n / 2)); }- }+ let params = epg_params(&family, a["dimensionsMm"].as_object().cloned().unwrap_or_default()); let mut na = json!({"type": family, "params": params}); for (from, to) in [("marking", "etch"), ("markingStyle", "etchStyle"), ("outputPath", "outputStep")] { if !a[from].is_null() { na[to] = a[from].clone(); } } parsed["args"] = na; parsed["command"] = json!("fusion_generate_package");@@ -1708,3 +1726,23 @@ mod standard_alias_tests { } } }++#[cfg(test)]+mod epg_param_tests {+ use super::*;+ fn dims(v: Value) -> serde_json::Map<String, Value> { v.as_object().cloned().unwrap() }+ #[test]+ fn each_generator_gets_pins_its_own_way() {+ // live 2026-10-28: SOIC-8 sent as 4 per side came out with 4 leads, QFP-48 as 12 per side with 24+ assert_eq!(epg_params("soic", dims(json!({"pins": 8})))["DPins"], 8);+ let q = epg_params("qfp", dims(json!({"pins": 48})));+ assert_eq!((q["DPins"].clone(), q["EPins"].clone()), (json!(24), json!(24)));+ let n = epg_params("qfn", dims(json!({"pins": 32, "D2": 3.3, "E2": 3.3})));+ assert_eq!((n["DPins"].clone(), n["EPins"].clone()), (json!(8), json!(8)));+ assert_eq!((n["D1"].clone(), n["E1"].clone(), n["thermal"].clone()), (json!(3.3), json!(3.3), json!(1)));+ assert!(n.get("D2").is_none());+ assert_eq!(epg_params("soic", dims(json!({"pins": 8, "D2": 2.3})))["thermal"], 1);+ assert_eq!(epg_params("soic", dims(json!({"pins": 8, "DPins": 6})))["DPins"], 6, "an explicit DPins wins");+ assert!(epg_params("bga", dims(json!({"pins": 64}))).get("DPins").is_none());+ }+}
rust/bridge/tests/golden/epg_script.json+4−4@@ -12,7 +12,7 @@ "etch": "103", "outputStep": "C:/tmp/r0603.step" },- "script": "\nimport sys, glob, os, importlib\nimport adsk.core, adsk.fusion\n\n# Locate EPG across install conventions + versions (NEVER hardcode the webdeploy hash).\n_bases = []\nfor env in ('ProgramFiles', 'ProgramFiles(x86)', 'ProgramW6432', 'LOCALAPPDATA'):\n root = os.environ.get(env)\n if root:\n _bases += glob.glob(os.path.join(root, 'Autodesk', 'webdeploy', 'production', '*',\n 'Api', 'InternalAddins', 'ElectronicsPackageGenerator'))\nif not _bases:\n raise RuntimeError('EPG_NOT_FOUND: ElectronicsPackageGenerator not present in this Fusion install')\nepg_dir = max(_bases, key=os.path.getmtime)\nparent = os.path.dirname(epg_dir)\nif parent not in sys.path:\n sys.path.insert(0, parent)\n\nmod = importlib.import_module('ElectronicsPackageGenerator.Scripts3d.chip')\n\ndoc = app.documents.add(adsk.core.DocumentTypes.FusionDesignDocumentType)\ndesign = adsk.fusion.Design.cast(app.activeProduct)\nmod.runWithInput({'D': 0.16, 'E': 0.08, 'A': 0.045, 'L': 0.03, 'L1': 0.02}, design)\nroot = design.rootComponent\n\ninv = [{'name': b.name, 'vol': round(b.volume, 6)} for b in root.bRepBodies]\netched = None\nif '103':\n # FIND THE TOP SURFACE the robust way (John's algorithm, 2026-07-04):\n # 1. bounding box of the WHOLE chip (all bodies);\n # 2. only faces whose centroid sits in the TOP 5% band of that bbox count;\n # 3. among those upward planar faces take the LARGEST AREA (the molded body\n # top beats terminal tops that tie it on height);\n # 4. lay the text out with a 10% margin inside that face.\n gmin, gmax = 1e9, -1e9\n for b in root.bRepBodies:\n bb = b.boundingBox\n gmin = min(gmin, bb.minPoint.z)\n gmax = max(gmax, bb.maxPoint.z)\n band_z = gmax - 0.05 * (gmax - gmin)\n top_face, top_area = None, -1.0\n for b in root.bRepBodies:\n for f in b.faces:\n if isinstance(f.geometry, adsk.core.Plane) and f.geometry.normal.z > 0.9 and f.centroid.z >= band_z and f.area > top_area:\n top_face, top_area = f, f.area\n if top_face is None:\n raise RuntimeError('ETCH_NO_TOP_FACE: no upward planar face in the top 5% of the chip bbox')\n body = top_face.body\n sk = root.sketches.add(top_face)\n center = sk.modelToSketchSpace(top_face.centroid)\n # Measure the face in SKETCH space (model-space bbox axes can be SWAPPED vs\n # the sketch axes the text lays out in). Project the model bbox corners.\n bb = top_face.boundingBox\n p_min = sk.modelToSketchSpace(bb.minPoint)\n p_max = sk.modelToSketchSpace(bb.maxPoint)\n face_w = abs(p_max.x - p_min.x) # extent along sketch-x (the default text baseline)\n face_h = abs(p_max.y - p_min.y) # extent along sketch-y\n # ALWAYS run the text along the LONGEST axis of the top face (most room for\n # the marking - John's rule, 2026-07-04). If the long axis is sketch-y,\n # rotate the text 90deg rather than squeezing it onto the short axis.\n import math as _math\n rot_deg = 90 if face_h > face_w else 0\n long_len = max(face_w, face_h)\n short_len = min(face_w, face_h)\n w = long_len * 0.80 # 10% margin each side along the long axis\n box_h_cap = short_len * 0.80 # 10% margin each side across it\n # multi-line support (e.g. MPN + variant on line 2): fit per-line\n lines = '103'.split(chr(10))\n n_lines = max(1, len(lines))\n max_chars = max(1, max(len(l) for l in lines))\n # width-aware auto-fit: glyph ~0.75*h wide; line block ~1.35*h per line\n h = (None / 10.0) if None else max(0.015, min(box_h_cap / (1.35 * n_lines), w / (0.75 * max_chars)))\n if rot_deg:\n c1 = adsk.core.Point3D.create(center.x - box_h_cap/2, center.y - w/2, 0)\n c2 = adsk.core.Point3D.create(center.x + box_h_cap/2, center.y + w/2, 0)\n else:\n c1 = adsk.core.Point3D.create(center.x - w/2, center.y - box_h_cap/2, 0)\n c2 = adsk.core.Point3D.create(center.x + w/2, center.y + box_h_cap/2, 0)\n ti = sk.sketchTexts.createInput2('103', h)\n ti.setAsMultiLine(c1, c2,\n adsk.core.HorizontalAlignments.CenterHorizontalAlignment,\n adsk.core.VerticalAlignments.MiddleVerticalAlignment, 0)\n if rot_deg:\n try:\n ti.angle = _math.pi / 2.0\n except Exception:\n rot_deg = 0 # older API without angle - fall back to unrotated\n txt = sk.sketchTexts.add(ti)\n # MEASURE-AND-CORRECT (the margin is a CONTRACT): font metrics vary, so the\n # 0.75h glyph estimate can under-shoot ('ADOM-A' rendered edge-to-edge). After\n # placing, measure the text's real bbox; if it busts the 10%-margin box,\n # recreate at the scaled-down height. Never trust an estimate you can measure.\n fit_iterations = 0\n measured_w = None\n for _pass in range(2):\n tb = txt.boundingBox\n t_ext_x = abs(tb.maxPoint.x - tb.minPoint.x)\n t_ext_y = abs(tb.maxPoint.y - tb.minPoint.y)\n measured_w = max(t_ext_x, t_ext_y) # the baseline-axis extent\n measured_c = min(t_ext_x, t_ext_y) # the cross-axis extent (line stack)\n if measured_w <= w and measured_c <= box_h_cap:\n break\n scale = min(w / max(measured_w, 1e-6), box_h_cap / max(measured_c, 1e-6)) * 0.95\n h = max(0.008, h * scale)\n txt.deleteMe()\n ti = sk.sketchTexts.createInput2('103', h)\n ti.setAsMultiLine(c1, c2,\n adsk.core.HorizontalAlignments.CenterHorizontalAlignment,\n adsk.core.VerticalAlignments.MiddleVerticalAlignment, 0)\n if rot_deg:\n try:\n ti.angle = _math.pi / 2.0\n except Exception:\n pass\n txt = sk.sketchTexts.add(ti)\n fit_iterations += 1\n marking_geom = {\n 'chipBBox': {'min': [round(gmin,5), 0, 0], 'maxZ': round(gmax,5)},\n 'chipBBoxZ': {'min': round(gmin,5), 'max': round(gmax,5)},\n 'topBandZ': round(band_z,5),\n 'topFace': {'area': round(top_area,6), 'centroidZ': round(top_face.centroid.z,5),\n 'sketchExtents': {'x': round(face_w,5), 'y': round(face_h,5)},\n 'body': body.name},\n 'longAxis': 'sketch-y' if face_h > face_w else 'sketch-x',\n 'rotatedDeg': rot_deg,\n 'textBox': {'c1': [round(c1.x,5), round(c1.y,5)], 'c2': [round(c2.x,5), round(c2.y,5)],\n 'marginPct': 10},\n 'textHeightMm': round(h*10,4), 'lines': n_lines, 'maxLineChars': max_chars,\n 'heightAuto': not bool(None),\n 'textWidthMeasuredMm': round(measured_w*10,4) if measured_w else None,\n 'fitIterations': fit_iterations,\n 'why': {'band': 'top 5% of whole-chip bbox excludes terminal tops that tie the body',\n 'largestArea': 'molded body top beats small terminal faces in the band',\n 'longAxis': 'text runs along the longest face axis for maximum room',\n 'fit': 'h = min(boxH/(1.35*lines), boxW/(0.75*maxChars)) - glyphs ~0.75h wide'},\n }\n if 'raised' == 'engraved':\n # engraved (sunken) cut - the pre-2026-07-04 default\n ext_in = root.features.extrudeFeatures.createInput(txt, adsk.fusion.FeatureOperations.CutFeatureOperation)\n ext_in.setDistanceExtent(False, adsk.core.ValueInput.createByReal(-0.003))\n ext_in.participantBodies = [body]\n root.features.extrudeFeatures.add(ext_in)\n else:\n # RAISED WHITE marking (default): humans need CONTRAST - a thin positive\n # extrude painted white on the dark body reads like real silkscreen ink,\n # far better than a same-color emboss. Reuses EPG's own rgb-appearance\n # utility so the white survives Fusion rendering (+ colored STEP).\n ext_in = root.features.extrudeFeatures.createInput(txt, adsk.fusion.FeatureOperations.NewBodyFeatureOperation)\n ext_in.setDistanceExtent(False, adsk.core.ValueInput.createByReal(0.003))\n mark = root.features.extrudeFeatures.add(ext_in)\n from ElectronicsPackageGenerator.Utilities import addin_utility as _au\n for i in range(mark.bodies.count):\n mb = mark.bodies.item(i)\n mb.name = 'Marking' if i == 0 else 'Marking' + str(i + 1)\n _au.apply_rgb_appearance(app, design, mb, 255, 255, 255, 'AdomMarkingWhite')\n etched = '103'\n\nstep_path = None\nif 'C:/tmp/r0603.step':\n em = design.exportManager\n opts = em.createSTEPExportOptions('C:/tmp/r0603.step')\n em.execute(opts)\n step_path = 'C:/tmp/r0603.step'\n\n# SIDECAR MANIFEST: every setting we picked + WHY + the bboxes we calculated,\n# so a marking refresh (e.g. adding a variant name as a 2nd line) can rework the\n# text without re-deriving anything. Written next to the STEP as <step>.manifest.json.\nmanifest = {\n 'schema': 'adom-fusion-generate-package/1',\n 'bridgeVersion': 'TESTVER',\n 'type': 'chip',\n 'paramsCm': {'D': 0.16, 'E': 0.08, 'A': 0.045, 'L': 0.03, 'L1': 0.02},\n 'paramsNote': 'paramsCm are EPG-native cm (mm inputs were /10)',\n 'marking': ({'text': '103', 'style': 'raised',\n 'depthMm': 0.003 * 10, **marking_geom} if etched else None),\n 'bodies': inv,\n 'outputs': {'step': step_path},\n 'refresh': {\n 'howTo': 'To re-mark (e.g. add a variant on line 2): call fusion_generate_package again '\n 'with the SAME type+paramsCm (unitsCm:true) and the new multi-line etch text '\n '(join lines with a newline). The generator is deterministic, so geometry and '\n 'placement reproduce exactly; this manifest carries the boxes/height the last '\n 'run chose for comparison.',\n 'example': {'type': 'chip', 'unitsCm': True, 'params': 'paramsCm from this file',\n 'etch': ('103' + chr(10) + 'VARIANT') if etched else 'MPN' + chr(10) + 'VARIANT'},\n },\n}\nmanifest_path = None\nif step_path:\n manifest_path = step_path + '.manifest.json'\n import json as _json\n with open(manifest_path, 'w', encoding='utf-8') as _f:\n _json.dump(manifest, _f, indent=2)\n\nvp = app.activeViewport\ncam = vp.camera\ncam.isSmoothTransition = False\ncam.target = adsk.core.Point3D.create(0, 0, 0)\ncam.eye = adsk.core.Point3D.create(0.25, -0.35, 0.45)\ncam.upVector = adsk.core.Vector3D.create(0, 0, 1)\ncam.isFitView = True\nvp.camera = cam\nvp.refresh()\n\nresult = {'type': 'chip', 'bodies': inv, 'etched': etched, 'stepPath': step_path,\n 'manifest': manifest, 'manifestPath': manifest_path,\n 'epgDir': epg_dir, 'doc': app.activeDocument.name}\n",+ "script": "\nimport sys, glob, os, importlib\nimport adsk.core, adsk.fusion\n\n# Locate EPG across install conventions + versions (NEVER hardcode the webdeploy hash).\n_bases = []\nfor env in ('ProgramFiles', 'ProgramFiles(x86)', 'ProgramW6432', 'LOCALAPPDATA'):\n root = os.environ.get(env)\n if root:\n _bases += glob.glob(os.path.join(root, 'Autodesk', 'webdeploy', 'production', '*',\n 'Api', 'InternalAddins', 'ElectronicsPackageGenerator'))\nif not _bases:\n raise RuntimeError('EPG_NOT_FOUND: ElectronicsPackageGenerator not present in this Fusion install')\nepg_dir = max(_bases, key=os.path.getmtime)\nparent = os.path.dirname(epg_dir)\nif parent not in sys.path:\n sys.path.insert(0, parent)\n\nmod = importlib.import_module('ElectronicsPackageGenerator.Scripts3d.chip')\n\ndoc = app.documents.add(adsk.core.DocumentTypes.FusionDesignDocumentType)\ndesign = adsk.fusion.Design.cast(app.activeProduct)\nmod.runWithInput({'D': 0.16, 'E': 0.08, 'A': 0.045, 'L': 0.03, 'L1': 0.02}, design)\nroot = design.rootComponent\n\ninv = [{'name': b.name, 'vol': round(b.volume, 6)} for b in root.bRepBodies]\netched = None\nif '103':\n # FIND THE TOP SURFACE the robust way (John's algorithm, 2026-07-04):\n # 1. bounding box of the WHOLE chip (all bodies);\n # 2. only faces whose centroid sits in the TOP 5% band of that bbox count;\n # 3. among those upward planar faces take the LARGEST AREA (the molded body\n # top beats terminal tops that tie it on height);\n # 4. lay the text out with a 10% margin inside that face.\n gmin, gmax = 1e9, -1e9\n for b in root.bRepBodies:\n bb = b.boundingBox\n gmin = min(gmin, bb.minPoint.z)\n gmax = max(gmax, bb.maxPoint.z)\n band_z = gmax - 0.05 * (gmax - gmin)\n top_face, top_area = None, -1.0\n for b in root.bRepBodies:\n for f in b.faces:\n if isinstance(f.geometry, adsk.core.Plane) and f.geometry.normal.z > 0.9 and f.centroid.z >= band_z and f.area > top_area:\n top_face, top_area = f, f.area\n if top_face is None:\n raise RuntimeError('ETCH_NO_TOP_FACE: no upward planar face in the top 5% of the chip bbox')\n body = top_face.body\n sk = root.sketches.add(top_face)\n center = sk.modelToSketchSpace(top_face.centroid)\n # Measure the face in SKETCH space (model-space bbox axes can be SWAPPED vs\n # the sketch axes the text lays out in). Project the model bbox corners.\n bb = top_face.boundingBox\n p_min = sk.modelToSketchSpace(bb.minPoint)\n p_max = sk.modelToSketchSpace(bb.maxPoint)\n face_w = abs(p_max.x - p_min.x) # extent along sketch-x (the default text baseline)\n face_h = abs(p_max.y - p_min.y) # extent along sketch-y\n # ALWAYS run the text along the LONGEST axis of the top face (most room for\n # the marking - John's rule, 2026-07-04). If the long axis is sketch-y,\n # rotate the text 90deg rather than squeezing it onto the short axis.\n import math as _math\n rot_deg = 90 if face_h > face_w else 0\n long_len = max(face_w, face_h)\n short_len = min(face_w, face_h)\n w = long_len * 0.80 # 10% margin each side along the long axis\n box_h_cap = short_len * 0.80 # 10% margin each side across it\n # multi-line support (e.g. MPN + variant on line 2): fit per-line\n lines = '103'.split(chr(10))\n n_lines = max(1, len(lines))\n max_chars = max(1, max(len(l) for l in lines))\n # width-aware auto-fit: glyph ~0.75*h wide; line block ~1.35*h per line\n h = (None / 10.0) if None else max(0.015, min(box_h_cap / (1.35 * n_lines), w / (0.75 * max_chars)))\n if rot_deg:\n c1 = adsk.core.Point3D.create(center.x - box_h_cap/2, center.y - w/2, 0)\n c2 = adsk.core.Point3D.create(center.x + box_h_cap/2, center.y + w/2, 0)\n else:\n c1 = adsk.core.Point3D.create(center.x - w/2, center.y - box_h_cap/2, 0)\n c2 = adsk.core.Point3D.create(center.x + w/2, center.y + box_h_cap/2, 0)\n ti = sk.sketchTexts.createInput2('103', h)\n ti.setAsMultiLine(c1, c2,\n adsk.core.HorizontalAlignments.CenterHorizontalAlignment,\n adsk.core.VerticalAlignments.MiddleVerticalAlignment, 0)\n if rot_deg:\n try:\n ti.angle = _math.pi / 2.0\n except Exception:\n rot_deg = 0 # older API without angle - fall back to unrotated\n txt = sk.sketchTexts.add(ti)\n # MEASURE-AND-CORRECT (the margin is a CONTRACT): font metrics vary, so the\n # 0.75h glyph estimate can under-shoot ('ADOM-A' rendered edge-to-edge). After\n # placing, measure the text's real bbox; if it busts the 10%-margin box,\n # recreate at the scaled-down height. Never trust an estimate you can measure.\n fit_iterations = 0\n measured_w = None\n for _pass in range(2):\n tb = txt.boundingBox\n t_ext_x = abs(tb.maxPoint.x - tb.minPoint.x)\n t_ext_y = abs(tb.maxPoint.y - tb.minPoint.y)\n measured_w = max(t_ext_x, t_ext_y) # the baseline-axis extent\n measured_c = min(t_ext_x, t_ext_y) # the cross-axis extent (line stack)\n if measured_w <= w and measured_c <= box_h_cap:\n break\n scale = min(w / max(measured_w, 1e-6), box_h_cap / max(measured_c, 1e-6)) * 0.95\n h = max(0.008, h * scale)\n txt.deleteMe()\n ti = sk.sketchTexts.createInput2('103', h)\n ti.setAsMultiLine(c1, c2,\n adsk.core.HorizontalAlignments.CenterHorizontalAlignment,\n adsk.core.VerticalAlignments.MiddleVerticalAlignment, 0)\n if rot_deg:\n try:\n ti.angle = _math.pi / 2.0\n except Exception:\n pass\n txt = sk.sketchTexts.add(ti)\n fit_iterations += 1\n marking_geom = {\n 'chipBBox': {'min': [round(gmin,5), 0, 0], 'maxZ': round(gmax,5)},\n 'chipBBoxZ': {'min': round(gmin,5), 'max': round(gmax,5)},\n 'topBandZ': round(band_z,5),\n 'topFace': {'area': round(top_area,6), 'centroidZ': round(top_face.centroid.z,5),\n 'sketchExtents': {'x': round(face_w,5), 'y': round(face_h,5)},\n 'body': body.name},\n 'longAxis': 'sketch-y' if face_h > face_w else 'sketch-x',\n 'rotatedDeg': rot_deg,\n 'textBox': {'c1': [round(c1.x,5), round(c1.y,5)], 'c2': [round(c2.x,5), round(c2.y,5)],\n 'marginPct': 10},\n 'textHeightMm': round(h*10,4), 'lines': n_lines, 'maxLineChars': max_chars,\n 'heightAuto': not bool(None),\n 'textWidthMeasuredMm': round(measured_w*10,4) if measured_w else None,\n 'fitIterations': fit_iterations,\n 'why': {'band': 'top 5% of whole-chip bbox excludes terminal tops that tie the body',\n 'largestArea': 'molded body top beats small terminal faces in the band',\n 'longAxis': 'text runs along the longest face axis for maximum room',\n 'fit': 'h = min(boxH/(1.35*lines), boxW/(0.75*maxChars)) - glyphs ~0.75h wide'},\n }\n if 'raised' == 'engraved':\n # engraved (sunken) cut - the pre-2026-07-04 default\n ext_in = root.features.extrudeFeatures.createInput(txt, adsk.fusion.FeatureOperations.CutFeatureOperation)\n ext_in.setDistanceExtent(False, adsk.core.ValueInput.createByReal(-0.003))\n ext_in.participantBodies = [body]\n root.features.extrudeFeatures.add(ext_in)\n else:\n # RAISED WHITE marking (default): humans need CONTRAST - a thin positive\n # extrude painted white on the dark body reads like real silkscreen ink,\n # far better than a same-color emboss. Reuses EPG's own rgb-appearance\n # utility so the white survives Fusion rendering (+ colored STEP).\n ext_in = root.features.extrudeFeatures.createInput(txt, adsk.fusion.FeatureOperations.NewBodyFeatureOperation)\n ext_in.setDistanceExtent(False, adsk.core.ValueInput.createByReal(0.003))\n mark = root.features.extrudeFeatures.add(ext_in)\n from ElectronicsPackageGenerator.Utilities import addin_utility as _au\n for i in range(mark.bodies.count):\n mb = mark.bodies.item(i)\n mb.name = 'Marking' if i == 0 else 'Marking' + str(i + 1)\n _au.apply_rgb_appearance(app, design, mb, 255, 255, 255, 'AdomMarkingWhite')\n etched = '103'\n\nstep_path = None\nif 'C:/tmp/r0603.step':\n # Fusion's STEP export writes nothing into a folder that does not exist: make it first\n os.makedirs(os.path.dirname(os.path.abspath('C:/tmp/r0603.step')), exist_ok=True)\n em = design.exportManager\n opts = em.createSTEPExportOptions('C:/tmp/r0603.step')\n em.execute(opts)\n if not os.path.isfile('C:/tmp/r0603.step'):\n raise RuntimeError('Fusion did not write the STEP file %s' % 'C:/tmp/r0603.step')\n step_path = 'C:/tmp/r0603.step'\n\n# SIDECAR MANIFEST: every setting we picked + WHY + the bboxes we calculated,\n# so a marking refresh (e.g. adding a variant name as a 2nd line) can rework the\n# text without re-deriving anything. Written next to the STEP as <step>.manifest.json.\nmanifest = {\n 'schema': 'adom-fusion-generate-package/1',\n 'bridgeVersion': 'TESTVER',\n 'type': 'chip',\n 'paramsCm': {'D': 0.16, 'E': 0.08, 'A': 0.045, 'L': 0.03, 'L1': 0.02},\n 'paramsNote': 'paramsCm are EPG-native cm (mm inputs were /10)',\n 'marking': ({'text': '103', 'style': 'raised',\n 'depthMm': 0.003 * 10, **marking_geom} if etched else None),\n 'bodies': inv,\n 'outputs': {'step': step_path},\n 'refresh': {\n 'howTo': 'To re-mark (e.g. add a variant on line 2): call fusion_generate_package again '\n 'with the SAME type+paramsCm (unitsCm:true) and the new multi-line etch text '\n '(join lines with a newline). The generator is deterministic, so geometry and '\n 'placement reproduce exactly; this manifest carries the boxes/height the last '\n 'run chose for comparison.',\n 'example': {'type': 'chip', 'unitsCm': True, 'params': 'paramsCm from this file',\n 'etch': ('103' + chr(10) + 'VARIANT') if etched else 'MPN' + chr(10) + 'VARIANT'},\n },\n}\nmanifest_path = None\nif step_path:\n manifest_path = step_path + '.manifest.json'\n import json as _json\n with open(manifest_path, 'w', encoding='utf-8') as _f:\n _json.dump(manifest, _f, indent=2)\n\nvp = app.activeViewport\ncam = vp.camera\ncam.isSmoothTransition = False\ncam.target = adsk.core.Point3D.create(0, 0, 0)\ncam.eye = adsk.core.Point3D.create(0.25, -0.35, 0.45)\ncam.upVector = adsk.core.Vector3D.create(0, 0, 1)\ncam.isFitView = True\nvp.camera = cam\nvp.refresh()\n\nresult = {'type': 'chip', 'bodies': inv, 'etched': etched, 'stepPath': step_path,\n 'manifest': manifest, 'manifestPath': manifest_path,\n 'epgDir': epg_dir, 'doc': app.activeDocument.name}\n", "errorCode": null }, {@@ -35,7 +35,7 @@ "etchDepthMm": 0.05, "etchHeightMm": 0.5 },- "script": "\nimport sys, glob, os, importlib\nimport adsk.core, adsk.fusion\n\n# Locate EPG across install conventions + versions (NEVER hardcode the webdeploy hash).\n_bases = []\nfor env in ('ProgramFiles', 'ProgramFiles(x86)', 'ProgramW6432', 'LOCALAPPDATA'):\n root = os.environ.get(env)\n if root:\n _bases += glob.glob(os.path.join(root, 'Autodesk', 'webdeploy', 'production', '*',\n 'Api', 'InternalAddins', 'ElectronicsPackageGenerator'))\nif not _bases:\n raise RuntimeError('EPG_NOT_FOUND: ElectronicsPackageGenerator not present in this Fusion install')\nepg_dir = max(_bases, key=os.path.getmtime)\nparent = os.path.dirname(epg_dir)\nif parent not in sys.path:\n sys.path.insert(0, parent)\n\nmod = importlib.import_module('ElectronicsPackageGenerator.Scripts3d.soic')\n\ndoc = app.documents.add(adsk.core.DocumentTypes.FusionDesignDocumentType)\ndesign = adsk.fusion.Design.cast(app.activeProduct)\nmod.runWithInput({'A': 0.175, 'A1': 0.01, 'b': 0.040999999999999995, 'D': 0.49000000000000005, 'E': 0.6, 'E1': 0.39, 'e': 0.127, 'L': 0.08, 'DPins': 8}, design)\nroot = design.rootComponent\n\ninv = [{'name': b.name, 'vol': round(b.volume, 6)} for b in root.bRepBodies]\netched = None\nif 'LM358\\nADOM-A':\n # FIND THE TOP SURFACE the robust way (John's algorithm, 2026-07-04):\n # 1. bounding box of the WHOLE chip (all bodies);\n # 2. only faces whose centroid sits in the TOP 5% band of that bbox count;\n # 3. among those upward planar faces take the LARGEST AREA (the molded body\n # top beats terminal tops that tie it on height);\n # 4. lay the text out with a 10% margin inside that face.\n gmin, gmax = 1e9, -1e9\n for b in root.bRepBodies:\n bb = b.boundingBox\n gmin = min(gmin, bb.minPoint.z)\n gmax = max(gmax, bb.maxPoint.z)\n band_z = gmax - 0.05 * (gmax - gmin)\n top_face, top_area = None, -1.0\n for b in root.bRepBodies:\n for f in b.faces:\n if isinstance(f.geometry, adsk.core.Plane) and f.geometry.normal.z > 0.9 and f.centroid.z >= band_z and f.area > top_area:\n top_face, top_area = f, f.area\n if top_face is None:\n raise RuntimeError('ETCH_NO_TOP_FACE: no upward planar face in the top 5% of the chip bbox')\n body = top_face.body\n sk = root.sketches.add(top_face)\n center = sk.modelToSketchSpace(top_face.centroid)\n # Measure the face in SKETCH space (model-space bbox axes can be SWAPPED vs\n # the sketch axes the text lays out in). Project the model bbox corners.\n bb = top_face.boundingBox\n p_min = sk.modelToSketchSpace(bb.minPoint)\n p_max = sk.modelToSketchSpace(bb.maxPoint)\n face_w = abs(p_max.x - p_min.x) # extent along sketch-x (the default text baseline)\n face_h = abs(p_max.y - p_min.y) # extent along sketch-y\n # ALWAYS run the text along the LONGEST axis of the top face (most room for\n # the marking - John's rule, 2026-07-04). If the long axis is sketch-y,\n # rotate the text 90deg rather than squeezing it onto the short axis.\n import math as _math\n rot_deg = 90 if face_h > face_w else 0\n long_len = max(face_w, face_h)\n short_len = min(face_w, face_h)\n w = long_len * 0.80 # 10% margin each side along the long axis\n box_h_cap = short_len * 0.80 # 10% margin each side across it\n # multi-line support (e.g. MPN + variant on line 2): fit per-line\n lines = 'LM358\\nADOM-A'.split(chr(10))\n n_lines = max(1, len(lines))\n max_chars = max(1, max(len(l) for l in lines))\n # width-aware auto-fit: glyph ~0.75*h wide; line block ~1.35*h per line\n h = (0.5 / 10.0) if 0.5 else max(0.015, min(box_h_cap / (1.35 * n_lines), w / (0.75 * max_chars)))\n if rot_deg:\n c1 = adsk.core.Point3D.create(center.x - box_h_cap/2, center.y - w/2, 0)\n c2 = adsk.core.Point3D.create(center.x + box_h_cap/2, center.y + w/2, 0)\n else:\n c1 = adsk.core.Point3D.create(center.x - w/2, center.y - box_h_cap/2, 0)\n c2 = adsk.core.Point3D.create(center.x + w/2, center.y + box_h_cap/2, 0)\n ti = sk.sketchTexts.createInput2('LM358\\nADOM-A', h)\n ti.setAsMultiLine(c1, c2,\n adsk.core.HorizontalAlignments.CenterHorizontalAlignment,\n adsk.core.VerticalAlignments.MiddleVerticalAlignment, 0)\n if rot_deg:\n try:\n ti.angle = _math.pi / 2.0\n except Exception:\n rot_deg = 0 # older API without angle - fall back to unrotated\n txt = sk.sketchTexts.add(ti)\n # MEASURE-AND-CORRECT (the margin is a CONTRACT): font metrics vary, so the\n # 0.75h glyph estimate can under-shoot ('ADOM-A' rendered edge-to-edge). After\n # placing, measure the text's real bbox; if it busts the 10%-margin box,\n # recreate at the scaled-down height. Never trust an estimate you can measure.\n fit_iterations = 0\n measured_w = None\n for _pass in range(2):\n tb = txt.boundingBox\n t_ext_x = abs(tb.maxPoint.x - tb.minPoint.x)\n t_ext_y = abs(tb.maxPoint.y - tb.minPoint.y)\n measured_w = max(t_ext_x, t_ext_y) # the baseline-axis extent\n measured_c = min(t_ext_x, t_ext_y) # the cross-axis extent (line stack)\n if measured_w <= w and measured_c <= box_h_cap:\n break\n scale = min(w / max(measured_w, 1e-6), box_h_cap / max(measured_c, 1e-6)) * 0.95\n h = max(0.008, h * scale)\n txt.deleteMe()\n ti = sk.sketchTexts.createInput2('LM358\\nADOM-A', h)\n ti.setAsMultiLine(c1, c2,\n adsk.core.HorizontalAlignments.CenterHorizontalAlignment,\n adsk.core.VerticalAlignments.MiddleVerticalAlignment, 0)\n if rot_deg:\n try:\n ti.angle = _math.pi / 2.0\n except Exception:\n pass\n txt = sk.sketchTexts.add(ti)\n fit_iterations += 1\n marking_geom = {\n 'chipBBox': {'min': [round(gmin,5), 0, 0], 'maxZ': round(gmax,5)},\n 'chipBBoxZ': {'min': round(gmin,5), 'max': round(gmax,5)},\n 'topBandZ': round(band_z,5),\n 'topFace': {'area': round(top_area,6), 'centroidZ': round(top_face.centroid.z,5),\n 'sketchExtents': {'x': round(face_w,5), 'y': round(face_h,5)},\n 'body': body.name},\n 'longAxis': 'sketch-y' if face_h > face_w else 'sketch-x',\n 'rotatedDeg': rot_deg,\n 'textBox': {'c1': [round(c1.x,5), round(c1.y,5)], 'c2': [round(c2.x,5), round(c2.y,5)],\n 'marginPct': 10},\n 'textHeightMm': round(h*10,4), 'lines': n_lines, 'maxLineChars': max_chars,\n 'heightAuto': not bool(0.5),\n 'textWidthMeasuredMm': round(measured_w*10,4) if measured_w else None,\n 'fitIterations': fit_iterations,\n 'why': {'band': 'top 5% of whole-chip bbox excludes terminal tops that tie the body',\n 'largestArea': 'molded body top beats small terminal faces in the band',\n 'longAxis': 'text runs along the longest face axis for maximum room',\n 'fit': 'h = min(boxH/(1.35*lines), boxW/(0.75*maxChars)) - glyphs ~0.75h wide'},\n }\n if 'engraved' == 'engraved':\n # engraved (sunken) cut - the pre-2026-07-04 default\n ext_in = root.features.extrudeFeatures.createInput(txt, adsk.fusion.FeatureOperations.CutFeatureOperation)\n ext_in.setDistanceExtent(False, adsk.core.ValueInput.createByReal(-0.005))\n ext_in.participantBodies = [body]\n root.features.extrudeFeatures.add(ext_in)\n else:\n # RAISED WHITE marking (default): humans need CONTRAST - a thin positive\n # extrude painted white on the dark body reads like real silkscreen ink,\n # far better than a same-color emboss. Reuses EPG's own rgb-appearance\n # utility so the white survives Fusion rendering (+ colored STEP).\n ext_in = root.features.extrudeFeatures.createInput(txt, adsk.fusion.FeatureOperations.NewBodyFeatureOperation)\n ext_in.setDistanceExtent(False, adsk.core.ValueInput.createByReal(0.005))\n mark = root.features.extrudeFeatures.add(ext_in)\n from ElectronicsPackageGenerator.Utilities import addin_utility as _au\n for i in range(mark.bodies.count):\n mb = mark.bodies.item(i)\n mb.name = 'Marking' if i == 0 else 'Marking' + str(i + 1)\n _au.apply_rgb_appearance(app, design, mb, 255, 255, 255, 'AdomMarkingWhite')\n etched = 'LM358\\nADOM-A'\n\nstep_path = None\nif '':\n em = design.exportManager\n opts = em.createSTEPExportOptions('')\n em.execute(opts)\n step_path = ''\n\n# SIDECAR MANIFEST: every setting we picked + WHY + the bboxes we calculated,\n# so a marking refresh (e.g. adding a variant name as a 2nd line) can rework the\n# text without re-deriving anything. Written next to the STEP as <step>.manifest.json.\nmanifest = {\n 'schema': 'adom-fusion-generate-package/1',\n 'bridgeVersion': 'TESTVER',\n 'type': 'soic',\n 'paramsCm': {'A': 0.175, 'A1': 0.01, 'b': 0.040999999999999995, 'D': 0.49000000000000005, 'E': 0.6, 'E1': 0.39, 'e': 0.127, 'L': 0.08, 'DPins': 8},\n 'paramsNote': 'paramsCm are EPG-native cm (mm inputs were /10)',\n 'marking': ({'text': 'LM358\\nADOM-A', 'style': 'engraved',\n 'depthMm': 0.005 * 10, **marking_geom} if etched else None),\n 'bodies': inv,\n 'outputs': {'step': step_path},\n 'refresh': {\n 'howTo': 'To re-mark (e.g. add a variant on line 2): call fusion_generate_package again '\n 'with the SAME type+paramsCm (unitsCm:true) and the new multi-line etch text '\n '(join lines with a newline). The generator is deterministic, so geometry and '\n 'placement reproduce exactly; this manifest carries the boxes/height the last '\n 'run chose for comparison.',\n 'example': {'type': 'soic', 'unitsCm': True, 'params': 'paramsCm from this file',\n 'etch': ('LM358\\nADOM-A' + chr(10) + 'VARIANT') if etched else 'MPN' + chr(10) + 'VARIANT'},\n },\n}\nmanifest_path = None\nif step_path:\n manifest_path = step_path + '.manifest.json'\n import json as _json\n with open(manifest_path, 'w', encoding='utf-8') as _f:\n _json.dump(manifest, _f, indent=2)\n\nvp = app.activeViewport\ncam = vp.camera\ncam.isSmoothTransition = False\ncam.target = adsk.core.Point3D.create(0, 0, 0)\ncam.eye = adsk.core.Point3D.create(0.25, -0.35, 0.45)\ncam.upVector = adsk.core.Vector3D.create(0, 0, 1)\ncam.isFitView = True\nvp.camera = cam\nvp.refresh()\n\nresult = {'type': 'soic', 'bodies': inv, 'etched': etched, 'stepPath': step_path,\n 'manifest': manifest, 'manifestPath': manifest_path,\n 'epgDir': epg_dir, 'doc': app.activeDocument.name}\n",+ "script": "\nimport sys, glob, os, importlib\nimport adsk.core, adsk.fusion\n\n# Locate EPG across install conventions + versions (NEVER hardcode the webdeploy hash).\n_bases = []\nfor env in ('ProgramFiles', 'ProgramFiles(x86)', 'ProgramW6432', 'LOCALAPPDATA'):\n root = os.environ.get(env)\n if root:\n _bases += glob.glob(os.path.join(root, 'Autodesk', 'webdeploy', 'production', '*',\n 'Api', 'InternalAddins', 'ElectronicsPackageGenerator'))\nif not _bases:\n raise RuntimeError('EPG_NOT_FOUND: ElectronicsPackageGenerator not present in this Fusion install')\nepg_dir = max(_bases, key=os.path.getmtime)\nparent = os.path.dirname(epg_dir)\nif parent not in sys.path:\n sys.path.insert(0, parent)\n\nmod = importlib.import_module('ElectronicsPackageGenerator.Scripts3d.soic')\n\ndoc = app.documents.add(adsk.core.DocumentTypes.FusionDesignDocumentType)\ndesign = adsk.fusion.Design.cast(app.activeProduct)\nmod.runWithInput({'A': 0.175, 'A1': 0.01, 'b': 0.040999999999999995, 'D': 0.49000000000000005, 'E': 0.6, 'E1': 0.39, 'e': 0.127, 'L': 0.08, 'DPins': 8}, design)\nroot = design.rootComponent\n\ninv = [{'name': b.name, 'vol': round(b.volume, 6)} for b in root.bRepBodies]\netched = None\nif 'LM358\\nADOM-A':\n # FIND THE TOP SURFACE the robust way (John's algorithm, 2026-07-04):\n # 1. bounding box of the WHOLE chip (all bodies);\n # 2. only faces whose centroid sits in the TOP 5% band of that bbox count;\n # 3. among those upward planar faces take the LARGEST AREA (the molded body\n # top beats terminal tops that tie it on height);\n # 4. lay the text out with a 10% margin inside that face.\n gmin, gmax = 1e9, -1e9\n for b in root.bRepBodies:\n bb = b.boundingBox\n gmin = min(gmin, bb.minPoint.z)\n gmax = max(gmax, bb.maxPoint.z)\n band_z = gmax - 0.05 * (gmax - gmin)\n top_face, top_area = None, -1.0\n for b in root.bRepBodies:\n for f in b.faces:\n if isinstance(f.geometry, adsk.core.Plane) and f.geometry.normal.z > 0.9 and f.centroid.z >= band_z and f.area > top_area:\n top_face, top_area = f, f.area\n if top_face is None:\n raise RuntimeError('ETCH_NO_TOP_FACE: no upward planar face in the top 5% of the chip bbox')\n body = top_face.body\n sk = root.sketches.add(top_face)\n center = sk.modelToSketchSpace(top_face.centroid)\n # Measure the face in SKETCH space (model-space bbox axes can be SWAPPED vs\n # the sketch axes the text lays out in). Project the model bbox corners.\n bb = top_face.boundingBox\n p_min = sk.modelToSketchSpace(bb.minPoint)\n p_max = sk.modelToSketchSpace(bb.maxPoint)\n face_w = abs(p_max.x - p_min.x) # extent along sketch-x (the default text baseline)\n face_h = abs(p_max.y - p_min.y) # extent along sketch-y\n # ALWAYS run the text along the LONGEST axis of the top face (most room for\n # the marking - John's rule, 2026-07-04). If the long axis is sketch-y,\n # rotate the text 90deg rather than squeezing it onto the short axis.\n import math as _math\n rot_deg = 90 if face_h > face_w else 0\n long_len = max(face_w, face_h)\n short_len = min(face_w, face_h)\n w = long_len * 0.80 # 10% margin each side along the long axis\n box_h_cap = short_len * 0.80 # 10% margin each side across it\n # multi-line support (e.g. MPN + variant on line 2): fit per-line\n lines = 'LM358\\nADOM-A'.split(chr(10))\n n_lines = max(1, len(lines))\n max_chars = max(1, max(len(l) for l in lines))\n # width-aware auto-fit: glyph ~0.75*h wide; line block ~1.35*h per line\n h = (0.5 / 10.0) if 0.5 else max(0.015, min(box_h_cap / (1.35 * n_lines), w / (0.75 * max_chars)))\n if rot_deg:\n c1 = adsk.core.Point3D.create(center.x - box_h_cap/2, center.y - w/2, 0)\n c2 = adsk.core.Point3D.create(center.x + box_h_cap/2, center.y + w/2, 0)\n else:\n c1 = adsk.core.Point3D.create(center.x - w/2, center.y - box_h_cap/2, 0)\n c2 = adsk.core.Point3D.create(center.x + w/2, center.y + box_h_cap/2, 0)\n ti = sk.sketchTexts.createInput2('LM358\\nADOM-A', h)\n ti.setAsMultiLine(c1, c2,\n adsk.core.HorizontalAlignments.CenterHorizontalAlignment,\n adsk.core.VerticalAlignments.MiddleVerticalAlignment, 0)\n if rot_deg:\n try:\n ti.angle = _math.pi / 2.0\n except Exception:\n rot_deg = 0 # older API without angle - fall back to unrotated\n txt = sk.sketchTexts.add(ti)\n # MEASURE-AND-CORRECT (the margin is a CONTRACT): font metrics vary, so the\n # 0.75h glyph estimate can under-shoot ('ADOM-A' rendered edge-to-edge). After\n # placing, measure the text's real bbox; if it busts the 10%-margin box,\n # recreate at the scaled-down height. Never trust an estimate you can measure.\n fit_iterations = 0\n measured_w = None\n for _pass in range(2):\n tb = txt.boundingBox\n t_ext_x = abs(tb.maxPoint.x - tb.minPoint.x)\n t_ext_y = abs(tb.maxPoint.y - tb.minPoint.y)\n measured_w = max(t_ext_x, t_ext_y) # the baseline-axis extent\n measured_c = min(t_ext_x, t_ext_y) # the cross-axis extent (line stack)\n if measured_w <= w and measured_c <= box_h_cap:\n break\n scale = min(w / max(measured_w, 1e-6), box_h_cap / max(measured_c, 1e-6)) * 0.95\n h = max(0.008, h * scale)\n txt.deleteMe()\n ti = sk.sketchTexts.createInput2('LM358\\nADOM-A', h)\n ti.setAsMultiLine(c1, c2,\n adsk.core.HorizontalAlignments.CenterHorizontalAlignment,\n adsk.core.VerticalAlignments.MiddleVerticalAlignment, 0)\n if rot_deg:\n try:\n ti.angle = _math.pi / 2.0\n except Exception:\n pass\n txt = sk.sketchTexts.add(ti)\n fit_iterations += 1\n marking_geom = {\n 'chipBBox': {'min': [round(gmin,5), 0, 0], 'maxZ': round(gmax,5)},\n 'chipBBoxZ': {'min': round(gmin,5), 'max': round(gmax,5)},\n 'topBandZ': round(band_z,5),\n 'topFace': {'area': round(top_area,6), 'centroidZ': round(top_face.centroid.z,5),\n 'sketchExtents': {'x': round(face_w,5), 'y': round(face_h,5)},\n 'body': body.name},\n 'longAxis': 'sketch-y' if face_h > face_w else 'sketch-x',\n 'rotatedDeg': rot_deg,\n 'textBox': {'c1': [round(c1.x,5), round(c1.y,5)], 'c2': [round(c2.x,5), round(c2.y,5)],\n 'marginPct': 10},\n 'textHeightMm': round(h*10,4), 'lines': n_lines, 'maxLineChars': max_chars,\n 'heightAuto': not bool(0.5),\n 'textWidthMeasuredMm': round(measured_w*10,4) if measured_w else None,\n 'fitIterations': fit_iterations,\n 'why': {'band': 'top 5% of whole-chip bbox excludes terminal tops that tie the body',\n 'largestArea': 'molded body top beats small terminal faces in the band',\n 'longAxis': 'text runs along the longest face axis for maximum room',\n 'fit': 'h = min(boxH/(1.35*lines), boxW/(0.75*maxChars)) - glyphs ~0.75h wide'},\n }\n if 'engraved' == 'engraved':\n # engraved (sunken) cut - the pre-2026-07-04 default\n ext_in = root.features.extrudeFeatures.createInput(txt, adsk.fusion.FeatureOperations.CutFeatureOperation)\n ext_in.setDistanceExtent(False, adsk.core.ValueInput.createByReal(-0.005))\n ext_in.participantBodies = [body]\n root.features.extrudeFeatures.add(ext_in)\n else:\n # RAISED WHITE marking (default): humans need CONTRAST - a thin positive\n # extrude painted white on the dark body reads like real silkscreen ink,\n # far better than a same-color emboss. Reuses EPG's own rgb-appearance\n # utility so the white survives Fusion rendering (+ colored STEP).\n ext_in = root.features.extrudeFeatures.createInput(txt, adsk.fusion.FeatureOperations.NewBodyFeatureOperation)\n ext_in.setDistanceExtent(False, adsk.core.ValueInput.createByReal(0.005))\n mark = root.features.extrudeFeatures.add(ext_in)\n from ElectronicsPackageGenerator.Utilities import addin_utility as _au\n for i in range(mark.bodies.count):\n mb = mark.bodies.item(i)\n mb.name = 'Marking' if i == 0 else 'Marking' + str(i + 1)\n _au.apply_rgb_appearance(app, design, mb, 255, 255, 255, 'AdomMarkingWhite')\n etched = 'LM358\\nADOM-A'\n\nstep_path = None\nif '':\n # Fusion's STEP export writes nothing into a folder that does not exist: make it first\n os.makedirs(os.path.dirname(os.path.abspath('')), exist_ok=True)\n em = design.exportManager\n opts = em.createSTEPExportOptions('')\n em.execute(opts)\n if not os.path.isfile(''):\n raise RuntimeError('Fusion did not write the STEP file %s' % '')\n step_path = ''\n\n# SIDECAR MANIFEST: every setting we picked + WHY + the bboxes we calculated,\n# so a marking refresh (e.g. adding a variant name as a 2nd line) can rework the\n# text without re-deriving anything. Written next to the STEP as <step>.manifest.json.\nmanifest = {\n 'schema': 'adom-fusion-generate-package/1',\n 'bridgeVersion': 'TESTVER',\n 'type': 'soic',\n 'paramsCm': {'A': 0.175, 'A1': 0.01, 'b': 0.040999999999999995, 'D': 0.49000000000000005, 'E': 0.6, 'E1': 0.39, 'e': 0.127, 'L': 0.08, 'DPins': 8},\n 'paramsNote': 'paramsCm are EPG-native cm (mm inputs were /10)',\n 'marking': ({'text': 'LM358\\nADOM-A', 'style': 'engraved',\n 'depthMm': 0.005 * 10, **marking_geom} if etched else None),\n 'bodies': inv,\n 'outputs': {'step': step_path},\n 'refresh': {\n 'howTo': 'To re-mark (e.g. add a variant on line 2): call fusion_generate_package again '\n 'with the SAME type+paramsCm (unitsCm:true) and the new multi-line etch text '\n '(join lines with a newline). The generator is deterministic, so geometry and '\n 'placement reproduce exactly; this manifest carries the boxes/height the last '\n 'run chose for comparison.',\n 'example': {'type': 'soic', 'unitsCm': True, 'params': 'paramsCm from this file',\n 'etch': ('LM358\\nADOM-A' + chr(10) + 'VARIANT') if etched else 'MPN' + chr(10) + 'VARIANT'},\n },\n}\nmanifest_path = None\nif step_path:\n manifest_path = step_path + '.manifest.json'\n import json as _json\n with open(manifest_path, 'w', encoding='utf-8') as _f:\n _json.dump(manifest, _f, indent=2)\n\nvp = app.activeViewport\ncam = vp.camera\ncam.isSmoothTransition = False\ncam.target = adsk.core.Point3D.create(0, 0, 0)\ncam.eye = adsk.core.Point3D.create(0.25, -0.35, 0.45)\ncam.upVector = adsk.core.Vector3D.create(0, 0, 1)\ncam.isFitView = True\nvp.camera = cam\nvp.refresh()\n\nresult = {'type': 'soic', 'bodies': inv, 'etched': etched, 'stepPath': step_path,\n 'manifest': manifest, 'manifestPath': manifest_path,\n 'epgDir': epg_dir, 'doc': app.activeDocument.name}\n", "errorCode": null }, {@@ -52,7 +52,7 @@ "etch": "it's \"q\"", "etchHeightMm": 1 },- "script": "\nimport sys, glob, os, importlib\nimport adsk.core, adsk.fusion\n\n# Locate EPG across install conventions + versions (NEVER hardcode the webdeploy hash).\n_bases = []\nfor env in ('ProgramFiles', 'ProgramFiles(x86)', 'ProgramW6432', 'LOCALAPPDATA'):\n root = os.environ.get(env)\n if root:\n _bases += glob.glob(os.path.join(root, 'Autodesk', 'webdeploy', 'production', '*',\n 'Api', 'InternalAddins', 'ElectronicsPackageGenerator'))\nif not _bases:\n raise RuntimeError('EPG_NOT_FOUND: ElectronicsPackageGenerator not present in this Fusion install')\nepg_dir = max(_bases, key=os.path.getmtime)\nparent = os.path.dirname(epg_dir)\nif parent not in sys.path:\n sys.path.insert(0, parent)\n\nmod = importlib.import_module('ElectronicsPackageGenerator.Scripts3d.qfn')\n\ndoc = app.documents.add(adsk.core.DocumentTypes.FusionDesignDocumentType)\ndesign = adsk.fusion.Design.cast(app.activeProduct)\nmod.runWithInput({'D': 0.5, 'E': 0.5, 'DPins': 4, 'EPins': 4, 'color_r': 30}, design)\nroot = design.rootComponent\n\ninv = [{'name': b.name, 'vol': round(b.volume, 6)} for b in root.bRepBodies]\netched = None\nif 'it\\'s \"q\"':\n # FIND THE TOP SURFACE the robust way (John's algorithm, 2026-07-04):\n # 1. bounding box of the WHOLE chip (all bodies);\n # 2. only faces whose centroid sits in the TOP 5% band of that bbox count;\n # 3. among those upward planar faces take the LARGEST AREA (the molded body\n # top beats terminal tops that tie it on height);\n # 4. lay the text out with a 10% margin inside that face.\n gmin, gmax = 1e9, -1e9\n for b in root.bRepBodies:\n bb = b.boundingBox\n gmin = min(gmin, bb.minPoint.z)\n gmax = max(gmax, bb.maxPoint.z)\n band_z = gmax - 0.05 * (gmax - gmin)\n top_face, top_area = None, -1.0\n for b in root.bRepBodies:\n for f in b.faces:\n if isinstance(f.geometry, adsk.core.Plane) and f.geometry.normal.z > 0.9 and f.centroid.z >= band_z and f.area > top_area:\n top_face, top_area = f, f.area\n if top_face is None:\n raise RuntimeError('ETCH_NO_TOP_FACE: no upward planar face in the top 5% of the chip bbox')\n body = top_face.body\n sk = root.sketches.add(top_face)\n center = sk.modelToSketchSpace(top_face.centroid)\n # Measure the face in SKETCH space (model-space bbox axes can be SWAPPED vs\n # the sketch axes the text lays out in). Project the model bbox corners.\n bb = top_face.boundingBox\n p_min = sk.modelToSketchSpace(bb.minPoint)\n p_max = sk.modelToSketchSpace(bb.maxPoint)\n face_w = abs(p_max.x - p_min.x) # extent along sketch-x (the default text baseline)\n face_h = abs(p_max.y - p_min.y) # extent along sketch-y\n # ALWAYS run the text along the LONGEST axis of the top face (most room for\n # the marking - John's rule, 2026-07-04). If the long axis is sketch-y,\n # rotate the text 90deg rather than squeezing it onto the short axis.\n import math as _math\n rot_deg = 90 if face_h > face_w else 0\n long_len = max(face_w, face_h)\n short_len = min(face_w, face_h)\n w = long_len * 0.80 # 10% margin each side along the long axis\n box_h_cap = short_len * 0.80 # 10% margin each side across it\n # multi-line support (e.g. MPN + variant on line 2): fit per-line\n lines = 'it\\'s \"q\"'.split(chr(10))\n n_lines = max(1, len(lines))\n max_chars = max(1, max(len(l) for l in lines))\n # width-aware auto-fit: glyph ~0.75*h wide; line block ~1.35*h per line\n h = (1 / 10.0) if 1 else max(0.015, min(box_h_cap / (1.35 * n_lines), w / (0.75 * max_chars)))\n if rot_deg:\n c1 = adsk.core.Point3D.create(center.x - box_h_cap/2, center.y - w/2, 0)\n c2 = adsk.core.Point3D.create(center.x + box_h_cap/2, center.y + w/2, 0)\n else:\n c1 = adsk.core.Point3D.create(center.x - w/2, center.y - box_h_cap/2, 0)\n c2 = adsk.core.Point3D.create(center.x + w/2, center.y + box_h_cap/2, 0)\n ti = sk.sketchTexts.createInput2('it\\'s \"q\"', h)\n ti.setAsMultiLine(c1, c2,\n adsk.core.HorizontalAlignments.CenterHorizontalAlignment,\n adsk.core.VerticalAlignments.MiddleVerticalAlignment, 0)\n if rot_deg:\n try:\n ti.angle = _math.pi / 2.0\n except Exception:\n rot_deg = 0 # older API without angle - fall back to unrotated\n txt = sk.sketchTexts.add(ti)\n # MEASURE-AND-CORRECT (the margin is a CONTRACT): font metrics vary, so the\n # 0.75h glyph estimate can under-shoot ('ADOM-A' rendered edge-to-edge). After\n # placing, measure the text's real bbox; if it busts the 10%-margin box,\n # recreate at the scaled-down height. Never trust an estimate you can measure.\n fit_iterations = 0\n measured_w = None\n for _pass in range(2):\n tb = txt.boundingBox\n t_ext_x = abs(tb.maxPoint.x - tb.minPoint.x)\n t_ext_y = abs(tb.maxPoint.y - tb.minPoint.y)\n measured_w = max(t_ext_x, t_ext_y) # the baseline-axis extent\n measured_c = min(t_ext_x, t_ext_y) # the cross-axis extent (line stack)\n if measured_w <= w and measured_c <= box_h_cap:\n break\n scale = min(w / max(measured_w, 1e-6), box_h_cap / max(measured_c, 1e-6)) * 0.95\n h = max(0.008, h * scale)\n txt.deleteMe()\n ti = sk.sketchTexts.createInput2('it\\'s \"q\"', h)\n ti.setAsMultiLine(c1, c2,\n adsk.core.HorizontalAlignments.CenterHorizontalAlignment,\n adsk.core.VerticalAlignments.MiddleVerticalAlignment, 0)\n if rot_deg:\n try:\n ti.angle = _math.pi / 2.0\n except Exception:\n pass\n txt = sk.sketchTexts.add(ti)\n fit_iterations += 1\n marking_geom = {\n 'chipBBox': {'min': [round(gmin,5), 0, 0], 'maxZ': round(gmax,5)},\n 'chipBBoxZ': {'min': round(gmin,5), 'max': round(gmax,5)},\n 'topBandZ': round(band_z,5),\n 'topFace': {'area': round(top_area,6), 'centroidZ': round(top_face.centroid.z,5),\n 'sketchExtents': {'x': round(face_w,5), 'y': round(face_h,5)},\n 'body': body.name},\n 'longAxis': 'sketch-y' if face_h > face_w else 'sketch-x',\n 'rotatedDeg': rot_deg,\n 'textBox': {'c1': [round(c1.x,5), round(c1.y,5)], 'c2': [round(c2.x,5), round(c2.y,5)],\n 'marginPct': 10},\n 'textHeightMm': round(h*10,4), 'lines': n_lines, 'maxLineChars': max_chars,\n 'heightAuto': not bool(1),\n 'textWidthMeasuredMm': round(measured_w*10,4) if measured_w else None,\n 'fitIterations': fit_iterations,\n 'why': {'band': 'top 5% of whole-chip bbox excludes terminal tops that tie the body',\n 'largestArea': 'molded body top beats small terminal faces in the band',\n 'longAxis': 'text runs along the longest face axis for maximum room',\n 'fit': 'h = min(boxH/(1.35*lines), boxW/(0.75*maxChars)) - glyphs ~0.75h wide'},\n }\n if 'raised' == 'engraved':\n # engraved (sunken) cut - the pre-2026-07-04 default\n ext_in = root.features.extrudeFeatures.createInput(txt, adsk.fusion.FeatureOperations.CutFeatureOperation)\n ext_in.setDistanceExtent(False, adsk.core.ValueInput.createByReal(-0.003))\n ext_in.participantBodies = [body]\n root.features.extrudeFeatures.add(ext_in)\n else:\n # RAISED WHITE marking (default): humans need CONTRAST - a thin positive\n # extrude painted white on the dark body reads like real silkscreen ink,\n # far better than a same-color emboss. Reuses EPG's own rgb-appearance\n # utility so the white survives Fusion rendering (+ colored STEP).\n ext_in = root.features.extrudeFeatures.createInput(txt, adsk.fusion.FeatureOperations.NewBodyFeatureOperation)\n ext_in.setDistanceExtent(False, adsk.core.ValueInput.createByReal(0.003))\n mark = root.features.extrudeFeatures.add(ext_in)\n from ElectronicsPackageGenerator.Utilities import addin_utility as _au\n for i in range(mark.bodies.count):\n mb = mark.bodies.item(i)\n mb.name = 'Marking' if i == 0 else 'Marking' + str(i + 1)\n _au.apply_rgb_appearance(app, design, mb, 255, 255, 255, 'AdomMarkingWhite')\n etched = 'it\\'s \"q\"'\n\nstep_path = None\nif '':\n em = design.exportManager\n opts = em.createSTEPExportOptions('')\n em.execute(opts)\n step_path = ''\n\n# SIDECAR MANIFEST: every setting we picked + WHY + the bboxes we calculated,\n# so a marking refresh (e.g. adding a variant name as a 2nd line) can rework the\n# text without re-deriving anything. Written next to the STEP as <step>.manifest.json.\nmanifest = {\n 'schema': 'adom-fusion-generate-package/1',\n 'bridgeVersion': 'TESTVER',\n 'type': 'qfn',\n 'paramsCm': {'D': 0.5, 'E': 0.5, 'DPins': 4, 'EPins': 4, 'color_r': 30},\n 'paramsNote': 'paramsCm are EPG-native cm (mm inputs were /10)',\n 'marking': ({'text': 'it\\'s \"q\"', 'style': 'raised',\n 'depthMm': 0.003 * 10, **marking_geom} if etched else None),\n 'bodies': inv,\n 'outputs': {'step': step_path},\n 'refresh': {\n 'howTo': 'To re-mark (e.g. add a variant on line 2): call fusion_generate_package again '\n 'with the SAME type+paramsCm (unitsCm:true) and the new multi-line etch text '\n '(join lines with a newline). The generator is deterministic, so geometry and '\n 'placement reproduce exactly; this manifest carries the boxes/height the last '\n 'run chose for comparison.',\n 'example': {'type': 'qfn', 'unitsCm': True, 'params': 'paramsCm from this file',\n 'etch': ('it\\'s \"q\"' + chr(10) + 'VARIANT') if etched else 'MPN' + chr(10) + 'VARIANT'},\n },\n}\nmanifest_path = None\nif step_path:\n manifest_path = step_path + '.manifest.json'\n import json as _json\n with open(manifest_path, 'w', encoding='utf-8') as _f:\n _json.dump(manifest, _f, indent=2)\n\nvp = app.activeViewport\ncam = vp.camera\ncam.isSmoothTransition = False\ncam.target = adsk.core.Point3D.create(0, 0, 0)\ncam.eye = adsk.core.Point3D.create(0.25, -0.35, 0.45)\ncam.upVector = adsk.core.Vector3D.create(0, 0, 1)\ncam.isFitView = True\nvp.camera = cam\nvp.refresh()\n\nresult = {'type': 'qfn', 'bodies': inv, 'etched': etched, 'stepPath': step_path,\n 'manifest': manifest, 'manifestPath': manifest_path,\n 'epgDir': epg_dir, 'doc': app.activeDocument.name}\n",+ "script": "\nimport sys, glob, os, importlib\nimport adsk.core, adsk.fusion\n\n# Locate EPG across install conventions + versions (NEVER hardcode the webdeploy hash).\n_bases = []\nfor env in ('ProgramFiles', 'ProgramFiles(x86)', 'ProgramW6432', 'LOCALAPPDATA'):\n root = os.environ.get(env)\n if root:\n _bases += glob.glob(os.path.join(root, 'Autodesk', 'webdeploy', 'production', '*',\n 'Api', 'InternalAddins', 'ElectronicsPackageGenerator'))\nif not _bases:\n raise RuntimeError('EPG_NOT_FOUND: ElectronicsPackageGenerator not present in this Fusion install')\nepg_dir = max(_bases, key=os.path.getmtime)\nparent = os.path.dirname(epg_dir)\nif parent not in sys.path:\n sys.path.insert(0, parent)\n\nmod = importlib.import_module('ElectronicsPackageGenerator.Scripts3d.qfn')\n\ndoc = app.documents.add(adsk.core.DocumentTypes.FusionDesignDocumentType)\ndesign = adsk.fusion.Design.cast(app.activeProduct)\nmod.runWithInput({'D': 0.5, 'E': 0.5, 'DPins': 4, 'EPins': 4, 'color_r': 30}, design)\nroot = design.rootComponent\n\ninv = [{'name': b.name, 'vol': round(b.volume, 6)} for b in root.bRepBodies]\netched = None\nif 'it\\'s \"q\"':\n # FIND THE TOP SURFACE the robust way (John's algorithm, 2026-07-04):\n # 1. bounding box of the WHOLE chip (all bodies);\n # 2. only faces whose centroid sits in the TOP 5% band of that bbox count;\n # 3. among those upward planar faces take the LARGEST AREA (the molded body\n # top beats terminal tops that tie it on height);\n # 4. lay the text out with a 10% margin inside that face.\n gmin, gmax = 1e9, -1e9\n for b in root.bRepBodies:\n bb = b.boundingBox\n gmin = min(gmin, bb.minPoint.z)\n gmax = max(gmax, bb.maxPoint.z)\n band_z = gmax - 0.05 * (gmax - gmin)\n top_face, top_area = None, -1.0\n for b in root.bRepBodies:\n for f in b.faces:\n if isinstance(f.geometry, adsk.core.Plane) and f.geometry.normal.z > 0.9 and f.centroid.z >= band_z and f.area > top_area:\n top_face, top_area = f, f.area\n if top_face is None:\n raise RuntimeError('ETCH_NO_TOP_FACE: no upward planar face in the top 5% of the chip bbox')\n body = top_face.body\n sk = root.sketches.add(top_face)\n center = sk.modelToSketchSpace(top_face.centroid)\n # Measure the face in SKETCH space (model-space bbox axes can be SWAPPED vs\n # the sketch axes the text lays out in). Project the model bbox corners.\n bb = top_face.boundingBox\n p_min = sk.modelToSketchSpace(bb.minPoint)\n p_max = sk.modelToSketchSpace(bb.maxPoint)\n face_w = abs(p_max.x - p_min.x) # extent along sketch-x (the default text baseline)\n face_h = abs(p_max.y - p_min.y) # extent along sketch-y\n # ALWAYS run the text along the LONGEST axis of the top face (most room for\n # the marking - John's rule, 2026-07-04). If the long axis is sketch-y,\n # rotate the text 90deg rather than squeezing it onto the short axis.\n import math as _math\n rot_deg = 90 if face_h > face_w else 0\n long_len = max(face_w, face_h)\n short_len = min(face_w, face_h)\n w = long_len * 0.80 # 10% margin each side along the long axis\n box_h_cap = short_len * 0.80 # 10% margin each side across it\n # multi-line support (e.g. MPN + variant on line 2): fit per-line\n lines = 'it\\'s \"q\"'.split(chr(10))\n n_lines = max(1, len(lines))\n max_chars = max(1, max(len(l) for l in lines))\n # width-aware auto-fit: glyph ~0.75*h wide; line block ~1.35*h per line\n h = (1 / 10.0) if 1 else max(0.015, min(box_h_cap / (1.35 * n_lines), w / (0.75 * max_chars)))\n if rot_deg:\n c1 = adsk.core.Point3D.create(center.x - box_h_cap/2, center.y - w/2, 0)\n c2 = adsk.core.Point3D.create(center.x + box_h_cap/2, center.y + w/2, 0)\n else:\n c1 = adsk.core.Point3D.create(center.x - w/2, center.y - box_h_cap/2, 0)\n c2 = adsk.core.Point3D.create(center.x + w/2, center.y + box_h_cap/2, 0)\n ti = sk.sketchTexts.createInput2('it\\'s \"q\"', h)\n ti.setAsMultiLine(c1, c2,\n adsk.core.HorizontalAlignments.CenterHorizontalAlignment,\n adsk.core.VerticalAlignments.MiddleVerticalAlignment, 0)\n if rot_deg:\n try:\n ti.angle = _math.pi / 2.0\n except Exception:\n rot_deg = 0 # older API without angle - fall back to unrotated\n txt = sk.sketchTexts.add(ti)\n # MEASURE-AND-CORRECT (the margin is a CONTRACT): font metrics vary, so the\n # 0.75h glyph estimate can under-shoot ('ADOM-A' rendered edge-to-edge). After\n # placing, measure the text's real bbox; if it busts the 10%-margin box,\n # recreate at the scaled-down height. Never trust an estimate you can measure.\n fit_iterations = 0\n measured_w = None\n for _pass in range(2):\n tb = txt.boundingBox\n t_ext_x = abs(tb.maxPoint.x - tb.minPoint.x)\n t_ext_y = abs(tb.maxPoint.y - tb.minPoint.y)\n measured_w = max(t_ext_x, t_ext_y) # the baseline-axis extent\n measured_c = min(t_ext_x, t_ext_y) # the cross-axis extent (line stack)\n if measured_w <= w and measured_c <= box_h_cap:\n break\n scale = min(w / max(measured_w, 1e-6), box_h_cap / max(measured_c, 1e-6)) * 0.95\n h = max(0.008, h * scale)\n txt.deleteMe()\n ti = sk.sketchTexts.createInput2('it\\'s \"q\"', h)\n ti.setAsMultiLine(c1, c2,\n adsk.core.HorizontalAlignments.CenterHorizontalAlignment,\n adsk.core.VerticalAlignments.MiddleVerticalAlignment, 0)\n if rot_deg:\n try:\n ti.angle = _math.pi / 2.0\n except Exception:\n pass\n txt = sk.sketchTexts.add(ti)\n fit_iterations += 1\n marking_geom = {\n 'chipBBox': {'min': [round(gmin,5), 0, 0], 'maxZ': round(gmax,5)},\n 'chipBBoxZ': {'min': round(gmin,5), 'max': round(gmax,5)},\n 'topBandZ': round(band_z,5),\n 'topFace': {'area': round(top_area,6), 'centroidZ': round(top_face.centroid.z,5),\n 'sketchExtents': {'x': round(face_w,5), 'y': round(face_h,5)},\n 'body': body.name},\n 'longAxis': 'sketch-y' if face_h > face_w else 'sketch-x',\n 'rotatedDeg': rot_deg,\n 'textBox': {'c1': [round(c1.x,5), round(c1.y,5)], 'c2': [round(c2.x,5), round(c2.y,5)],\n 'marginPct': 10},\n 'textHeightMm': round(h*10,4), 'lines': n_lines, 'maxLineChars': max_chars,\n 'heightAuto': not bool(1),\n 'textWidthMeasuredMm': round(measured_w*10,4) if measured_w else None,\n 'fitIterations': fit_iterations,\n 'why': {'band': 'top 5% of whole-chip bbox excludes terminal tops that tie the body',\n 'largestArea': 'molded body top beats small terminal faces in the band',\n 'longAxis': 'text runs along the longest face axis for maximum room',\n 'fit': 'h = min(boxH/(1.35*lines), boxW/(0.75*maxChars)) - glyphs ~0.75h wide'},\n }\n if 'raised' == 'engraved':\n # engraved (sunken) cut - the pre-2026-07-04 default\n ext_in = root.features.extrudeFeatures.createInput(txt, adsk.fusion.FeatureOperations.CutFeatureOperation)\n ext_in.setDistanceExtent(False, adsk.core.ValueInput.createByReal(-0.003))\n ext_in.participantBodies = [body]\n root.features.extrudeFeatures.add(ext_in)\n else:\n # RAISED WHITE marking (default): humans need CONTRAST - a thin positive\n # extrude painted white on the dark body reads like real silkscreen ink,\n # far better than a same-color emboss. Reuses EPG's own rgb-appearance\n # utility so the white survives Fusion rendering (+ colored STEP).\n ext_in = root.features.extrudeFeatures.createInput(txt, adsk.fusion.FeatureOperations.NewBodyFeatureOperation)\n ext_in.setDistanceExtent(False, adsk.core.ValueInput.createByReal(0.003))\n mark = root.features.extrudeFeatures.add(ext_in)\n from ElectronicsPackageGenerator.Utilities import addin_utility as _au\n for i in range(mark.bodies.count):\n mb = mark.bodies.item(i)\n mb.name = 'Marking' if i == 0 else 'Marking' + str(i + 1)\n _au.apply_rgb_appearance(app, design, mb, 255, 255, 255, 'AdomMarkingWhite')\n etched = 'it\\'s \"q\"'\n\nstep_path = None\nif '':\n # Fusion's STEP export writes nothing into a folder that does not exist: make it first\n os.makedirs(os.path.dirname(os.path.abspath('')), exist_ok=True)\n em = design.exportManager\n opts = em.createSTEPExportOptions('')\n em.execute(opts)\n if not os.path.isfile(''):\n raise RuntimeError('Fusion did not write the STEP file %s' % '')\n step_path = ''\n\n# SIDECAR MANIFEST: every setting we picked + WHY + the bboxes we calculated,\n# so a marking refresh (e.g. adding a variant name as a 2nd line) can rework the\n# text without re-deriving anything. Written next to the STEP as <step>.manifest.json.\nmanifest = {\n 'schema': 'adom-fusion-generate-package/1',\n 'bridgeVersion': 'TESTVER',\n 'type': 'qfn',\n 'paramsCm': {'D': 0.5, 'E': 0.5, 'DPins': 4, 'EPins': 4, 'color_r': 30},\n 'paramsNote': 'paramsCm are EPG-native cm (mm inputs were /10)',\n 'marking': ({'text': 'it\\'s \"q\"', 'style': 'raised',\n 'depthMm': 0.003 * 10, **marking_geom} if etched else None),\n 'bodies': inv,\n 'outputs': {'step': step_path},\n 'refresh': {\n 'howTo': 'To re-mark (e.g. add a variant on line 2): call fusion_generate_package again '\n 'with the SAME type+paramsCm (unitsCm:true) and the new multi-line etch text '\n '(join lines with a newline). The generator is deterministic, so geometry and '\n 'placement reproduce exactly; this manifest carries the boxes/height the last '\n 'run chose for comparison.',\n 'example': {'type': 'qfn', 'unitsCm': True, 'params': 'paramsCm from this file',\n 'etch': ('it\\'s \"q\"' + chr(10) + 'VARIANT') if etched else 'MPN' + chr(10) + 'VARIANT'},\n },\n}\nmanifest_path = None\nif step_path:\n manifest_path = step_path + '.manifest.json'\n import json as _json\n with open(manifest_path, 'w', encoding='utf-8') as _f:\n _json.dump(manifest, _f, indent=2)\n\nvp = app.activeViewport\ncam = vp.camera\ncam.isSmoothTransition = False\ncam.target = adsk.core.Point3D.create(0, 0, 0)\ncam.eye = adsk.core.Point3D.create(0.25, -0.35, 0.45)\ncam.upVector = adsk.core.Vector3D.create(0, 0, 1)\ncam.isFitView = True\nvp.camera = cam\nvp.refresh()\n\nresult = {'type': 'qfn', 'bodies': inv, 'etched': etched, 'stepPath': step_path,\n 'manifest': manifest, 'manifestPath': manifest_path,\n 'epgDir': epg_dir, 'doc': app.activeDocument.name}\n", "errorCode": null }, {@@ -65,7 +65,7 @@ }, "etch": "" },- "script": "\nimport sys, glob, os, importlib\nimport adsk.core, adsk.fusion\n\n# Locate EPG across install conventions + versions (NEVER hardcode the webdeploy hash).\n_bases = []\nfor env in ('ProgramFiles', 'ProgramFiles(x86)', 'ProgramW6432', 'LOCALAPPDATA'):\n root = os.environ.get(env)\n if root:\n _bases += glob.glob(os.path.join(root, 'Autodesk', 'webdeploy', 'production', '*',\n 'Api', 'InternalAddins', 'ElectronicsPackageGenerator'))\nif not _bases:\n raise RuntimeError('EPG_NOT_FOUND: ElectronicsPackageGenerator not present in this Fusion install')\nepg_dir = max(_bases, key=os.path.getmtime)\nparent = os.path.dirname(epg_dir)\nif parent not in sys.path:\n sys.path.insert(0, parent)\n\nmod = importlib.import_module('ElectronicsPackageGenerator.Scripts3d.chip')\n\ndoc = app.documents.add(adsk.core.DocumentTypes.FusionDesignDocumentType)\ndesign = adsk.fusion.Design.cast(app.activeProduct)\nmod.runWithInput({'D': 0.3, 'E': 1.0000000000000002e-06, 'A': 1234567890123456.8}, design)\nroot = design.rootComponent\n\ninv = [{'name': b.name, 'vol': round(b.volume, 6)} for b in root.bRepBodies]\netched = None\nif '':\n # FIND THE TOP SURFACE the robust way (John's algorithm, 2026-07-04):\n # 1. bounding box of the WHOLE chip (all bodies);\n # 2. only faces whose centroid sits in the TOP 5% band of that bbox count;\n # 3. among those upward planar faces take the LARGEST AREA (the molded body\n # top beats terminal tops that tie it on height);\n # 4. lay the text out with a 10% margin inside that face.\n gmin, gmax = 1e9, -1e9\n for b in root.bRepBodies:\n bb = b.boundingBox\n gmin = min(gmin, bb.minPoint.z)\n gmax = max(gmax, bb.maxPoint.z)\n band_z = gmax - 0.05 * (gmax - gmin)\n top_face, top_area = None, -1.0\n for b in root.bRepBodies:\n for f in b.faces:\n if isinstance(f.geometry, adsk.core.Plane) and f.geometry.normal.z > 0.9 and f.centroid.z >= band_z and f.area > top_area:\n top_face, top_area = f, f.area\n if top_face is None:\n raise RuntimeError('ETCH_NO_TOP_FACE: no upward planar face in the top 5% of the chip bbox')\n body = top_face.body\n sk = root.sketches.add(top_face)\n center = sk.modelToSketchSpace(top_face.centroid)\n # Measure the face in SKETCH space (model-space bbox axes can be SWAPPED vs\n # the sketch axes the text lays out in). Project the model bbox corners.\n bb = top_face.boundingBox\n p_min = sk.modelToSketchSpace(bb.minPoint)\n p_max = sk.modelToSketchSpace(bb.maxPoint)\n face_w = abs(p_max.x - p_min.x) # extent along sketch-x (the default text baseline)\n face_h = abs(p_max.y - p_min.y) # extent along sketch-y\n # ALWAYS run the text along the LONGEST axis of the top face (most room for\n # the marking - John's rule, 2026-07-04). If the long axis is sketch-y,\n # rotate the text 90deg rather than squeezing it onto the short axis.\n import math as _math\n rot_deg = 90 if face_h > face_w else 0\n long_len = max(face_w, face_h)\n short_len = min(face_w, face_h)\n w = long_len * 0.80 # 10% margin each side along the long axis\n box_h_cap = short_len * 0.80 # 10% margin each side across it\n # multi-line support (e.g. MPN + variant on line 2): fit per-line\n lines = ''.split(chr(10))\n n_lines = max(1, len(lines))\n max_chars = max(1, max(len(l) for l in lines))\n # width-aware auto-fit: glyph ~0.75*h wide; line block ~1.35*h per line\n h = (None / 10.0) if None else max(0.015, min(box_h_cap / (1.35 * n_lines), w / (0.75 * max_chars)))\n if rot_deg:\n c1 = adsk.core.Point3D.create(center.x - box_h_cap/2, center.y - w/2, 0)\n c2 = adsk.core.Point3D.create(center.x + box_h_cap/2, center.y + w/2, 0)\n else:\n c1 = adsk.core.Point3D.create(center.x - w/2, center.y - box_h_cap/2, 0)\n c2 = adsk.core.Point3D.create(center.x + w/2, center.y + box_h_cap/2, 0)\n ti = sk.sketchTexts.createInput2('', h)\n ti.setAsMultiLine(c1, c2,\n adsk.core.HorizontalAlignments.CenterHorizontalAlignment,\n adsk.core.VerticalAlignments.MiddleVerticalAlignment, 0)\n if rot_deg:\n try:\n ti.angle = _math.pi / 2.0\n except Exception:\n rot_deg = 0 # older API without angle - fall back to unrotated\n txt = sk.sketchTexts.add(ti)\n # MEASURE-AND-CORRECT (the margin is a CONTRACT): font metrics vary, so the\n # 0.75h glyph estimate can under-shoot ('ADOM-A' rendered edge-to-edge). After\n # placing, measure the text's real bbox; if it busts the 10%-margin box,\n # recreate at the scaled-down height. Never trust an estimate you can measure.\n fit_iterations = 0\n measured_w = None\n for _pass in range(2):\n tb = txt.boundingBox\n t_ext_x = abs(tb.maxPoint.x - tb.minPoint.x)\n t_ext_y = abs(tb.maxPoint.y - tb.minPoint.y)\n measured_w = max(t_ext_x, t_ext_y) # the baseline-axis extent\n measured_c = min(t_ext_x, t_ext_y) # the cross-axis extent (line stack)\n if measured_w <= w and measured_c <= box_h_cap:\n break\n scale = min(w / max(measured_w, 1e-6), box_h_cap / max(measured_c, 1e-6)) * 0.95\n h = max(0.008, h * scale)\n txt.deleteMe()\n ti = sk.sketchTexts.createInput2('', h)\n ti.setAsMultiLine(c1, c2,\n adsk.core.HorizontalAlignments.CenterHorizontalAlignment,\n adsk.core.VerticalAlignments.MiddleVerticalAlignment, 0)\n if rot_deg:\n try:\n ti.angle = _math.pi / 2.0\n except Exception:\n pass\n txt = sk.sketchTexts.add(ti)\n fit_iterations += 1\n marking_geom = {\n 'chipBBox': {'min': [round(gmin,5), 0, 0], 'maxZ': round(gmax,5)},\n 'chipBBoxZ': {'min': round(gmin,5), 'max': round(gmax,5)},\n 'topBandZ': round(band_z,5),\n 'topFace': {'area': round(top_area,6), 'centroidZ': round(top_face.centroid.z,5),\n 'sketchExtents': {'x': round(face_w,5), 'y': round(face_h,5)},\n 'body': body.name},\n 'longAxis': 'sketch-y' if face_h > face_w else 'sketch-x',\n 'rotatedDeg': rot_deg,\n 'textBox': {'c1': [round(c1.x,5), round(c1.y,5)], 'c2': [round(c2.x,5), round(c2.y,5)],\n 'marginPct': 10},\n 'textHeightMm': round(h*10,4), 'lines': n_lines, 'maxLineChars': max_chars,\n 'heightAuto': not bool(None),\n 'textWidthMeasuredMm': round(measured_w*10,4) if measured_w else None,\n 'fitIterations': fit_iterations,\n 'why': {'band': 'top 5% of whole-chip bbox excludes terminal tops that tie the body',\n 'largestArea': 'molded body top beats small terminal faces in the band',\n 'longAxis': 'text runs along the longest face axis for maximum room',\n 'fit': 'h = min(boxH/(1.35*lines), boxW/(0.75*maxChars)) - glyphs ~0.75h wide'},\n }\n if 'raised' == 'engraved':\n # engraved (sunken) cut - the pre-2026-07-04 default\n ext_in = root.features.extrudeFeatures.createInput(txt, adsk.fusion.FeatureOperations.CutFeatureOperation)\n ext_in.setDistanceExtent(False, adsk.core.ValueInput.createByReal(-0.003))\n ext_in.participantBodies = [body]\n root.features.extrudeFeatures.add(ext_in)\n else:\n # RAISED WHITE marking (default): humans need CONTRAST - a thin positive\n # extrude painted white on the dark body reads like real silkscreen ink,\n # far better than a same-color emboss. Reuses EPG's own rgb-appearance\n # utility so the white survives Fusion rendering (+ colored STEP).\n ext_in = root.features.extrudeFeatures.createInput(txt, adsk.fusion.FeatureOperations.NewBodyFeatureOperation)\n ext_in.setDistanceExtent(False, adsk.core.ValueInput.createByReal(0.003))\n mark = root.features.extrudeFeatures.add(ext_in)\n from ElectronicsPackageGenerator.Utilities import addin_utility as _au\n for i in range(mark.bodies.count):\n mb = mark.bodies.item(i)\n mb.name = 'Marking' if i == 0 else 'Marking' + str(i + 1)\n _au.apply_rgb_appearance(app, design, mb, 255, 255, 255, 'AdomMarkingWhite')\n etched = ''\n\nstep_path = None\nif '':\n em = design.exportManager\n opts = em.createSTEPExportOptions('')\n em.execute(opts)\n step_path = ''\n\n# SIDECAR MANIFEST: every setting we picked + WHY + the bboxes we calculated,\n# so a marking refresh (e.g. adding a variant name as a 2nd line) can rework the\n# text without re-deriving anything. Written next to the STEP as <step>.manifest.json.\nmanifest = {\n 'schema': 'adom-fusion-generate-package/1',\n 'bridgeVersion': 'TESTVER',\n 'type': 'chip',\n 'paramsCm': {'D': 0.3, 'E': 1.0000000000000002e-06, 'A': 1234567890123456.8},\n 'paramsNote': 'paramsCm are EPG-native cm (mm inputs were /10)',\n 'marking': ({'text': '', 'style': 'raised',\n 'depthMm': 0.003 * 10, **marking_geom} if etched else None),\n 'bodies': inv,\n 'outputs': {'step': step_path},\n 'refresh': {\n 'howTo': 'To re-mark (e.g. add a variant on line 2): call fusion_generate_package again '\n 'with the SAME type+paramsCm (unitsCm:true) and the new multi-line etch text '\n '(join lines with a newline). The generator is deterministic, so geometry and '\n 'placement reproduce exactly; this manifest carries the boxes/height the last '\n 'run chose for comparison.',\n 'example': {'type': 'chip', 'unitsCm': True, 'params': 'paramsCm from this file',\n 'etch': ('' + chr(10) + 'VARIANT') if etched else 'MPN' + chr(10) + 'VARIANT'},\n },\n}\nmanifest_path = None\nif step_path:\n manifest_path = step_path + '.manifest.json'\n import json as _json\n with open(manifest_path, 'w', encoding='utf-8') as _f:\n _json.dump(manifest, _f, indent=2)\n\nvp = app.activeViewport\ncam = vp.camera\ncam.isSmoothTransition = False\ncam.target = adsk.core.Point3D.create(0, 0, 0)\ncam.eye = adsk.core.Point3D.create(0.25, -0.35, 0.45)\ncam.upVector = adsk.core.Vector3D.create(0, 0, 1)\ncam.isFitView = True\nvp.camera = cam\nvp.refresh()\n\nresult = {'type': 'chip', 'bodies': inv, 'etched': etched, 'stepPath': step_path,\n 'manifest': manifest, 'manifestPath': manifest_path,\n 'epgDir': epg_dir, 'doc': app.activeDocument.name}\n",+ "script": "\nimport sys, glob, os, importlib\nimport adsk.core, adsk.fusion\n\n# Locate EPG across install conventions + versions (NEVER hardcode the webdeploy hash).\n_bases = []\nfor env in ('ProgramFiles', 'ProgramFiles(x86)', 'ProgramW6432', 'LOCALAPPDATA'):\n root = os.environ.get(env)\n if root:\n _bases += glob.glob(os.path.join(root, 'Autodesk', 'webdeploy', 'production', '*',\n 'Api', 'InternalAddins', 'ElectronicsPackageGenerator'))\nif not _bases:\n raise RuntimeError('EPG_NOT_FOUND: ElectronicsPackageGenerator not present in this Fusion install')\nepg_dir = max(_bases, key=os.path.getmtime)\nparent = os.path.dirname(epg_dir)\nif parent not in sys.path:\n sys.path.insert(0, parent)\n\nmod = importlib.import_module('ElectronicsPackageGenerator.Scripts3d.chip')\n\ndoc = app.documents.add(adsk.core.DocumentTypes.FusionDesignDocumentType)\ndesign = adsk.fusion.Design.cast(app.activeProduct)\nmod.runWithInput({'D': 0.3, 'E': 1.0000000000000002e-06, 'A': 1234567890123456.8}, design)\nroot = design.rootComponent\n\ninv = [{'name': b.name, 'vol': round(b.volume, 6)} for b in root.bRepBodies]\netched = None\nif '':\n # FIND THE TOP SURFACE the robust way (John's algorithm, 2026-07-04):\n # 1. bounding box of the WHOLE chip (all bodies);\n # 2. only faces whose centroid sits in the TOP 5% band of that bbox count;\n # 3. among those upward planar faces take the LARGEST AREA (the molded body\n # top beats terminal tops that tie it on height);\n # 4. lay the text out with a 10% margin inside that face.\n gmin, gmax = 1e9, -1e9\n for b in root.bRepBodies:\n bb = b.boundingBox\n gmin = min(gmin, bb.minPoint.z)\n gmax = max(gmax, bb.maxPoint.z)\n band_z = gmax - 0.05 * (gmax - gmin)\n top_face, top_area = None, -1.0\n for b in root.bRepBodies:\n for f in b.faces:\n if isinstance(f.geometry, adsk.core.Plane) and f.geometry.normal.z > 0.9 and f.centroid.z >= band_z and f.area > top_area:\n top_face, top_area = f, f.area\n if top_face is None:\n raise RuntimeError('ETCH_NO_TOP_FACE: no upward planar face in the top 5% of the chip bbox')\n body = top_face.body\n sk = root.sketches.add(top_face)\n center = sk.modelToSketchSpace(top_face.centroid)\n # Measure the face in SKETCH space (model-space bbox axes can be SWAPPED vs\n # the sketch axes the text lays out in). Project the model bbox corners.\n bb = top_face.boundingBox\n p_min = sk.modelToSketchSpace(bb.minPoint)\n p_max = sk.modelToSketchSpace(bb.maxPoint)\n face_w = abs(p_max.x - p_min.x) # extent along sketch-x (the default text baseline)\n face_h = abs(p_max.y - p_min.y) # extent along sketch-y\n # ALWAYS run the text along the LONGEST axis of the top face (most room for\n # the marking - John's rule, 2026-07-04). If the long axis is sketch-y,\n # rotate the text 90deg rather than squeezing it onto the short axis.\n import math as _math\n rot_deg = 90 if face_h > face_w else 0\n long_len = max(face_w, face_h)\n short_len = min(face_w, face_h)\n w = long_len * 0.80 # 10% margin each side along the long axis\n box_h_cap = short_len * 0.80 # 10% margin each side across it\n # multi-line support (e.g. MPN + variant on line 2): fit per-line\n lines = ''.split(chr(10))\n n_lines = max(1, len(lines))\n max_chars = max(1, max(len(l) for l in lines))\n # width-aware auto-fit: glyph ~0.75*h wide; line block ~1.35*h per line\n h = (None / 10.0) if None else max(0.015, min(box_h_cap / (1.35 * n_lines), w / (0.75 * max_chars)))\n if rot_deg:\n c1 = adsk.core.Point3D.create(center.x - box_h_cap/2, center.y - w/2, 0)\n c2 = adsk.core.Point3D.create(center.x + box_h_cap/2, center.y + w/2, 0)\n else:\n c1 = adsk.core.Point3D.create(center.x - w/2, center.y - box_h_cap/2, 0)\n c2 = adsk.core.Point3D.create(center.x + w/2, center.y + box_h_cap/2, 0)\n ti = sk.sketchTexts.createInput2('', h)\n ti.setAsMultiLine(c1, c2,\n adsk.core.HorizontalAlignments.CenterHorizontalAlignment,\n adsk.core.VerticalAlignments.MiddleVerticalAlignment, 0)\n if rot_deg:\n try:\n ti.angle = _math.pi / 2.0\n except Exception:\n rot_deg = 0 # older API without angle - fall back to unrotated\n txt = sk.sketchTexts.add(ti)\n # MEASURE-AND-CORRECT (the margin is a CONTRACT): font metrics vary, so the\n # 0.75h glyph estimate can under-shoot ('ADOM-A' rendered edge-to-edge). After\n # placing, measure the text's real bbox; if it busts the 10%-margin box,\n # recreate at the scaled-down height. Never trust an estimate you can measure.\n fit_iterations = 0\n measured_w = None\n for _pass in range(2):\n tb = txt.boundingBox\n t_ext_x = abs(tb.maxPoint.x - tb.minPoint.x)\n t_ext_y = abs(tb.maxPoint.y - tb.minPoint.y)\n measured_w = max(t_ext_x, t_ext_y) # the baseline-axis extent\n measured_c = min(t_ext_x, t_ext_y) # the cross-axis extent (line stack)\n if measured_w <= w and measured_c <= box_h_cap:\n break\n scale = min(w / max(measured_w, 1e-6), box_h_cap / max(measured_c, 1e-6)) * 0.95\n h = max(0.008, h * scale)\n txt.deleteMe()\n ti = sk.sketchTexts.createInput2('', h)\n ti.setAsMultiLine(c1, c2,\n adsk.core.HorizontalAlignments.CenterHorizontalAlignment,\n adsk.core.VerticalAlignments.MiddleVerticalAlignment, 0)\n if rot_deg:\n try:\n ti.angle = _math.pi / 2.0\n except Exception:\n pass\n txt = sk.sketchTexts.add(ti)\n fit_iterations += 1\n marking_geom = {\n 'chipBBox': {'min': [round(gmin,5), 0, 0], 'maxZ': round(gmax,5)},\n 'chipBBoxZ': {'min': round(gmin,5), 'max': round(gmax,5)},\n 'topBandZ': round(band_z,5),\n 'topFace': {'area': round(top_area,6), 'centroidZ': round(top_face.centroid.z,5),\n 'sketchExtents': {'x': round(face_w,5), 'y': round(face_h,5)},\n 'body': body.name},\n 'longAxis': 'sketch-y' if face_h > face_w else 'sketch-x',\n 'rotatedDeg': rot_deg,\n 'textBox': {'c1': [round(c1.x,5), round(c1.y,5)], 'c2': [round(c2.x,5), round(c2.y,5)],\n 'marginPct': 10},\n 'textHeightMm': round(h*10,4), 'lines': n_lines, 'maxLineChars': max_chars,\n 'heightAuto': not bool(None),\n 'textWidthMeasuredMm': round(measured_w*10,4) if measured_w else None,\n 'fitIterations': fit_iterations,\n 'why': {'band': 'top 5% of whole-chip bbox excludes terminal tops that tie the body',\n 'largestArea': 'molded body top beats small terminal faces in the band',\n 'longAxis': 'text runs along the longest face axis for maximum room',\n 'fit': 'h = min(boxH/(1.35*lines), boxW/(0.75*maxChars)) - glyphs ~0.75h wide'},\n }\n if 'raised' == 'engraved':\n # engraved (sunken) cut - the pre-2026-07-04 default\n ext_in = root.features.extrudeFeatures.createInput(txt, adsk.fusion.FeatureOperations.CutFeatureOperation)\n ext_in.setDistanceExtent(False, adsk.core.ValueInput.createByReal(-0.003))\n ext_in.participantBodies = [body]\n root.features.extrudeFeatures.add(ext_in)\n else:\n # RAISED WHITE marking (default): humans need CONTRAST - a thin positive\n # extrude painted white on the dark body reads like real silkscreen ink,\n # far better than a same-color emboss. Reuses EPG's own rgb-appearance\n # utility so the white survives Fusion rendering (+ colored STEP).\n ext_in = root.features.extrudeFeatures.createInput(txt, adsk.fusion.FeatureOperations.NewBodyFeatureOperation)\n ext_in.setDistanceExtent(False, adsk.core.ValueInput.createByReal(0.003))\n mark = root.features.extrudeFeatures.add(ext_in)\n from ElectronicsPackageGenerator.Utilities import addin_utility as _au\n for i in range(mark.bodies.count):\n mb = mark.bodies.item(i)\n mb.name = 'Marking' if i == 0 else 'Marking' + str(i + 1)\n _au.apply_rgb_appearance(app, design, mb, 255, 255, 255, 'AdomMarkingWhite')\n etched = ''\n\nstep_path = None\nif '':\n # Fusion's STEP export writes nothing into a folder that does not exist: make it first\n os.makedirs(os.path.dirname(os.path.abspath('')), exist_ok=True)\n em = design.exportManager\n opts = em.createSTEPExportOptions('')\n em.execute(opts)\n if not os.path.isfile(''):\n raise RuntimeError('Fusion did not write the STEP file %s' % '')\n step_path = ''\n\n# SIDECAR MANIFEST: every setting we picked + WHY + the bboxes we calculated,\n# so a marking refresh (e.g. adding a variant name as a 2nd line) can rework the\n# text without re-deriving anything. Written next to the STEP as <step>.manifest.json.\nmanifest = {\n 'schema': 'adom-fusion-generate-package/1',\n 'bridgeVersion': 'TESTVER',\n 'type': 'chip',\n 'paramsCm': {'D': 0.3, 'E': 1.0000000000000002e-06, 'A': 1234567890123456.8},\n 'paramsNote': 'paramsCm are EPG-native cm (mm inputs were /10)',\n 'marking': ({'text': '', 'style': 'raised',\n 'depthMm': 0.003 * 10, **marking_geom} if etched else None),\n 'bodies': inv,\n 'outputs': {'step': step_path},\n 'refresh': {\n 'howTo': 'To re-mark (e.g. add a variant on line 2): call fusion_generate_package again '\n 'with the SAME type+paramsCm (unitsCm:true) and the new multi-line etch text '\n '(join lines with a newline). The generator is deterministic, so geometry and '\n 'placement reproduce exactly; this manifest carries the boxes/height the last '\n 'run chose for comparison.',\n 'example': {'type': 'chip', 'unitsCm': True, 'params': 'paramsCm from this file',\n 'etch': ('' + chr(10) + 'VARIANT') if etched else 'MPN' + chr(10) + 'VARIANT'},\n },\n}\nmanifest_path = None\nif step_path:\n manifest_path = step_path + '.manifest.json'\n import json as _json\n with open(manifest_path, 'w', encoding='utf-8') as _f:\n _json.dump(manifest, _f, indent=2)\n\nvp = app.activeViewport\ncam = vp.camera\ncam.isSmoothTransition = False\ncam.target = adsk.core.Point3D.create(0, 0, 0)\ncam.eye = adsk.core.Point3D.create(0.25, -0.35, 0.45)\ncam.upVector = adsk.core.Vector3D.create(0, 0, 1)\ncam.isFitView = True\nvp.camera = cam\nvp.refresh()\n\nresult = {'type': 'chip', 'bodies': inv, 'etched': etched, 'stepPath': step_path,\n 'manifest': manifest, 'manifestPath': manifest_path,\n 'epgDir': epg_dir, 'doc': app.activeDocument.name}\n", "errorCode": null }, {