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
Performance on big assemblies: revision digest, inspect, references, write guards, bom and efficiency_report
14 files changed
+297−79
diff truncated
addin/AdomBridge/commands/mechanical/assemblies.py+2−2addin/AdomBridge/commands/mechanical/assembly_state.py+5−2addin/AdomBridge/commands/mechanical/bill_of_materials.py+57−22addin/AdomBridge/commands/mechanical/core.py+94−17addin/AdomBridge/commands/mechanical/efficiency.py+15−12addin/AdomBridge/commands/mechanical/geometry.py+3−3addin/AdomBridge/commands/mechanical/operations.py+25−14addin/AdomBridge/commands/mechanical/readback.py+32−1addin/AdomBridge/commands/mechanical/regular_joints.py+5−1addin/AdomBridge/commands/mechanical/schema.json+20addin/AdomBridge/commands/mechanical/transactions.py+2−4docs/MECHANICAL.md+17−1rust/core/tests/golden/mech_capabilities.json+20rust/core/tests/golden/mech_validation.jsonaddin/AdomBridge/commands/mechanical/assemblies.py+2−2@@ -4,7 +4,7 @@ import math import adsk.core import adsk.fusion -from .core import (identity, items, number, point_mm, ref, require, resolve, same, target)+from .core import (identity, in_root_context, items, number, point_mm, ref, require, resolve, same, target) from .assembly_state import GROUP, MOTION_SCALES, joint_data, joint_motion, metadata, rigid_group_data from .sketches import collection, component, named @@ -265,7 +265,7 @@ def component_metadata(design, value, args): def require_root_context(design, entity): context = entity if entity.objectType.endswith("::Occurrence") else getattr(entity, "assemblyContext", None) if context is not None:- require(any(same(o, context) and o.fullPathName == context.fullPathName for o in items(design.rootComponent.allOccurrences)),+ require(in_root_context(design, context), "root_context_required", "Assembly analysis and joints require a root-context occurrence reference from inspect.") else: owner = getattr(entity, "parentComponent", None) or getattr(entity, "component", None)
addin/AdomBridge/commands/mechanical/assembly_state.py+5−2@@ -21,11 +21,14 @@ def attributes(entity): _native_properties = {} -def native_properties(component):- """(partNumber, description), read once per component revision this session."""+def native_properties(component, read=True):+ """(partNumber, description), read once per component revision this session. read=False answers+ None for a component not read yet instead of reading it.""" from .core import identity key = (identity(component), str(component.revisionId)) if key not in _native_properties:+ if not read:+ return None if len(_native_properties) > 50000: _native_properties.clear() _native_properties[key] = (getattr(component, "partNumber", ""), getattr(component, "description", ""))
addin/AdomBridge/commands/mechanical/bill_of_materials.py+57−22@@ -2,11 +2,12 @@ import csv from pathlib import Path import tempfile+import time import adsk.core import adsk.fusion from . import bom_views-from .assembly_state import metadata+from .assembly_state import metadata, native_properties from .core import identity, items, occurrence_suppression, ref, require, target from .exporting import publish_staged from .configurations import provenance, snapshot as configuration_snapshot, source_link@@ -39,7 +40,11 @@ def wire_length_mm(comp): return round(best * 10, 1) if best is not None else stock_length_mm(comp) -def generate(design, value, args):+def generate(design, value, args, started=None, budget_seconds=None):+ """budget_seconds, counted from started: past it, part numbers and descriptions not read yet are left+ for the next run (each is a cloud read of about 0.5 s on a cloud assembly: 280 of them took 282 s)."""+ deadline = started + budget_seconds if started is not None and budget_seconds else None+ pending = [] configuration = configuration_snapshot(design, strict=True) table = configuration["table"] columns = {column["id"]: column for column in table["columns"]} if table else {}@@ -59,6 +64,18 @@ def generate(design, value, args): physical = {"mass": 0.0, "volume": 0.0, "bodyInstances": 0} accuracy = adsk.fusion.CalculationAccuracy.HighCalculationAccuracy budget = {"visited": 0}+ # what the walks read about a definition, read once per definition instead of once per instance+ # (a 4,579-occurrence assembly has 1,249 definitions): every instance of a definition weighs the same+ counts, physicals, metas, kits, refs = {}, {}, {}, {}, {}+ def body_count(comp, key):+ if key not in counts:+ counts[key] = comp.bRepBodies.count+ return counts[key]+ def direct_physical(comp, key):+ if key not in physicals:+ props = [b.getPhysicalProperties(accuracy) for b in items(comp.bRepBodies)]+ physicals[key] = (sum(p.mass for p in props), sum(p.volume * 1000 for p in props), len(props))+ return physicals[key] def visit(comp, depth, ancestry): budget["visited"] += 1 require(depth <= 100 and budget["visited"] <= 20000, "bom_budget", "Assembly exceeds combined purchasing/physical traversal budget.")@@ -69,12 +86,10 @@ def generate(design, value, args): # once per physical instance, including root bodies and repeated children. def physical_walk(comp, occurrence=None, depth=0, ancestry=()): key = visit(comp, depth, ancestry)- bodies = items(occurrence.bRepBodies if occurrence else comp.bRepBodies)- for body in bodies:- props = body.getPhysicalProperties(accuracy)- physical["mass"] += props.mass- physical["volume"] += props.volume * 1000- physical["bodyInstances"] += 1+ mass, volume, bodies = direct_physical(comp, key)+ physical["mass"] += mass+ physical["volume"] += volume+ physical["bodyInstances"] += bodies children = items(occurrence.childOccurrences if occurrence else comp.occurrences) for child in children: if suppression(child) is None:@@ -87,11 +102,19 @@ def generate(design, value, args): 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):+ if occurrence and bom_views.is_library(comp.name, body_count(comp, key)) and not args.get("includeLibraries", False): # a palette of parts kept to copy from: counting it adds one of everything it holds excluded.append({"path": path, "reason": "library_group"}) return- data = metadata(comp, args.get("nativeProperties", True))+ if key not in metas:+ native = args.get("nativeProperties", True)+ if native and deadline is not None and time.perf_counter() > deadline and native_properties(comp, read=False) is None:+ native = False+ pending.append(key)+ metas[key] = metadata(comp, native)+ if key in pending:+ metas[key]["nativePending"] = True+ data = dict(metas[key]) if data.get("excludeFromBom", False): excluded.append({"path": path, "reason": "excludeFromBom"}) return@@ -104,26 +127,29 @@ def generate(design, value, args): # 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 not (explicit or not occurrence or not children or not args.get("inferKits", True)) and key not in kits:+ kits[key] = bom_views.inferred_kit(comp.name, [child.component.name for child in children])+ kit_reason = None if explicit or not occurrence or not children or not args.get("inferKits", True) else kits[key] if kit_reason: data = {**data, "kitReason": kit_reason} purchased = (bool(data.get("asPurchased", False)) or bool(kit_reason)) and not args.get("explodePurchased", False)+ if key not in refs:+ refs[key] = ref(comp, value) row = {"path": path, "parentPath": parent_path, "depth": depth, "componentId": key, "metadata": data,- "bodyCount": comp.bRepBodies.count, "childCount": len(children), "asPurchased": purchased,- "componentRef": ref(comp, value), "occurrenceRef": ref(occurrence, value) if occurrence else None}+ "bodyCount": body_count(comp, key), "childCount": len(children), "asPurchased": purchased,+ "componentRef": dict(refs[key]), "occurrenceRef": ref(occurrence, value) if occurrence else None} instances.append(row)- if comp.bRepBodies.count or purchased:+ if body_count(comp, key) or purchased: 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)]+ direct_mass, direct_volume, _ = (0.0, 0.0, 0) if purchased else direct_physical(comp, key) kind = None if purchased else bom_views.category(data) parts[key] = {"componentId": key, **data, "quantity": 0, "occurrencePaths": [], "scope": "component_subtree" if purchased else "direct_component_bodies",- "bodyCount": comp.bRepBodies.count,+ "bodyCount": body_count(comp, key), "lengthMm": stock_length_mm(comp) if kind in bom_views.LINEAR else wire_length_mm(comp) if kind == "wire" else None,- "unitMass": props.mass if props else sum(p.mass for p in direct_properties),- "unitVolume": props.volume * 1000 if props else sum(p.volume * 1000 for p in direct_properties)}+ "unitMass": props.mass if props else direct_mass,+ "unitVolume": props.volume * 1000 if props else direct_volume} parts[key]["quantity"] += 1 parts[key]["occurrencePaths"].append(path) if not purchased:@@ -153,20 +179,28 @@ def generate(design, value, args): "physical": {**physical, "scope": "all unsuppressed direct-body instances; BOM exclusions do not remove physical mass", "materialBasis": "Fusion assigned/default material; supplier provenance does not certify modeled material density"}, "units": {"mass": "kg", "volume": "mm3"}, "calculationAccuracy": "high",- "identityScope": "running add-in session; supplier metadata persists in native archives"}+ "identityScope": "running add-in session; supplier metadata persists in native archives",+ **({"nativePending": len(pending),+ "nativeHint": "%d part numbers and descriptions were left unread (each is a cloud read of about 0.5 s): run bom "+ "again and it continues from what this run read, or pass budgetSeconds to wait longer, or "+ "nativeProperties:false to skip them." % len(pending)} if pending else {})} def bom(app, args):+ started = time.perf_counter() _, design, value = target(app, args)- return generate(design, value, args)+ return generate(design, value, args, started, args.get("budgetSeconds", 40)) def bom_export(app, args):+ started = time.perf_counter() _, design, value = target(app, args, True, model_write=False) destination = Path(args["filename"]) require(destination.is_absolute() and destination.suffix.lower() == ".csv" and not destination.is_symlink() and not destination.is_dir(), "invalid_destination", "BOM destination must be an absolute regular .csv path.") require(args.get("overwrite", False) or not destination.exists(), "destination_exists", "Pass overwrite:true to replace an existing CSV.")- result = generate(design, value, args)+ # journaled, but its first answer still has ab's 60 s, and Fusion stays busy while it reads: the same+ # budget as bom, and a CSV written past it says how many part numbers it left blank (export again)+ result = generate(design, value, args, started, args.get("budgetSeconds", 40)) destination.parent.mkdir(parents=True, exist_ok=True) published = {} try:@@ -216,6 +250,7 @@ def bom_export(app, args): raise published["cleanupWarning"] = str(exc) return {"target": value, "partCount": result["partCount"], "mode": args.get("mode", "flat"), "rowCount": len(rows),+ **{k: result[k] for k in ("nativePending", "nativeHint") if k in result}, "totals": result["totals"], "warningCount": len(result["warnings"]), "excludedCount": len(result["excluded"]), "calculationAccuracy": result["calculationAccuracy"], "configuration": result["configuration"], **published}
addin/AdomBridge/commands/mechanical/core.py+94−17@@ -55,6 +55,7 @@ def concrete(entity): # many minutes. The indexes only narrow the candidates; equality is still native object equality. _identity_buckets = {} _identity_tokens = {}+_identity_token_of = {} # identity key -> the token read when the entity was registered MAX_IDENTITIES = 100000 @@ -85,23 +86,40 @@ def identity(entity): 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+ _identity_token_of.clear()+ bucket = _identity_bucket(entity)+ for obj, key in _identity_buckets.get(bucket, ()):+ if obj.isValid and same(obj, entity):+ return key+ # only a miss (new, or renamed since) reads the token: an occurrence's entityToken costs about 2 ms,+ # which was a quarter of every revision digest on a 4,579-occurrence assembly+ token = _identity_token(entity)+ for obj, key in _identity_tokens.get(token, ()) if token else ():+ if obj.isValid and same(obj, entity):+ _identity_buckets.setdefault(bucket, []).append((obj, key))+ _identity_token_of[key] = token[1]+ 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))+ _identity_token_of[key] = token[1] return key +def entity_token(entity):+ """The token a reference carries. Reading an occurrence's costs about 5 ms, which was over half of+ inspect on a 4,579-occurrence assembly, so an occurrence registered this session reuses the token+ read when it was registered: Fusion resolves every token it gave an entity to that same entity."""+ if entity.objectType.endswith("::Occurrence"):+ token = _identity_token_of.get(identity(entity))+ if token:+ return token+ return entity.entityToken++ def document_identity(doc): require(threading.current_thread() is threading.main_thread(), "wrong_thread", "Document identity requires the Fusion main thread.") require(doc is not None and doc.isValid, "wrong_document", "Document identity requires a live document.")@@ -157,6 +175,25 @@ def reference_version(component): return {"error": str(error)[:120]} +def components_by_occurrence(design, all_occurrences, size):+ """Every component of the design, found through the occurrences the digest reads anyway: Fusion keeps+ a component only while it has an occurrence (the root aside). allComponents.item() costs about 5 ms a+ component (6 s of a 1,249-component digest); occurrence.component about 0.05 ms. If the counts ever+ disagree, the slow full list is used."""+ root = design.rootComponent+ found, seen = [root], {identity(root)}+ for occurrence in all_occurrences:+ comp = occurrence.component+ key = identity(comp)+ if key not in seen:+ seen.add(key)+ found.append(comp)+ if len(found) == size:+ return found+ collection = design.allComponents+ return [collection.item(index) for index in range(size)]++ def revision(design): """Initialize all mesh collections, then capture the unchanged revision graph. @@ -189,7 +226,8 @@ def revision(design): collection = design.allComponents size = collection.count 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)]+ all_occurrences = items(design.rootComponent.allOccurrences)+ component_handles = components_by_occurrence(design, all_occurrences, size) mesh_collections = [] # Retain all native collection handles through the full digest. # 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.@@ -223,7 +261,7 @@ def revision(design): [[identity(s), str(s.revisionId)] for s in items(comp.sketches)], component_state(comp), [feature_data(f, order=i) for i, f in enumerate(items(comp.features))] if hasattr(comp, "features") else [], construction_state(comp)]) occurrences = [[identity(o), identity(o.component), list(o.transform2.asArray()),- occurrence_suppression(o), occurrence_state(o)] for o in items(design.rootComponent.allOccurrences)]+ occurrence_suppression(o), occurrence_state(o)] for o in all_occurrences] params = [[p.name, p.expression, p.unit] for p in items(design.allParameters)] result = digest([components, occurrences, params, design.timeline.count, design.timeline.markerPosition, configuration_snapshot(design)])@@ -337,8 +375,8 @@ def ref(entity, target_value, occurrence=None): native = entity if is_occurrence else (getattr(entity, "nativeObject", None) or entity) context = None if is_occurrence else (occurrence or getattr(entity, "assemblyContext", None)) result = {"documentId": target_value["documentId"], "revision": target_value["revision"],- "token": native.entityToken, "objectType": native.objectType,- "occurrenceToken": context.entityToken if context else None,+ "token": entity_token(native), "objectType": native.objectType,+ "occurrenceToken": entity_token(context) if context else None, "occurrencePath": context.fullPathName if context else (entity.fullPathName if is_occurrence else None)} if native.objectType == "adsk::fusion::Component" and same(native, native.parentDesign.rootComponent): # Fusion can emit a root token that findEntityByToken cannot resolve.@@ -353,10 +391,44 @@ def ref(entity, target_value, occurrence=None): return result +def occurrence_at(design, path):+ """The root-context occurrence at a full path, found level by level with a few reads instead of by+ reading every occurrence's path (about 0.3 s per lookup on a 4,579-occurrence assembly). A name can+ itself hold '+', so each level takes the shortest name that exists. None when nothing is there."""+ level, parts = design.rootComponent.occurrences, path.split("+") if path else []+ try:+ while parts:+ for size in range(1, len(parts) + 1):+ occurrence = level.itemByName("+".join(parts[:size]))+ if occurrence is not None:+ break+ else:+ return None+ parts = parts[size:]+ if not parts:+ return occurrence+ level = occurrence.childOccurrences+ except (AttributeError, RuntimeError):+ return None # callers fall back to reading every occurrence+ return None+++def in_root_context(design, occurrence):+ """Whether an occurrence is one of the root context's (rootComponent.allOccurrences), by its path+ first; only a miss reads them all."""+ found = occurrence_at(design, occurrence.fullPathName)+ if found is not None and same(found, occurrence):+ return True+ return any(same(occurrence, item) for item in items(design.rootComponent.allOccurrences))++ def occurrence_context(design, entity, path): entity = concrete(entity) require(entity.objectType.endswith("::Occurrence"), "invalid_context", "Context must resolve to an occurrence.") native = getattr(entity, "nativeObject", None) or entity+ found = occurrence_at(design, path)+ if found is not None and found.fullPathName == path and same(getattr(found, "nativeObject", None) or found, native):+ return found candidates = [o for o in items(design.rootComponent.allOccurrences) if o.fullPathName == path and same(getattr(o, "nativeObject", None) or o, native)] if candidates:@@ -516,19 +588,24 @@ def parameter_data(design, parameter): return row -def recovery_snapshot(design):- """Semantic recovery witness; native revision IDs legitimately change on undo."""+def recovery_snapshot(design, health_state=None):+ """Semantic recovery witness; native revision IDs legitimately change on undo. health_state is the+ health just read on this same unchanged model, when the caller has it (each walk reads every timeline+ item, about 23 ms apiece on a big assembly).""" from .readback import construction_state, feature_data, sketch_data, topology_witness from .assembly_state import component_state, occurrence_state from .configurations import snapshot as configuration_snapshot configuration = configuration_snapshot(design, strict=True)+ all_occurrences = items(design.rootComponent.allOccurrences) return {"configuration": configuration, "parameters": [[p.name, p.expression, p.unit] for p in items(design.allParameters)], "components": [[identity(c), c.name, [topology_witness(b) for b in items(c.bRepBodies)], [sketch_data(s) for s in items(c.sketches)],- [feature_data(f, order=i) for i, f in enumerate(items(c.features))], component_state(c), construction_state(c)] for c in items(design.allComponents)],- "occurrences": [[identity(o), list(o.transform2.asArray()), occurrence_state(o)] for o in items(design.rootComponent.allOccurrences)],- "timeline": design.timeline.count, "timelineMarker": design.timeline.markerPosition, "health": health(design)}+ [feature_data(f, order=i) for i, f in enumerate(items(c.features))], component_state(c), construction_state(c)]+ for c in components_by_occurrence(design, all_occurrences, design.allComponents.count)],+ "occurrences": [[identity(o), list(o.transform2.asArray()), occurrence_state(o)] for o in all_occurrences],+ "timeline": design.timeline.count, "timelineMarker": design.timeline.markerPosition,+ "health": health_state if health_state is not None else health(design)} def number(value, name, positive=False):
addin/AdomBridge/commands/mechanical/efficiency.py+15−12@@ -8,7 +8,7 @@ import time import adsk.core import adsk.fusion -from .core import active, require+from .core import components_by_occurrence, current_target, identity, require, target from .bom_views import GAUGE, WIRING_HARDWARE # limits people cite (Autodesk's large-assembly article, AU classes, forum benchmarks); see the playbook@@ -68,10 +68,13 @@ def measure(app, design, budget_seconds=45.0): 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)]+ # components through the occurrences (allComponents.item costs about 5 ms each on a big assembly)+ occurrence_list = root.allOccurrences+ all_occurrences = [occurrence_list.item(i) for i in range(occurrence_list.count)]+ every = components_by_occurrence(design, all_occurrences, 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["occurrences"] = len(all_occurrences) 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,@@ -123,17 +126,16 @@ def measure(app, design, budget_seconds=45.0): 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):+ for occurrence in all_occurrences: if not spare(): break- token = every_occurrence.item(i).component.entityToken- instance_counts[token] = instance_counts.get(token, 0) + 1+ key = identity(occurrence.component)+ instance_counts[key] = instance_counts.get(key, 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)+ instances = instance_counts.get(identity(comp), 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@@ -212,17 +214,18 @@ def findings(m): 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)+ # a full target (with its revision), like every other read: a caller keeps the last one it got, and a+ # target without a revision made the next write refuse+ _, design, value = target(app, args) metrics = measure(app, design, float(args.get("budgetSeconds", 120)))- result = {"target": {"documentId": key}, "complete": not metrics["skipped"], "metrics": metrics, "findings": findings(metrics),+ result = {"target": value, "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)+ result["target"] = current_target(app) return result
addin/AdomBridge/commands/mechanical/geometry.py+3−3@@ -4,7 +4,7 @@ import math import adsk.core import adsk.fusion -from .core import (MAX_ENTITIES, UNITS, items, number, point_mm, ref, require,+from .core import (MAX_ENTITIES, UNITS, in_root_context, items, number, point_mm, ref, require, resolve, same, target) from .readback import concrete_geometry, geometry_data from .sketches import component, named, point@@ -26,14 +26,14 @@ def owner(entity): def root_entity(design, entity): """Never guess which instance of a non-root definition the caller means.""" if entity.objectType.endswith("::Occurrence"):- require(any(same(entity, item) for item in items(design.rootComponent.allOccurrences)),+ require(in_root_context(design, entity), "root_context_required", "Occurrence must be resolved in the root assembly context.") else: context = getattr(entity, "assemblyContext", None) require(context is not None or same(owner(entity), design.rootComponent), "occurrence_required", "Root-space geometry in a child component requires an occurrence-context reference.") if context is not None:- require(any(same(context, item) for item in items(design.rootComponent.allOccurrences)),+ require(in_root_context(design, context), "root_context_required", "Geometry context must be a root assembly occurrence.") return entity
addin/AdomBridge/commands/mechanical/operations.py+25−14@@ -4,7 +4,7 @@ import re import adsk.core import adsk.fusion -from .core import (MechanicalError, active, body_data, current_target, health,+from .core import (MechanicalError, components_by_occurrence, active, body_data, current_target, health, identity, items, number, parameter_data, recovery_snapshot, ref, require, require_healthy, resolve, target, UNITS) from .exporting import export_design@@ -24,11 +24,19 @@ def inspect(app, args): # 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):+ # body metrics (physical properties and a bounding box, about 10 ms a body) and timeline health+ # (about 22 ms a timeline item) are read for a small design or when asked: on a 4,579-occurrence+ # assembly they were 40 s of a 103 s inspect; inspect_entity and the health op read them for one thing+ metrics = args.get("bodyMetrics", design.allComponents.count <= 200)+ with_health = args.get("health", design.timeline.count <= 200)+ # allComponents.item() costs about 5 ms a component, and inspect walked the collection five times+ all_occurrences = items(design.rootComponent.allOccurrences)+ all_components = components_by_occurrence(design, all_occurrences, design.allComponents.count)+ for comp in all_components: 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)+ row = body_data(body) if metrics else {"name": body.name, "space": "component"} row.update({"ref": ref(body, value), "componentId": identity(comp)}) bodies.append(row) for sketch in items(comp.sketches):@@ -44,7 +52,7 @@ def inspect(app, args): from .readback import plane_data for plane in [comp.xYConstructionPlane, comp.xZConstructionPlane, comp.yZConstructionPlane] + items(comp.constructionPlanes): planes.append({**plane_data(plane, value), "componentId": identity(comp)})- for occ in items(design.rootComponent.allOccurrences):+ for occ in all_occurrences: matrix = list(occ.transform2.asArray()) # Matrix4x4.asArray is row-major; translation lies at 3,7,11. for i in (3, 7, 11):@@ -56,17 +64,20 @@ 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, "componentPropertiesRead": bool(native), "document": {"name": doc.name, "creationId": doc.creationId,+ return {"target": value, "componentPropertiesRead": bool(native), "bodyMetricsRead": bool(metrics),+ "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,- "joints": [joint_data(j, value) for comp in items(design.allComponents)+ "joints": [joint_data(j, value) for comp in all_components for name in ("joints", "asBuiltJoints") for j in items(getattr(comp, name))],- "rigidGroups": [rigid_group_data(g, value) for comp in items(design.allComponents) for g in items(comp.rigidGroups)],- "jointOrigins": [origin_data(o, value) for comp in items(design.allComponents) for o in items(comp.jointOrigins)],- "motionLinks": [link_data(o, value) for comp in items(design.allComponents) for o in items(comp.motionLinks)] if hasattr(design.rootComponent, "motionLinks") else [],+ "rigidGroups": [rigid_group_data(g, value) for comp in all_components for g in items(comp.rigidGroups)],+ "jointOrigins": [origin_data(o, value) for comp in all_components for o in items(comp.jointOrigins)],+ "motionLinks": [link_data(o, value) for comp in all_components for o in items(comp.motionLinks)] if hasattr(design.rootComponent, "motionLinks") else [], "parameters": [parameter_data(design, p) for p in items(design.allParameters)],- "health": health(design), "complete": configuration["complete"], "configuration": configuration}+ "health": health(design) if with_health else {"skipped": True, "timelineCount": design.timeline.count,+ "hint": "Big timeline: run op health, or pass health:true."},+ "complete": configuration["complete"], "configuration": configuration} def inspect_entity(app, args):@@ -179,13 +190,13 @@ def create_box(design, dimensions): def parameter_set(app, args): _, design, before_target = target(app, args, True)- require_healthy(design, False)+ healthy = require_healthy(design, False) name, expression = args.get("name"), args.get("expression") require(isinstance(name, str) and isinstance(expression, str) and expression.strip(), "invalid_input", "name and expression are required strings.") parameter = design.allParameters.itemByName(name) require(parameter is not None, "parameter_missing", "No parameter named " + name) require(design.unitsManager.isValidExpression(expression, parameter.unit), "invalid_expression", "Expression is invalid for the parameter's unit.")- before, old = recovery_snapshot(design), parameter.expression+ before, old = recovery_snapshot(design, healthy), parameter.expression try: parameter.expression = expression state = require_healthy(design)@@ -205,13 +216,13 @@ def parameter_set(app, args): def parameter_create(app, args): _, design, _ = target(app, args, True)- require_healthy(design, False)+ healthy = require_healthy(design, False) name, expression, unit = args.get("name"), args.get("expression"), args.get("unit") require(isinstance(name, str) and re.fullmatch(r"[A-Za-z][A-Za-z0-9_]{0,63}", name), "invalid_input", "Use a short parameter identifier.") require(unit in ("mm", "deg", "kg", ""), "invalid_input", "unit must be mm, deg, kg or empty for unitless.") require(isinstance(expression, str) and design.unitsManager.isValidExpression(expression, unit), "invalid_expression", "Expression must match the declared unit.") require(design.allParameters.itemByName(name) is None, "parameter_exists", "Parameter already exists.")- before = recovery_snapshot(design)+ before = recovery_snapshot(design, healthy) parameter = None try: parameter = design.userParameters.add(name, adsk.core.ValueInput.createByString(expression), unit, args.get("comment", ""))
addin/AdomBridge/commands/mechanical/readback.py+32−1@@ -2,7 +2,7 @@ import json import adsk.core import adsk.fusion-from .core import body_data, digest, items, point_mm, ref, require, same+from .core import body_data, digest, identity, items, point_mm, ref, require, same def concrete_geometry(geometry):@@ -251,7 +251,26 @@ def topology_data(body, value): return result +# topology witnesses by body: a body's revisionId changes whenever its geometry does, and the rest of the+# witness (its name, and its mass through its material) is in the key, so a write reads face by face only+# the bodies that changed since the last witness instead of every body in the design+_WITNESSES = {}++ def topology_witness(body):+ material = body.material+ key = (identity(body), str(body.revisionId), body.name, material.id if material is not None else None)+ cached = _WITNESSES.get(key)+ if cached is not None:+ return json.loads(cached)+ result = _topology_witness(body)+ if len(_WITNESSES) > 50000:+ _WITNESSES.clear()+ _WITNESSES[key] = json.dumps(result)+ return result+++def _topology_witness(body): result = body_data(body) # Compare geometry rather than revision or transient topology IDs after undo. result["faceGeometry"] = sorted([@@ -263,6 +282,12 @@ def topology_witness(body): return result +# features whose timeline position Fusion will not give (imported or grouped features raise on the+# index getter), keyed by identity and their component's revision: each refused read cost about 12 ms,+# three quarters of a 653-feature digest's index reads, for an index the digest never had+_UNINDEXED = set()++ def feature_data(feature, value=None, order=None): result = {"name": feature.name, "type": feature.objectType} if order is not None:@@ -272,6 +297,11 @@ def feature_data(feature, value=None, order=None): timeline = getattr(feature, "timelineObject", None) if timeline: result["suppressed"] = timeline.isSuppressed+ owner = getattr(feature, "parentComponent", None)+ known = (identity(feature), str(owner.revisionId) if owner is not None else None)+ if known in _UNINDEXED:+ result.update(index=None, indexUnavailable="native_timeline_position_unavailable")+ return result try: result["index"] = timeline.index except RuntimeError as exc:@@ -282,5 +312,6 @@ def feature_data(feature, value=None, order=None): if "InternalValidationError : res >= 0" not in str(exc): raise require(timeline.isValid, "invalid_timeline", "Feature timeline object is no longer valid.")+ _UNINDEXED.add(known) result.update(index=None, indexUnavailable="native_timeline_position_unavailable") return result
addin/AdomBridge/commands/mechanical/regular_joints.py+5−1@@ -299,9 +299,13 @@ def poses(design): def pose_result(design, before): live = items(design.rootComponent.allOccurrences) require(len(live) == len(before), "joint_structure_changed", "Joint solving unexpectedly changed the occurrence count.")+ # each occurrence against the live one at its path, not against every live one (that was n squared+ # native comparisons: hours on a 4,579-occurrence assembly)+ live_by_path = {o.fullPathName: o for o in live} changed = [] for occurrence, old, grounded, ground_to_parent in before:- require(occurrence.isValid and any(same(occurrence, o) for o in live), "joint_structure_changed", "Joint solving replaced an occurrence.")+ require(occurrence.isValid and same(occurrence, live_by_path.get(occurrence.fullPathName)), "joint_structure_changed",+ "Joint solving replaced an occurrence.") current = list(occurrence.transform2.asArray()) moved = any(not math.isclose(a, b, abs_tol=1e-8, rel_tol=1e-8) for a, b in zip(current, old)) require(bool(occurrence.isGrounded) == grounded and bool(getattr(occurrence, "isGroundToParent", False)) == ground_to_parent,
addin/AdomBridge/commands/mechanical/schema.json+20@@ -15,6 +15,14 @@ "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."+ },+ "bodyMetrics": {+ "type": "boolean",+ "description": "Read each body's physical properties and bounding box (about 10 ms a body). Default: true for designs of 200 components or fewer."+ },+ "health": {+ "type": "boolean",+ "description": "Read timeline health (about 22 ms a timeline item). Default: true for timelines of 200 items or fewer; otherwise use op health." } }, "description": "Inspect the active Design without mutation. Returns target identity/revision, native component-space bodies and occurrence transforms, parameters and feature health."@@ -4832,6 +4840,12 @@ "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."+ },+ "budgetSeconds": {+ "type": "number",+ "minimum": 5,+ "maximum": 3600,+ "description": "Stop the cloud reads of part numbers and descriptions (about 0.5 s each on a cloud assembly) once the call has run this long (default 40); the rest come back as nativePending, and the next run continues from what this one read." } }, "required": [@@ -4894,6 +4908,12 @@ "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."+ },+ "budgetSeconds": {+ "type": "number",+ "minimum": 5,+ "maximum": 3600,+ "description": "As for bom (default 40): rows of components whose part number was left for later have it blank, and the response's nativePending counts them; export again to fill them in." } }, "required": [
addin/AdomBridge/commands/mechanical/transactions.py+2−4@@ -54,8 +54,7 @@ def probe(app, args): def _probe(app, args): global _verified, _probe_evidence doc, design, _ = target(app, args, True)- require_healthy(design, False)- before = recovery_snapshot(design)+ before = recovery_snapshot(design, require_healthy(design, False)) name = "AdomProbe" + uuid.uuid4().hex started = False try:@@ -112,8 +111,7 @@ def execute(app, args, operation): require(_verified, "transaction_not_verified", "Run transaction_probe in this add-in session before typed feature mutations.") doc, design, value = target(app, args, True) require(design.timeline.markerPosition == design.timeline.count, "timeline_not_at_end", "Finish editing history before a typed model mutation.")- require_healthy(design, False)- before = recovery_snapshot(design)+ before = recovery_snapshot(design, require_healthy(design, False)) started, committed = False, False try: command(app, 'PTransaction.Start "Adom mechanical operation"')
docs/MECHANICAL.md+17−1@@ -22,6 +22,22 @@ To create a separate design, call `new_design` with the session ID and a unique Existing-document mutations also require `target: {"documentId":"...","revision":"..."}` from inspection. Never fabricate or cache these values across an add-in restart. +## Big assemblies++The mechanical layer is measured on a 4,579-occurrence, 1,249-component production assembly built from referenced designs:++| Call | Time |+|---|---|+| revision digest (every guarded call pays it) | 5 s |+| `inspect` with `detail:"target"` | 5 s |+| `inspect` (full) | 13 s |+| resolving one occurrence reference | 6 ms |+| `bom` (part numbers cached) | 15 s |++- On a design with more than 200 components, `inspect` leaves out each body's physical properties and bounds (`bodyMetrics:true` reads them; `bodyMetricsRead` says which you got). With more than 200 timeline items it leaves out the timeline health walk, which reads every item at about 23 ms apiece there (`health:true` reads it, or call `health`).+- The first guarded call after the add-in loads registers every entity once: on that assembly, the first revision digest takes about 45 s instead of 5 s.+- Writes keep their full guards: a health check before, a full recompute and health check after, and the recovery witness. The witness reads again only the bodies whose revision changed since the last one.+ ## Plan exploded assembly steps `exploded_view_plan` returns an immutable source-bound instance/home-pose plan,@@ -316,7 +332,7 @@ Metadata always wins: `makeBuy`, `asPurchased`, `category`, `supplier`, `supplie - 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.+`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). So that `bom` answers inside ab's 60 s budget, it stops those reads once the call has run `budgetSeconds` (default 40): the components not read yet come back with `nativePending`, the response carries `nativePending` (a count) and `nativeHint`, and the next `bom` continues from what this one read. `bom_export` is journaled and reads them all unless given `budgetSeconds`. `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.
rust/core/tests/golden/mech_capabilities.json+20@@ -30,6 +30,14 @@ "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."+ },+ "bodyMetrics": {+ "type": "boolean",+ "description": "Read each body's physical properties and bounding box (about 10 ms a body). Default: true for designs of 200 components or fewer."+ },+ "health": {+ "type": "boolean",+ "description": "Read timeline health (about 22 ms a timeline item). Default: true for timelines of 200 items or fewer; otherwise use op health." } }, "description": "Inspect the active Design without mutation. Returns target identity/revision, native component-space bodies and occurrence transforms, parameters and feature health."@@ -4847,6 +4855,12 @@ "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."+ },+ "budgetSeconds": {+ "type": "number",+ "minimum": 5,+ "maximum": 3600,+ "description": "Stop the cloud reads of part numbers and descriptions (about 0.5 s each on a cloud assembly) once the call has run this long (default 40); the rest come back as nativePending, and the next run continues from what this one read." } }, "required": [@@ -4909,6 +4923,12 @@ "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."+ },+ "budgetSeconds": {+ "type": "number",+ "minimum": 5,+ "maximum": 3600,+ "description": "As for bom (default 40): rows of components whose part number was left for later have it blank, and the response's nativePending counts them; export again to fill them in." } }, "required": [
rust/core/tests/golden/mech_validation.json@@ -1 +1 @@