← Commit history
CHANGELOG.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" }\ No newline at end of file
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()