app
Fusion - the Fusion 360 Bridge
Public Made by Adomby adom
Drive Autodesk Fusion 360 from the cloud via Adom Bridge: component libraries, IPC package generation, board layout, exports (STEP/Gerbers/BOM/CPL), fast APS cloud search, and parametric modeling.
← Commit history
Design efficiency: efficiency_report, moves that edit the initial position, patterns of features, faces and components, and the efficiency playbook (builds on feature/bom-v2)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
14 files changed
+1757−68
diff truncated
CHANGELOG.md+40addin/AdomBridge/commands/mechanical/__init__.py+3−2addin/AdomBridge/commands/mechanical/assemblies.py+57−2addin/AdomBridge/commands/mechanical/assembly_state.py+26−5addin/AdomBridge/commands/mechanical/bill_of_materials.py+96−12addin/AdomBridge/commands/mechanical/bom_views.py+419addin/AdomBridge/commands/mechanical/core.py+117−10addin/AdomBridge/commands/mechanical/efficiency.py+231addin/AdomBridge/commands/mechanical/features.py+100−17addin/AdomBridge/commands/mechanical/operations.py+11−2addin/AdomBridge/commands/mechanical/schema.json+314−8docs/MECHANICAL.md+28−2rust/core/tests/golden/mech_capabilities.json+315−8rust/core/tests/golden/mech_validation.jsonCHANGELOG.md+40@@ -3,6 +3,46 @@ 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.+- Efficiency (builds on the BOM change above):+ - `efficiency_report` reads what makes a design slow (timeline make-up and health, Position snapshots, Move features,+ size, repeated definitions made by Paste New, instance-weighted faces, imported history, wires in a parametric design,+ big sketches, pattern compute options, modeled threads, meshes, empty components) and returns findings with the fix.+ It never changes the model and is journaled like `bom_export`; a 4,579-occurrence cell took 76 s.+ - `occurrence_update` keeps a move by editing the occurrence's initial position (Fusion's Edit Initial Position) when+ Fusion allows it, so nothing joins the timeline; an earlier occurrence is edited with the marker rolled back to it and+ restored. `method` chooses, and the reply's `placement` says which was used. Nine moves: 0 snapshots, against 9.+ - Rectangular and circular patterns take features, faces or components (occurrences, patterned in their parent) as well+ as bodies, with `computeOption` (identical by default, optimized, adjust). The revision guard accepts a component+ pattern's copies. On a 100-hole plate a feature pattern recomputed in 0.24 s, the sketch of circles in 0.87 s, adjust+ in 2.42 s.+ - The `fusion-design-efficiency` skill: the playbook, with those benchmarks and its sources.+ ## 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/__init__.py+3−2@@ -14,7 +14,7 @@ import adsk.core from .core import SESSION_ID, MechanicalError, document_identity, require from .operations import OPERATIONS, WRITES from .journal import Journal, JournalError-from . import handoff, transactions, sketches, features, assemblies, purchased, bill_of_materials, geometry, sketch_edits, regular_joints, motion_links, configurations, render_scene, exploded_view, exploded_preview+from . import handoff, transactions, sketches, features, assemblies, purchased, bill_of_materials, geometry, sketch_edits, regular_joints, motion_links, configurations, render_scene, exploded_view, exploded_preview, efficiency for _name, _handler in {**sketches.OPERATIONS, **features.OPERATIONS, **assemblies.OPERATIONS, **geometry.OPERATIONS, **sketch_edits.OPERATIONS, **regular_joints.OPERATIONS, **motion_links.OPERATIONS}.items():@@ -32,7 +32,8 @@ for _name, _handler in {**exploded_preview.WRITES, **exploded_preview.READS}.ite WRITES.update(exploded_preview.WRITES) OPERATIONS.update(purchased.OPERATIONS) OPERATIONS.update(bill_of_materials.OPERATIONS)-WRITES.update(purchased.WRITES | bill_of_materials.WRITES)+OPERATIONS.update(efficiency.OPERATIONS)+WRITES.update(purchased.WRITES | bill_of_materials.WRITES | efficiency.WRITES) OPERATIONS["transaction_probe"] = transactions.probe WRITES.add("transaction_probe")
addin/AdomBridge/commands/mechanical/assemblies.py+57−2@@ -157,6 +157,46 @@ def occurrence_insert(design, value, args): return lambda fresh: {**created_occurrence_result(design, parent, occ, fresh, expected), "positionCaptured": captured} +def initial_position_editable(occ):+ """Can Fusion edit this occurrence's initial position? (The API spells the property isVaild...)"""+ for name in ("isVaildForEditInitialPosition", "isValidForEditInitialPosition"):+ try:+ value = getattr(occ, name, None)+ except RuntimeError:+ return False+ if value is not None:+ return bool(value)+ return False+++def edit_initial_position(design, occ, matrix):+ """Set the occurrence's initial position. Fusion edits only the last occurrence in the timeline in+ place ("Cannot be edited before rolling back"), so roll the marker to just after this occurrence's+ creation, edit it there and put the marker back, as the Edit Initial Position command does."""+ try:+ occ.initialTransform = matrix+ return True+ except RuntimeError:+ pass+ try:+ item = occ.timelineObject+ except RuntimeError:+ return False+ if item is None:+ return False+ timeline = design.timeline+ marker = timeline.markerPosition+ try:+ if not item.rollTo(False):+ return False+ occ.initialTransform = matrix+ return True+ except RuntimeError:+ return False+ finally:+ timeline.markerPosition = marker++ def occurrence_update(design, value, args): require(any(k in args for k in ("transform", "grounded", "groundedToParent")), "invalid_input", "Occurrence update requires a placement or grounding change.") occ = resolve(design, value, args["occurrence"], ("Occurrence",))@@ -164,14 +204,29 @@ def occurrence_update(design, value, args): occ.isGrounded = args["grounded"] if "groundedToParent" in args: occ.isGroundToParent = args["groundedToParent"]+ placed = None if "transform" in args: require(not occ.isGrounded and not occ.isGroundToParent, "grounded_occurrence", "Unground the occurrence explicitly before moving it.") expected = transform(args["transform"])- occ.transform2 = expected+ method = args.get("method", "auto")+ # Edit Initial Position: the placement the occurrence was created with, so no Position snapshot+ # joins the timeline. Fusion allows it for a root-level occurrence without joints or patterns+ # (the property's name is misspelled in the API).+ if method != "capture" and initial_position_editable(occ):+ if edit_initial_position(design, occ, expected):+ placed = "initial_position"+ else:+ require(method == "auto", "initial_position_refused", "Fusion refused to edit this occurrence's initial position.")+ require(placed or method != "initial_position", "initial_position_unavailable",+ "Fusion cannot edit this occurrence's initial position (nested, patterned or jointed): use method capture.")+ if placed is None:+ occ.transform2 = expected+ placed = "capture" actual = occ.transform2.asArray() require(all(abs(a-b) < 1e-8 for a,b in zip(actual, expected.asArray())), "placement_refused", "Constraints prevented the requested occurrence transform.") if design.snapshots.hasPendingSnapshot: require(design.snapshots.add() is not None, "placement_failed", "Could not capture occurrence position in the timeline.")+ placed = "capture" require("grounded" not in args or occ.isGrounded == args["grounded"], "grounding_refused", "Grounding did not match the request.") require("groundedToParent" not in args or occ.isGroundToParent == args["groundedToParent"], "grounding_refused", "Parent grounding did not match the request.") def result(fresh):@@ -181,7 +236,7 @@ def occurrence_update(design, value, args): "placement_refused", "Recompute did not retain the requested occurrence transform.") require("grounded" not in args or occ.isGrounded == args["grounded"], "grounding_refused", "Recompute did not retain grounding.") require("groundedToParent" not in args or occ.isGroundToParent == args["groundedToParent"], "grounding_refused", "Recompute did not retain parent grounding.")- return {"occurrence": occurrence_data(occ, fresh)}+ return {"occurrence": occurrence_data(occ, fresh), **({"placement": placed} if placed else {})} return result
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+117−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,11 +242,53 @@ 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+ element = pattern_element(occurrence)+ if element is not None:+ return bool(element.isSuppressed)+ raise return bool(timeline.isSuppressed) if timeline is not None else None +def pattern_element(occurrence):+ """The pattern element that made an occurrence, or None. A component pattern's copies have no+ timeline object of their own (Fusion raises InternalValidationError); the element carries their+ suppression. Looked up only on that error, in the owning component's patterns."""+ try:+ context = getattr(occurrence, "assemblyContext", None)+ owner = context.component if context is not None else occurrence.component.parentDesign.rootComponent+ path = occurrence.fullPathName+ groups = (owner.features.rectangularPatternFeatures, owner.features.circularPatternFeatures)+ except (AttributeError, RuntimeError):+ return None+ for group in groups:+ for i in range(group.count):+ elements = group.item(i).patternElements+ for j in range(elements.count):+ element = elements.item(j)+ if any(o.fullPathName == path for o in element.occurrences):+ return element+ return None++ def target(app, args, write=False, model_write=True): doc, design, key = active(app) wanted = args.get("target")
addin/AdomBridge/commands/mechanical/efficiency.pyadded+231@@ -0,0 +1,231 @@+"""Design efficiency: what makes a design slow to recompute, open and edit, read from the model and+held against the playbook (skills/fusion-design-efficiency). It never changes the model and needs no+revision digest; a time budget stops the costly passes early and says so.+findings() is pure, so every rule is tested offline."""+import re+import time++import adsk.core+import adsk.fusion++from .core import active, require+from .bom_views import GAUGE, WIRING_HARDWARE++# limits people cite (Autodesk's large-assembly article, AU classes, forum benchmarks); see the playbook+LIMITS = {"occurrences": 1000, "components": 1000, "timeline": 500, "sketchCurves": 200, "baseFeatures": 50,+ "facesPerDefinition": 20000, "wires": 20}+COPY_NAME = re.compile(r" \(\d+\)$")+++def size(things):+ """Count a Fusion collection (.count) or vector (len): allJoints and friends return vectors."""+ count = getattr(things, "count", None)+ return count if isinstance(count, int) else len(things)+++def _kind(entity):+ return entity.objectType.split("::")[-1] if entity is not None else "None"+++def measure(app, design, budget_seconds=45.0):+ """The numbers the playbook's rules read, cheapest first; passes past the budget are skipped."""+ start = time.perf_counter()+ skipped = []++ def spare():+ return time.perf_counter() - start < budget_seconds++ root = design.rootComponent+ parametric = design.designType == adsk.fusion.DesignTypes.ParametricDesignType+ intents = {}+ if hasattr(adsk.fusion, "DesignIntentTypes"):+ for name in ("PartDesignIntentType", "AssemblyDesignIntentType", "HybridDesignIntentType"):+ if hasattr(adsk.fusion.DesignIntentTypes, name):+ intents[getattr(adsk.fusion.DesignIntentTypes, name)] = name[:-len("DesignIntentType")].lower()+ m = {"designType": "parametric" if parametric else "direct",+ "designIntent": intents.get(getattr(design, "designIntent", None))}++ timeline = {"count": 0, "groups": 0, "byType": {}, "warnings": 0, "errors": 0, "suppressed": 0}+ if parametric:+ tl = design.timeline+ timeline["count"] = tl.count+ for i in range(tl.count):+ item = tl.item(i)+ if item.isGroup:+ timeline["groups"] += 1+ continue+ try:+ kind = _kind(item.entity)+ except Exception:+ kind = "unreadable"+ timeline["byType"][kind] = timeline["byType"].get(kind, 0) + 1+ state = getattr(item, "healthState", 0)+ timeline["warnings"] += state == adsk.fusion.FeatureHealthStates.WarningFeatureHealthState+ timeline["errors"] += state == adsk.fusion.FeatureHealthStates.ErrorFeatureHealthState+ timeline["suppressed"] += bool(getattr(item, "isSuppressed", False))+ m["timeline"] = timeline+ snapshots = getattr(design, "snapshots", None)+ m["snapshots"] = snapshots.count if snapshots is not None else 0+ m["pendingSnapshot"] = bool(snapshots.hasPendingSnapshot) if snapshots is not None else False++ every = [design.allComponents.item(i) for i in range(design.allComponents.count)]+ own = [c for c in every if c.parentDesign == design]+ m["components"] = {"all": len(every), "own": len(own), "referenced": len(every) - len(own)}+ m["occurrences"] = root.allOccurrences.count+ m["joints"] = {"joints": size(root.allJoints), "asBuilt": size(root.allAsBuiltJoints), "rigidGroups": size(root.allRigidGroups)}++ counts = {"bodies": 0, "faces": 0, "meshBodies": 0, "baseFeatures": 0, "moveFeatures": 0, "sketches": 0,+ "sketchCurves": 0, "modeledThreads": 0, "emptyComponents": 0, "wires": 0, "copyNames": 0}+ big_sketches, patterns, per_definition = [], [], []+ for comp in own:+ bodies = comp.bRepBodies+ faces = sum(bodies.item(i).faces.count for i in range(bodies.count))+ counts["bodies"] += bodies.count+ counts["faces"] += faces+ counts["meshBodies"] += comp.meshBodies.count+ features = comp.features+ counts["baseFeatures"] += features.baseFeatures.count+ counts["moveFeatures"] += features.moveFeatures.count+ for sketch in (comp.sketches.item(i) for i in range(comp.sketches.count)):+ counts["sketches"] += 1+ curves = sketch.sketchCurves.count+ counts["sketchCurves"] += curves+ if curves > LIMITS["sketchCurves"]:+ big_sketches.append({"component": comp.name, "sketch": sketch.name, "curves": curves})+ threads = features.threadFeatures+ counts["modeledThreads"] += sum(1 for i in range(threads.count) if threads.item(i).isModeled)+ for group in (features.rectangularPatternFeatures, features.circularPatternFeatures):+ for i in range(group.count):+ pattern = group.item(i)+ option = getattr(pattern, "patternComputeOption", None)+ names = {adsk.fusion.PatternComputeOptions.AdjustPatternCompute: "adjust",+ adsk.fusion.PatternComputeOptions.IdenticalPatternCompute: "identical",+ adsk.fusion.PatternComputeOptions.OptimizedPatternCompute: "optimized"}+ # a component pattern has no compute option to choose (Fusion reports adjust), so it is not a finding+ kinds = getattr(adsk.fusion, "PatternEntityTypes", None)+ try:+ of_occurrences = kinds is not None and pattern.patternEntityType == kinds.OccurrencesPatternType+ except (AttributeError, RuntimeError):+ of_occurrences = False+ patterns.append({"component": comp.name, "name": pattern.name,+ "computeOption": None if of_occurrences else names.get(option), "ofComponents": of_occurrences})+ if not bodies.count and not comp.occurrences.count and not comp.sketches.count and not comp.meshBodies.count:+ counts["emptyComponents"] += 1+ if GAUGE.search(comp.name) and not WIRING_HARDWARE.search(comp.name):+ counts["wires"] += 1+ counts["copyNames"] += bool(COPY_NAME.search(comp.name))+ per_definition.append((comp, faces))+ m.update(counts)+ m["bigSketches"] = big_sketches[:20]+ m["patterns"] = patterns[:50]++ # costly passes: instance-weighted faces and repeated definitions+ heavy, signatures = [], {}+ # instances per definition in one walk (allOccurrencesByComponent rescans every occurrence per call)+ instance_counts = {}+ every_occurrence = root.allOccurrences+ for i in range(every_occurrence.count):+ if not spare():+ break+ token = every_occurrence.item(i).component.entityToken+ instance_counts[token] = instance_counts.get(token, 0) + 1+ for comp, faces in per_definition:+ if not spare():+ skipped.append("heavy geometry and duplicate definitions")+ break+ instances = instance_counts.get(comp.entityToken, 0)+ if faces * max(instances, 1) > LIMITS["facesPerDefinition"]:+ heavy.append({"component": comp.name, "faces": faces, "instances": instances, "weightedFaces": faces * max(instances, 1)})+ bodies = comp.bRepBodies+ if 0 < bodies.count <= 5:+ key = []+ for i in range(bodies.count):+ body = bodies.item(i)+ box = body.boundingBox+ key.append((body.faces.count, round((box.maxPoint.x - box.minPoint.x) * 1000), round((box.maxPoint.y - box.minPoint.y) * 1000),+ round((box.maxPoint.z - box.minPoint.z) * 1000)))+ signatures.setdefault(tuple(sorted(key)), []).append(comp.name)+ heavy.sort(key=lambda h: -h["weightedFaces"])+ m["heavyDefinitions"] = heavy[:15]+ groups = sorted((names for names in signatures.values() if len(names) > 1), key=len, reverse=True)+ m["duplicateDefinitions"] = {"groups": len(groups), "extraDefinitions": sum(len(g) - 1 for g in groups),+ "largest": [{"count": len(g), "names": g[:5]} for g in groups[:10]]}+ m["skipped"] = skipped+ m["seconds"] = round(time.perf_counter() - start, 2)+ return m+++def findings(m):+ """Each rule the design breaks, with what was measured, the limit and the fix, worst first."""+ out = []++ def add(rule, severity, measured, limit, fix):+ out.append({"rule": rule, "severity": severity, "measured": measured, "limit": limit, "fix": fix})++ if m.get("snapshots"):+ add("placement-snapshots", "high" if m["snapshots"] > 10 else "medium", m["snapshots"], 0,+ "Place each occurrence where it belongs when inserting it (occurrence_insert transform), and move it with "+ "occurrence_update, which edits its initial position when Fusion allows: neither writes a Position snapshot.")+ if m.get("pendingSnapshot"):+ add("pending-snapshot", "medium", True, False, "A moved occurrence is waiting to be captured: capture or revert it before the next recompute.")+ if m.get("moveFeatures"):+ add("move-features", "medium", m["moveFeatures"], 0, "Model bodies in place, or move their occurrence instead of adding Move features.")+ dups = (m.get("duplicateDefinitions") or {}).get("extraDefinitions", 0)+ if dups:+ add("duplicate-definitions", "high" if dups > 20 else "medium", dups, 0,+ "Copies made as new components (Paste New): insert instances of one component so it is computed once and counted right.")+ size = max(m.get("occurrences", 0), (m.get("components") or {}).get("all", 0))+ if size > LIMITS["occurrences"]:+ add("design-size", "high", size, LIMITS["occurrences"],+ "Split the design into sub-designs inserted by reference: each computes on its own and opens fast.")+ if (m.get("timeline") or {}).get("count", 0) > LIMITS["timeline"]:+ add("timeline-length", "medium", m["timeline"]["count"], LIMITS["timeline"],+ "Shorten the timeline: placement at creation, instances instead of copies, sub-designs by reference.")+ if m.get("designType") == "parametric" and m.get("baseFeatures", 0) > LIMITS["baseFeatures"]:+ add("imported-history", "medium", m["baseFeatures"], LIMITS["baseFeatures"],+ "Keep imported and generated geometry in direct-mode designs inserted by reference, out of this timeline.")+ if m.get("designType") == "parametric" and m.get("wires", 0) > LIMITS["wires"]:+ add("wires-in-assembly", "high", m["wires"], LIMITS["wires"],+ "Build the wiring in its own direct-mode design and insert it by reference.")+ heavy = m.get("heavyDefinitions") or []+ if heavy:+ add("heavy-geometry", "medium", [h["component"] for h in heavy[:5]], LIMITS["facesPerDefinition"],+ "Defeature these models (delete small faces, replace detail with primitives) or keep a simplified copy.")+ if m.get("bigSketches"):+ add("sketch-size", "medium", len(m["bigSketches"]), LIMITS["sketchCurves"],+ "Split big sketches; pattern the 3D feature instead of repeating sketch entities.")+ adjust = [p for p in m.get("patterns") or [] if p.get("computeOption") == "adjust"]+ if adjust:+ add("pattern-compute", "medium", len(adjust), 0, "Set these patterns' compute option to identical or optimized.")+ timeline = m.get("timeline") or {}+ if timeline.get("errors") or timeline.get("warnings"):+ add("timeline-health", "high" if timeline.get("errors") else "low", {"errors": timeline.get("errors", 0), "warnings": timeline.get("warnings", 0)}, 0,+ "Fix the timeline's errors and warnings: each one costs time on every recompute.")+ if m.get("modeledThreads"):+ add("modeled-threads", "medium", m["modeledThreads"], 0, "Use cosmetic threads and unthreaded vendor models.")+ if m.get("meshBodies"):+ add("mesh-bodies", "low", m["meshBodies"], 0, "Convert or replace mesh bodies; they are heavy to display and cannot be edited.")+ if m.get("emptyComponents"):+ add("empty-components", "low", m["emptyComponents"], 0, "Delete empty components; each still takes part in the compute.")+ order = {"high": 0, "medium": 1, "low": 2}+ return sorted(out, key=lambda f: order[f["severity"]])+++def efficiency_report(app, args):+ doc, design, key = active(app)+ wanted = args.get("target") or {}+ require(wanted.get("documentId") == key, "wrong_document", "Target is not the active document.", activeDocumentId=key)+ metrics = measure(app, design, float(args.get("budgetSeconds", 120)))+ result = {"target": {"documentId": key}, "complete": not metrics["skipped"], "metrics": metrics, "findings": findings(metrics),+ "playbook": "skills/fusion-design-efficiency/SKILL.md"}+ if args.get("computeTiming", False):+ # the one number that sums everything up, and the slowest to get: opt in+ start = time.perf_counter()+ design.computeAll()+ result["computeAllSeconds"] = round(time.perf_counter() - start, 2)+ return result+++# journaled like bom_export: model-read-only, but a big design's report outlives the relay's 60 s+OPERATIONS = {"efficiency_report": efficiency_report}+WRITES = {"efficiency_report"}
addin/AdomBridge/commands/mechanical/features.py+100−17@@ -40,6 +40,98 @@ def factory(features, name): return result +PATTERN_INPUTS = ("bodies", "features", "faces", "occurrences")+# Fusion's pattern compute options: identical repeats the first result (fast), optimized is fastest but+# can drop instances that touch other geometry, adjust recomputes every instance (slowest)+COMPUTE_OPTIONS = {"identical": "IdenticalPatternCompute", "optimized": "OptimizedPatternCompute", "adjust": "AdjustPatternCompute"}+++def occurrence_parent(design, occurrence):+ """(the component that owns an occurrence, the occurrence as that component sees it). A root-context+ proxy belongs to its context's component; otherwise it is the root's own child or Fusion names its source."""+ context = getattr(occurrence, "assemblyContext", None)+ if context is not None:+ return context.component, occurrence.nativeObject+ root = design.rootComponent+ if any(same(child, occurrence) for child in items(root.occurrences)):+ return root, occurrence+ source = getattr(occurrence, "sourceComponent", None)+ require(source is not None, "unsupported_capability", "Fusion did not name the component that owns this occurrence.")+ return source, occurrence+++def pattern_inputs(design, value, args, comp):+ """What a pattern repeats, (kind, entities, component to build it in): exactly one of bodies, features,+ faces or occurrences. Occurrences are patterned in their shared parent (Fusion's pattern components),+ so the part stays one definition; the rest pattern inside the target component as before."""+ given = [key for key in PATTERN_INPUTS if key in args]+ require(len(given) <= 1, "mixed_pattern_inputs", "A pattern repeats one kind of entity; pass only one of " + ", ".join(given) + ".")+ require(given, "invalid_selection", "A pattern needs bodies, features, faces or occurrences.")+ key = given[0]+ if key == "occurrences":+ raw = args["occurrences"]+ require(isinstance(raw, list) and raw, "invalid_selection", "occurrences requires at least one reference.")+ owned = [occurrence_parent(design, resolve(design, value, r, ("Occurrence",))) for r in raw]+ parent = owned[0][0]+ require(all(same(owner_comp, parent) for owner_comp, _ in owned), "mixed_parents",+ "Patterned occurrences must share one parent component.")+ require(not args.get("component") or same(comp, parent), "wrong_component",+ "Occurrences are patterned in their parent component; omit component or pass that parent.")+ return key, [occurrence for _, occurrence in owned], parent+ entities = selected(design, value, args, key, {"bodies": ("BRepBody",), "faces": ("BRepFace",), "features": ()}[key], comp)+ if key == "features":+ require(all(entity.objectType.endswith("Feature") and getattr(entity, "timelineObject", None) is not None for entity in entities),+ "wrong_entity_type", "Patterned features must be timeline features (an extrude, a hole, a fillet).")+ return key, entities, comp+++def compute_option(inp, args, key):+ """Set the compute option on a body, face or feature pattern (identical unless asked); component+ patterns have none. Returns the option set, or None."""+ options = getattr(adsk.fusion, "PatternComputeOptions", None)+ if key == "occurrences" or options is None or not hasattr(inp, "patternComputeOption"):+ return None+ name = args.get("computeOption", "identical")+ inp.patternComputeOption = getattr(options, COMPUTE_OPTIONS[name])+ return name+++def compute_name(feature, requested):+ """The compute option Fusion reports on the created pattern, by name (the requested one when unreadable)."""+ options = getattr(adsk.fusion, "PatternComputeOptions", None)+ try:+ actual = getattr(feature, "patternComputeOption", None)+ except RuntimeError:+ actual = None+ if options is None or actual is None:+ return requested+ return next((name for name, attr in COMPUTE_OPTIONS.items() if getattr(options, attr, None) == actual), requested)+++def pattern_input(design, value, args, comp, kind, q):+ """(feature group, input, what is patterned, compute option) for a rectangular or circular pattern."""+ key, entities, comp = pattern_inputs(design, value, args, comp)+ features = comp.features+ if kind == "rectangular_pattern":+ if any(name in args for name in ("axis2", "count2", "spacing2")):+ require("count2" in args and "spacing2" in args, "invalid_input", "Second pattern direction requires count2 and spacing2; axis2 defaults to y.")+ group = factory(features, "rectangularPatternFeatures")+ inp = group.createInput(collection(entities), axis(design, value, comp, args["axis"]),+ q("count", ""), q("spacing"), adsk.fusion.PatternDistanceType.SpacingPatternDistanceType)+ inp.quantityTwo = quantity(design, 1, "", True)+ inp.isSymmetricInDirectionOne = False+ inp.isSymmetricInDirectionTwo = False+ if "count2" in args:+ require(inp.setDirectionTwo(axis(design, value, comp, args.get("axis2", "y")), q("count2", ""), q("spacing2")), "pattern_failed", "Second pattern direction was refused.")+ else:+ group = factory(features, "circularPatternFeatures")+ inp = group.createInput(collection(entities), axis(design, value, comp, args["axis"]))+ inp.quantity = q("count", "")+ inp.totalAngle = quantity(design, args.get("angle", 360), "deg", True)+ inp.isSymmetric = False+ return group, inp, key, compute_option(inp, args, key)++ def feature_create(design, value, args): comp = component(design, value, args) features, kind = comp.features, args["type"]@@ -141,23 +233,8 @@ def feature_create(design, value, args): group = factory(features, "mirrorFeatures") inp = group.createInput(collection(select("bodies", ("BRepBody",))), plane(design, value, comp, args["plane"])) inp.isCombine = False- elif kind == "rectangular_pattern":- if any(key in args for key in ("axis2", "count2", "spacing2")):- require("count2" in args and "spacing2" in args, "invalid_input", "Second pattern direction requires count2 and spacing2; axis2 defaults to y.")- group = factory(features, "rectangularPatternFeatures")- inp = group.createInput(collection(select("bodies", ("BRepBody",))), axis(design, value, comp, args["axis"]),- q("count", ""), q("spacing"), adsk.fusion.PatternDistanceType.SpacingPatternDistanceType)- inp.quantityTwo = quantity(design, 1, "", True)- inp.isSymmetricInDirectionOne = False- inp.isSymmetricInDirectionTwo = False- if "count2" in args:- require(inp.setDirectionTwo(axis(design, value, comp, args.get("axis2", "y")), q("count2", ""), q("spacing2")), "pattern_failed", "Second pattern direction was refused.")- elif kind == "circular_pattern":- group = factory(features, "circularPatternFeatures")- inp = group.createInput(collection(select("bodies", ("BRepBody",))), axis(design, value, comp, args["axis"]))- inp.quantity = q("count", "")- inp.totalAngle = quantity(design, args.get("angle", 360), "deg", True)- inp.isSymmetric = False+ elif kind in ("rectangular_pattern", "circular_pattern"):+ group, inp, patterned, requested = pattern_input(design, value, args, comp, kind, q) elif kind == "draft": group = factory(features, "draftFeatures") inp = group.createInput(select("faces", ("BRepFace",)), plane(design, value, comp, args["plane"]), args.get("tangentChain", False))@@ -174,6 +251,12 @@ def feature_create(design, value, args): else: require(False, "unsupported_feature", "This feature type is not supported.") feature = named(group.add(inp), args)+ if kind in ("rectangular_pattern", "circular_pattern"):+ elements = getattr(feature, "patternElements", None)+ pattern = {"patterned": patterned, "computeOption": compute_name(feature, requested),+ "instanceCount": elements.count if elements is not None else None}+ return lambda fresh: {"feature": feature_data(feature, fresh), "pattern": pattern,+ "bodies": [topology_data(body, fresh) for body in items(feature.bodies)]} return lambda fresh: {"feature": feature_data(feature, fresh), "bodies": [topology_data(body, fresh) for body in items(feature.bodies)]}
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+314−8@@ -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": {@@ -1102,7 +1114,10 @@ "threadType": {}, "designation": {}, "threadClass": {},- "modeled": {}+ "modeled": {},+ "features": {},+ "occurrences": {},+ "computeOption": {} }, "required": [ "op",@@ -1111,7 +1126,7 @@ "target", "type" ],- "description": "Create a typed native solid feature. All length quantities are mm (or expressions), angles deg. Cut/intersect require explicit participant bodies. Join needs a single existing component body; combine selects multiple bodies explicitly. Feature availability is runtime-checked; missing APIs fail before commit.",+ "description": "Create a typed native solid feature. All length quantities are mm (or expressions), angles deg. Cut/intersect require explicit participant bodies. Join needs a single existing component body; combine selects multiple bodies explicitly. Feature availability is runtime-checked; missing APIs fail before commit. Rectangular and circular patterns repeat exactly one of bodies, features, faces or occurrences (a component pattern, built in the occurrences' shared parent so the part stays one definition); computeOption is identical (default), optimized or adjust and does not apply to component patterns.", "oneOf": [ { "type": "object",@@ -2368,11 +2383,60 @@ "type": "string" } ]+ },+ "features": {+ "type": "array",+ "items": {+ "type": "object",+ "required": [+ "documentId",+ "revision",+ "objectType"+ ],+ "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring."+ },+ "minItems": 1,+ "maxItems": 256+ },+ "faces": {+ "type": "array",+ "items": {+ "type": "object",+ "required": [+ "documentId",+ "revision",+ "objectType"+ ],+ "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring."+ },+ "minItems": 1,+ "maxItems": 256+ },+ "occurrences": {+ "type": "array",+ "items": {+ "type": "object",+ "required": [+ "documentId",+ "revision",+ "objectType"+ ],+ "description": "Occurrence reference returned by inspect in this document revision; all must share one parent component, where the component pattern is built."+ },+ "minItems": 1,+ "maxItems": 256+ },+ "computeOption": {+ "enum": [+ "identical",+ "optimized",+ "adjust"+ ],+ "description": "identical (default) repeats the first result; optimized is fastest but can drop instances that touch other geometry; adjust recomputes every instance (slowest). Ignored for occurrences." } }, "required": [ "type",- "bodies", "axis", "count", "spacing"@@ -2472,11 +2536,60 @@ "type": "string" } ]+ },+ "features": {+ "type": "array",+ "items": {+ "type": "object",+ "required": [+ "documentId",+ "revision",+ "objectType"+ ],+ "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring."+ },+ "minItems": 1,+ "maxItems": 256+ },+ "faces": {+ "type": "array",+ "items": {+ "type": "object",+ "required": [+ "documentId",+ "revision",+ "objectType"+ ],+ "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring."+ },+ "minItems": 1,+ "maxItems": 256+ },+ "occurrences": {+ "type": "array",+ "items": {+ "type": "object",+ "required": [+ "documentId",+ "revision",+ "objectType"+ ],+ "description": "Occurrence reference returned by inspect in this document revision; all must share one parent component, where the component pattern is built."+ },+ "minItems": 1,+ "maxItems": 256+ },+ "computeOption": {+ "enum": [+ "identical",+ "optimized",+ "adjust"+ ],+ "description": "identical (default) repeats the first result; optimized is fastest but can drop instances that touch other geometry; adjust recomputes every instance (slowest). Ignored for occurrences." } }, "required": [ "type",- "bodies", "axis", "count" ],@@ -2890,6 +3003,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": [],@@ -3011,6 +3171,15 @@ }, "groundedToParent": { "type": "boolean"+ },+ "method": {+ "type": "string",+ "enum": [+ "auto",+ "initial_position",+ "capture"+ ],+ "description": "How a new transform is kept. auto (default): edit the occurrence's initial position when Fusion allows it (a root-level occurrence without joints or patterns), which adds nothing to the timeline, else capture a Position snapshot. initial_position: refuse rather than capture. capture: always capture a snapshot. The reply's placement says which was used." } }, "required": [@@ -3082,6 +3251,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 +4801,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 +4855,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": [@@ -6989,5 +7273,27 @@ } }, "description": "Main-thread live preview ownership/geometry/history observation. Cached operation receipts do not establish current graphics liveness. Old-session history remains separately readable through mech_operation_status; no native reference adoption or lifecycle repair is provided here."+ },+ "efficiency_report": {+ "type": "object",+ "description": "Read what makes the design slow to recompute, open and edit, held against the efficiency playbook (skills/fusion-design-efficiency): timeline make-up and health, Position snapshots, Move features, size, repeated definitions made by Paste New, instance-weighted faces, imported history, wires in a parametric design, big sketches, pattern compute options, modeled threads, meshes and empty components. Returns metrics and findings (rule, severity, measured, limit, fix). Read-only and needs no revision digest. It is journaled like bom_export, so a long report on a big design is followed with fusion_mech_operation_status after the relay's 60 s; passes past budgetSeconds are skipped and listed, with complete:false.",+ "properties": {+ "budgetSeconds": {+ "type": "number",+ "minimum": 5,+ "maximum": 600,+ "description": "Stop the costly passes after this many seconds (default 120)."+ },+ "computeTiming": {+ "type": "boolean",+ "description": "Also time a full computeAll (slow on big designs; default false)."+ }+ },+ "required": [+ "op",+ "sessionId",+ "operationId",+ "target"+ ] } }
docs/MECHANICAL.md+28−2@@ -170,6 +170,8 @@ Create a sketch with `sketch_create` on `xy`, `xz`, `yz`, a construction plane o Pass current profile references to `feature_create`. The schema enumerates extrusion, revolve, fillet, chamfer, shell, hole, loft, sweep, combine, split, move, mirror, rectangular/circular pattern, draft and thread inputs. Creation targets a native component definition. Assembly-context proxy geometry is for inspection; obtain native references when authoring a definition. Cut/intersect operations require explicit participant bodies. Join requires an unambiguous single body; use combine for explicit multiple-body selection. +A rectangular or circular pattern repeats exactly one kind of entity: `bodies`, `features`, `faces` or `occurrences`. Pattern a feature or its faces rather than drawing every instance in a sketch: one hole cut once and patterned is far cheaper to recompute than a sketch full of circles. `occurrences` makes a component pattern. It is built in the occurrences' shared parent component, so the part stays one definition and the BOM counts every instance; mixed inputs (`mixed_pattern_inputs`) and occurrences with different parents (`mixed_parents`) are refused. `computeOption` sets Fusion's pattern compute option for bodies, features and faces: `identical` (the default) repeats the first result, `optimized` is fastest but can drop instances that touch other geometry, and `adjust` recomputes every instance and is the slowest. Component patterns have no compute option. The reply's `pattern` says what was patterned, the option Fusion reports and the number of instances.+ Use `feature_edit` to rename or suppress a feature or change the expressions of parameters owned by that feature. `entity_delete` handles supported native deletable entities. `parameter_batch` groups user-parameter creation/deletion and user/model-parameter expression edits in one measured model transaction. Generic mixed operations with file/cloud rollback are outside this contract. ## Select, measure and edit everyday geometry@@ -257,12 +259,16 @@ See [build handoff](build-2026-10-02.md) for the verified checkpoint and live ho `component_create` creates an internal definition and its first occurrence. These operations require a Hybrid design; they never silently change the intent of an existing Part or Assembly design. `occurrence_insert` reuses a definition, or uses `independentCopy:true` to create a separate definition. `parent` is a component-definition reference, so adding children to it affects every instance of that definition. -Create/insert transforms use parent-component coordinates. Creation, insertion and CAD import apply the requested pose to every currently placed parent instance, capture pending positions, and verify the resulting root-context matrices after recompute; `affectedOccurrences` lists these instances and `placementScope` is `all_current_parent_instances`. The definition-local base transform can differ from the computed position after a capture. Occurrence updates use the selected instance's assembly context. The transform input uses `position:[x,y,z]` in mm and `axis:[x,y,z]`, `angle` in degrees; it specifies an absolute pose. `occurrence_update` can ground/unground explicitly and captures changed positions in the timeline. Constrained or grounded placements refuse changes that cannot be applied and measured.+Create/insert transforms use parent-component coordinates. Creation, insertion and CAD import apply the requested pose to every currently placed parent instance, capture pending positions, and verify the resulting root-context matrices after recompute; `affectedOccurrences` lists these instances and `placementScope` is `all_current_parent_instances`. The definition-local base transform can differ from the computed position after a capture. Occurrence updates use the selected instance's assembly context. The transform input uses `position:[x,y,z]` in mm and `axis:[x,y,z]`, `angle` in degrees; it specifies an absolute pose. `occurrence_update` can ground/unground explicitly. A new transform edits the occurrence's initial position when Fusion allows it (a root-level occurrence without joints or patterns; earlier occurrences are edited with the timeline marker rolled back and restored), so nothing joins the timeline; otherwise the position is captured as a snapshot. `method` (`auto`, `initial_position`, `capture`) chooses, and the reply's `placement` says which was used. Constrained or grounded placements refuse changes that cannot be applied and measured. `joint_create` creates an **as-built** rigid, revolute, slider, cylindrical, pin-slot, planar or ball joint. First place the occurrences, then supply exact occurrence references and joint geometry for non-rigid motion. Joint axes are in joint coordinates. Read the returned unit metadata: joint-geometry transform matrices retain explicitly labeled native cm translations, while motion values use mm/degrees. `joint_edit` reads/sets enabled limits, rest positions and motion values in mm/degrees, and rejects out-of-limit driving. Rigid groups have separate create/edit/delete operations. Joint and group suppression changes capture pending native state before recompute; responses report `positionCaptured`. Full motion-study authoring remains a comparison gap. `interference` analyzes selected occurrence instances or root-context body proxies and returns overlap volumes without adding interference bodies to the model. `assembly_properties` reads whole-assembly, component, occurrence or body mass, volume, area and center of mass with explicit units and coordinate scope. Material values reflect Fusion's assigned/default materials; they are not a supplier certification. +## Design efficiency++`efficiency_report` reads what makes a design slow to recompute, open and edit: timeline make-up and health, Position snapshots, Move features, size, repeated definitions made by Paste New, instance-weighted faces, imported history and wires in a parametric design, big sketches, pattern compute options, modeled threads, meshes and empty components. It returns `metrics` and `findings` (rule, severity, measured, limit, fix), worst first. It never changes the model and needs no revision digest; it is journaled like `bom_export`, so a long report on a big design is followed with `fusion_mech_operation_status`. Passes past `budgetSeconds` (default 120) are skipped and listed with `complete:false`; `computeTiming:true` also times a full `computeAll`. The rules and the benchmarks behind them are in `skills/fusion-design-efficiency/SKILL.md`.+ ## McMaster and purchased CAD `mcmaster_discover` reports availability of Fusion's native Insert McMaster-Carr command. `mcmaster_open` launches its interactive supplier picker in the targeted Hybrid design. Its recorded result is a launch request, not proof that a part was inserted. Inspect again after insertion or cancellation. This operation does not place orders.@@ -284,7 +290,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+315−8@@ -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": {@@ -1117,7 +1129,10 @@ "threadType": {}, "designation": {}, "threadClass": {},- "modeled": {}+ "modeled": {},+ "features": {},+ "occurrences": {},+ "computeOption": {} }, "required": [ "op",@@ -1126,7 +1141,7 @@ "target", "type" ],- "description": "Create a typed native solid feature. All length quantities are mm (or expressions), angles deg. Cut/intersect require explicit participant bodies. Join needs a single existing component body; combine selects multiple bodies explicitly. Feature availability is runtime-checked; missing APIs fail before commit.",+ "description": "Create a typed native solid feature. All length quantities are mm (or expressions), angles deg. Cut/intersect require explicit participant bodies. Join needs a single existing component body; combine selects multiple bodies explicitly. Feature availability is runtime-checked; missing APIs fail before commit. Rectangular and circular patterns repeat exactly one of bodies, features, faces or occurrences (a component pattern, built in the occurrences' shared parent so the part stays one definition); computeOption is identical (default), optimized or adjust and does not apply to component patterns.", "oneOf": [ { "type": "object",@@ -2383,11 +2398,60 @@ "type": "string" } ]+ },+ "features": {+ "type": "array",+ "items": {+ "type": "object",+ "required": [+ "documentId",+ "revision",+ "objectType"+ ],+ "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring."+ },+ "minItems": 1,+ "maxItems": 256+ },+ "faces": {+ "type": "array",+ "items": {+ "type": "object",+ "required": [+ "documentId",+ "revision",+ "objectType"+ ],+ "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring."+ },+ "minItems": 1,+ "maxItems": 256+ },+ "occurrences": {+ "type": "array",+ "items": {+ "type": "object",+ "required": [+ "documentId",+ "revision",+ "objectType"+ ],+ "description": "Occurrence reference returned by inspect in this document revision; all must share one parent component, where the component pattern is built."+ },+ "minItems": 1,+ "maxItems": 256+ },+ "computeOption": {+ "enum": [+ "identical",+ "optimized",+ "adjust"+ ],+ "description": "identical (default) repeats the first result; optimized is fastest but can drop instances that touch other geometry; adjust recomputes every instance (slowest). Ignored for occurrences." } }, "required": [ "type",- "bodies", "axis", "count", "spacing"@@ -2487,11 +2551,60 @@ "type": "string" } ]+ },+ "features": {+ "type": "array",+ "items": {+ "type": "object",+ "required": [+ "documentId",+ "revision",+ "objectType"+ ],+ "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring."+ },+ "minItems": 1,+ "maxItems": 256+ },+ "faces": {+ "type": "array",+ "items": {+ "type": "object",+ "required": [+ "documentId",+ "revision",+ "objectType"+ ],+ "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring."+ },+ "minItems": 1,+ "maxItems": 256+ },+ "occurrences": {+ "type": "array",+ "items": {+ "type": "object",+ "required": [+ "documentId",+ "revision",+ "objectType"+ ],+ "description": "Occurrence reference returned by inspect in this document revision; all must share one parent component, where the component pattern is built."+ },+ "minItems": 1,+ "maxItems": 256+ },+ "computeOption": {+ "enum": [+ "identical",+ "optimized",+ "adjust"+ ],+ "description": "identical (default) repeats the first result; optimized is fastest but can drop instances that touch other geometry; adjust recomputes every instance (slowest). Ignored for occurrences." } }, "required": [ "type",- "bodies", "axis", "count" ],@@ -2905,6 +3018,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": [],@@ -3026,6 +3186,15 @@ }, "groundedToParent": { "type": "boolean"+ },+ "method": {+ "type": "string",+ "enum": [+ "auto",+ "initial_position",+ "capture"+ ],+ "description": "How a new transform is kept. auto (default): edit the occurrence's initial position when Fusion allows it (a root-level occurrence without joints or patterns), which adds nothing to the timeline, else capture a Position snapshot. initial_position: refuse rather than capture. capture: always capture a snapshot. The reply's placement says which was used." } }, "required": [@@ -3097,6 +3266,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 +4816,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 +4870,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": [@@ -7004,6 +7288,28 @@ } }, "description": "Main-thread live preview ownership/geometry/history observation. Cached operation receipts do not establish current graphics liveness. Old-session history remains separately readable through mech_operation_status; no native reference adoption or lifecycle repair is provided here."+ },+ "efficiency_report": {+ "type": "object",+ "description": "Read what makes the design slow to recompute, open and edit, held against the efficiency playbook (skills/fusion-design-efficiency): timeline make-up and health, Position snapshots, Move features, size, repeated definitions made by Paste New, instance-weighted faces, imported history, wires in a parametric design, big sketches, pattern compute options, modeled threads, meshes and empty components. Returns metrics and findings (rule, severity, measured, limit, fix). Read-only and needs no revision digest. It is journaled like bom_export, so a long report on a big design is followed with fusion_mech_operation_status after the relay's 60 s; passes past budgetSeconds are skipped and listed, with complete:false.",+ "properties": {+ "budgetSeconds": {+ "type": "number",+ "minimum": 5,+ "maximum": 600,+ "description": "Stop the costly passes after this many seconds (default 120)."+ },+ "computeTiming": {+ "type": "boolean",+ "description": "Also time a full computeAll (slow on big designs; default false)."+ }+ },+ "required": [+ "op",+ "sessionId",+ "operationId",+ "target"+ ] } }, "transactions": {@@ -7034,6 +7340,7 @@ "component_create", "component_metadata", "construction_axis",+ "efficiency_report", "entity_delete", "exploded_preview_create", "exploded_preview_dispose",
rust/core/tests/golden/mech_validation.json@@ -1 +1 @@