← Commit history

generate_body pin counts per generator, exposed pad, STEP folder (unreleased, 1.13.4)

Colby Knox ·0e7aa24727 ·14d ago ·parent 3ba2942
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  },  {