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
Publish 1.9.246
4 files changed
+153−11
.release-page-head+1−1@@ -1 +1 @@-9a5ad34cbb1ed7b751dd42ba27a62d8daad77797+678adbedb0211aaf92dcfacc630d695a8c8e24eb
library_bundle.pyadded+144@@ -0,0 +1,144 @@+"""Cloud Library bundle -> parts[] for the fusion_install_library_bundle composite (#68).++Pure helpers, no Fusion, no bridge state: unpack a staged zip safely, find the .lbr and its+STEP models, place them under a stable library folder, and pair every deviceset with its+model by name. server.py wires the result into the existing create_library chain, so the+bundle lane runs the identical convert -> bind -> prove code path as every other import.++Bundle shapes accepted (a wrapping top-level folder is fine):+ <Lib>.lbr + <Part>.step ... (beside it, or under 3dmodels/ or models/)+ a bare .lbr staged directly (lbrPath) with its STEPs as siblings+"""+import os+import re+import shutil+import zipfile++MAX_ZIP_BYTES = 256 * 1024 * 1024+MAX_UNPACKED_BYTES = 512 * 1024 * 1024+_NAME_OK = re.compile(r"^[A-Za-z0-9][A-Za-z0-9 ._-]{0,63}$")+_MODEL_EXT = (".step", ".stp")+++def safe_extract(zip_path: str, dest: str) -> int:+ """Extract with a zip-slip guard and an unpacked-size cap. Returns files extracted."""+ total = count = 0+ dest = os.path.realpath(dest)+ with zipfile.ZipFile(zip_path) as zf:+ for info in zf.infolist():+ name = info.filename+ if not name or name.endswith("/"):+ continue+ norm = name.replace("\\", "/")+ if norm.startswith("/") or re.match(r"^[A-Za-z]:", norm) or ".." in norm.split("/"):+ raise ValueError("unsafe zip entry: %s" % name)+ total += info.file_size+ if total > MAX_UNPACKED_BYTES:+ raise ValueError("bundle unpacks past the %d MB cap" % (MAX_UNPACKED_BYTES // 2 ** 20))+ target = os.path.realpath(os.path.join(dest, norm))+ if not target.startswith(dest):+ raise ValueError("unsafe zip entry: %s" % name)+ os.makedirs(os.path.dirname(target), exist_ok=True)+ with zf.open(info) as src, open(target, "wb") as out:+ shutil.copyfileobj(src, out)+ count += 1+ return count+++def find_bundle_files(root: str):+ """The shallowest .lbr wins; models are every .step/.stp anywhere in the bundle."""+ lbrs, models = [], []+ for d, _dirs, files in os.walk(root):+ for f in files:+ p = os.path.join(d, f)+ if f.lower().endswith(".lbr"):+ lbrs.append(p)+ elif f.lower().endswith(_MODEL_EXT):+ models.append(p)+ lbrs.sort(key=lambda p: (p.count(os.sep), p))+ return (lbrs[0] if lbrs else None), sorted(models), lbrs+++def _stem(p: str) -> str:+ return os.path.splitext(os.path.basename(p))[0]+++def _norm(s: str) -> str:+ return re.sub(r"[^a-z0-9]", "", (s or "").lower())+++def pair_parts(lbr_path: str, models: list, metadata: dict) -> dict:+ """Pair every deviceset in the .lbr with a STEP by name.++ 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}.+ """+ devicesets = [d.get("name") or "" for d in (metadata.get("devicesets") or [])]+ packages = {}+ for d in metadata.get("devicesets") or []:+ pk = ""+ for v in d.get("variants") or []:+ if v.get("package"):+ pk = v["package"]+ break+ packages[d.get("name") or ""] = pk+ by_stem = {_stem(m): m for m in models}+ by_norm = {_norm(_stem(m)): m for m in models}+ parts, unbound, used = [], [], set()++ def _take(m, how, ds):+ used.add(m)+ parts.append({"package": ds, "lbrPath": lbr_path, "modelPath": m, "matchedBy": how})++ for ds in devicesets:+ pk = packages.get(ds) or ""+ cands = [(ds, "deviceset"), (pk, "package")] if pk else [(ds, "deviceset")]+ hit = None+ for name, how in cands:+ if name in by_stem and by_stem[name] not in used:+ hit = (by_stem[name], how); break+ if not hit:+ for name, how in cands:+ n = _norm(name)+ if n and n in by_norm and by_norm[n] not in used:+ hit = (by_norm[n], how + "-normalised"); break+ if not hit:+ for name, how in cands:+ n = _norm(name)+ for k, m in by_norm.items():+ if n and m not in used and (k.startswith(n) or n.startswith(k)) and len(n) >= 4:+ hit = (m, how + "-prefix"); break+ if hit:+ break+ if hit:+ _take(hit[0], hit[1], 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,+ "modelsUnused": [m for m in models if m not in used]}+++def library_name_ok(name: str) -> bool:+ return bool(_NAME_OK.match(name or ""))+++def place_library(lbr_path: str, models: list, dest_dir: str) -> dict:+ """Copy the .lbr and its models to the stable install folder; return the new paths."""+ os.makedirs(dest_dir, exist_ok=True)+ new_lbr = os.path.join(dest_dir, os.path.basename(lbr_path))+ shutil.copyfile(lbr_path, new_lbr)+ new_models = {}+ for m in models:+ t = os.path.join(dest_dir, os.path.basename(m))+ if os.path.realpath(m) != os.path.realpath(t):+ shutil.copyfile(m, t)+ new_models[m] = t+ return {"lbrPath": new_lbr.replace("\\", "/"),+ "models": {k: v.replace("\\", "/") for k, v in new_models.items()}}
page.json+7−10@@ -11,10 +11,6 @@ "uninstall": "./uninstall.sh" }, "tags": [- "ab",- "bridge",- "fusion",- "desktop-app", "fusion360", "autodesk", "cad",@@ -79,10 +75,7 @@ "build a basic parts library", "add a 3d model to a component", "make a fusion 360 library with 3d",- "set up the fusion bridge",- "fusion dashboard",- "fusion status page",- "open the fusion launch page"+ "set up the fusion bridge" ], "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.",@@ -111,5 +104,9 @@ "label": "Part volume", "prompt": "How much material does each printed part in this assembly use?" }- ]-}+ ],+ "author": {+ "name": "John Lauer",+ "email": "[email protected]"+ }+}
tools/release-files.txt+1@@ -1,6 +1,7 @@ BRIDGE_VERSION LICENSE ad_client.py+library_bundle.py addin/AdomBridge/AdomBridge.manifest addin/AdomBridge/AdomBridge.py addin/AdomBridge/commands/__init__.py