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.
master
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260
"""Definition identity, nested instance quantities, purchased collapse and CSV."""
import csv
from pathlib import Path
import tempfile
import time
import adsk.core
import adsk.fusion
from . import bom_views
from .assembly_state import metadata, native_properties
from .core import identity, items, occurrence_suppression, ref, require, target
from .exporting import publish_staged
from .configurations import provenance, snapshot as configuration_snapshot, source_link
def stock_length_mm(comp):
"""A linear part's cut length: the longest edge of its largest body's oriented minimum bounding
box (the axis-aligned box when Fusion has no oriented one), in mm."""
best = None
for body in items(comp.bRepBodies):
box = getattr(body, "orientedMinimumBoundingBox", None)
if box is not None:
extent = max(box.length, box.width, box.height)
else:
low, high = body.boundingBox.minPoint, body.boundingBox.maxPoint
extent = max(high.x - low.x, high.y - low.y, high.z - low.z)
best = extent if best is None else max(best, extent)
return round(best * 10, 2) if best is not None else None
def wire_length_mm(comp):
"""A wire's length: the volume of its longest body over the area of one of its two planar end caps,
exact for any constant section swept along any path (the oriented box undercounts a routed wire)."""
best = None
for body in items(comp.bRepBodies):
caps = [face for face in items(body.faces) if face.geometry.objectType == adsk.core.Plane.classType()]
if len(caps) == 2 and caps[0].area > 0 and abs(caps[0].area - caps[1].area) <= 0.01 * caps[0].area:
length = body.volume / caps[0].area
best = length if best is None else max(best, length)
return round(best * 10, 1) if best is not None else stock_length_mm(comp)
def generate(design, value, args, started=None, budget_seconds=None):
"""budget_seconds, counted from started: past it, part numbers and descriptions not read yet are left
for the next run (each is a cloud read of about 0.5 s on a cloud assembly: 280 of them took 282 s)."""
deadline = started + budget_seconds if started is not None and budget_seconds else None
pending = []
configuration = configuration_snapshot(design, strict=True)
table = configuration["table"]
columns = {column["id"]: column for column in table["columns"]} if table else {}
suppressed = [columns[cell["columnId"]]["source"] for cell in table["cells"]
if cell["rowId"] == table["activeRowId"] and cell["type"] == "ConfigurationSuppressCell"
and cell["isEffectivelySuppressed"]] if table else []
def suppression(occurrence):
if occurrence_suppression(occurrence) is True:
return "suppressed"
for source in suppressed:
if source["objectType"].endswith("::Occurrence"):
candidate = source_link(design, occurrence)
if candidate["entityId"] == source["entityId"] and (source["occurrenceId"] is None or candidate["occurrenceId"] == source["occurrenceId"]):
return "configuration_suppressed"
return None
parts, instances, excluded, hidden_counted = {}, [], [], []
physical = {"mass": 0.0, "volume": 0.0, "bodyInstances": 0}
accuracy = adsk.fusion.CalculationAccuracy.HighCalculationAccuracy
budget = {"visited": 0}
# what the walks read about a definition, read once per definition instead of once per instance
# (a 4,579-occurrence assembly has 1,249 definitions): every instance of a definition weighs the same
counts, physicals, metas, kits, refs = {}, {}, {}, {}, {}
def body_count(comp, key):
if key not in counts:
counts[key] = comp.bRepBodies.count
return counts[key]
def direct_physical(comp, key):
if key not in physicals:
props = [b.getPhysicalProperties(accuracy) for b in items(comp.bRepBodies)]
physicals[key] = (sum(p.mass for p in props), sum(p.volume * 1000 for p in props), len(props))
return physicals[key]
def visit(comp, depth, ancestry):
budget["visited"] += 1
require(depth <= 100 and budget["visited"] <= 20000, "bom_budget", "Assembly exceeds combined purchasing/physical traversal budget.")
key = identity(comp)
require(key not in ancestry, "cyclic_assembly", "Assembly contains cyclic component containment.")
return key
# Physical totals are independent of purchasing collapse. Count direct bodies
# once per physical instance, including root bodies and repeated children.
def physical_walk(comp, occurrence=None, depth=0, ancestry=()):
key = visit(comp, depth, ancestry)
mass, volume, bodies = direct_physical(comp, key)
physical["mass"] += mass
physical["volume"] += volume
physical["bodyInstances"] += bodies
children = items(occurrence.childOccurrences if occurrence else comp.occurrences)
for child in children:
if suppression(child) is None:
physical_walk(child.component, child, depth + 1, ancestry + (key,))
def walk(comp, occurrence=None, parent_path=None, depth=0, ancestry=(), hidden=False):
key = visit(comp, depth, ancestry)
path = occurrence.fullPathName if occurrence else "<root>"
reason = suppression(occurrence) if occurrence else None
if reason:
excluded.append({"path": path, "reason": reason})
return
if occurrence and bom_views.is_library(comp.name, body_count(comp, key)) and not args.get("includeLibraries", False):
# a palette of parts kept to copy from: counting it adds one of everything it holds
excluded.append({"path": path, "reason": "library_group"})
return
if key not in metas:
native = args.get("nativeProperties", True)
if native and deadline is not None and time.perf_counter() > deadline and native_properties(comp, read=False) is None:
native = False
pending.append(key)
metas[key] = metadata(comp, native)
if key in pending:
metas[key]["nativePending"] = True
data = dict(metas[key])
if data.get("excludeFromBom", False):
excluded.append({"path": path, "reason": "excludeFromBom"})
return
if occurrence and not hidden and not occurrence.isLightBulbOn:
hidden = True
if len(hidden_counted) < 50:
hidden_counted.append({"path": path, "name": comp.name})
children = items(occurrence.childOccurrences if occurrence else comp.occurrences)
# an imported vendor model, a circuit board or a modeled bought product is one bought line unless
# someone said otherwise: without this a 3,000-occurrence cell listed 90 resistors and every screw
# inside a machine's vendor model
explicit = "asPurchased" in data or data.get("makeBuy") == "make"
if not (explicit or not occurrence or not children or not args.get("inferKits", True)) and key not in kits:
kits[key] = bom_views.inferred_kit(comp.name, [child.component.name for child in children])
kit_reason = None if explicit or not occurrence or not children or not args.get("inferKits", True) else kits[key]
if kit_reason:
data = {**data, "kitReason": kit_reason}
purchased = (bool(data.get("asPurchased", False)) or bool(kit_reason)) and not args.get("explodePurchased", False)
if key not in refs:
refs[key] = ref(comp, value)
row = {"path": path, "parentPath": parent_path, "depth": depth, "componentId": key, "metadata": data,
"bodyCount": body_count(comp, key), "childCount": len(children), "asPurchased": purchased,
"componentRef": dict(refs[key]), "occurrenceRef": ref(occurrence, value) if occurrence else None}
instances.append(row)
if body_count(comp, key) or purchased:
if key not in parts:
props = comp.getPhysicalProperties(accuracy) if purchased else None
direct_mass, direct_volume, _ = (0.0, 0.0, 0) if purchased else direct_physical(comp, key)
kind = None if purchased else bom_views.category(data)
parts[key] = {"componentId": key, **data, "quantity": 0, "occurrencePaths": [],
"scope": "component_subtree" if purchased else "direct_component_bodies",
"bodyCount": body_count(comp, key),
"lengthMm": stock_length_mm(comp) if kind in bom_views.LINEAR else wire_length_mm(comp) if kind == "wire" else None,
"unitMass": props.mass if props else direct_mass,
"unitVolume": props.volume * 1000 if props else direct_volume}
parts[key]["quantity"] += 1
parts[key]["occurrencePaths"].append(path)
if not purchased:
for child in children:
walk(child.component, child, path, depth + 1, ancestry + (key,), hidden)
walk(design.rootComponent)
if args.get("physicalTotals", True):
physical_walk(design.rootComponent)
rows = list(parts.values())
# the views a buyer and a builder read: indented with item numbers, the purchasing list, the
# cut list (and a stock plan when a stock length is given), fasteners, totals and warnings
flat = bom_views.flat(rows, {r["componentId"]: r.get("lengthMm") for r in rows}, args.get("currency", "USD"))
cuts = bom_views.cut_list(flat)
warnings = bom_views.warnings(flat) + [
{"code": "hidden_counted", "component": h["name"], "message": "Hidden in the model but counted: set excludeFromBom if it is not part of the build."}
for h in hidden_counted]
views = {"indented": bom_views.indented(instances, rows), "flat": flat, "cutList": cuts,
"wireList": bom_views.wire_list(flat), "orderList": bom_views.order_list(flat),
"fasteners": bom_views.fastener_rollup(flat), "totals": bom_views.totals(flat), "warnings": warnings}
if args.get("stockLengthMm"):
views["stockPlan"] = bom_views.stock_plan(cuts, args["stockLengthMm"], args.get("kerfMm", 3.0))
return {"target": value, "complete": True, "configuration": provenance(configuration), **views,
"configurationSuppression": [cell for cell in table["cells"] if cell["rowId"] == table["activeRowId"] and cell["type"] == "ConfigurationSuppressCell"] if table else [],
"parts": rows, "instances": instances, "excluded": excluded, "hiddenCounted": hidden_counted,
"partCount": len(rows), "totalPurchasingInstances": sum(r["quantity"] for r in rows),
"physical": {**physical, "scope": "all unsuppressed direct-body instances; BOM exclusions do not remove physical mass",
"materialBasis": "Fusion assigned/default material; supplier provenance does not certify modeled material density"},
"units": {"mass": "kg", "volume": "mm3"}, "calculationAccuracy": "high",
"identityScope": "running add-in session; supplier metadata persists in native archives",
**({"nativePending": len(pending),
"nativeHint": "%d part numbers and descriptions were left unread (each is a cloud read of about 0.5 s): run bom "
"again and it continues from what this run read, or pass budgetSeconds to wait longer, or "
"nativeProperties:false to skip them." % len(pending)} if pending else {})}
def bom(app, args):
started = time.perf_counter()
_, design, value = target(app, args)
return generate(design, value, args, started, args.get("budgetSeconds", 40))
def bom_export(app, args):
started = time.perf_counter()
_, design, value = target(app, args, True, model_write=False)
destination = Path(args["filename"])
require(destination.is_absolute() and destination.suffix.lower() == ".csv" and not destination.is_symlink() and not destination.is_dir(), "invalid_destination", "BOM destination must be an absolute regular .csv path.")
require(args.get("overwrite", False) or not destination.exists(), "destination_exists", "Pass overwrite:true to replace an existing CSV.")
# journaled, but its first answer still has ab's 60 s, and Fusion stays busy while it reads: the same
# budget as bom, and a CSV written past it says how many part numbers it left blank (export again)
result = generate(design, value, args, started, args.get("budgetSeconds", 40))
destination.parent.mkdir(parents=True, exist_ok=True)
published = {}
try:
with tempfile.TemporaryDirectory(prefix=".adom-bom-", dir=str(destination.parent)) as folder:
staged = Path(folder) / "bom.csv"
mode = args.get("mode", "flat")
# flat: the purchasing list; indented: the structure with item numbers and quantities
# per parent; cutlist and fasteners: what to cut and what hardware to buy
keys = ["partNumber", "name", "description", "quantity", "makeBuy", "makeBuyReason", "category", "process", "finish",
"material", "stock", "lengthMm", "supplier", "supplierPartNumber", "supplierInferred", "sourceUrl",
"leadTimeDays", "unitCost", "currency", "extendedCost", "unitMassKg", "totalMassKg", "asPurchased",
"kitReason", "bodyCount", "notes", "componentId"]
rows = result["flat"]
if mode == "structured":
keys = ["path", "parentPath", "depth", "componentId", "name", "partNumber", "description", "quantity", "bodyCount", "childCount", "asPurchased", "supplier", "supplierPartNumber", "sourceUrl", "sha256"]
rows = [{**row["metadata"], **row, "quantity": 1} for row in result["instances"]]
elif mode == "indented":
keys = ["itemNumber", "level", "name", "partNumber", "description", "kind", "makeBuy", "category", "qtyPerParent", "totalQty", "componentId"]
rows = result["indented"]
elif mode == "cutlist":
keys = ["profile", "category", "lengthMm", "quantity", "components"]
rows = [{**c, "components": "; ".join(c["components"])} for c in result["cutList"]]
elif mode == "fasteners":
keys = ["spec", "quantity", "supplierPartNumber", "names"]
rows = [{**f, "names": "; ".join(f["names"])} for f in result["fasteners"]]
elif mode == "wires":
keys = ["wireId", "gauge", "color", "lengthMm", "quantity", "name"]
rows = [{**w, "gauge": g["gauge"], "color": g["color"]} for g in result["wireList"] for w in g["wires"]]
elif mode == "order":
keys = ["supplier", "orderNumber", "quantity", "unitCost", "currency", "extendedCost", "inferred", "lines", "names"]
rows = [{**o, "names": "; ".join(o["names"])} for o in result["orderList"]]
configuration = result["configuration"]
context = {"documentId": value["documentId"], "revision": value["revision"],
"configurationTableId": configuration["tableId"], "configurationRowId": configuration["rowId"],
"configurationRowName": configuration["rowName"]}
keys += list(context)
rows = [{**row, **context} for row in rows]
with staged.open("w", encoding="utf-8", newline="") as stream:
writer = csv.writer(stream)
writer.writerow(keys)
for part in rows:
values = [part.get(key, "") for key in keys]
writer.writerow(["'" + v if isinstance(v, str) and v[:1] in ("=", "+", "-", "@") else v for v in values])
published.update(publish_staged(staged, destination, args.get("overwrite", False)))
except Exception as exc:
if not published.get("published"):
raise
published["cleanupWarning"] = str(exc)
return {"target": value, "partCount": result["partCount"], "mode": args.get("mode", "flat"), "rowCount": len(rows),
**{k: result[k] for k in ("nativePending", "nativeHint") if k in result},
"totals": result["totals"], "warningCount": len(result["warnings"]),
"excludedCount": len(result["excluded"]),
"calculationAccuracy": result["calculationAccuracy"], "configuration": result["configuration"], **published}
OPERATIONS = {"bom": bom, "bom_export": bom_export}
WRITES = {"bom_export"}