app
Fusion - the Fusion 360 Bridge
Public Made by Adomby adom
Drive Autodesk Fusion 360 from the cloud via Adom Bridge: component libraries, IPC package generation, board layout, exports (STEP/Gerbers/BOM/CPL), fast APS cloud search, and parametric modeling.
← Commit history
Update 102 file(s)
11 files changed
+194−26
BRIDGE_VERSION+1−1@@ -1 +1 @@-1.9.271+1.9.272
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]"- }-}+ ]+}
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: