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.
Mechanical BOM that reads a large real assembly right: views, make/buy reasons, kits, libraries, wires, cut and order lists #24
feature/bom-v2 → masterThe mechanical BOM, made to read a large real assembly right.
Why
On a 4,579-occurrence production cell (1,249 components, under NDA so not named here) the 1.13 BOM:
- listed 859 lines, 814 of them "make";
- counted a hidden parts library a second time;
- listed 90 resistors from inside circuit boards, and every screw inside the vendors' machine models.
A guarded operation on that cell took about 20 minutes, because identity lookups compared every pair and Fusion reads part number and description from the cloud (about 0.6 s per component).
What changes
Views beside the purchasing list:
indented: item numbers, quantity per parent, kits and phantoms;cutList, with a first-fit-decreasingstockPlanfor extrusion;wireList;orderList: bought lines merged by supplier part number;fasteners,totalsandwarnings.
bom_exportgains the modesindented,cutlist,fasteners,wiresandorder.Every line says why it is made or bought (
makeBuyReason). Metadata wins. Otherwise the name is read:- made here (fabricated, 3D printed, cut from);
- cut from stock (wire, extrusion, DIN rail, duct);
- a McMaster number, also filled in as the supplier part number and marked
supplierInferred; - a fastener, electronics, hardware or a cable;
- a vendor note such as "(vendor STEP)", or a maker's name.
totals.inferredLinessays how much was read rather than stated.Kits: an assembly that reads as a bought product, or whose children are a board's footprints, counts as one bought line (
inferKits).Libraries: a body-less group named as a library is excluded (
includeLibraries).Not ordered: stand-in geometry ("(stand-in from 71535K205)") and items supplied with something else are ordered by nobody.
Wire length is body volume over end-cap area, which is exact for a section swept along any path.
New fields on
component_createandcomponent_metadata:category,makeBuy,process,finish,unitCost,currency,leadTimeDays,stock,notesandrevision.Scale:
- the revision guard accepts components inserted by reference (they enter by source document version), and occurrences inside a reference no longer raise on
timelineObject; - identity lookups are indexed;
- cloud part-number and description reads are cached per component revision, and skipped with
nativeProperties:false; inspectgainsdetail:"target"andcomponentProperties.
- the revision guard accepts components inserted by reference (they enter by source document version), and occurrences inside a reference no longer raise on
Results on that cell
| 1.13 | this branch | |
|---|---|---|
| lines / total quantity | 859 / 3,020 | 269 / 962 |
| make / buy / ordered by nobody | 814 / 45 / 0 | 155 / 105 / 9 |
| kits | 0 | 25 |
| guarded operation | about 20 min | about 40 s |
Details:
- Wires: 114 wires, 92.42 m, the same as a direct geometry probe of every wire body.
- Cut list: exact for 4040 and 2020, now with the DIN rail and wire duct.
- Order list: 91 lines. For example, 10 separately modeled cable ties become one line, 7130K59 × 25.
Checks
- Python:
tests/test_mechanical_bom.pyhas 21 tests, one per rule. The whole mechanical suite passes (111 tests). - Rust:
tools/rust-ship.shis all green on master 1.13.15 plus this branch: Linux build, tests, Windows build, guards. - Goldens: regenerated with the research evidence folder, byte-identical in both trees.
- Live:
bom_checkon Fusion 2705 passed every check:- a hidden library excluded;
- a wire bent 90° at 50 mm measured as 78.5 mm (50·π/2);
- a vendor model counted as one $140 line;
- all seven export modes written and read back.
For the release
- The add-in's mechanical package changed: bump the add-in version so Fusion loads it.
schema.jsonchanged, and the exe embeds it.docs/MECHANICAL.mddescribes the views and the name rules.
🤖 Generated with Claude Code
Diff Skip to comments
CHANGELOG.md+27@@ -3,6 +3,33 @@ All notable changes to the Adom Bridge Fusion 360 bridge, newest first. Canonical: https://wiki.adom.inc/adom/fusion-bridge · GitHub backup: `adom-inc/fusion-bridge` +## 1.13.16 (unreleased)++The mechanical BOM reads a large real assembly right, and the revision guard accepts assemblies built from referenced designs.++- BOM views beside the purchasing list: `indented` (item numbers, quantity per parent, kits and phantoms), `cutList` with a+ first-fit-decreasing `stockPlan` for extrusion, `wireList`, `orderList` (bought lines merged by supplier part number),+ `fasteners`, `totals` and `warnings`. `bom_export` gains the modes `indented`, `cutlist`, `fasteners`, `wires` and `order`.+- Every line says why it is made or bought (`makeBuyReason`). Metadata wins; otherwise the name is read: made here+ (fabricated, 3D printed, cut from), cut from stock (wire, extrusion, DIN rail, duct), a McMaster number (also filled in as+ the supplier part number, marked `supplierInferred`), a fastener, electronics, hardware or a cable, a vendor note such as+ "(vendor STEP)", or a maker's name. `totals.inferredLines` counts what was read rather than stated.+- An assembly that reads as a bought product (an imported .stp or STEP-translator model, a vendor note, a maker, a McMaster+ number) or whose children are a circuit board's footprints counts as one bought line (`inferKits`). A body-less group+ named as a library is excluded (`includeLibraries`). Stand-in geometry and items supplied with something else are+ ordered by nobody.+- Wires are measured as body volume over end-cap area, exact for a section swept along any path (the box undercounts a+ routed wire).+- `component_create` and `component_metadata` take `category`, `makeBuy`, `process`, `finish`, `unitCost`, `currency`,+ `leadTimeDays`, `stock`, `notes` and `revision`.+- Scale, measured on a 4,579-occurrence cell: the revision guard accepts components inserted by reference (they enter by+ source document version), identity lookups are indexed instead of compared pairwise, and Fusion's cloud part number and+ description reads (about 0.6 s per component) are cached per component revision or skipped with `nativeProperties:false`.+ `inspect` gains `detail:"target"` and `componentProperties`. A guarded operation on that cell went from about 20 minutes+ to about 40 s.+- For the release: the add-in's mechanical package changed (bump the add-in version so Fusion loads it) and so did+ `schema.json`, which the exe embeds.+ ## 1.13.15 (2026-10-29) - `fusion_not_supported`: the standard verbs that belong to Altium's own machinery (Altium 365, its script agent and
addin/AdomBridge/commands/mechanical/assembly_state.py+26−5@@ -15,11 +15,31 @@ def attributes(entity): return {attr.name: attr.value for attr in items(attrs) if attr.groupName == GROUP} if attrs else {} -def metadata(component):+# Component.partNumber and .description are cloud reads on a cloud document: about 0.6 s each per+# component (measured 2026-10-28), so a 1,006-component cell took 21 minutes just+# to read them. They are cached per component and revisionId (which changes when either does).+_native_properties = {}+++def native_properties(component):+ """(partNumber, description), read once per component revision this session."""+ from .core import identity+ key = (identity(component), str(component.revisionId))+ if key not in _native_properties:+ if len(_native_properties) > 50000:+ _native_properties.clear()+ _native_properties[key] = (getattr(component, "partNumber", ""), getattr(component, "description", ""))+ return _native_properties[key]+++def metadata(component, native=True):+ """A component's BOM and supplier metadata. native=False skips the two slow cloud reads (part+ number and description come back None): the revision witness relies on revisionId for them.""" raw = attributes(component)- result = {"name": component.name, "partNumber": getattr(component, "partNumber", ""),- "description": getattr(component, "description", ""), "attributes": raw}- for name in ("supplier", "supplierPartNumber", "sourceUrl", "sourceFile", "sha256", "asPurchased", "excludeFromBom"):+ part_number, description = native_properties(component) if native else (None, None)+ result = {"name": component.name, "partNumber": part_number, "description": description, "attributes": raw}+ for name in ("supplier", "supplierPartNumber", "sourceUrl", "sourceFile", "sha256", "asPurchased", "excludeFromBom",+ "category", "makeBuy", "process", "finish", "unitCost", "currency", "leadTimeDays", "stock", "notes", "revision"): if name in raw: result[name] = json.loads(raw[name]) material = getattr(component, "material", None)@@ -91,7 +111,8 @@ def rigid_group_data(group, value=None): def component_state(comp):- result = metadata(comp)+ # revisionId (in the witness) changes when the part number or description does: no cloud reads+ result = metadata(comp, native=False) result["joints"] = [joint_data(joint) for name in ("joints", "asBuiltJoints") for joint in items(getattr(comp, name))] if hasattr(comp, "joints") else [] result["rigidGroups"] = [rigid_group_data(group) for group in items(comp.rigidGroups)] if hasattr(comp, "rigidGroups") else []
addin/AdomBridge/commands/mechanical/bill_of_materials.py+96−12@@ -2,14 +2,43 @@ import csv from pathlib import Path import tempfile+import adsk.core import adsk.fusion +from . import bom_views from .assembly_state import metadata 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): configuration = configuration_snapshot(design, strict=True) table = configuration["table"]@@ -26,7 +55,7 @@ def generate(design, value, args): if candidate["entityId"] == source["entityId"] and (source["occurrenceId"] is None or candidate["occurrenceId"] == source["occurrenceId"]): return "configuration_suppressed" return None- parts, instances, excluded = {}, [], []+ parts, instances, excluded, hidden_counted = {}, [], [], [] physical = {"mass": 0.0, "volume": 0.0, "bodyInstances": 0} accuracy = adsk.fusion.CalculationAccuracy.HighCalculationAccuracy budget = {"visited": 0}@@ -51,19 +80,35 @@ def generate(design, value, args): 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=()):+ 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>"- data = metadata(comp) 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, comp.bRepBodies.count) 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+ data = metadata(comp, args.get("nativeProperties", True)) 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)- purchased = bool(data.get("asPurchased", False)) and not args.get("explodePurchased", False)+ # 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"+ kit_reason = None if explicit or not occurrence or not children or not args.get("inferKits", True) else \+ bom_views.inferred_kit(comp.name, [child.component.name for child in children])+ if kit_reason:+ data = {**data, "kitReason": kit_reason}+ purchased = (bool(data.get("asPurchased", False)) or bool(kit_reason)) and not args.get("explodePurchased", False) row = {"path": path, "parentPath": parent_path, "depth": depth, "componentId": key, "metadata": data, "bodyCount": comp.bRepBodies.count, "childCount": len(children), "asPurchased": purchased, "componentRef": ref(comp, value), "occurrenceRef": ref(occurrence, value) if occurrence else None}@@ -72,22 +117,38 @@ def generate(design, value, args): if key not in parts: props = comp.getPhysicalProperties(accuracy) if purchased else None direct_properties = [] if purchased else [b.getPhysicalProperties(accuracy) for b in items(comp.bRepBodies)]+ 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": comp.bRepBodies.count,+ "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 sum(p.mass for p in direct_properties), "unitVolume": props.volume * 1000 if props else sum(p.volume * 1000 for p in direct_properties)} 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,))+ walk(child.component, child, path, depth + 1, ancestry + (key,), hidden) walk(design.rootComponent)- physical_walk(design.rootComponent)+ if args.get("physicalTotals", True):+ physical_walk(design.rootComponent) rows = list(parts.values())- return {"target": value, "complete": True, "configuration": provenance(configuration),+ # 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,+ "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"},@@ -111,11 +172,32 @@ def bom_export(app, args): try: with tempfile.TemporaryDirectory(prefix=".adom-bom-", dir=str(destination.parent)) as folder: staged = Path(folder) / "bom.csv"- keys = ["componentId", "partNumber", "name", "description", "quantity", "supplier", "supplierPartNumber", "sourceUrl", "sha256", "unitMass", "unitVolume", "scope"]- rows = result["parts"]- if args.get("mode", "flat") == "structured":+ 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"],@@ -133,7 +215,9 @@ def bom_export(app, args): if not published.get("published"): raise published["cleanupWarning"] = str(exc)- return {"target": value, "partCount": result["partCount"], "mode": args.get("mode", "flat"),+ return {"target": value, "partCount": result["partCount"], "mode": args.get("mode", "flat"), "rowCount": len(rows),+ "totals": result["totals"], "warningCount": len(result["warnings"]),+ "excludedCount": len(result["excluded"]), "calculationAccuracy": result["calculationAccuracy"], "configuration": result["configuration"], **published}
addin/AdomBridge/commands/mechanical/bom_views.pyadded+419@@ -0,0 +1,419 @@+"""The BOM's views, from the walk's plain rows: the indented structure with item numbers, the flat+purchasing list, the cut list, the fastener rollup, totals and data warnings. Pure functions, so+every rule here is tested offline; bill_of_materials.generate feeds them."""+import re+++def words(pattern):+ """A case-blind pattern bounded by anything but a letter, so BHCS_M5x8 and L_Bracket still read."""+ return re.compile(r"(?<![A-Za-z])(?:%s)(?![A-Za-z])" % pattern, re.I)+++FASTENER = re.compile(words(r"(screw|bolt|nut|washer|bhcs|shcs|fhcs|socket head|button head|flat head|t-?nut|standoff|rivet|"+ r"insert|set screw|dowel|pin|fastener)s?").pattern + r"|(?<![A-Za-z])m\d+(?:[._]\d+)?\s*x\s*\d+", re.I)+# a circuit board's own parts, as KiCad and Fusion name footprints and reference designators+FOOTPRINT = re.compile(r"^(R|C|L|D|CP|FB|LED)_\d{4}|_\d{4}_\d{4}metric|^(sot|sod|soic|ssop|tssop|msop|qfn|dfn|qfp|lqfp|tqfp|bga|son)[-_]?\d"+ r"|^d_(sod|sma|smb|smc|do)|^cp_elec", re.I)+REFDES = re.compile(r"^(R|C|L|D|U|Q|J|P|Y|F|FB|LED|SW|TP|K|T|X)\d{1,4}$")+BOARD = re.compile(r"(?<![A-Za-z])pcba?(?![A-Za-z])|^board$", re.I)+BRACKET = words(r"(bracket|bkt|gusset|brace|corner connector|joining plate)s?")+# a McMaster-Carr catalog number (92095A208, 4259N11, 7950K164); no X, so 2160X1440 stays a size+MCMASTER = re.compile(r"(?<![A-Za-z0-9])(\d{4,5}[A-WYZ]\d{1,3})(?![A-Za-z0-9])")+IMPORTED = re.compile(r"\.(f3d|step|stp|iges|igs|sat|x_t)\s*$|\bstep translator\b", re.I)+# what a name says about where a part comes from: a note like "(vendor STEP)" or "(modelled)", a maker, or made here+VENDOR_NOTE = re.compile(r"\([^()]*\b(vendor|oem|cots|as installed|as purchased|genuine|step|stl|modell?ed|no cad)\b[^()]*\)?", re.I)+# geometry borrowed to stand in for part of something bought elsewhere ("(stand-in from 71535K205)"): never ordered by itself+BORROWED = re.compile(r"(?<![A-Za-z])(stand-?in|placeholder)(?![A-Za-z])|(?<!cut )(?<![A-Za-z])from\s+\d{4,5}[A-WYZ]\d{1,3}(?![A-Za-z0-9])", re.I)+# comes in the box with something else that is bought ("power cord, supplied with the machine"); matched even+# when a long name was cut short mid-word ("(supplie")+SUPPLIED = re.compile(r"(?<![A-Za-z])(supplied|included|comes) with(?![A-Za-z])|\(suppli", re.I)+# reasons for a line nobody orders or makes by itself+NOT_ORDERED = ("stand-in", "supplied")+MAKER = re.compile(words(r"abb|adafruit|allen[- ]bradley|amphenol|automationdirect|banner|bosch rexroth|bussmann|eaton|festo|"+ r"hammond|hiwin|idec|igus|keyence|littelfuse|mean ?well|misumi|molex|omron|orange pi|panduit|phidgets|"+ r"phoenix contact|raspberry pi|schneider|shelly|siemens|smc|sparkfun|stride|te connectivity|thk|wago|"+ r"waveshare|weidm[uü]ller").pattern + r"|\b80/20\b", re.I)+ACCESSORY = words(r"(mount|bracket|adapter|plate|holder|clip|cover|shelf|spacer|stand|tray|guard|cradle|shim)s?")+FABRICATED = words(r"fabricated|machined|3dp|3d[-_ ]?printed|printed|laser[-_ ]?cut|water[-_ ]?jet|cut from|weldment")+# a hidden-away palette of parts to copy from ("Vendor library"), not part of the build+LIBRARY = re.compile(r"\b(library|libraries)\b", re.I)+GAUGE = re.compile(r"(?<![0-9])(\d{1,2}(?:-\d{1,2})?)\s*AWG(?![A-Za-z])", re.I)+WIRING_HARDWARE = words(r"(ferrule|terminal|lug|crimp|connector|grommet|clamp|tie|splice|heat ?shrink|sleeve|gland|plug)s?")+COLOR = words(r"green[-/]yellow|black|white|red|green|blue|yellow|orange|brown|gr[ae]y|violet|purple|pink|clear")+WIRE_ID = re.compile(r"^(W\d+)(?![0-9A-Za-z])")+SHORTEST_CUT_MM = 25.0+MANY_BODIES = 20+EXTRUSION = re.compile(r"(?<![0-9A-Za-z])(1010|1515|2020|2040|2060|2080|3030|3060|4040|4080|8020)(?![0-9A-Za-z])|extrusion|\bprofile\b", re.I)+PROFILE = re.compile(r"(?<![0-9A-Za-z])(1010|1515|2020|2040|2060|2080|3030|3060|4040|4080)(?![0-9A-Za-z])")+# other stock that is bought by the stick and cut to length, so it goes on the cut list too+DIN_RAIL = words(r"din[- ]?rails?")+DUCT = words(r"ducts?")+SECTION = re.compile(r"(\d+(?:\.\d+)?)\s*x\s*(\d+(?:\.\d+)?)", re.I)+LINEAR = ("extrusion", "din rail", "duct")+# bought cable assemblies (a cord, a patch cable); a wire cut from a spool names its gauge and is "wire"+CABLE = words(r"(wire|cable|harness|ferrule|cord)s?")+DEFAULT_NAME = re.compile(r"^(component|body|extruded|extrude|untitled)[_ ]?\d*$", re.I)+# Fusion fills a new component's part number with its creation time (2026-10-28-13-58-44-431)+AUTO_PART_NUMBER = re.compile(r"^\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}-\d+$")+# reasons that come from someone's metadata rather than from reading the name+STATED = ("metadata", "marked purchased", "supplier set")+++def part_number(meta):+ """The part number a buyer can use: empty when Fusion's automatic timestamp or the name stands in."""+ pn = (meta.get("partNumber") or "").strip()+ return "" if not pn or AUTO_PART_NUMBER.match(pn) or pn == (meta.get("name") or "") else pn+++def category(meta):+ """The metadata category, else one inferred from the name. A cut wire names its gauge; hardware wins+ over a profile size in the name (a "T Nut Drop In 2020 M5" is a fastener, a "2020 ... Bkt" hardware)."""+ if meta.get("category"):+ return meta["category"]+ name = meta.get("name") or ""+ if GAUGE.search(name) and (WIRE_ID.match(name) or not WIRING_HARDWARE.search(name)):+ return "wire"+ if FASTENER.search(name):+ return "fastener"+ if FOOTPRINT.search(name) or BOARD.search(name):+ return "electronics"+ if BRACKET.search(name):+ return "hardware"+ if DIN_RAIL.search(name):+ return "din rail"+ if DUCT.search(name):+ return "duct"+ if EXTRUSION.search(name):+ return "extrusion"+ if CABLE.search(name):+ return "cable"+ return None+++def inferred_supplier(meta):+ """(supplier, supplier part number): the metadata's, else a McMaster-Carr number in the name or part number."""+ if meta.get("supplierPartNumber"):+ return meta.get("supplier"), meta["supplierPartNumber"]+ supplier = meta.get("supplier")+ if supplier and "mcmaster" not in supplier.lower():+ return supplier, None+ match = MCMASTER.search(meta.get("name") or "") or MCMASTER.search(part_number(meta))+ return ("McMaster-Carr", match.group(1)) if match else (supplier, None)+++def vendor_signal(name):+ """Why a name reads as a bought product, or None: a McMaster number, a vendor note, a maker's name+ (not on an accessory such as "Orange Pi Mount"). A name that says it is made here never reads as bought."""+ name = name or ""+ if FABRICATED.search(name):+ return None+ if BORROWED.search(name):+ return "stand-in geometry"+ if SUPPLIED.search(name):+ return "supplied with another item"+ match = MCMASTER.search(name)+ if match:+ return "McMaster-Carr " + match.group(1) + " in the name"+ note = VENDOR_NOTE.search(name)+ if note:+ return "vendor note " + note.group(0)+ maker = MAKER.search(name)+ if maker and not ACCESSORY.search(name):+ return "maker named: " + maker.group(0)+ return None+++def inferred_kit(name, child_names):+ """Why an assembly should count as one bought line though nobody marked it purchased: an imported+ model (a name ending .stp or Open CASCADE's "STEP translator"), a circuit board (children named like+ footprints and reference designators), or a name that reads as a bought product. None otherwise."""+ name = name or ""+ if FABRICATED.search(name):+ return None+ if IMPORTED.search(name):+ return "imported model"+ board = sum(1 for child in child_names if FOOTPRINT.search(child or "") or REFDES.match(child or "") or BOARD.search(child or ""))+ if board >= 5 or (board >= 3 and any(BOARD.search(child or "") for child in child_names)):+ return "circuit board"+ return vendor_signal(name)+++def is_library(name, body_count):+ """A body-less group named as a library: a palette of parts to copy from, not part of the build."""+ return not body_count and bool(LIBRARY.search(name or ""))+++def make_buy_reason(meta, has_bodies):+ """(make, buy or None; why). Metadata wins, then a name that says it is made here, then cut stock+ (wire, extrusion), then what the name says about a supplier or maker, then modeled bodies."""+ if meta.get("makeBuy") in ("make", "buy"):+ return meta["makeBuy"], "metadata"+ if meta.get("asPurchased"):+ return "buy", "marked purchased"+ if meta.get("kitReason"):+ return (None if meta["kitReason"].startswith(NOT_ORDERED) else "buy"), meta["kitReason"]+ if meta.get("supplierPartNumber") or meta.get("supplier"):+ return "buy", "supplier set"+ name = meta.get("name") or ""+ if FABRICATED.search(name):+ return "make", "named as made here"+ if BORROWED.search(name):+ return None, "stand-in geometry"+ if SUPPLIED.search(name):+ return None, "supplied with another item"+ kind = category(meta)+ if kind == "wire" or kind in LINEAR:+ return "make", "cut from %s stock" % kind+ if inferred_supplier(meta)[1]:+ return "buy", "McMaster-Carr number in the name"+ if kind in ("fastener", "electronics", "hardware", "cable"):+ return "buy", kind+ signal = vendor_signal(name)+ if signal:+ return "buy", signal+ return ("make", "modeled bodies") if has_bodies else (None, None)+++def make_buy(meta, has_bodies):+ return make_buy_reason(meta, has_bodies)[0]+++def profile(meta):+ """The stock profile of a linear part: metadata stock, else read from the name (2020, 4040, DIN rail+ 35x7.5, duct 25x40 cover)."""+ if meta.get("stock"):+ return meta["stock"]+ name = meta.get("name") or ""+ kind = category(meta)+ if kind in ("din rail", "duct"):+ section = SECTION.search(name)+ label = kind if kind == "duct" else "DIN rail"+ if section:+ label += " %sx%s" % (section.group(1), section.group(2))+ return label + (" cover" if kind == "duct" and ACCESSORY.search(name) else "")+ match = PROFILE.search(name)+ return match.group(1) if match else None+++def wire_spec(name):+ """(wire id, gauge, color) read from a wire's name ("W001 1L1 12AWG black XD1:L-Q1:1")."""+ name = name or ""+ gauge = GAUGE.search(name)+ color = COLOR.search(name, gauge.end()) if gauge else None+ wire_id = WIRE_ID.match(name)+ return (wire_id.group(1) if wire_id else None, gauge.group(1) if gauge else None,+ color.group(0).lower().replace("grey", "gray") if color else None)+++def indented(instances, parts):+ """Tree order, one line per distinct child component under each parent instance, with item+ numbers (1, 1.2, 1.2.1), quantity per parent and the total rolled up through every level.+ Lines repeat per parent instance only when the parents differ; identical siblings merge."""+ children = {}+ for row in instances:+ children.setdefault(row["parentPath"], []).append(row)+ lines = []++ def emit(parent_path, prefix, level, multiplier):+ groups, order = {}, []+ for row in children.get(parent_path, []):+ key = row["componentId"]+ if key not in groups:+ groups[key] = []+ order.append(key)+ groups[key].append(row)+ for index, key in enumerate(order, 1):+ rows = groups[key]+ first, number = rows[0], (prefix + "." if prefix else "") + str(index)+ meta = first["metadata"]+ has_bodies = first["bodyCount"] > 0+ # a body-less grouping is a phantom (counted through, never stocked) unless someone gave it+ # a real part number, a make/buy or the assembly category+ kind = ("kit" if first["asPurchased"] else "part" if has_bodies and not first["childCount"]+ else "assembly" if has_bodies or part_number(meta) or meta.get("makeBuy") or meta.get("category") == "assembly"+ else "phantom")+ per_parent = len(rows)+ decision, reason = make_buy_reason(meta, has_bodies)+ lines.append({"itemNumber": number, "level": level, "componentId": key, "name": meta.get("name"),+ "partNumber": part_number(meta), "description": meta.get("description") or "",+ "kind": kind, "makeBuy": decision, "makeBuyReason": reason, "category": category(meta),+ "qtyPerParent": per_parent, "totalQty": per_parent * multiplier,+ "paths": [r["path"] for r in rows][:10], "pathCount": len(rows)})+ if not first["asPurchased"]:+ # every instance of this child has the same subtree; recurse through the first, scaled+ emit(first["path"], number, level + 1, per_parent * multiplier)++ emit("<root>", "", 1, 1)+ return lines+++def flat(parts, lengths=None, currency="USD"):+ """The purchasing list: one line per component definition with total quantity, make/buy,+ category, cost and mass (extended by quantity)."""+ lengths = lengths or {}+ lines = []+ for part in parts:+ qty = part["quantity"]+ cost = part.get("unitCost")+ decision, reason = make_buy_reason(part, part.get("scope") == "direct_component_bodies")+ supplier, supplier_pn = inferred_supplier(part)+ stock = profile(part)+ if decision is None and not part.get("supplierPartNumber"):+ supplier, supplier_pn = None, None+ if decision == "make" and supplier_pn and not part.get("supplierPartNumber"):+ # "Bench top HDPE (cut from 8619K87)": the catalog number is the stock it is made from+ stock, supplier, supplier_pn = stock or supplier_pn, None, None+ lines.append({"componentId": part["componentId"], "name": part.get("name"), "partNumber": part_number(part),+ "description": part.get("description") or "", "quantity": qty,+ "makeBuy": decision, "makeBuyReason": reason,+ "category": category(part), "process": part.get("process"), "finish": part.get("finish"),+ "material": (part.get("material") or {}).get("name"), "stock": stock,+ "lengthMm": lengths.get(part["componentId"]), "bodyCount": part.get("bodyCount"),+ "supplier": supplier, "supplierPartNumber": supplier_pn,+ "supplierInferred": bool(supplier_pn) and not part.get("supplierPartNumber"),+ "kitReason": part.get("kitReason"),+ "sourceUrl": part.get("sourceUrl"), "leadTimeDays": part.get("leadTimeDays"),+ "unitCost": cost, "currency": part.get("currency") or (currency if cost is not None else None),+ "extendedCost": round(cost * qty, 2) if isinstance(cost, (int, float)) else None,+ "unitMassKg": part.get("unitMass"), "totalMassKg": (part.get("unitMass") or 0) * qty,+ "asPurchased": bool(part.get("asPurchased") or (part.get("kitReason") and decision == "buy")),+ "notes": part.get("notes")})+ return lines+++def cut_list(lines, round_to=0.5):+ """Extrusions grouped by profile and cut length (mm, rounded to round_to), most first."""+ groups = {}+ for line in lines:+ if line["category"] not in LINEAR or not line.get("lengthMm") or line["lengthMm"] < SHORTEST_CUT_MM:+ continue+ length = round(line["lengthMm"] / round_to) * round_to+ key = (line.get("stock") or "unknown profile", length)+ entry = groups.setdefault(key, {"profile": key[0], "category": line["category"], "lengthMm": length, "quantity": 0, "components": []})+ entry["quantity"] += line["quantity"]+ entry["components"].append(line["name"])+ return sorted(groups.values(), key=lambda g: (g["profile"], -g["lengthMm"]))+++def stock_plan(cuts, stock_length, kerf=3.0):+ """Bars of stock_length per extrusion profile for the cut list, first-fit decreasing with a kerf per+ cut. A cut longer than the stock is reported, never silently dropped. DIN rail and duct come in+ other stick lengths, so they stay on the cut list without a bar plan."""+ plans = {}+ for profile_name in sorted({c["profile"] for c in cuts if c.get("category", "extrusion") == "extrusion"}):+ pieces = sorted((c["lengthMm"] for c in cuts if c["profile"] == profile_name for _ in range(c["quantity"])), reverse=True)+ bars, too_long = [], [p for p in pieces if p > stock_length]+ for piece in (p for p in pieces if p <= stock_length):+ for bar in bars:+ if bar["used"] + piece + (kerf if bar["cuts"] else 0) <= stock_length:+ bar["used"] += piece + (kerf if bar["cuts"] else 0)+ bar["cuts"].append(piece)+ break+ else:+ bars.append({"used": piece, "cuts": [piece]})+ plans[profile_name] = {"stockLengthMm": stock_length, "kerfMm": kerf, "bars": len(bars),+ "offcutsMm": [round(stock_length - b["used"], 1) for b in bars],+ "cutsPerBar": [b["cuts"] for b in bars], "tooLong": too_long}+ return plans+++def wire_list(lines):+ """Cut wires grouped by gauge and color, with each wire's length and the total to buy, thickest first."""+ groups = {}+ for line in lines:+ if line["category"] != "wire":+ continue+ wire_id, gauge, color = wire_spec(line["name"])+ entry = groups.setdefault((gauge, color), {"gauge": gauge, "color": color, "count": 0, "totalLengthMm": 0.0, "wires": []})+ entry["count"] += line["quantity"]+ if line.get("lengthMm"):+ entry["totalLengthMm"] = round(entry["totalLengthMm"] + line["lengthMm"] * line["quantity"], 1)+ entry["wires"].append({"wireId": wire_id, "name": line["name"], "lengthMm": line.get("lengthMm"), "quantity": line["quantity"]})+ def thickness(entry):+ try:+ return int((entry["gauge"] or "99").split("-")[0])+ except ValueError:+ return 99+ return sorted(groups.values(), key=lambda g: (thickness(g), g["color"] or ""))+++def order_list(lines):+ """What to order: bought lines merged by supplier part number (else part number, else name), so 17+ cable ties modeled as 17 components become one line of 17."""+ groups = {}+ for line in lines:+ if line["makeBuy"] != "buy":+ continue+ if line["supplierPartNumber"]:+ key = (line["supplier"] or "", line["supplierPartNumber"])+ else:+ key = ("", line["partNumber"] or line["name"] or line["componentId"])+ entry = groups.setdefault(key, {"supplier": key[0] or None, "orderNumber": key[1], "quantity": 0, "names": [],+ "unitCost": line["unitCost"], "currency": line["currency"], "extendedCost": 0.0,+ "inferred": False, "lines": 0})+ entry["quantity"] += line["quantity"]+ entry["lines"] += 1+ entry["inferred"] = entry["inferred"] or line["supplierInferred"] or line["makeBuyReason"] not in STATED+ if line["name"] not in entry["names"] and len(entry["names"]) < 20:+ entry["names"].append(line["name"])+ if entry["extendedCost"] is not None:+ entry["extendedCost"] = round(entry["extendedCost"] + line["extendedCost"], 2) if line["extendedCost"] is not None else None+ if line["unitCost"] != entry["unitCost"]:+ entry["unitCost"] = None+ return sorted(groups.values(), key=lambda g: (g["supplier"] or "~", g["orderNumber"]))+++def fastener_rollup(lines):+ """Fasteners grouped by part number (else name), with total quantity."""+ groups = {}+ for line in lines:+ if line["category"] != "fastener":+ continue+ key = line["partNumber"] or line["supplierPartNumber"] or line["name"]+ entry = groups.setdefault(key, {"spec": key, "quantity": 0, "names": [], "supplierPartNumber": line["supplierPartNumber"]})+ entry["quantity"] += line["quantity"]+ if line["name"] not in entry["names"]:+ entry["names"].append(line["name"])+ return sorted(groups.values(), key=lambda g: -g["quantity"])+++def warnings(lines):+ """What would stop someone ordering or building from this BOM, one row per problem."""+ out = []+ for line in lines:+ name = line["name"] or ""+ if DEFAULT_NAME.match(name):+ out.append({"code": "default_name", "component": name, "message": "Rename it: a default name tells a buyer nothing."})+ if line["makeBuy"] == "buy" and not line["partNumber"] and not line["supplierPartNumber"]:+ out.append({"code": "no_part_number", "component": name, "message": "Set a real part number: Fusion's automatic one is a timestamp."})+ if line["makeBuy"] == "buy" and not line["supplierPartNumber"]:+ out.append({"code": "buy_without_supplier_part_number", "component": name, "message": "Bought, but no supplier part number to order by."})+ if line["makeBuy"] == "buy" and line["unitCost"] is None:+ out.append({"code": "buy_without_cost", "component": name, "message": "Bought, but no unit cost: the total cost is incomplete."})+ if line["makeBuy"] == "make" and not line["material"] and line["category"] != "wire":+ out.append({"code": "make_without_material", "component": name, "message": "Made, but no material: the mass and the process are guesses."})+ if line["makeBuy"] == "make" and (line.get("bodyCount") or 0) > MANY_BODIES:+ out.append({"code": "make_with_many_bodies", "component": name, "message": "Made, but %d bodies in one component reads like an imported model: mark it asPurchased or excludeFromBom." % line["bodyCount"]})+ if line["category"] in LINEAR and not line.get("lengthMm"):+ out.append({"code": "cut_without_length", "component": name, "message": "Cut from %s stock, but no measurable length to put on the cut list." % line["category"]})+ elif line["category"] in LINEAR and line["lengthMm"] < SHORTEST_CUT_MM:+ out.append({"code": "short_cut", "component": name, "message": "Named like %s stock but %.1f mm long: kept off the cut list (set its category if it is hardware)." % (line["category"], line["lengthMm"])})+ if line["makeBuy"] is None and (line.get("makeBuyReason") or "").startswith("stand-in"):+ out.append({"code": "stand_in", "component": name, "message": "Borrowed geometry, ordered by nobody: check that what it stands in for is on the BOM."})+ return out+++def totals(lines):+ priced = [l for l in lines if l["makeBuy"] == "buy"]+ costs = {}+ for line in lines:+ if line["extendedCost"] is not None:+ costs[line["currency"]] = round(costs.get(line["currency"], 0) + line["extendedCost"], 2)+ return {"lines": len(lines), "buyLines": sum(1 for l in lines if l["makeBuy"] == "buy"),+ "makeLines": sum(1 for l in lines if l["makeBuy"] == "make"),+ # how much of make/buy was read from names rather than stated in metadata: the review list+ "inferredLines": sum(1 for l in lines if l["makeBuy"] and l["makeBuyReason"] not in STATED),+ "kitLines": sum(1 for l in lines if l.get("kitReason")),+ "totalQuantity": sum(l["quantity"] for l in lines),+ "costCoverage": round(sum(1 for l in priced if l["unitCost"] is not None) / len(priced), 3) if priced else None,+ "extendedCost": costs, "totalMassKg": round(sum(l["totalMassKg"] for l in lines), 4)}
addin/AdomBridge/commands/mechanical/core.py+93−10@@ -50,14 +50,55 @@ def concrete(entity): return entity +# Lookup indexes over _entities. A lookup used to compare against every entity registered this+# session, so a 1,249-component assembly (about 10,000 identities) made the revision guard run for+# many minutes. The indexes only narrow the candidates; equality is still native object equality.+_identity_buckets = {}+_identity_tokens = {}+MAX_IDENTITIES = 100000+++def _identity_bucket(entity):+ """Type plus name and owner (an occurrence's full path): same-named entities share a bucket and+ are told apart by native equality, as before."""+ kind = getattr(entity, "objectType", "")+ try:+ if kind.endswith("::Occurrence"):+ return (kind, entity.fullPathName)+ owner = getattr(entity, "parentComponent", None) or getattr(entity, "parentSketch", None) or getattr(entity, "body", None)+ return (kind, getattr(entity, "name", None), getattr(owner, "name", None))+ except Exception:+ return (kind,)+++def _identity_token(entity):+ try:+ return (entity.objectType, entity.entityToken)+ except Exception:+ return None++ def identity(entity):- """Object equality, never name or token equality. Retained for this session only."""- for obj, key in _entities:- if obj.isValid and same(obj, entity):- return key- require(len(_entities) < MAX_ENTITIES, "identity_budget", "Session entity budget exhausted.")+ """Object equality, never name or token equality. Retained for this session only. The candidates+ are the entity's bucket, then (for one renamed since) its token's; an entity whose name and token+ both changed gets a new identity, which reads as a changed revision, never a false match."""+ if not _entities and (_identity_buckets or _identity_tokens):+ _identity_buckets.clear() # the registry was reset+ _identity_tokens.clear()+ bucket, token = _identity_bucket(entity), _identity_token(entity)+ for candidates in (_identity_buckets.get(bucket, ()), _identity_tokens.get(token, ()) if token else ()):+ for obj, key in candidates:+ if obj.isValid and same(obj, entity):+ filed = _identity_buckets.setdefault(bucket, [])+ if not any(k == key for _, k in filed):+ filed.append((obj, key))+ return key+ require(len(_entities) < MAX_IDENTITIES, "identity_budget", "Session entity budget exhausted.") key = str(uuid.uuid4()) _entities.append((entity, key))+ _identity_buckets.setdefault(bucket, []).append((entity, key))+ if token:+ _identity_tokens.setdefault(token, []).append((entity, key)) return key @@ -106,6 +147,16 @@ def _revision_document_observation(design, document): return result +def reference_version(component):+ """A component inserted by reference: its source document's identity and version, which is what+ changes when someone edits it there (the edit arrives here as a new version)."""+ try:+ data_file = component.parentDesign.parentDocument.dataFile+ return {"dataFileId": data_file.id, "version": data_file.versionNumber} if data_file else {"dataFileId": None}+ except Exception as error:+ return {"error": str(error)[:120]}++ def revision(design): """Initialize all mesh collections, then capture the unchanged revision graph. @@ -140,9 +191,20 @@ def revision(design): require(type(size) is int and 0 <= size <= MAX_ENTITIES, "inspection_budget", "Component collection exceeds inspection budget.") component_handles = [collection.item(index) for index in range(size)] mesh_collections = [] # Retain all native collection handles through the full digest.- for comp in component_handles:- require(comp is not None and comp.isValid is True and same(comp.parentDesign, design),- "revision_unavailable", "Component does not belong to the exact Design.")+ # A component inserted by reference belongs to its own document's Design: it is read-only+ # here, so it enters the revision by its source version instead of its native internals.+ # Requiring every component to belong to this Design refused every assembly built from+ # referenced sub-assemblies (2026-10-28: a production cell built from 23 referenced designs).+ referenced = set()+ for index, comp in enumerate(component_handles):+ require(comp is not None and comp.isValid is True, "revision_unavailable", "Component handle is invalid.")+ if not same(comp.parentDesign, design):+ # only a non-root component from another saved document is a reference+ source = getattr(comp.parentDesign, "parentDocument", None)+ require(not same(comp, design.rootComponent) and source is not None and getattr(source, "dataFile", None) is not None,+ "revision_unavailable", "Component does not belong to the exact Design.")+ referenced.add(index)+ continue meshes = comp.meshBodies mesh_collections.append(meshes) # Retain before count/validation can fail. require(meshes is not None and meshes.isValid is True and meshes.objectType == "adsk::fusion::MeshBodies",@@ -152,7 +214,10 @@ def revision(design): "inspection_budget", "Mesh collection exceeds inspection budget.") checked(observe("after_initialization"), before) components = []- for comp in component_handles:+ for index, comp in enumerate(component_handles):+ if index in referenced:+ components.append([identity(comp), "referenced", comp.name, reference_version(comp)])+ continue components.append([identity(comp), str(comp.revisionId), comp.name, [[identity(b), str(b.revisionId)] for b in items(comp.bRepBodies)], [[identity(s), str(s.revisionId)] for s in items(comp.sketches)],@@ -177,8 +242,26 @@ def revision(design): raise MechanicalError(getattr(error, "code", "revision_unavailable"), str(error)[:1024], **details) from error +def inside_reference(occurrence):+ """Is this occurrence inside a design inserted by reference (it or an ancestor is the reference)?"""+ node = occurrence+ while node is not None:+ if getattr(node, "isReferencedComponent", False):+ return True+ node = getattr(node, "assemblyContext", None)+ return False++ def occurrence_suppression(occurrence):- timeline = occurrence.timelineObject+ """Suppressed in this design's timeline; None when it has no timeline object here. An occurrence+ inside a referenced design has none in this document (Fusion raises InternalValidationError, seen+ on a production cell): its suppression is decided in its source document."""+ try:+ timeline = occurrence.timelineObject+ except RuntimeError:+ if inside_reference(occurrence):+ return None+ raise return bool(timeline.isSuppressed) if timeline is not None else None
addin/AdomBridge/commands/mechanical/operations.py+11−2@@ -14,9 +14,18 @@ def inspect(app, args): from .assembly_state import metadata, joint_data, rigid_group_data doc, design, key = active(app) value = target(app, args)[2] if args.get("target") else current_target(app)+ if args.get("detail") == "target":+ # just the guard a write or a BOM needs, and the size: a full inspect of a big assembly+ # (thousands of bodies) cannot answer inside the relay's budget+ return {"target": value, "document": {"name": doc.name, "creationId": doc.creationId, "saved": doc.isSaved},+ "counts": {"components": design.allComponents.count, "occurrences": design.rootComponent.allOccurrences.count,+ "timeline": design.timeline.count}} bodies, components, occurrences, sketches, features, axes, planes = [], [], [], [], [], [], []+ # part numbers and descriptions are cloud reads (~0.6 s each per component on a cloud document):+ # read them for a small design, or when asked; a big one gets them from bom or inspect_entity+ native = args.get("componentProperties", design.allComponents.count <= 200) for comp in items(design.allComponents):- components.append({"id": identity(comp), **metadata(comp), "ref": ref(comp, value),+ components.append({"id": identity(comp), **metadata(comp, native), "ref": ref(comp, value), "bodyCount": comp.bRepBodies.count, "sketchCount": comp.sketches.count}) for body in items(comp.bRepBodies): row = body_data(body)@@ -47,7 +56,7 @@ def inspect(app, args): from .joint_state import origin_data, link_data from .configurations import snapshot as configuration_snapshot configuration = configuration_snapshot(design)- return {"target": value, "document": {"name": doc.name, "creationId": doc.creationId,+ return {"target": value, "componentPropertiesRead": bool(native), "document": {"name": doc.name, "creationId": doc.creationId, "saved": doc.isSaved}, "designIntent": str(getattr(design, "designIntent", "unavailable")), "units": UNITS, "components": components, "bodies": bodies, "occurrences": occurrences, "sketches": sketches, "features": features, "constructionAxes": axes, "constructionPlanes": planes,
addin/AdomBridge/commands/mechanical/schema.json+178−4@@ -4,7 +4,19 @@ "required": [ "op" ],- "properties": {},+ "properties": {+ "componentProperties": {+ "type": "boolean",+ "description": "Read each component's part number and description (cloud reads, about 0.6 s each per component on a cloud document). Default: true for 200 components or fewer."+ },+ "detail": {+ "enum": [+ "full",+ "target"+ ],+ "description": "target: only the target, document and counts (what a write or a BOM needs), for an assembly too big for a full inspect inside the relay's budget. Default full."+ }+ }, "description": "Inspect the active Design without mutation. Returns target identity/revision, native component-space bodies and occurrence transforms, parameters and feature health." }, "inspect_entity": {@@ -2890,6 +2902,53 @@ }, "excludeFromBom": { "type": "boolean"+ },+ "category": {+ "type": "string",+ "minLength": 1,+ "maxLength": 60,+ "description": "e.g. extrusion, fastener, printed, machined, sheet metal, electronics, cable."+ },+ "makeBuy": {+ "enum": [+ "make",+ "buy"+ ]+ },+ "process": {+ "type": "string",+ "maxLength": 120,+ "description": "How a made part is made: 3D print, laser cut, CNC, bent sheet..."+ },+ "finish": {+ "type": "string",+ "maxLength": 120+ },+ "unitCost": {+ "type": "number",+ "minimum": 0+ },+ "currency": {+ "type": "string",+ "description": "ISO 4217 code such as USD."+ },+ "leadTimeDays": {+ "type": "integer",+ "minimum": 0,+ "maximum": 3650+ },+ "stock": {+ "type": "string",+ "maxLength": 60,+ "description": "The stock a linear part is cut from (2020, 4040, 8020-1010...): the cut list groups by it."+ },+ "notes": {+ "type": "string",+ "maxLength": 2000+ },+ "revision": {+ "type": "string",+ "maxLength": 40 } }, "required": [],@@ -3082,6 +3141,53 @@ }, "excludeFromBom": { "type": "boolean"+ },+ "category": {+ "type": "string",+ "minLength": 1,+ "maxLength": 60,+ "description": "e.g. extrusion, fastener, printed, machined, sheet metal, electronics, cable."+ },+ "makeBuy": {+ "enum": [+ "make",+ "buy"+ ]+ },+ "process": {+ "type": "string",+ "maxLength": 120,+ "description": "How a made part is made: 3D print, laser cut, CNC, bent sheet..."+ },+ "finish": {+ "type": "string",+ "maxLength": 120+ },+ "unitCost": {+ "type": "number",+ "minimum": 0+ },+ "currency": {+ "type": "string",+ "description": "ISO 4217 code such as USD."+ },+ "leadTimeDays": {+ "type": "integer",+ "minimum": 0,+ "maximum": 3650+ },+ "stock": {+ "type": "string",+ "maxLength": 60,+ "description": "The stock a linear part is cut from (2020, 4040, 8020-1010...): the cut list groups by it."+ },+ "notes": {+ "type": "string",+ "maxLength": 2000+ },+ "revision": {+ "type": "string",+ "maxLength": 40 } }, "required": [],@@ -4585,13 +4691,44 @@ "properties": { "explodePurchased": { "type": "boolean"+ },+ "stockLengthMm": {+ "type": "number",+ "exclusiveMinimum": 0,+ "description": "Stock bar length for the cut list's bar plan (first-fit decreasing)."+ },+ "kerfMm": {+ "type": "number",+ "minimum": 0,+ "maximum": 50,+ "description": "Saw kerf per cut in the bar plan; default 3."+ },+ "currency": {+ "type": "string",+ "description": "ISO 4217 code (USD, EUR) for lines with a unit cost and none of their own; default USD."+ },+ "nativeProperties": {+ "type": "boolean",+ "description": "Read part numbers and descriptions (default true; cached per component revision). false gives the structure, quantities and metadata attributes in seconds on a big cloud assembly."+ },+ "physicalTotals": {+ "type": "boolean",+ "description": "Sum mass and volume over every body instance (default true). false skips that walk on a big assembly; per-line unit mass is still read."+ },+ "inferKits": {+ "type": "boolean",+ "description": "Count an assembly as one bought line when its name reads as a bought product (an imported .stp/STEP translator model, a McMaster number, a vendor note such as \"(vendor STEP)\" or \"(modelled)\", a maker's name) or its children are a circuit board's footprints. Default true. An explicit asPurchased or makeBuy wins, and so does a name that says it is made here (fabricated, 3D printed, machined)."+ },+ "includeLibraries": {+ "type": "boolean",+ "description": "Count body-less groups named as a library (\"Vendor library\", a palette of parts kept to copy from). Default false: they are listed in excluded with reason library_group." } }, "required": [ "op", "target" ],- "description": "Structured component-definition and instance BOM; quantities by actual identity, root bodies plus nested children; purchased metadata collapses subtrees unless exploded. Physical mass includes all unsuppressed bodies exactly once per instance. Includes exact current active configuration provenance; incomplete configuration scope refuses. Visibility does not remove quantity."+ "description": "Structured component-definition and instance BOM; quantities by actual identity, root bodies plus nested children; purchased metadata collapses subtrees unless exploded. Physical mass includes all unsuppressed bodies exactly once per instance. Includes exact current active configuration provenance; incomplete configuration scope refuses. Visibility does not remove quantity. Returns the indented structure with item numbers, the flat purchasing list (make/buy, category, cost, mass), the extrusion cut list (and a bar plan with stockLengthMm), the fastener rollup, totals with cost coverage, and warnings for missing data." }, "bom_export": { "type": "object",@@ -4608,8 +4745,45 @@ "mode": { "enum": [ "flat",- "structured"- ]+ "structured",+ "indented",+ "cutlist",+ "fasteners",+ "wires",+ "order"+ ],+ "description": "flat: the purchasing list with make/buy and its reason; structured: one row per instance; indented: item numbers and quantity per parent; cutlist: extrusions by profile and length; fasteners: hardware by part number; wires: each cut wire with gauge, color and length; order: bought lines merged by supplier part number."+ },+ "stockLengthMm": {+ "type": "number",+ "exclusiveMinimum": 0,+ "description": "Stock bar length for the cut list's bar plan (first-fit decreasing)."+ },+ "kerfMm": {+ "type": "number",+ "minimum": 0,+ "maximum": 50,+ "description": "Saw kerf per cut in the bar plan; default 3."+ },+ "currency": {+ "type": "string",+ "description": "ISO 4217 code (USD, EUR) for lines with a unit cost and none of their own; default USD."+ },+ "nativeProperties": {+ "type": "boolean",+ "description": "Read part numbers and descriptions (default true; cached per component revision). false gives the structure, quantities and metadata attributes in seconds on a big cloud assembly."+ },+ "physicalTotals": {+ "type": "boolean",+ "description": "Sum mass and volume over every body instance (default true). false skips that walk on a big assembly; per-line unit mass is still read."+ },+ "inferKits": {+ "type": "boolean",+ "description": "Count an assembly as one bought line when its name reads as a bought product (an imported .stp/STEP translator model, a McMaster number, a vendor note such as \"(vendor STEP)\" or \"(modelled)\", a maker's name) or its children are a circuit board's footprints. Default true. An explicit asPurchased or makeBuy wins, and so does a name that says it is made here (fabricated, 3D printed, machined)."+ },+ "includeLibraries": {+ "type": "boolean",+ "description": "Count body-less groups named as a library (\"Vendor library\", a palette of parts kept to copy from). Default false: they are listed in excluded with reason library_group." } }, "required": [
docs/MECHANICAL.md+21−1@@ -284,7 +284,27 @@ The importer hashes the actual source file, imports into a new wrapper component `bom` returns grouped purchasing parts plus a structured instance tree. It distinguishes component definitions from repeated occurrences, includes root-owned bodies, and keeps distinct same-named components separate. A purchased subassembly counts as one purchased item unless `explodePurchased:true`. `excludeFromBom` removes that purchasing subtree; physical totals still include its unsuppressed bodies. Typed BOM JSON and CSV publication responses declare `calculationAccuracy:"high"`, matching the high-accuracy physical-property workflow. -`bom_export` writes `mode:"flat"` or `mode:"structured"` CSV to an absolute `filename`, with explicit overwrite and staged publication. Rows retain supplier provenance. Quantities do not depend on component display names. Save an F3D archive to retain Fusion metadata; STEP metadata persistence is not promised. Traversal budgets fail explicitly rather than presenting a truncated BOM as complete.+`bom` also returns the views a buyer and a builder read:++- `indented`: the structure with item numbers (1, 1.2, 1.2.1), quantity per parent and the total rolled up through every level. Each line is a part, an assembly, a kit (bought, not expanded) or a phantom (a body-less grouping that is counted through, never stocked).+- `flat`: one line per component definition with make or buy, `makeBuyReason`, category, material, stock, length, supplier, cost and mass.+- `cutList`: extrusions grouped by profile and cut length. Pass `stockLengthMm` (and `kerfMm`) for `stockPlan`, a first-fit-decreasing plan of bars per profile that reports any cut longer than the stock.+- `wireList`: cut wires grouped by gauge and colour, each measured as body volume over end-cap area. That is exact for any constant section swept along any path, where the bounding box undercounts a routed wire.+- `orderList`: bought lines merged by supplier part number, so repeated models of one catalog item become one line.+- `fasteners`, `totals` (cost per currency, cost coverage, mass, and how many lines were inferred from names) and `warnings` (default names, missing part numbers, costs or materials, short "extrusions", made parts with many bodies, hidden groups that were counted).++Metadata always wins: `makeBuy`, `asPurchased`, `category`, `supplier`, `supplierPartNumber`, `unitCost` and the other `component_metadata` fields. When nobody set them, the BOM reads the name and says so in `makeBuyReason`:++- A name that says made here (fabricated, machined, 3D printed, cut from) is made.+- A wire (a gauge in the name) or an extrusion is cut from stock.+- A McMaster-Carr number in the name becomes the supplier part number, marked `supplierInferred`.+- Fasteners, electronics and hardware are bought, and so is a name with a vendor note ("(vendor STEP)", "(modelled)", "(as installed)") or a maker's name, unless it is an accessory such as a mount or plate.+- An assembly that reads as a bought product (an imported .stp or STEP-translator model, a vendor note, a maker, a McMaster number) or whose children are a circuit board's footprints counts as one bought line. `inferKits:false` turns that off.+- A body-less group named as a library, a palette of parts kept to copy from, is excluded with reason `library_group` unless `includeLibraries:true`.++`nativeProperties:false` skips Fusion's part number and description, which are cloud reads of about 0.6 s per component on a cloud document (cached per component revision after the first read). `physicalTotals:false` skips the physical walk.++`bom_export` writes CSV to an absolute `filename`, with explicit overwrite and staged publication. The modes are `flat` (the default), `structured` (one row per instance), `indented`, `cutlist`, `fasteners`, `wires` and `order`. Rows retain supplier provenance. Quantities do not depend on component display names. Save an F3D archive to retain Fusion metadata; STEP metadata persistence is not promised. Traversal budgets fail explicitly rather than presenting a truncated BOM as complete. ## Regular assemblies (checkpoint 6)
rust/core/tests/golden/mech_capabilities.json+178−4@@ -19,7 +19,19 @@ "required": [ "op" ],- "properties": {},+ "properties": {+ "componentProperties": {+ "type": "boolean",+ "description": "Read each component's part number and description (cloud reads, about 0.6 s each per component on a cloud document). Default: true for 200 components or fewer."+ },+ "detail": {+ "enum": [+ "full",+ "target"+ ],+ "description": "target: only the target, document and counts (what a write or a BOM needs), for an assembly too big for a full inspect inside the relay's budget. Default full."+ }+ }, "description": "Inspect the active Design without mutation. Returns target identity/revision, native component-space bodies and occurrence transforms, parameters and feature health." }, "inspect_entity": {@@ -2905,6 +2917,53 @@ }, "excludeFromBom": { "type": "boolean"+ },+ "category": {+ "type": "string",+ "minLength": 1,+ "maxLength": 60,+ "description": "e.g. extrusion, fastener, printed, machined, sheet metal, electronics, cable."+ },+ "makeBuy": {+ "enum": [+ "make",+ "buy"+ ]+ },+ "process": {+ "type": "string",+ "maxLength": 120,+ "description": "How a made part is made: 3D print, laser cut, CNC, bent sheet..."+ },+ "finish": {+ "type": "string",+ "maxLength": 120+ },+ "unitCost": {+ "type": "number",+ "minimum": 0+ },+ "currency": {+ "type": "string",+ "description": "ISO 4217 code such as USD."+ },+ "leadTimeDays": {+ "type": "integer",+ "minimum": 0,+ "maximum": 3650+ },+ "stock": {+ "type": "string",+ "maxLength": 60,+ "description": "The stock a linear part is cut from (2020, 4040, 8020-1010...): the cut list groups by it."+ },+ "notes": {+ "type": "string",+ "maxLength": 2000+ },+ "revision": {+ "type": "string",+ "maxLength": 40 } }, "required": [],@@ -3097,6 +3156,53 @@ }, "excludeFromBom": { "type": "boolean"+ },+ "category": {+ "type": "string",+ "minLength": 1,+ "maxLength": 60,+ "description": "e.g. extrusion, fastener, printed, machined, sheet metal, electronics, cable."+ },+ "makeBuy": {+ "enum": [+ "make",+ "buy"+ ]+ },+ "process": {+ "type": "string",+ "maxLength": 120,+ "description": "How a made part is made: 3D print, laser cut, CNC, bent sheet..."+ },+ "finish": {+ "type": "string",+ "maxLength": 120+ },+ "unitCost": {+ "type": "number",+ "minimum": 0+ },+ "currency": {+ "type": "string",+ "description": "ISO 4217 code such as USD."+ },+ "leadTimeDays": {+ "type": "integer",+ "minimum": 0,+ "maximum": 3650+ },+ "stock": {+ "type": "string",+ "maxLength": 60,+ "description": "The stock a linear part is cut from (2020, 4040, 8020-1010...): the cut list groups by it."+ },+ "notes": {+ "type": "string",+ "maxLength": 2000+ },+ "revision": {+ "type": "string",+ "maxLength": 40 } }, "required": [],@@ -4600,13 +4706,44 @@ "properties": { "explodePurchased": { "type": "boolean"+ },+ "stockLengthMm": {+ "type": "number",+ "exclusiveMinimum": 0,+ "description": "Stock bar length for the cut list's bar plan (first-fit decreasing)."+ },+ "kerfMm": {+ "type": "number",+ "minimum": 0,+ "maximum": 50,+ "description": "Saw kerf per cut in the bar plan; default 3."+ },+ "currency": {+ "type": "string",+ "description": "ISO 4217 code (USD, EUR) for lines with a unit cost and none of their own; default USD."+ },+ "nativeProperties": {+ "type": "boolean",+ "description": "Read part numbers and descriptions (default true; cached per component revision). false gives the structure, quantities and metadata attributes in seconds on a big cloud assembly."+ },+ "physicalTotals": {+ "type": "boolean",+ "description": "Sum mass and volume over every body instance (default true). false skips that walk on a big assembly; per-line unit mass is still read."+ },+ "inferKits": {+ "type": "boolean",+ "description": "Count an assembly as one bought line when its name reads as a bought product (an imported .stp/STEP translator model, a McMaster number, a vendor note such as \"(vendor STEP)\" or \"(modelled)\", a maker's name) or its children are a circuit board's footprints. Default true. An explicit asPurchased or makeBuy wins, and so does a name that says it is made here (fabricated, 3D printed, machined)."+ },+ "includeLibraries": {+ "type": "boolean",+ "description": "Count body-less groups named as a library (\"Vendor library\", a palette of parts kept to copy from). Default false: they are listed in excluded with reason library_group." } }, "required": [ "op", "target" ],- "description": "Structured component-definition and instance BOM; quantities by actual identity, root bodies plus nested children; purchased metadata collapses subtrees unless exploded. Physical mass includes all unsuppressed bodies exactly once per instance. Includes exact current active configuration provenance; incomplete configuration scope refuses. Visibility does not remove quantity."+ "description": "Structured component-definition and instance BOM; quantities by actual identity, root bodies plus nested children; purchased metadata collapses subtrees unless exploded. Physical mass includes all unsuppressed bodies exactly once per instance. Includes exact current active configuration provenance; incomplete configuration scope refuses. Visibility does not remove quantity. Returns the indented structure with item numbers, the flat purchasing list (make/buy, category, cost, mass), the extrusion cut list (and a bar plan with stockLengthMm), the fastener rollup, totals with cost coverage, and warnings for missing data." }, "bom_export": { "type": "object",@@ -4623,8 +4760,45 @@ "mode": { "enum": [ "flat",- "structured"- ]+ "structured",+ "indented",+ "cutlist",+ "fasteners",+ "wires",+ "order"+ ],+ "description": "flat: the purchasing list with make/buy and its reason; structured: one row per instance; indented: item numbers and quantity per parent; cutlist: extrusions by profile and length; fasteners: hardware by part number; wires: each cut wire with gauge, color and length; order: bought lines merged by supplier part number."+ },+ "stockLengthMm": {+ "type": "number",+ "exclusiveMinimum": 0,+ "description": "Stock bar length for the cut list's bar plan (first-fit decreasing)."+ },+ "kerfMm": {+ "type": "number",+ "minimum": 0,+ "maximum": 50,+ "description": "Saw kerf per cut in the bar plan; default 3."+ },+ "currency": {+ "type": "string",+ "description": "ISO 4217 code (USD, EUR) for lines with a unit cost and none of their own; default USD."+ },+ "nativeProperties": {+ "type": "boolean",+ "description": "Read part numbers and descriptions (default true; cached per component revision). false gives the structure, quantities and metadata attributes in seconds on a big cloud assembly."+ },+ "physicalTotals": {+ "type": "boolean",+ "description": "Sum mass and volume over every body instance (default true). false skips that walk on a big assembly; per-line unit mass is still read."+ },+ "inferKits": {+ "type": "boolean",+ "description": "Count an assembly as one bought line when its name reads as a bought product (an imported .stp/STEP translator model, a McMaster number, a vendor note such as \"(vendor STEP)\" or \"(modelled)\", a maker's name) or its children are a circuit board's footprints. Default true. An explicit asPurchased or makeBuy wins, and so does a name that says it is made here (fabricated, 3D printed, machined)."+ },+ "includeLibraries": {+ "type": "boolean",+ "description": "Count body-less groups named as a library (\"Vendor library\", a palette of parts kept to copy from). Default false: they are listed in excluded with reason library_group." } }, "required": [
rust/core/tests/golden/mech_validation.json@@ -1 +1 @@
Reviews
Comments
Log in to comment.
No comments yet.