← Commit history
CHANGELOG.md+26
@@ -3,6 +3,32 @@ 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.17 (unreleased)++From Drew Owens (PR #24). 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.+- The add-in is 1.0.24, so Fusion reloads the changed mechanical package; the exe embeds the new `schema.json`.+ ## 1.13.16 (2026-10-29)  - Engine 0.19.0 (standard 0.22.1): review_board's rule classes from standard-lane's fix.
addin/AdomBridge/AdomBridge.manifest+1−1
@@ -6,7 +6,7 @@     "description": {         "": "Adom Desktop Conduit bridge add-in. Exposes a localhost HTTP API for external automation of Fusion 360."     },-    "version": "1.0.23",+    "version": "1.0.24",     "runOnStartup": true,     "supportedOS": "windows" }\ No newline at end of file
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 @@