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