← Commit history

Merge PR #22: Add fusion_assembly_bom and fusion_physical_properties verbs

John Lauer ·c9df75385d ·2mo ago ·parent dfd87ee
5 files changed +219−1
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.7.9\ No newline at end of file+1.7.10\ No newline at end of file
addin/AdomBridge/commands/__init__.py+4
@@ -50,6 +50,8 @@ from .manufacturing import ( ) from .silkscreen_capture import handle_take_silkscreen_screenshot from .modeling import handle_run_modeling_script+from .assembly_bom import handle_assembly_bom+from .physical_properties import handle_physical_properties  COMMAND_HANDLERS = {     # App state@@ -71,6 +73,8 @@ COMMAND_HANDLERS = {     "export_sat": handle_export_sat,     "export_skp": handle_export_skp,     "get_design_info": handle_get_design_info,+    "assembly_bom": handle_assembly_bom,+    "physical_properties": handle_physical_properties,     "get_parameters": handle_get_parameters,     "set_parameter": handle_set_parameter,     "take_screenshot": handle_take_screenshot,
addin/AdomBridge/commands/assembly_bom.pyadded+140
@@ -0,0 +1,140 @@+"""Structured, kit-aware mechanical assembly BOM (mirrors Fusion's Manage -> BOM).++The electronics `export_bom` only works on an open PCB board. This is its mechanical+counterpart: it walks the active Design assembly and returns a purchasing BOM that+respects the component hierarchy. It recurses through organizational subassemblies but+counts a physical part or a purchased kit/unit ONCE, so hardware modeled INSIDE a kit+(for example a "... with fasteners" bracket, or a bearing/pulley unit) is not+double-counted the way a flat allOccurrences walk would count it.+"""++import csv++import adsk.core+import adsk.fusion+++def _material_name(comp):+    try:+        if comp.material:+            return comp.material.name+    except Exception:+        pass+    try:+        if comp.bRepBodies.count > 0 and comp.bRepBodies.item(0).material:+            return comp.bRepBodies.item(0).material.name+    except Exception:+        pass+    return ""+++def _attr(comp, name):+    try:+        v = getattr(comp, name, "") or ""+        return v.strip()+    except Exception:+        return ""+++def handle_assembly_bom(app: adsk.core.Application, args: dict) -> dict:+    """Return a structured, kit-aware parts list for the active mechanical design.++    args:+      treatAsUnit  optional list of name substrings whose subassemblies are counted once+                   and NOT exploded (default: ["with fasteners"]).+      exclude      optional list of top-level component-name prefixes to skip entirely.+      includePhysicalProperties  optional bool -- add volume_cm3 / mass_kg per line.+      outputPath   optional str -- also write a CSV to this Windows path.+    """+    design = adsk.fusion.Design.cast(app.activeProduct)+    if not design:+        return {"success": False, "error": "No active Fusion Design.",+                "_hint": "Open a mechanical design in the Design workspace, then retry."}++    args = args or {}+    kit = [k.lower() for k in (args.get("treatAsUnit") or ["with fasteners", "with fastener"])]+    exclude = tuple(args.get("exclude") or [])+    include_pp = bool(args.get("includePhysicalProperties", False))++    def is_leaf(comp):+        n = comp.name.lower()+        if any(k in n for k in kit):+            return True+        return comp.bRepBodies.count > 0  # a physical part is a leaf++    parts = {}++    def walk(occs):+        for i in range(occs.count):+            occ = occs.item(i)+            comp = occ.component+            name = comp.name+            if any(name.startswith(e) for e in exclude):+                continue+            children = occ.childOccurrences+            has_children = children is not None and children.count > 0+            if is_leaf(comp):+                row = parts.get(name)+                if row is None:+                    row = {+                        "componentName": name,+                        "partNumber": _attr(comp, "partNumber"),+                        "description": _attr(comp, "description"),+                        "material": _material_name(comp),+                        "quantity": 1,+                        "bodies": comp.bRepBodies.count,+                    }+                    if include_pp:+                        try:+                            pp = comp.getPhysicalProperties(+                                adsk.fusion.CalculationAccuracy.LowCalculationAccuracy)+                            row["volume_cm3"] = round(pp.volume, 4)+                            row["mass_kg"] = round(pp.mass, 6)+                        except Exception:+                            row["volume_cm3"] = None+                            row["mass_kg"] = None+                    parts[name] = row+                else:+                    row["quantity"] += 1+            elif has_children:+                walk(children)++    walk(design.rootComponent.occurrences)+    rows = list(parts.values())++    doc_name = design.rootComponent.name+    try:+        if design.parentDocument:+            doc_name = design.parentDocument.name+    except Exception:+        pass++    out = {+        "success": True,+        "design": doc_name,+        "partCount": len(rows),+        "totalInstances": sum(r["quantity"] for r in rows),+        "treatAsUnit": kit,+        "parts": rows,+    }++    outpath = args.get("outputPath")+    if outpath:+        try:+            cols = ["Part Number", "Part Name", "Description", "Material", "Quantity"]+            if include_pp:+                cols += ["Volume cm3", "Mass kg"]+            with open(outpath, "w", newline="", encoding="utf-8") as f:+                w = csv.writer(f)+                w.writerow(cols)+                for r in rows:+                    line = [r["partNumber"], r["componentName"], r["description"],+                            r["material"], r["quantity"]]+                    if include_pp:+                        line += [r.get("volume_cm3"), r.get("mass_kg")]+                    w.writerow(line)+            out["outputPath"] = outpath+        except Exception as e:+            out["csvError"] = str(e)++    return out
addin/AdomBridge/commands/physical_properties.pyadded+64
@@ -0,0 +1,64 @@+"""Per-component physical properties (volume, mass, density, area, center of mass).++Fusion exposes physical properties only through the modeling API, so before this verb a+caller had to run getPhysicalProperties inside fusion_run_modeling_script. This verb reads+them directly, per component, so BOM/costing tools can price by volume or mass without a+custom script.+"""++import adsk.core+import adsk.fusion++_ACC = {+    "low": adsk.fusion.CalculationAccuracy.LowCalculationAccuracy,+    "medium": adsk.fusion.CalculationAccuracy.MediumCalculationAccuracy,+    "high": adsk.fusion.CalculationAccuracy.HighCalculationAccuracy,+    "veryhigh": adsk.fusion.CalculationAccuracy.VeryHighCalculationAccuracy,+}+++def handle_physical_properties(app: adsk.core.Application, args: dict) -> dict:+    """Return physical properties per unique component in the active design.++    args:+      names      optional list of component names to measure (default: all).+      accuracy   optional "low" | "medium" | "high" | "veryhigh" (default: low).+    """+    design = adsk.fusion.Design.cast(app.activeProduct)+    if not design:+        return {"success": False, "error": "No active Fusion Design.",+                "_hint": "Open a design in the Design workspace, then retry."}++    args = args or {}+    names = set(args.get("names") or [])+    acc = _ACC.get((args.get("accuracy") or "low").lower(),+                   adsk.fusion.CalculationAccuracy.LowCalculationAccuracy)++    out = {}+    seen = set()+    for occ in design.rootComponent.allOccurrences:+        comp = occ.component+        if comp.name in seen:+            continue+        if names and comp.name not in names:+            continue+        seen.add(comp.name)+        try:+            pp = comp.getPhysicalProperties(acc)+            entry = {+                "volume_cm3": round(pp.volume, 4),+                "mass_kg": round(pp.mass, 6),+                "density": round(pp.density, 6),+                "area_cm2": round(pp.area, 4),+            }+            try:+                com = pp.centerOfMass+                entry["center_of_mass"] = [round(com.x, 4), round(com.y, 4), round(com.z, 4)]+            except Exception:+                pass+            out[comp.name] = entry+        except Exception as e:+            out[comp.name] = {"error": str(e)[:80]}++    return {"success": True, "count": len(out), "accuracy": (args.get("accuracy") or "low"),+            "properties": out}
describe.py+10
@@ -135,6 +135,16 @@ _T = [      {"script": "required str (Python using adsk; globals: adsk, app, ui, design)"},      {"result": "obj", "documentName": "str"}, 180, None, True, {"script": "import adsk.fusion\n# ...build geometry..."}), +    ("fusion_assembly_bom", "Structured, kit-aware MECHANICAL bill of materials for the active Design assembly (the counterpart to the electronics-only fusion_export_bom). Recurses organizational subassemblies but counts a physical part or a purchased kit/unit ONCE, so hardware modeled inside a kit (a '... with fasteners' bracket, a bearing/pulley) is NOT double-counted the way a flat allOccurrences walk counts it. Matches Fusion's Manage -> BOM quantities. Optionally writes a CSV.",+     {"treatAsUnit": "optional [str] name substrings counted as one unit (default ['with fasteners'])", "exclude": "optional [str] top-level name prefixes to skip", "includePhysicalProperties": "optional bool (adds volume_cm3/mass_kg per line)", "outputPath": "optional str (Windows CSV path)"},+     {"success": "bool", "design": "str", "partCount": "int", "totalInstances": "int", "parts": "[{componentName, partNumber, description, material, quantity, bodies}]"},+     120, None, False, {"treatAsUnit": ["with fasteners"], "includePhysicalProperties": True}),++    ("fusion_physical_properties", "Per-component physical properties (volume cm3, mass kg, density, area, center of mass) for the active design. Reads them directly so BOM/costing tools no longer have to run getPhysicalProperties inside a modeling script. Pass names to scope to specific components.",+     {"names": "optional [str] component names to measure (default all)", "accuracy": "optional low|medium|high|veryhigh (default low)"},+     {"success": "bool", "count": "int", "properties": "{name: {volume_cm3, mass_kg, density, area_cm2, center_of_mass}}"},+     120, None, False, {"accuracy": "low"}),+     ("fusion_set_preference", "Set Fusion appearance/navigation preferences in the LIVE session - applies immediately, NO restart. Keys: theme (light/darkblue/darkgray/classic/device/dark; some builds only ship LightGray/DarkBlue/Device, failures reported per-key), invertScrollZoom (bool - mouse scroll/zoom direction), orbitScheme (fusion360/alias/inventor/solidworks/tinkercad/powermill), modelingOrientation (yup/zup), gestureNav (bool), cameraPivot (bool), lengthUnit (mm/cm/m/in/ft - active design). Per-key outcome in `applied`, resulting state in `current`.",      {"theme": "optional str", "invertScrollZoom": "optional bool", "orbitScheme": "optional str", "modelingOrientation": "optional str", "gestureNav": "optional bool", "cameraPivot": "optional bool", "lengthUnit": "optional str"},      {"applied": "obj", "current": "obj"}, 30, None, True, {"theme": "dark"}),