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
Big-assembly performance (PR #28 by Drew Owens); add-in 1.0.28
18 files changed
+635−91
CHANGELOG.md+15addin/AdomBridge/AdomBridge.manifest+1−1addin/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+20tests/test_mechanical.py+5−3tests/test_mechanical_efficiency.py+10−8tests/test_mechanical_perf.py+307CHANGELOG.md+15@@ -3,6 +3,21 @@ All notable changes to the Adom Bridge Fusion 360 bridge, newest first. Canonical: https://wiki.adom.inc/adom/fusion-bridge ยท GitHub backup: `adom-inc/fusion-bridge` +## 1.13.27 (unreleased)++From Drew Owens (PR #28): the mechanical layer on big assemblies, profiled inside Fusion on a 4,579-occurrence+production assembly built from 23 referenced designs.++- The revision digest every guarded call pays went from 32.6 s to 5.2 s: entity tokens are read only on a miss,+ refused timeline-index reads are remembered per feature revision, and components come from the occurrences read.+- References: an occurrence registered this session reuses its token, and a reference resolves by walking its path's+ names (248 ms to 5.7 ms). Joint solving compares each occurrence with the one at its path instead of every pair.+- Write guards keep each body's witness by revision and reuse the pre-check's health walk.+- `bom` reads metadata and physical properties once per definition; `budgetSeconds` (default 40) stops the cloud+ part-number reads and marks the rest `nativePending` (cached for the next run). `inspect` leaves out body metrics+ above 200 components (`bodyMetrics:true`) and the health walk above 200 timeline items (`health:true`).+- `efficiency_report` returns the full target. The add-in is 1.0.28; the exe embeds the new `schema.json`.+ ## 1.13.26 (2026-10-30) - Engine 0.21.1 (standard 0.24.2): lint_library's duplicate_pad_number counts same-numbered pads that touch or overlap
addin/AdomBridge/AdomBridge.manifest+1−1@@ -6,7 +6,7 @@ "description": { "": "Adom Desktop Conduit bridge add-in. Exposes a localhost HTTP API for external automation of Fusion 360." },- "version": "1.0.27",+ "version": "1.0.28", "runOnStartup": true, "supportedOS": "windows" }
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,@@ -336,7 +352,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": [
tests/test_mechanical.py+5−3@@ -76,7 +76,8 @@ class Safety(unittest.TestCase): def test_child_revision_changes_target_even_when_root_unchanged(self): root=component('root');child=component('child')- design=NS(isValid=True,parentDocument=Entity(isSaved=False,isModified=True),allComponents=Collection([root,child]),rootComponent=NS(allOccurrences=Collection()),allParameters=Collection(),timeline=NS(count=0,markerPosition=0))+ root.allOccurrences=Collection() # as in Fusion, the design's rootComponent IS the root listed in allComponents+ design=NS(isValid=True,parentDocument=Entity(isSaved=False,isModified=True),allComponents=Collection([root,child]),rootComponent=root,allParameters=Collection(),timeline=NS(count=0,markerPosition=0)) root.parentDesign=child.parentDesign=design before=c.revision(design);child.revisionId='r2';self.assertNotEqual(c.revision(design),before) @@ -84,7 +85,8 @@ class Safety(unittest.TestCase): root=component('root');child=component('child') occurrence=Entity(component=child,transform2=NS(asArray=lambda:[1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1]), timelineObject=NS(isSuppressed=False),isGrounded=False)- design=NS(isValid=True,parentDocument=Entity(isSaved=False,isModified=True),allComponents=Collection([root,child]),rootComponent=NS(allOccurrences=Collection([occurrence])),+ root.allOccurrences=Collection([occurrence])+ design=NS(isValid=True,parentDocument=Entity(isSaved=False,isModified=True),allComponents=Collection([root,child]),rootComponent=root, allParameters=Collection(),timeline=NS(count=0,markerPosition=0)) root.parentDesign=child.parentDesign=design before=c.revision(design); occurrence.timelineObject.isSuppressed=True@@ -142,7 +144,7 @@ class Safety(unittest.TestCase): def check(*args): if p.expression=='-1 mm':raise c.MechanicalError('unhealthy_model','bad feature') return {'healthy':True}- with patch.object(ops,'target',return_value=(None,design,{})),patch.object(ops,'require_healthy',side_effect=check),patch.object(ops,'recovery_snapshot',side_effect=lambda d:{'expression':p.expression}),patch.object(ops,'current_target',return_value={}):+ with patch.object(ops,'target',return_value=(None,design,{})),patch.object(ops,'require_healthy',side_effect=check),patch.object(ops,'recovery_snapshot',side_effect=lambda d,health_state=None:{'expression':p.expression}),patch.object(ops,'current_target',return_value={}): with self.assertRaises(c.MechanicalError) as error:ops.parameter_set(None,{'name':'depth','expression':'-1 mm'}) self.assertEqual(p.expression,'10 mm');self.assertTrue(error.exception.details['recovery']['verified'])
tests/test_mechanical_efficiency.py+10−8@@ -72,10 +72,13 @@ class Measure(unittest.TestCase): for comp in comps: comp.entityToken = comp.name occurrences = [NS(component=c) for c in (plate, copy, wire, breaker, breaker, threaded)]- root = NS(allOccurrences=Collection(occurrences), allJoints=Collection([]), allAsBuiltJoints=Collection([]), allRigidGroups=Collection([]))+ # Fusion's allComponents holds the root component too+ root = component("Root", allOccurrences=Collection(occurrences), allJoints=Collection([]), allAsBuiltJoints=Collection([]),+ allRigidGroups=Collection([]), occurrences=occurrences)+ root.entityToken = "Root" design = NS(rootComponent=root, designType=1, timeline=Collection(tl_items), snapshots=NS(count=1, hasPendingSnapshot=False),- allComponents=Collection(comps))- for comp in comps:+ allComponents=Collection([root] + comps))+ for comp in [root] + comps: comp.parentDesign = design m = e.measure(None, design) self.assertEqual((m["timeline"]["count"], m["timeline"]["groups"], m["timeline"]["warnings"], m["timeline"]["byType"]),@@ -86,12 +89,11 @@ class Measure(unittest.TestCase): self.assertEqual(m["skipped"], []) def test_a_spent_budget_skips_the_costly_passes_and_says_so(self):- design = NS(rootComponent=NS(allOccurrences=Collection([]), allJoints=Collection([]), allAsBuiltJoints=Collection([]),- allRigidGroups=Collection([])),- designType=0, snapshots=NS(count=0, hasPendingSnapshot=False), allComponents=Collection([])) a = component("A", [body(6)])- a.parentDesign = design- design.allComponents = Collection([a])+ root = component("Root", allOccurrences=Collection([NS(component=a)]), allJoints=Collection([]), allAsBuiltJoints=Collection([]),+ allRigidGroups=Collection([]), occurrences=[NS(component=a)])+ design = NS(rootComponent=root, designType=0, snapshots=NS(count=0, hasPendingSnapshot=False), allComponents=Collection([root, a]))+ a.parentDesign = root.parentDesign = design m = e.measure(None, design, budget_seconds=0) self.assertEqual(m["skipped"], ["heavy geometry and duplicate definitions"])
tests/test_mechanical_perf.pyadded+307@@ -0,0 +1,307 @@+"""Hot paths of the revision digest, offline: what each native read costs is the point, so these count reads."""+import importlib+import time+import unittest.mock+from types import SimpleNamespace as NS+import unittest++from test_mechanical import adsk++core = importlib.import_module('mechanical_test_commands.mechanical.core')+readback = importlib.import_module('mechanical_test_commands.mechanical.readback')+regular_joints = importlib.import_module('mechanical_test_commands.mechanical.regular_joints')+bom = importlib.import_module('mechanical_test_commands.mechanical.bill_of_materials')+++class Counted:+ """An occurrence whose entityToken read is counted (it costs about 2 ms in Fusion)."""+ objectType = "adsk::fusion::Occurrence"++ def __init__(self, path):+ self.fullPathName, self.reads, self.isValid = path, 0, True++ @property+ def entityToken(self):+ self.reads += 1+ return "token-" + self.fullPathName+++class Identity(unittest.TestCase):+ def test_a_known_entity_is_found_without_reading_its_token(self):+ occ = Counted("Frame:1+Rail:3")+ first = core.identity(occ)+ reads = occ.reads+ for _ in range(5):+ self.assertEqual(core.identity(occ), first)+ self.assertEqual(occ.reads, reads)++ def test_a_renamed_entity_is_still_found_by_its_token(self):+ occ = Counted("Frame:1+Rail:4")+ key = core.identity(occ)+ occ.fullPathName = "Frame:1+Rail renamed:4" # new bucket; same token string? no: token follows the object+ occ.__class__ = type("Renamed", (Counted,), {"entityToken": property(lambda self: "token-Frame:1+Rail:4")})+ self.assertEqual(core.identity(occ), key)++ def test_a_reference_reuses_the_token_read_at_registration(self):+ occ = Counted("Frame:1+Rail:5")+ core.identity(occ)+ reads = occ.reads+ for _ in range(3):+ self.assertEqual(core.entity_token(occ), "token-Frame:1+Rail:5")+ self.assertEqual(occ.reads, reads)++ def test_an_unregistered_occurrence_reads_its_token_once(self):+ occ = Counted("Frame:1+Rail:6")+ self.assertEqual(core.entity_token(occ), "token-Frame:1+Rail:6")+ self.assertEqual(core.entity_token(occ), "token-Frame:1+Rail:6")+ self.assertEqual(occ.reads, 1)++ def test_other_entities_read_their_own_token(self):+ body = NS(objectType="adsk::fusion::BRepBody", entityToken="body-token")+ self.assertEqual(core.entity_token(body), "body-token")+++class Body:+ """A body whose witness reads are counted (physical properties cost milliseconds in Fusion)."""+ objectType = "adsk::fusion::BRepBody"++ def __init__(self, name, component):+ self.name, self.parentComponent, self.isValid, self.revisionId = name, component, True, "b1"+ self.material, self.reads = NS(id="steel"), 0+ empty = NS(count=0, item=lambda i: None)+ self.faces, self.edges, self.isSolid = empty, empty, True+ self.boundingBox = NS(minPoint=NS(x=0, y=0, z=0), maxPoint=NS(x=1, y=1, z=1))++ @property+ def physicalProperties(self):+ self.reads += 1+ return NS(volume=1, area=6, mass=7.8, centerOfMass=NS(x=0.5, y=0.5, z=0.5))+++class Witnesses(unittest.TestCase):+ def test_an_unchanged_body_is_not_read_again(self):+ body = Body("Plate", NS(name="Bracket"))+ first = readback.topology_witness(body)+ self.assertEqual(readback.topology_witness(body), first)+ self.assertEqual(body.reads, 1)++ def test_a_changed_body_material_or_name_is_read_again(self):+ body = Body("Plate", NS(name="Bracket 2"))+ readback.topology_witness(body)+ body.revisionId = "b2"+ readback.topology_witness(body)+ body.material = NS(id="aluminum")+ readback.topology_witness(body)+ self.assertEqual(body.reads, 3)++ def test_a_cached_witness_is_a_copy(self):+ body = Body("Plate", NS(name="Bracket 3"))+ readback.topology_witness(body)["name"] = "changed by a caller"+ self.assertEqual(readback.topology_witness(body)["name"], "Plate")+++class Feature:+ objectType = "adsk::fusion::BaseFeature"++ def __init__(self, name, component):+ self.name, self.parentComponent, self.isValid, self.index_reads = name, component, True, 0+ feature = self++ class Timeline:+ isSuppressed, isValid = False, True++ @property+ def index(self):+ feature.index_reads += 1+ raise RuntimeError("2 : InternalValidationError : res >= 0")+ self.timelineObject = Timeline()+++class UnindexedFeatures(unittest.TestCase):+ def test_a_refused_timeline_index_is_asked_once_per_component_revision(self):+ comp = NS(name="Vendor model", revisionId="r1")+ feature = Feature("Base Feature1", comp)+ for _ in range(4):+ data = readback.feature_data(feature, order=0)+ self.assertEqual((data["index"], data["indexUnavailable"]), (None, "native_timeline_position_unavailable"))+ self.assertEqual(feature.index_reads, 1)+ comp.revisionId = "r2" # the component changed: ask again+ readback.feature_data(feature, order=0)+ self.assertEqual(feature.index_reads, 2)++++class Components(unittest.TestCase):+ def comp(self, name):+ return NS(objectType="adsk::fusion::Component", name=name, isValid=True)++ def design(self, comps, occurrences):+ reads = []+ collection = NS(count=len(comps), item=lambda i: reads.append(i) or comps[i])+ return NS(rootComponent=comps[0], allComponents=collection), reads++ def test_components_come_from_occurrences_without_reading_the_slow_collection(self):+ root, a, b = self.comp("root"), self.comp("A"), self.comp("B")+ occurrences = [NS(component=a), NS(component=b), NS(component=a)]+ design, reads = self.design([root, a, b], occurrences)+ found = core.components_by_occurrence(design, occurrences, 3)+ self.assertEqual([c.name for c in found], ["root", "A", "B"])+ self.assertEqual(reads, [])++ def test_a_count_mismatch_falls_back_to_the_full_list(self):+ root, a, orphan = self.comp("root"), self.comp("A"), self.comp("Orphan")+ design, reads = self.design([root, a, orphan], [NS(component=a)])+ found = core.components_by_occurrence(design, [NS(component=a)], 3)+ self.assertEqual([c.name for c in found], ["root", "A", "Orphan"])+ self.assertEqual(reads, [0, 1, 2])+++class Level:+ """Occurrences under one parent, found by name the way Fusion's itemByName does."""+ def __init__(self, occurrences=()):+ self.values = list(occurrences)++ @property+ def count(self):+ return len(self.values)++ def item(self, index):+ return self.values[index]++ def itemByName(self, name):+ return next((o for o in self.values if o.name == name), None)+++class Occ:+ objectType = "adsk::fusion::Occurrence"+ compared = 0++ def __init__(self, name, children=(), parent=None):+ self.name, self.isValid, self.parent = name, True, parent+ self.childOccurrences = Level(children)+ for child in children:+ child.parent = self++ @property+ def fullPathName(self):+ return (self.parent.fullPathName + "+" if self.parent else "") + self.name++ def __eq__(self, other):+ Occ.compared += 1+ return self is other++ __hash__ = object.__hash__+++class Paths(unittest.TestCase):+ def tree(self):+ c = Occ("Nut:1")+ b = Occ("Bolt:1", [c])+ a = Occ("Frame:1", [b])+ plus = Occ("M3+washer:1", [Occ("Ring:2")])+ every = [a, b, c, plus, plus.childOccurrences.item(0)]++ class Everything(Level):+ reads = 0++ def item(self, index):+ Everything.reads += 1+ return self.values[index]+ all_occurrences = Everything(every)+ design = NS(rootComponent=NS(occurrences=Level([a, plus]), allOccurrences=all_occurrences))+ return design, a, b, c, plus, all_occurrences++ def test_an_occurrence_is_found_by_walking_its_path(self):+ design, a, b, c, plus, _ = self.tree()+ self.assertIs(core.occurrence_at(design, "Frame:1+Bolt:1+Nut:1"), c)+ self.assertIs(core.occurrence_at(design, "Frame:1"), a)+ self.assertIsNone(core.occurrence_at(design, "Frame:1+Washer:1"))+ self.assertIsNone(core.occurrence_at(design, ""))++ def test_a_name_holding_a_plus_is_still_found(self):+ design, a, b, c, plus, _ = self.tree()+ self.assertIs(core.occurrence_at(design, "M3+washer:1+Ring:2"), plus.childOccurrences.item(0))++ def test_resolving_a_context_does_not_read_every_occurrence(self):+ design, a, b, c, plus, every = self.tree()+ every.__class__.reads = 0+ self.assertIs(core.occurrence_context(design, c, "Frame:1+Bolt:1+Nut:1"), c)+ self.assertTrue(core.in_root_context(design, b))+ self.assertEqual(every.__class__.reads, 0)++ def test_an_occurrence_outside_the_root_context_still_reads_them_all(self):+ design, a, b, c, plus, every = self.tree()+ local = Occ("Nut:1") # a definition-local occurrence: its path is relative to its parent+ every.__class__.reads = 0+ self.assertFalse(core.in_root_context(design, local))+ self.assertEqual(every.__class__.reads, every.count)+++class JointPoses(unittest.TestCase):+ def test_pose_check_compares_each_occurrence_once(self):+ occurrences = [Occ("Part:%d" % i) for i in range(200)]+ for o in occurrences:+ o.transform2 = NS(asArray=lambda: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1])+ o.isGrounded, o.isGroundToParent = False, False+ design = NS(rootComponent=NS(allOccurrences=Level(occurrences)))+ before = regular_joints.poses(design)+ Occ.compared = 0+ result = regular_joints.pose_result(design, before)+ self.assertEqual(result["changedOccurrences"], [])+ self.assertLessEqual(Occ.compared, len(occurrences))+++class Part:+ """A component whose part number is a counted cloud read (about 0.5 s each in Fusion)."""+ objectType = "adsk::fusion::Component"++ def __init__(self, name):+ self.name, self.isValid, self.revisionId, self.reads = name, True, "r1", 0+ self.bRepBodies, self.occurrences, self.attributes, self.material = Level(), Level(), Level(), None++ @property+ def partNumber(self):+ self.reads += 1+ return "PN-" + self.name++ description = "a part"+++class BomBudget(unittest.TestCase):+ def design(self, tag):+ parts = [Part("%s %d" % (tag, i)) for i in range(3)]+ occurrences = [NS(objectType="adsk::fusion::Occurrence", component=p, fullPathName=p.name + ":1", isLightBulbOn=True,+ timelineObject=NS(isSuppressed=False), childOccurrences=Level(), bRepBodies=Level(), isConfiguration=False)+ for p in parts]+ root = Part(tag + " root")+ root.occurrences, root.allOccurrences = Level(occurrences), Level(occurrences)+ design = NS(isConfiguredDesign=False, isConfiguration=False, configurationRowId="", rootComponent=root)+ return design, [root] + parts++ def generate(self, design, started):+ with unittest.mock.patch.object(bom, "ref", return_value={}):+ return bom.generate(design, {"documentId": "D", "revision": "R"}, {}, started, 40)++ def test_past_the_budget_part_numbers_are_left_for_the_next_run(self):+ design, comps = self.design("Late")+ result = self.generate(design, time.perf_counter() - 100)+ self.assertEqual(result["nativePending"], 4)+ self.assertEqual(sum(c.reads for c in comps), 0)+ self.assertIn("run bom again", result["nativeHint"])++ def test_inside_the_budget_every_part_number_is_read(self):+ design, comps = self.design("Early")+ result = self.generate(design, time.perf_counter())+ self.assertNotIn("nativePending", result)+ self.assertEqual([r["metadata"]["partNumber"] for r in result["instances"]], ["PN-" + c.name for c in comps])++ def test_what_an_earlier_run_read_is_used_past_the_budget(self):+ design, comps = self.design("Again")+ self.generate(design, time.perf_counter())+ result = self.generate(design, time.perf_counter() - 100)+ self.assertNotIn("nativePending", result)+ self.assertEqual(sum(c.reads for c in comps), 4)+++if __name__ == "__main__":+ unittest.main()