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.
Performance on big assemblies: revision digest, inspect, references, write guards and bom #28
perf/big-assemblies → masterThe mechanical layer on big assemblies. I profiled it inside Fusion (cProfile) on a 4,579-occurrence, 1,249-component production assembly built from 23 referenced designs. I also timed it end to end through ab on scratch assemblies of 56, 450 and 1,100 occurrences.
On the 4,579-occurrence assembly
| Call | Before | After |
|---|---|---|
| revision digest (every guarded call pays it) | 32.6 s | 5.2 s |
inspect with detail:"target" |
36 to 60 s, often past ab's 60 s | 5 to 6 s |
inspect (full) |
102.8 s, past ab's 60 s | 12.7 to 15.4 s |
| resolving one occurrence reference | 248 ms | 5.7 ms |
bom, part numbers not read yet this session |
296 s, past ab's 60 s, Fusion busy for minutes | 44.6 s; the unread part numbers come back as nativePending |
bom, part numbers already read |
14.8 s | not measured again (the per-definition reads only take work away) |
What changes
- Revision digest:
identity()read every entity's token on every call (an occurrence token costs 2 to 5 ms). It now reads one only on a miss: a new entity, or one renamed since.feature_data()asked every feature for its timeline index, at 12 ms each, and three quarters of them always refuse (imported or grouped features). A refusal is now remembered per feature and component revision.- Components come from the occurrences the digest reads anyway, at 0.05 ms each instead of 5 ms for
allComponents.item. If the counts ever disagree it falls back to the full list.
- References:
- An occurrence registered this session reuses the token read when it was registered. That token is refreshed when the occurrence is matched after a rename.
- Resolving an occurrence reference used to read the full path of every occurrence in the design. It now walks down the path's names, and falls back to the full walk only on a miss.
- The same path lookup serves the root-context checks of
measure,geometry_query,interferenceand joints.
- Joint solving compared every occurrence with every other to prove none was replaced: n squared native comparisons, which is hours at this size. It now compares each occurrence with the one at its path.
- Write guards:
- The recovery witness kept each body's face-by-face witness by identity, revisionId, name and material, so it reads again only bodies that changed.
- It takes the health walk the write's pre-check just did, instead of walking the timeline a second time.
- It finds components through the occurrences.
bom:- Metadata, body count, physical properties, kit inference and the component reference are read once per definition instead of once per instance.
budgetSeconds(default 40, counted from the start of the call) stops the cloud reads of part numbers and descriptions, about 0.5 s each, once spent. The components left unread carrynativePending, and the response carriesnativePending(a count) andnativeHint. The next run continues from what this one read: they stay cached per component revision.bom_exporttakes the same budget. It is journaled, but its first answer still has ab's 60 s, and Fusion stays busy while it reads. A cold export of that assembly kept Fusion busy for 4 minutes.
efficiency_reportfinds components through the occurrences and counts instances by identity instead of by token. It also returns the full target: without the revision, a caller that keeps the last target it got had its next write refused.inspecton a design with more than 200 components leaves out body metrics;bodyMetrics:truereads them, andbodyMetricsReadsays which you got. With more than 200 timeline items it leaves out the health walk;health:truereads it, or callhealth.
Fusion costs measured (2705, that assembly)
| Native read | Cost |
|---|---|
Occurrence.entityToken |
2 to 5 ms |
Design.allComponents.item |
5 ms |
TimelineObject.index |
12 ms (three quarters refuse on imported features) |
Timeline.item |
23 ms at any index (about 1 ms on a scratch design) |
Component.partNumber, .description |
0.5 s each, cloud reads |
getPhysicalProperties |
fast once Fusion has computed it |
Design.findEntityByToken |
fast |
Fusion's Python 3.12 cProfile records every thread, so selectors.select lines in a profile are the add-in's HTTP thread, not the operation.
Not changed, for you to decide
Every write ends with design.computeAll() and a health walk. At 1,100 occurrences that is 7.4 s of a 13.1 s parameter_set; on the 4,579-occurrence assembly computeAll alone takes 72 to 99 s. The recompute also gives every body a new revisionId, so the next write re-reads every witness. Fusion has already recomputed what the edit touched by the time the write returns. Making the full recompute opt-in above a size, or per operation, would make big-assembly writes several times faster. It is a change to the safety guarantee, so I left it.
Checks
- Python: 160 mechanical tests pass on master plus this change. The new
tests/test_mechanical_perf.pycounts native reads:- token reads, refused index reads, slow component reads and the fallback;
- path walks, including names that contain
+; - joint pose comparisons;
- witness cache hits and misses;
- the bom budget.
- Rust:
tools/rust-ship.shis all green on master (84da2de) plus this change. Goldens are regenerated with the research evidence folder. - Live, Fusion 2705, dev pin on the production assembly:
- all 4,579 occurrence references that
inspectemits resolved to their own occurrence throughresolve(); - the cached tokens matched a fresh read in all 209 sampled;
bom,assembly_properties,efficiency_report,health,configuration_inspect,inspect_entity,measureandinterferenceall answered inside ab's budget.
- all 4,579 occurrence references that
- Scale suite: every read and the common writes timed through ab on scratch assemblies; the scratch documents were closed afterwards.
| Occurrences | 56 | 450 |
|---|---|---|
| reads (inspect, measure, interference, health, ...) | 0.9 to 1.1 s | 1.1 to 2.1 s |
bom |
2.8 s | 7.9 s |
| writes (parameter, metadata, occurrence update, insert, delete, joint, rigid group, component) | 2.4 to 2.8 s | 5.1 to 7.2 s |
For the release
schema.jsonchanged (inspectbodyMetricsandhealth;bomandbom_exportbudgetSeconds), and the exe embeds it.- The add-in's mechanical package changed: bump the add-in version.
- Independent of #27: this applies cleanly to master 84da2de.
🤖 Generated with Claude Code
Diff Skip to comments
addin/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 @@
Reviews
Comments
Log in to comment.
No comments yet.