← Commit history
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.9.271\ No newline at end of file+1.9.272\ No newline at end of file
adom-bridge-fusion-manifest.json+4−4
@@ -1,10 +1,10 @@ {   "manifest_version": 1,   "name": "fusion360",-  "version": "1.9.271",-  "url": "https://wiki.adom.inc/download/adom/fusion-bridge/1.9.271/adom-bridge-fusion-v1.9.271.zip",-  "sha256": "4230a08082bd0393c771c2191f0a2877205bbab73a9146fbccf5464b1fbdcdbb",-  "size": 561005,+  "version": "1.9.272",+  "url": "https://wiki.adom.inc/download/adom/fusion-bridge/1.9.272/adom-bridge-fusion-v1.9.272.zip",+  "sha256": "15febf85d61a57d30d842f42540e564925050fdf324c9f954892e09d2a1dfe0d",+  "size": 564192,   "verbPrefixes": [     "fusion_"   ],
bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "fusion360",   "displayName": "Autodesk Fusion 360",-  "version": "1.9.271",+  "version": "1.9.272",   "description": "Drive Autodesk Fusion 360 from the cloud: launch Fusion, electronics board layout, design rules, exports (STEP/IGES/STL/3MF/USDZ/OBJ/DXF/DWG/Gerbers/BOM/CPL), fast APS server-indexed cloud search plus browse/recent/file-info/versions, cloud file download/upload and folder creation, and in-app parametric modeling (fusion_run_modeling_script). Never-charge: APS calls are capped to the free tier.",   "homepage": "https://wiki.adom.inc/adom/fusion-bridge",   "author": "Adom Inc.",
dashboard/Cargo.lock+1−1
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "fusion-dashboard"-version = "1.9.271"+version = "1.9.272" dependencies = [  "serde_json", ]
dashboard/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.9.271"+version = "1.9.272" edition = "2021"  [dependencies]
dashboard/ui/quick-actions.html+3−2
@@ -232,12 +232,13 @@ function connect(){status($('connStatus'),'probing 127.0.0.1:24680-24689...');$(  // ---- part files ---------------------------------------------------------------------- var skipModel=false;-function fetchPartFiles(ref){return fetch('https://wiki.adom.inc/api/v1/pages/'+ref+'/files',{signal:timeout(15000)}).then(function(r){if(!r.ok)throw new Error(ref+': wiki answered '+r.status);return r.json()}).then(function(j){var files=j.files||[];var lbr=files.filter(function(f){return /\.lbr$/i.test(f.name)});var steps=files.filter(function(f){return /\.(step|stp)$/i.test(f.name)});if(!lbr.length)throw new Error(ref+' publishes no .lbr');var base='https://wiki.adom.inc/api/v1/pages/'+ref+'/files/';return fetch('https://wiki.adom.inc/api/v1/pages/'+ref,{signal:timeout(15000)}).then(function(r){return r.ok?r.json():{}}).catch(function(){return{}}).then(function(pg){var page=pg.page||pg;return{ref:ref,name:ref.split('/').pop(),title:page.title||'',brief:page.brief||'',lbr:{name:lbr[0].name,url:base+lbr[0].name,size:lbr[0].size},steps:steps.map(function(f){return{name:f.name,url:base+f.name,size:f.size}}),lastCommit:files.map(function(f){return (f.lastCommit||{}).timestamp||0}).reduce(function(a,b){return Math.max(a,b)},0)}})})}+function fetchPartFiles(ref){return fetch('https://wiki.adom.inc/api/v1/pages/'+ref+'/files',{signal:timeout(15000)}).then(function(r){if(!r.ok)throw new Error(ref+': wiki answered '+r.status);return r.json()}).then(function(j){var files=j.files||[];var lbr=files.filter(function(f){return /\.lbr$/i.test(f.name)});var ksym=files.filter(function(f){return /\.kicad_sym$/i.test(f.name)})[0];var steps=files.filter(function(f){return /\.(step|stp)$/i.test(f.name)});if(!lbr.length)throw new Error(ref+' publishes no .lbr');var base='https://wiki.adom.inc/api/v1/pages/'+ref+'/files/';return fetch('https://wiki.adom.inc/api/v1/pages/'+ref,{signal:timeout(15000)}).then(function(r){return r.ok?r.json():{}}).catch(function(){return{}}).then(function(pg){var page=pg.page||pg;var mpnP=ksym?fetch(base+ksym.name,{signal:timeout(15000)}).then(function(r){return r.ok?r.text():''}).then(function(t){var m=/\(property "Value" "([^"]+)"/.exec(t);return m?m[1]:''}).catch(function(){return''}):Promise.resolve('');return mpnP.then(function(mpn){return{ref:ref,name:ref.split('/').pop(),title:page.title||'',brief:page.brief||'',mpn:mpn,lbr:{name:lbr[0].name,url:base+lbr[0].name,size:lbr[0].size},steps:steps.map(function(f){return{name:f.name,url:base+f.name,size:f.size}}),lastCommit:files.map(function(f){return (f.lastCommit||{}).timestamp||0}).reduce(function(a,b){return Math.max(a,b)},0)}})})})} function resolvePart(rec){var refs=$('partRef').value.split(',').map(function(x){return x.trim().replace(/^\/+|\/+$/g,'')}).filter(Boolean);if(!refs.length)return;status($('partStatus'),'resolving '+refs.length+' page'+(refs.length===1?'':'s')+'...');part=null;$('btnImport').disabled=true;return Promise.all(refs.map(fetchPartFiles)).then(function(ps){var multi=ps.length>1;part={refs:refs,parts:ps,multi:multi,name:multi?('adom-multi-'+ps.length+'parts'):ps[0].name,lbr:ps[0].lbr,steps:skipModel?[]:[].concat.apply([],ps.map(function(p){return p.steps}))};var info={};if(multi){info.library=part.name+' (merged from '+ps.length+' pages)';ps.forEach(function(p,i){var r=catalogPages[p.ref];info['part '+(i+1)]=p.ref+(r?' · published '+fmtDate(r.last_commit_at||r.updated_at):'')+' · '+p.lbr.name+(p.steps.length?' + '+p.steps[0].name:' (no model)')})}else{var p=ps[0];rec=rec&&rec.slug?rec:(catalogPages[p.ref]||null);info.page='https://wiki.adom.inc/'+p.ref;if(rec){info.title=(rec.title||'')+(rec.brief?' - '+String(rec.brief).slice(0,90):'');info.version=rec.version||'';info.published=fmtDate(rec.last_commit_at||rec.updated_at)+(rec.created_at?' (page created '+fmtDate(rec.created_at)+')':'')}else if(p.lastCommit){info['files published']=fmtDate(p.lastCommit*1000)}info.library=p.lbr.name+' ('+p.lbr.size+' B)';info.models=p.steps.map(function(s){return s.name+' ('+s.size+' B)'}).join(', ')||'none'}if(skipModel)info.models='SKIPPED by the switch above: the library will install unbound';kv($('partKv'),info);status($('partStatus'),multi?ps.length+' parts, ready':'ready','ok');$('btnImport').disabled=false;$('importNote').textContent=part.steps.length?(multi?'one call for all '+ps.length+' parts, then the page polls':'one call, then the page polls'):'no STEP: nothing to bind, the library installs unbound';toast('Resolved '+(multi?ps.length+' parts':ps[0].lbr.name)+(part.steps.length?' + '+part.steps.length+' model(s)':' (no models)'),'ok')}).catch(function(e){status($('partStatus'),e.message,'bad');toast('Could not resolve: '+e.message,'err')})} // Merge N EAGLE libraries into one multi-deviceset library (packages, symbols, devicesets appended; first library's header kept). // A library with no deviceset description or MPN is unusable to an EE; when the wiki page has // them (its title is the part number, its brief the one-line description) fill the gaps and say so.-function fillPartMeta(text,rec,fills){if(!rec||(!rec.brief&&!rec.title))return text;try{var P=new DOMParser();var d=P.parseFromString(text,'application/xml');var changed=false;[].forEach.call(d.querySelectorAll('library > devicesets > deviceset'),function(ds){var name=ds.getAttribute('name');var desc=ds.querySelector(':scope > description');if((!desc||!(desc.textContent||'').trim())&&rec.brief){if(!desc){desc=d.createElement('description');ds.insertBefore(desc,ds.firstChild)}desc.textContent=rec.brief;fills.push({deviceset:name,field:'description',from:rec.ref});changed=true}[].forEach.call(ds.querySelectorAll('devices > device'),function(dev){var techs=dev.querySelectorAll('technologies > technology');if(!techs.length){var ts=dev.querySelector('technologies');if(!ts){ts=d.createElement('technologies');dev.appendChild(ts)}var t=d.createElement('technology');t.setAttribute('name','');ts.appendChild(t);techs=[t]}[].forEach.call(techs,function(t){var has=[].some.call(t.querySelectorAll('attribute'),function(a){return (a.getAttribute('name')||'').toUpperCase()==='MPN'&&(a.getAttribute('value')||'').trim()});if(!has&&rec.title){var a=d.createElement('attribute');a.setAttribute('name','MPN');a.setAttribute('value',rec.title);a.setAttribute('constant','no');t.appendChild(a);fills.push({deviceset:name,field:'MPN',from:rec.ref});changed=true}})})});return changed?new XMLSerializer().serializeToString(d):text}catch(e){return text}}+var GENERIC_DESC=/^(inductor|resistor|capacitor|unpolarized capacitor|polarized capacitor|light emitting diode|led|diode|generic connector.*|four pin crystal.*|crystal|two pin crystal.*|fuse|ferrite bead|transistor.*|mosfet.*)$/i;var GENERIC_NAME=/^(L|R|C|D|LED|Q|Y|X|F|FB|J|Conn_\d+x\d+.*|Crystal(_\w+)?|Resistor|Capacitor|Inductor)$/i;+function fillPartMeta(text,rec,fills){if(!rec||(!rec.brief&&!rec.title))return text;try{var P=new DOMParser();var d=P.parseFromString(text,'application/xml');var changed=false;[].forEach.call(d.querySelectorAll('library > devicesets > deviceset'),function(ds){var name=ds.getAttribute('name');var mpn=(rec.mpn||'').trim();if(!mpn||GENERIC_NAME.test(mpn))mpn=(rec.ref||'').split('/').pop().toUpperCase();if(GENERIC_NAME.test(name||'')&&mpn){var newName=mpn.replace(/[^A-Za-z0-9._-]+/g,'_');ds.setAttribute('name',newName);fills.push({deviceset:name,field:'name -> '+newName,from:rec.ref});name=newName;changed=true}var desc=ds.querySelector(':scope > description');var descText=desc?(desc.textContent||'').trim():'';if((!descText||GENERIC_DESC.test(descText))&&rec.brief){if(!desc){desc=d.createElement('description');ds.insertBefore(desc,ds.firstChild)}desc.textContent=rec.brief;fills.push({deviceset:name,field:'description',from:rec.ref});changed=true}[].forEach.call(ds.querySelectorAll('devices > device'),function(dev){var techs=dev.querySelectorAll('technologies > technology');if(!techs.length){var ts=dev.querySelector('technologies');if(!ts){ts=d.createElement('technologies');dev.appendChild(ts)}var t=d.createElement('technology');t.setAttribute('name','');ts.appendChild(t);techs=[t]}[].forEach.call(techs,function(t){var has=[].some.call(t.querySelectorAll('attribute'),function(a){return (a.getAttribute('name')||'').toUpperCase()==='MPN'&&(a.getAttribute('value')||'').trim()});if(!has&&mpn){var a=d.createElement('attribute');a.setAttribute('name','MPN');a.setAttribute('value',mpn);a.setAttribute('constant','no');t.appendChild(a);fills.push({deviceset:name,field:'MPN',from:rec.ref});changed=true}})})});return changed?new XMLSerializer().serializeToString(d):text}catch(e){return text}} function mergeLbrs(texts){if(texts.length===1)return texts[0];var P=new DOMParser();var base=P.parseFromString(texts[0],'application/xml');function sec(doc,tag){var el=doc.querySelector('library > '+tag);if(!el){el=doc.createElement(tag);doc.querySelector('library').appendChild(el)}return el}var seen={packages:{},symbols:{},devicesets:{}};['packages','symbols','devicesets'].forEach(function(t){[].forEach.call(sec(base,t).children,function(c){seen[t][c.getAttribute('name')]=1})});for(var i=1;i<texts.length;i++){var d=P.parseFromString(texts[i],'application/xml');['packages','symbols','devicesets'].forEach(function(t){var src=d.querySelector('library > '+t);if(!src)return;[].forEach.call(src.children,function(c){var n=c.getAttribute('name');if(seen[t][n])return;seen[t][n]=1;sec(base,t).appendChild(base.importNode(c,true))})});var p3=d.querySelector('library > packages3d');if(p3){var b3=sec(base,'packages3d');[].forEach.call(p3.children,function(c){b3.appendChild(base.importNode(c,true))})}}return new XMLSerializer().serializeToString(base)}  // ---- Ray's recent parts: random picks with their publish date --------------------------
install.sh+1−1
@@ -21,7 +21,7 @@ echo "Installed fusion-bridge skills: $(ls "$PKG_DIR/skills" 2>/dev/null | tr '\ # binary (the three-artifact pattern: bridge zip -> ab, THIS pkg -> container skills + # this download, page media -> wiki). Per the wiki's PKG_VS_RELEASE policy, binaries are # platform-tagged RELEASE assets, never tarball content - the installer fetches them.-DASH_VERSION="1.9.271"+DASH_VERSION="1.9.272" DASH_URL="https://wiki.adom.inc/download/adom/fusion-bridge/${DASH_VERSION}/fusion-dashboard" DEST="" if [ -w /usr/local/bin ]; then DEST=/usr/local/bin
library_bundle.py+15−3
@@ -73,8 +73,10 @@ def pair_parts(lbr_path: str, models: list, metadata: dict) -> dict:     Match order per deviceset: exact deviceset name, exact package name, then a normalised     (alnum-only, case-folded) match on either, then a normalised prefix match. When the     library has exactly one deviceset and the bundle exactly one model, they pair regardless-    of name (a one-part library is N=1, not a special case). Returns-    {parts:[{package, lbrPath, modelPath, matchedBy}], unbound:[{package, why}], models}.+    of name (a one-part library is N=1, not a special case). Devicesets that share an already+    paired PACKAGE are not unbound: they are listed in shared[] (the package3d is per package and+    the binding pass covers every device on it). Returns {parts:[{package, lbrPath, modelPath,+    matchedBy}], unbound:[{package, why}], shared:[{deviceset, package, via}], models}.     """     devicesets = [d.get("name") or "" for d in (metadata.get("devicesets") or [])]     packages = {}@@ -94,8 +96,16 @@ def pair_parts(lbr_path: str, models: list, metadata: dict) -> dict:         parts.append({"deviceset": ds, "package": packages.get(ds) or ds, "lbrPath": lbr_path,                       "modelPath": m, "matchedBy": how}) +    shared = []+    bound_pkgs = {}     for ds in devicesets:         pk = packages.get(ds) or ""+        if pk and pk in bound_pkgs:+            # Several devicesets on ONE package (symbol variants, value clones): the 3D package+            # is per PACKAGE, so the mint done for the first deviceset serves all of them and the+            # binding pass writes the urn into every device that uses the package.+            shared.append({"deviceset": ds, "package": pk, "via": bound_pkgs[pk]})+            continue         cands = [(ds, "deviceset"), (pk, "package")] if pk else [(ds, "deviceset")]         hit = None         for name, how in cands:@@ -116,13 +126,15 @@ def pair_parts(lbr_path: str, models: list, metadata: dict) -> dict:                     break         if hit:             _take(hit[0], hit[1], ds)+            if pk:+                bound_pkgs[pk] = ds         else:             unbound.append({"package": ds, "why": "no STEP in the bundle named like the deviceset"                                                    + (" or its package %r" % pk if pk else "")})     if not parts and len(devicesets) == 1 and len(models) == 1:         unbound.clear()         _take(models[0], "only-one-of-each", devicesets[0])-    return {"parts": parts, "unbound": unbound, "models": models,+    return {"parts": parts, "unbound": unbound, "shared": shared, "models": models,             "modelsUnused": [m for m in models if m not in used]}  
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "fusion-bridge",-  "version": "1.9.271",+  "version": "1.9.272",   "type": "app",   "description": "This package installs the bridge's SKILLS into your container so your AI knows how to drive it; Adom Bridge loads the bridge runtime itself from the release zip.",   "dependencies": {
page.json+11−8
@@ -1,6 +1,6 @@ {   "slug": "fusion-bridge",-  "version": "1.9.271",+  "version": "1.9.272",   "type": "app",   "description": "This package installs the bridge's SKILLS into your container so your AI knows how to drive it; Adom Bridge loads the bridge runtime itself from the release zip.",   "dependencies": {@@ -11,6 +11,10 @@     "uninstall": "./uninstall.sh"   },   "tags": [+    "ab",+    "bridge",+    "fusion",+    "desktop-app",     "fusion360",     "autodesk",     "cad",@@ -75,7 +79,10 @@     "build a basic parts library",     "add a 3d model to a component",     "make a fusion 360 library with 3d",-    "set up the fusion bridge"+    "set up the fusion bridge",+    "fusion dashboard",+    "fusion status page",+    "open the fusion launch page"   ],   "discovery_pitch": "Use the Adom Bridge Fusion 360 bridge when the user wants to DRIVE Autodesk Fusion 360 from the cloud: open/search their team's Fusion cloud designs, lay out a PCB or do electronics, export STEP/IGES/STL/Gerbers/BOM, or build a component library with real rendering 3D. It runs Fusion on the user's own machine via Adom Bridge. NOT for web/browser/login/form-filling (that's the native-browser extension) and NOT for KiCad (that's the kicad bridge).",   "brief": "Drive Autodesk Fusion 360 from the cloud via Adom Bridge: component libraries, IPC package generation, board layout, exports (STEP/Gerbers/BOM/CPL), fast APS cloud search, and parametric modeling.",@@ -104,9 +111,5 @@       "label": "Part volume",       "prompt": "How much material does each printed part in this assembly use?"     }-  ],-  "author": {-    "name": "John Lauer",-    "email": "[email protected]"-  }-}\ No newline at end of file+  ]+}
server.py+155−3
@@ -8414,8 +8414,21 @@ def _library_artifact_names(lbr_path: str) -> dict:         return {}     def names(tag):         return [e.get("name") for e in root.iter(tag) if e.get("name")]+    # Deviceset -> its gate symbols / device packages. A deviceset's symbol and footprint+    # are NOT required to share its name (the wiki's ABM8-16MHZ sits on symbol+package+    # "Crystal_GND24", SRN6045-100M on package "L"), so a prove step that EDITs+    # <deviceset>.sym must follow the gate, not guess the name (2026-08-29: three parts of+    # a ten-part library "not proven" for exactly this).+    dev_sym, dev_pac = {}, {}+    for ds in root.iter("deviceset"):+        dn = ds.get("name")+        if not dn:+            continue+        dev_sym[dn] = sorted({g.get("symbol") for g in ds.iter("gate") if g.get("symbol")})+        dev_pac[dn] = sorted({d.get("package") for d in ds.iter("device") if d.get("package")})     return {"sym": names("symbol"), "pac": names("package"), "dev": names("deviceset"),-            "p3d": [e.get("name") for e in root.iter("package3d") if e.get("name")]}+            "p3d": [e.get("name") for e in root.iter("package3d") if e.get("name")],+            "dev_sym": dev_sym, "dev_pac": dev_pac}   def _resolve_artifact_name(name: str, suffix: str, lbr_path: str):@@ -8432,7 +8445,16 @@ def _resolve_artifact_name(name: str, suffix: str, lbr_path: str):     Returns (resolved_name, None) or (None, error_dict). Exact match wins; then a unique     case-insensitive match, because EAGLE names round-trip through tools that change case.     """-    have = _library_artifact_names(lbr_path).get(suffix if suffix in ("sym", "pac", "dev") else "", [])+    _names = _library_artifact_names(lbr_path)+    have = _names.get(suffix if suffix in ("sym", "pac", "dev") else "", [])+    if have and name not in have and suffix in ("sym", "pac"):+        # The caller named the DEVICESET (the part); follow it to the one symbol / package+        # it actually uses. Ambiguous (multi-gate, multi-package) falls through to the+        # matching below and its honest error.+        via = (_names.get("dev_sym" if suffix == "sym" else "dev_pac") or {}).get(name) or []+        if len(via) == 1 and via[0] in have:+            print("[Fusion Bridge] resolved %s.%s via its deviceset -> %s" % (name, suffix, via[0]))+            return via[0], None     if not have:         # REFUSE, do not wave it through (reversed 2026-08-21, John caught the prompt         # live): "cannot read the library -> do not block" meant a corrupt .lbr let the@@ -11819,9 +11841,84 @@ def _lint_part_metadata(path: str) -> dict:                                         "they are not actually different parts - or one MPN "                                         "is wrong." % ", ".join(sorted(set(                                             m for m in named if named.count(m) > 1))[:3])})+    # GEOMETRY (John, 2026-08-29, the JST pad that would short and the crystal's stacked GND+    # pins): things an EE would reject on sight, reported before Fusion ever draws them.+    try:+        warns.extend(_lint_library_geometry(path))+    except Exception:+        pass     return {"ok": not warns, "warnings": warns, "totals": md["totals"]}  +def _lint_library_geometry(path: str) -> list:+    """Shape-aware geometry checks on a .lbr: pins stacked at one point, pad copper that+    overlaps or comes within 0.2 mm of a neighbour, generic class names/descriptions.+    Returns warning dicts in the same shape as the metadata lint."""+    import xml.etree.ElementTree as _ET+    import re as _re+    out = []+    root = _ET.parse((path or "").replace("\\", "/")).getroot()+    lib = root.find(".//library")+    if lib is None:+        return out+    # 1. symbol pins at identical coordinates+    for sym in lib.findall("./symbols/symbol"):+        seen = {}+        for pin in sym.findall("pin"):+            key = (round(float(pin.get("x") or 0), 3), round(float(pin.get("y") or 0), 3))+            if key in seen:+                out.append({"code": "symbol_pins_stacked", "symbol": sym.get("name"),+                            "detail": "pins %r and %r sit at the same point %s; one hides the other"+                                      % (seen[key], pin.get("name"), key)})+            seen[key] = pin.get("name")+        if not sym.findall("wire") and not sym.findall("rectangle") and not sym.findall("circle") \+                and not sym.findall("polygon") and len(sym.findall("pin")) <= 4:+            out.append({"code": "symbol_no_body", "symbol": sym.get("name"),+                        "detail": "symbol has pins but no body graphics (the converter dropped them)"})+    # 2. pad copper overlap / clearance, shape-aware+    def _box(el):+        x, y = float(el.get("x") or 0), float(el.get("y") or 0)+        if el.tag == "smd":+            dx, dy = float(el.get("dx") or 0), float(el.get("dy") or 0)+        else:+            dia = float(el.get("diameter") or 0) or (float(el.get("drill") or 0) * 1.5)+            shape = (el.get("shape") or "round").lower()+            dx, dy = (dia * 2, dia) if shape == "long" else (dia, dia)+        rot = str(el.get("rot") or "")+        if any(r in rot for r in ("R90", "R270")):+            dx, dy = dy, dx+        return (x - dx / 2, y - dy / 2, x + dx / 2, y + dy / 2)+    for pkg in lib.findall("./packages/package"):+        pads = pkg.findall("smd") + pkg.findall("pad")+        boxes = [(p.get("name"), _box(p)) for p in pads]+        for i in range(len(boxes)):+            for j in range(i + 1, len(boxes)):+                (na, a), (nb, b) = boxes[i], boxes[j]+                gap_x = max(a[0] - b[2], b[0] - a[2])+                gap_y = max(a[1] - b[3], b[1] - a[3])+                gap = max(gap_x, gap_y)+                if gap < 0:+                    out.append({"code": "pad_overlap", "package": pkg.get("name"),+                                "detail": "pads %r and %r overlap (copper intersects): a short on the board"+                                          % (na, nb)})+                elif gap < 0.2:+                    out.append({"code": "pad_clearance", "package": pkg.get("name"),+                                "detail": "pads %r and %r are only %.2f mm apart" % (na, nb, gap)})+    # 3. generic names / descriptions that tell an EE nothing+    _gen_name = _re.compile(r"^(L|R|C|D|LED|Q|Y|X|F|FB|J|Conn_\d+x\d+.*|Crystal(_\w+)?|Resistor|Capacitor|Inductor)$", _re.I)+    _gen_desc = _re.compile(r"^(inductor|resistor|capacitor|unpolarized capacitor|polarized capacitor|light emitting diode|led|diode|generic connector.*|four pin crystal.*|crystal)$", _re.I)+    for ds in lib.findall("./devicesets/deviceset"):+        n = ds.get("name") or ""+        d = (ds.findtext("description") or "").strip()+        if _gen_name.match(n):+            out.append({"code": "generic_deviceset_name", "deviceset": n,+                        "detail": "deviceset is named %r, a class name, not a part; the MPN belongs here" % n})+        if d and _gen_desc.match(d):+            out.append({"code": "generic_description", "deviceset": n,+                        "detail": "description %r says the class, not the part (value, package, maker)" % d})+    return out++ def _part_metadata_report(path: str) -> dict:     """A SHORT, user-facing metadata report an import can carry all the way up the chain. @@ -13116,6 +13213,11 @@ def _handle_install_library_bundle(fusion_info: dict, args: dict) -> dict:                "mint": "each part minted on its own single-deviceset library so the generator loads "                        "that part's footprint; STEP placed as published (orient:false, origin trusted)",                "unbound": pairing["unbound"], "modelsUnused": [os.path.basename(m) for m in pairing["modelsUnused"]],+               # Devicesets that SHARE an already paired package (symbol variants, value clones)+               # are not unbound: the package3d is per package and the bind pass writes the urn+               # into every device on it. Reported so a caller can tell "4 variants on one+               # footprint" from "4 parts with no model" (2026-08-29, CH340G-LR/SQ/LRC/CHIP).+               "shared": pairing.get("shared") or [],                "partMetadata": report, "fusion": plan.get("fusion"), "plan": plan.get("plan"),                "estimatedSec": plan.get("estimatedSec"),                "otherLbrsInBundle": [os.path.basename(x) for x in all_lbrs[1:]]}@@ -14702,10 +14804,60 @@ COMMAND_HANDLERS["uninstall"] = _handle_uninstall COMMAND_HANDLERS["demo_progress"] = lambda _info, args: _handle_demo_progress(args or {})  +def _arm_crash_log():+    """A death record that SURVIVES the respawn (2026-08-29: a ten-part import died at 39%+    with `bridge_restarted_mid_run`, and nothing explained why: ab truncates+    fusion360.log on every spawn, so the previous process's last words were gone, and+    fusion360.ad.log only lists spawns). This bridge now appends to its OWN+    ~/.adom/bridge-logs/fusion360.crash.log: a start line per process, every uncaught+    exception (main thread and worker threads), native faults via faulthandler, and a+    clean-exit line. A start line with neither a traceback nor a clean-exit line after it+    means the process was killed from outside (an ab reap, a kill, a logoff)."""+    try:+        import atexit+        import faulthandler+        d = os.path.join(os.path.expanduser("~"), ".adom", "bridge-logs")+        os.makedirs(d, exist_ok=True)+        path = os.path.join(d, "fusion360.crash.log")+        try:+            if os.path.getsize(path) > 2 * 1024 * 1024:+                with open(path, "rb") as f:+                    f.seek(-512 * 1024, os.SEEK_END)+                    tail = f.read()+                with open(path, "wb") as f:+                    f.write(tail)+        except Exception:+            pass+        fh = open(path, "a", buffering=1, encoding="utf-8", errors="replace")+        stamp = lambda: _t2.strftime("%Y-%m-%d %H:%M:%S")+        fh.write("[%s] START pid %s version %s argv %s\n" % (stamp(), os.getpid(), BRIDGE_VERSION, sys.argv[1:]))+        faulthandler.enable(file=fh, all_threads=True)++        def _hook(exc_type, exc, tb):+            fh.write("[%s] UNCAUGHT in main thread (pid %s):\n" % (stamp(), os.getpid()))+            traceback.print_exception(exc_type, exc, tb, file=fh)+            sys.__excepthook__(exc_type, exc, tb)+        sys.excepthook = _hook++        def _thook(args):+            fh.write("[%s] UNCAUGHT in thread %r (pid %s):\n" % (stamp(), getattr(args.thread, "name", "?"), os.getpid()))+            traceback.print_exception(args.exc_type, args.exc_value, args.exc_traceback, file=fh)+            try:+                traceback.print_exception(args.exc_type, args.exc_value, args.exc_traceback)+            except Exception:+                pass+        threading.excepthook = _thook+        atexit.register(lambda: fh.write("[%s] CLEAN EXIT pid %s\n" % (stamp(), os.getpid())))+    except Exception:+        traceback.print_exc()++ if __name__ == "__main__":     # Surface any fatal startup error to stderr (which ab captures into     # ~/.adom/bridge-logs/fusion360.log) so a spawn-crash is debuggable, then-    # re-raise for a non-zero exit.+    # re-raise for a non-zero exit. The crash log (fusion360.crash.log) is the record+    # that survives ab's per-spawn truncation of fusion360.log.+    _arm_crash_log()     try:         main()     except Exception: