← Commit history

Publish 1.9.246

John Lauer ·b47eed7696 ·1mo ago ·parent 678adbe
4 files changed +153−11
.release-page-head+1−1
@@ -1 +1 @@-9a5ad34cbb1ed7b751dd42ba27a62d8daad77797\ No newline at end of file+678adbedb0211aaf92dcfacc630d695a8c8e24eb\ No newline at end of file
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]"+  }+}\ No newline at end of file
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