← All Pull Requests

Writes on designs that already carry faults #36

Merged Released in 1.13.75 on 5 Nov 2026 Approved opened by Drew Owens 2026-11-05
Merges feature/writes-on-faulted-designs → master

Mechanical writes on designs that already carry faults

Why

Every typed write walked the timeline and refused with unhealthy_model if any item had a fault. The recovery witness also read the constraint graph of every sketch in the design and refused with unsupported_constraint_graph at the first relationship type the reader cannot read. The 4,579-occurrence production assembly carries 15 old timeline faults and one such relationship, so every write refused there. The bridge could build new designs but could not edit the real one.

What changed (add-in only: no schema, validation or exe change)

  • Fault baseline (compute.py). The faults present before a write are its baseline: the health the previous write verified at the same revision, otherwise the same timeline walk as before, so there is no extra walk. A write succeeds when its result adds no fault, changes none, and leaves no fault on what it edits in place. A fault is the same fault when its item's name, health state and message are the same. The index is left out because it moves when an earlier item is deleted, and lookalikes are counted. "Edits in place" means the feature of feature_edit, the feature owning a parameter it sets, the sketch of a sketch edit, or the occurrence, joint, rigid group, joint origin or motion link it edits. Inputs it only reads do not count. Anything else refuses with unhealthy_model and rolls back as before, with newFaults and editedFaults in the details. With a healthy baseline the rule is the old one.
  • health_before no longer refuses. settle, failure recovery (Abort, full recompute, semantic witness), transaction_probe, export and the regular-joint solve check judge against the baseline. The #33 checkpoint policy is unchanged. A failed computeAll still refuses: it returns true on a model with faults (measured).
  • Replies. Model writes, transaction_probe and export carry preExistingFaults: count, the first five faults, more, and cleared for faults the write removed. health.healthy stays false while old faults remain.
  • Sketch graphs (readback.py, transactions.py, core.py). Only the sketches a write names, or whose curve, point, constraint or dimension it names, must read completely, and they still refuse before any change. Every other sketch enters the witness with what can be read, and an unsupported relationship enters as its type. A profile's sketch does not count, since a feature reads only the profile. inspect_entity on such a sketch lists those relationships in constraintGraph.unreadable instead of refusing, so its profiles can be used.
  • docs/MECHANICAL.md: new section "Designs that already carry faults"; recovery paragraph updated.

Tests

  • Base: master 2d4f0c3 (1.13.74).
  • Offline: 441 Python tests pass, 5 skipped (24 new in tests/test_mechanical_baseline.py; test_mechanical_compute.py updated for the new settle signature).
  • No Rust or schema change. tools/rust-ship.sh was green (276 Rust tests) on the earlier base 347943c and was not rerun here.
  • Generated files (goldens, contracts/describe.json) were regenerated on master 2d4f0c3, with no change; if another of today's PRs merges first, they'll be regenerated again.

Live (Fusion 2705, scratch designs; this branch's mechanical package and master's loaded side by side in modeling scripts, no deploy)

Faulted design (three runs, same results; the last on the final commit): 20 filleted boxes, 15 planes whose face's feature was deleted (15 timeline warnings), and one sketch with a PolygonConstraint, which readback.py has no reader for (91 timeline items).

  • master: transaction_probe and parameter_set refuse with unhealthy_model (about 0.02 s each).

  • This branch: the probe passes and reports preExistingFaults 15 (first five, more 10). These succeed: parameter_create, parameter_set, sketch_create, sketch_geometry, an extrude, component_create, inspect_entity of the polygon sketch (constraintGraph.complete: false), an extrude of its profile, and a STEP export (151 KB).

  • Refused and rolled back:

    • parameter_set of a box height to 0 mm: newFaults P18 extrude, expression restored, recovery verified.
    • feature_edit of a box's own distance to 0 mm: newFaults P15 extrude, PTransaction.Abort, recovery verified.
    • feature_edit of a lost plane's offset that leaves it lost: editedFaults, Abort, verified.
    • sketch_geometry on the polygon sketch: unsupported_constraint_graph before any change.
  • Cleared: suppressing one lost plane and deleting another succeed, with cleared naming them. Two writes ran a checkpoint computeAll and were held to the same baseline.

  • Timing, the same 30 writes (5 rounds of parameter create and set, sketch, rectangle, extrude, rename) on same-size designs, 90 timeline items, mean per write:

    run master (no baseline) this branch
    healthy design, first pair 0.225 s 0.242 s
    healthy design, second pair 0.239 s 0.256 s
    15 faults + polygon sketch refuses every write 0.234 s

    An earlier run had this branch faster (0.203 s against 0.236 s), so the gap is run-to-run noise. The baseline's own work measured under 15 µs to compare 15 faults and under 0.4 ms to find the sketches a write names. The walk itself is unchanged: about 10 ms on these designs, 15.6 s on the production assembly.

Live on a server-side copy of the 4,579-occurrence production assembly

DataFile.copyWithInput (referenced designs and drawings not copied) into a new scratch folder. The copy was opened visibly, tested with this branch's package, closed without saving, then deleted with its folder. The original was never opened.

  • Copy: ready within 15 s. Open: 70 s, the copy plus its 23 referenced designs.
  • 1,249 components, 4,579 occurrences, 689 timeline items. First revision digest 34 s, then 5 s. Health walk 14 s.
  • Baseline as expected: 15 faults, all warnings (Capture Position snapshots with no positions, and paste instances whose source context is lost), nothing unreadable.
  • 3 of 19 sketches hold relationships the graph reader cannot read: 4 RectangularPatternConstraint and 2 MidPointConstraint linked outside their sketch. They enter the witness leniently. A full recovery witness took 7 s once the bodies were read, with no refusal; on master it refuses with unsupported_constraint_graph (seen on this assembly on 2026-10-30).
  • parameter_create succeeded in 46 s, with preExistingFaults 15 (first five, more 10), no new fault and no full recompute.
  • Design.computeAll: true in 66 s, with the same 15 faults afterwards.
  • transaction_probe was not run. It would take about 5 minutes there, over the 3-minute script budget. The cause is outside this PR: computeAll changes the revision of the 1,591 bodies the design computes itself, so the witness reads them again (220 s; the first full read of all 2,642 bodies took 300 s). The write after a checkpoint pays the same. Keying the witness cache by something a recompute does not change would fix both: a follow-up.
  • Close: 23 s with no dialog; the referenced designs closed with it.

Add-in change only: it needs the add-in version bump at release (manifest 1.0.33 today).

🤖 Generated with Claude Code

Diff Skip to comments

addin/AdomBridge/commands/mechanical/compute.py+167−11
@@ -13,10 +13,17 @@ timeline's health after Fusion's own compute, and the full recompute runs at a c - before an export leaves Fusion; - whenever a caller asks: health with recompute:true. -The counters and the last health a write verified are per document, for this add-in session."""+The counters and the last health a write verified are per document, for this add-in session.++A write is held to the faults the model carried before it (its baseline), not to a perfect model: a+production design can carry old timeline faults that nobody is about to fix. The write may not add a+fault, change one (its health state or message), or leave a fault on what it edits in place; faults it+clears are reported. With a healthy baseline this is the old rule: any fault refuses."""+from collections import Counter import time -from .core import health, require+from . import core+from .core import MechanicalError, health, same  CHECKPOINT = 6.0 # a move to another part checkpoints edits worth one: an edit of existing history, a joint, a delete, two appends@@ -90,30 +97,179 @@ def status(document):             "writesSinceFullCompute": led["writes"], "focus": led["focus"], "lastFullCompute": led["last"]}  +FIRST = 5  # faults a reply lists; the count covers the rest+++def fault_rows(state):+    """A health read's faults, then the items whose health could not be read."""+    return list(state.get("faults") or ()) + list(state.get("unknown") or ())+++def fault_key(row):+    """A fault as writes compare it: the item's name, health state and message. Not its timeline index,+    which moves when an earlier item is deleted."""+    if "error" in row:+        return ("unreadable", row.get("name"), row.get("error"))+    return ("fault", row.get("name"), row.get("state"), row.get("message"))+++def unmatched(rows, others):+    """The rows the other read does not account for, counting repeats: two faults with the same name and+    message are two. Matching runs in timeline order, so the later of two lookalikes is the one left over."""+    budget = Counter(fault_key(row) for row in others)+    left = []+    for row in rows:+        key = fault_key(row)+        if budget[key] > 0:+            budget[key] -= 1+        else:+            left.append(row)+    return left+++def summary(baseline, state=None):+    """The faults the model carried before the write, for its reply: how many, the first few, and the ones+    the write cleared (deleted, suppressed or repaired)."""+    rows = fault_rows(baseline)+    result = {"count": len(rows), "faults": rows[:FIRST]}+    if len(rows) > FIRST:+        result["more"] = len(rows) - FIRST+    if state is not None:+        cleared = unmatched(rows, fault_rows(state))+        if cleared:+            result["cleared"] = {"count": len(cleared), "faults": cleared[:FIRST]}+    return result+++def _native(entity):+    try:+        return getattr(entity, "nativeObject", None) or entity+    except Exception:+        return entity+++# What a write edits in place, by argument. A fault the model already carried there that the write did not+# clear is the write's own result. Inputs it only reads (a profile, an axis, joint geometry) are not, and a+# new item it creates is held to "no new fault".+SUBJECTS = {"feature_edit": "feature", "occurrence_update": "occurrence", "joint_edit": "joint",+            "regular_joint_edit": "joint", "rigid_group_edit": "group", "joint_origin_edit": "origin",+            "motion_link_edit": "link", **{op: "sketch" for op in (+                "sketch_geometry", "sketch_constraint", "sketch_dimension", "sketch_project", "sketch_edit",+                "sketch_curve_edit", "sketch_offset")}}+++def owner_of(design, name):+    """The feature (or sketch dimension, or joint) that owns a model parameter, as a list; a user parameter has none."""+    try:+        parameter = design.allParameters.itemByName(name)+        owner = getattr(parameter, "createdBy", None) if parameter is not None else None+    except (AttributeError, RuntimeError):+        return []+    return [owner] if owner is not None else []+++def subjects(design, value, args):+    """The entities a write edits in place (SUBJECTS; a parameter batch edits the owners of the parameters it+    sets), read before the edit. A reference that does not resolve is left for the operation to refuse."""+    found = []+    key = SUBJECTS.get(args.get("op"))+    if key and isinstance(args.get(key), dict):+        try:+            found.append(core.resolve(design, value, args[key]))+        except (MechanicalError, AttributeError, RuntimeError):+            pass+    if args.get("op") == "parameter_batch":+        for change in args.get("changes") or ():+            if isinstance(change, dict) and change.get("action") == "set":+                found.extend(owner_of(design, change.get("name")))+    return found+++def edited(natives, edits):+    """The faults among natives ((row, timeline item) pairs) that sit on an entity the write edits in place:+    a fault left there means the write's own result is not healthy."""+    if not edits:+        return []+    mine = [_native(entity) for entity in edits if entity is not None]+    found = []+    for row, item in natives:+        try:+            entity = item.entity+        except Exception:+            continue+        if entity is None:+            continue+        entity = _native(entity)+        try:+            if any(same(entity, other) for other in mine):+                found.append(row)+        except Exception:+            continue+    return found+++def judge(state, baseline, natives=(), edits=(), **details):+    """Refuse (unhealthy_model) a health read a write may not leave behind: a fault the baseline lacks (a+    new one, or an existing one whose state or message changed), a fault left on an entity the write edits+    in place, or a full recompute that failed. (Design.computeAll returns true on a model that carries+    faults: measured with 15 lost-reference warnings and with a failed extrude.)"""+    added = unmatched(fault_rows(state), fault_rows(baseline))+    own = [] if added else edited(natives, edits)+    failed = state.get("computeSucceeded") is False+    if not (added or own or failed):+        return+    if added:+        message = "The write added a fault, or changed one the model already had."+    elif own:+        message = "The write left a fault on an entity it edited."+    else:+        message = "Recompute failed."+    raise MechanicalError("unhealthy_model", message, health=state, newFaults=added, editedFaults=own,+                          preExistingFaults=summary(baseline), **details)++ def health_before(design, document, revision):-    """The model's health before a write: what the last write verified when nothing changed since-    (same revision), otherwise a walk of the timeline. Refuses an unhealthy model."""+    """The model's health before a write, the baseline the write is held to: what the last write verified+    when nothing changed since (same revision), otherwise a walk of the timeline. Faults the model already+    carries do not refuse the write; settle refuses one that adds a fault or changes one."""     cached = ledger(document)["verified"]     if cached is not None and cached["revision"] == revision:         return dict(cached["health"])-    state = health(design, False)-    require(state["healthy"], "unhealthy_model", "Recompute or feature health check failed.", health=state)-    return state+    return health(design, False)  -def settle(design, document, args):+def settle(design, document, args, baseline, edits=()):     """After a write's edit and before it commits. Fusion's own compute has run; the full recompute runs-    first when a checkpoint is due. Refuses an unhealthy result, as require_healthy does."""+    first when a checkpoint is due. Refuses a result that judge refuses against the baseline; edits are+    the entities the write edits in place (subjects)."""     w = weight(args.get("op"), args)     focus = focus_of(args)     reason = due(document, w, focus)     started = time.perf_counter()-    state = health(design, reason is not None)+    natives = []+    state = health(design, reason is not None, natives)     info = {"full": reason is not None, "reason": reason, "seconds": round(time.perf_counter() - started, 2), "weight": w, "focus": focus}-    require(state["healthy"], "unhealthy_model", "Recompute or feature health check failed.", health=state, compute=info)+    judge(state, baseline, natives, edits, compute=info)     return state, info  +def midway(design):+    """Inside a write, before it settles (Fusion solves a joint as it is added): the model's health now,+    held to the write's baseline."""+    baseline = core.write_baseline()+    state = health(design, False)+    judge(state, baseline if baseline is not None else {})+    return state+++def restored(design, baseline):+    """After a failed write is undone, or the transaction probe: the full recompute, and no fault the+    model did not carry before."""+    state = health(design, True)+    judge(state, baseline)+    return state++ def committed(document, revision, state, info):     """Count a committed write, remember the health it verified, and report the ledger."""     led = ledger(document)
addin/AdomBridge/commands/mechanical/core.py+39−5
@@ -3,6 +3,7 @@ Session IDs are capabilities for this running add-in, not persistent CAD IDs. Tokens are only resolver inputs: they are never compared to decide entity identity. """+from contextlib import contextmanager import hashlib import json import math@@ -30,6 +31,25 @@ def require(condition, code, message, **details):         raise MechanicalError(code, message, **details)  +# The write in progress and the model's health before it, the baseline it is held to (compute.judge), for+# a check inside the operation. Fusion runs one write at a time on its main thread.+_write = None+++@contextmanager+def write_context(baseline):+    global _write+    previous, _write = _write, {"baseline": baseline}+    try:+        yield _write+    finally:+        _write = previous+++def write_baseline():+    return _write["baseline"] if _write is not None else None++ def items(collection):     require(collection.count <= MAX_ENTITIES, "inspection_budget", "Collection exceeds inspection budget.")     return [collection.item(i) for i in range(collection.count)]@@ -509,7 +529,9 @@ def resolve(design, target_value, value, allowed=()):     return entity  -def health(design, recompute=False):+def health(design, recompute=False, natives=None):+    """Every timeline item's health. natives, when given, collects (row, timeline item) for each fault and+    unreadable item, so a write can tell which of them it edited."""     computed = None     if recompute:         computed = bool(design.computeAll())@@ -529,8 +551,17 @@ def health(design, recompute=False):             if state != adsk.fusion.FeatureHealthStates.HealthyFeatureHealthState:                 faults.append({"index": index, "name": item.name, "state": int(state),                                "message": item.errorOrWarningMessage})+                if natives is not None:+                    natives.append((faults[-1], item))         except Exception as exc:-            unknown.append({"index": index, "error": str(exc)})+            row = {"index": index, "error": str(exc)}+            try:+                row["name"] = item.name+            except Exception:+                pass+            unknown.append(row)+            if natives is not None:+                natives.append((row, item))     return {"healthy": computed is not False and not faults and not unknown,             "computeSucceeded": computed, "complete": not unknown, "faults": faults,             "unknown": unknown, "suppressed": suppressed, "timelineCount": design.timeline.count}@@ -588,10 +619,13 @@ def parameter_data(design, parameter):     return row  -def recovery_snapshot(design, health_state=None):+def recovery_snapshot(design, health_state=None, strict_sketches=()):     """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)."""+    item, about 23 ms apiece on a big assembly). strict_sketches are the sketches the write edits or reads:+    their constraint graph must read completely, or the write refuses before it changes anything. Every+    other sketch enters with what can be read, and a relationship the reader does not support enters as+    its type, so an unsupported relationship elsewhere in the design does not block the write."""     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@@ -600,7 +634,7 @@ def recovery_snapshot(design, health_state=None):     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)],+                            [sketch_data(s, strict=any(same(s, t) for t in strict_sketches)) 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 components_by_occurrence(design, all_occurrences, design.allComponents.count)],             "occurrences": [[identity(o), list(o.transform2.asArray()), occurrence_state(o)] for o in all_occurrences],
addin/AdomBridge/commands/mechanical/operations.py+23−11
@@ -93,7 +93,8 @@ def inspect_entity(app, args):         result["edgeRefs"] = [ref(e, value) for e in items(entity.edges)]     elif entity.objectType.endswith("::Sketch"):         from .readback import sketch_data-        result.update(sketch_data(entity, value))+        # a read: a relationship the graph reader does not support is listed in constraintGraph+        result.update(sketch_data(entity, value, strict=False))     elif entity.objectType.endswith("::Occurrence"):         result["bodyRefs"] = [ref(b, value, entity) for b in items(entity.component.bRepBodies)]         result["childRefs"] = [ref(o, value) for o in items(entity.childOccurrences)]@@ -198,21 +199,27 @@ def parameter_set(app, args):     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, healthy), parameter.expression+    # a model parameter is its feature's: a fault left on that feature is this write's own result+    owner = compute.owner_of(design, name)     try:         parameter.expression = expression-        state, settled = compute.settle(design, before_target["documentId"], args)+        state, settled = compute.settle(design, before_target["documentId"], args, healthy, owner)     except Exception as exc:         recovery = {"strategy": "restore_parameter_expression", "verified": False,                     "scope": "parameter expressions, body metrics, occurrence transforms, counts and health"}         try:             parameter.expression = old-            require_healthy(design)+            compute.restored(design, healthy)             recovery["verified"] = recovery_snapshot(design) == before         except Exception as restore_error:             recovery["error"] = str(restore_error)-        raise MechanicalError("parameter_edit_failed", str(exc), recovery=recovery, target=current_target(app))+        details = {"recovery": recovery, "target": current_target(app)}+        if isinstance(exc, MechanicalError) and exc.code == "unhealthy_model":+            details.update(causeCode=exc.code, causeDetails=exc.details)+        raise MechanicalError("parameter_edit_failed", str(exc), **details)     fresh = current_target(app)     return {"target": fresh, "parameter": parameter_data(design, parameter), "health": state,+            "preExistingFaults": compute.summary(healthy, state),             "compute": compute.committed(before_target["documentId"], fresh["revision"], state, settled),             "recoveryStrategy": "restore_parameter_expression", "transactional": False} @@ -230,20 +237,24 @@ def parameter_create(app, args):     try:         parameter = design.userParameters.add(name, adsk.core.ValueInput.createByString(expression), unit, args.get("comment", ""))         require(parameter is not None, "parameter_create_failed", "Fusion returned no parameter.")-        state, settled = compute.settle(design, value["documentId"], args)+        state, settled = compute.settle(design, value["documentId"], args, healthy)     except Exception as exc:         recovery = {"strategy": "delete_created_parameter", "verified": False}         try:             created = parameter or design.userParameters.itemByName(name)             if created:                 created.deleteMe()-            require_healthy(design)+            compute.restored(design, healthy)             recovery["verified"] = recovery_snapshot(design) == before         except Exception as restore_error:             recovery["error"] = str(restore_error)-        raise MechanicalError("parameter_create_failed", str(exc), recovery=recovery)+        details = {"recovery": recovery}+        if isinstance(exc, MechanicalError) and exc.code == "unhealthy_model":+            details.update(causeCode=exc.code, causeDetails=exc.details)+        raise MechanicalError("parameter_create_failed", str(exc), **details)     fresh = current_target(app)     return {"target": fresh, "parameter": parameter_data(design, parameter), "health": state,+            "preExistingFaults": compute.summary(healthy, state),             "compute": compute.committed(value["documentId"], fresh["revision"], state, settled), "transactional": False}  @@ -258,15 +269,16 @@ def check_health(app, args):  def export(app, args):     _, design, value = target(app, args, True, model_write=False)+    # faults the model already carries leave with it and are reported; the recompute may not add one+    baseline = compute.health_before(design, value["documentId"], value["revision"])     if compute.pending(value["documentId"]):         # the design leaves Fusion: the edits since the last full recompute get one now-        require(compute.checkpoint(design, value["documentId"], "export")["healthy"], "unhealthy_model",-                "Recompute or feature health check failed.", compute=compute.status(value["documentId"]))-    else:-        require_healthy(design, False)+        compute.judge(compute.checkpoint(design, value["documentId"], "export"), baseline,+                      compute=compute.status(value["documentId"]))     result = export_design(design, args)     result["target"] = current_target(app)     result["compute"] = compute.status(value["documentId"])+    result["preExistingFaults"] = compute.summary(baseline)     return result  
addin/AdomBridge/commands/mechanical/readback.py+41−6
@@ -2,7 +2,7 @@ import json import adsk.core import adsk.fusion-from .core import body_data, digest, identity, items, point_mm, ref, require, same+from .core import MechanicalError, body_data, digest, identity, items, point_mm, ref, require, same   def concrete_geometry(geometry):@@ -100,18 +100,41 @@ def plane_data(plane, value=None):     return result  -def sketch_data(sketch, value=None):+def sketch_data(sketch, value=None, strict=True):+    """A sketch's curves, points and constraint graph. strict (the default) refuses a relationship the+    graph reader cannot read completely (unsupported_constraint_graph); otherwise that relationship, or a+    curve whose geometry cannot be read, is reported as its type with the reason, and constraintGraph says+    the graph is incomplete. A fully readable sketch reads the same either way."""     curves, points = items(sketch.sketchCurves), items(sketch.sketchPoints)     constraints, dimensions = items(sketch.geometricConstraints), items(sketch.sketchDimensions)     graph = SketchGraph(curves, points, constraints)+    unreadable = []++    def read(reader, fallback):+        if strict:+            return reader()+        try:+            return reader()+        except (MechanicalError, AttributeError, RuntimeError) as error:+            if isinstance(error, MechanicalError) and error.code not in ("unsupported_constraint_graph", "unavailable_geometry"):+                raise+            row = fallback()+            row["unreadable"] = str(error)[:300]+            unreadable.append({"type": row.get("type"), "reason": row["unreadable"]})+            return row+     result = {"name": sketch.name, "space": "sketch", "lengthUnit": "mm",               "fullyConstrained": sketch.isFullyConstrained,-              "curves": [curve_data(curve, value) for curve in curves],+              "curves": [read(lambda: curve_data(curve, value), lambda: {"type": curve.objectType}) for curve in curves],               "points": [{"position": point_mm(p.geometry), "fixed": p.isFixed, **({"ref": ref(p, value)} if value else {})}                          for p in points],-              "curvePointLinks": [graph.curve_points(curve) for curve in curves],-              "constraints": [constraint_data(constraint, graph) for constraint in constraints],-              "dimensions": [dimension_data(dimension, graph) for dimension in dimensions]}+              "curvePointLinks": [read(lambda: graph.curve_points(curve), lambda: {"type": curve.objectType}) for curve in curves],+              "constraints": [read(lambda: constraint_data(constraint, graph), lambda: {"type": constraint.objectType})+                              for constraint in constraints],+              "dimensions": [read(lambda: dimension_data(dimension, graph), lambda: dimension_summary(dimension))+                             for dimension in dimensions]}+    if unreadable:+        result["constraintGraph"] = {"complete": False, "unreadable": unreadable}     if value:         # where the sketch sits: its x, y and normal in component space, so a sketch-space point         # (every curve, point and centroid above) maps to the model without guessing the plane@@ -222,6 +245,18 @@ def constraint_data(constraint, graph):     return result  +def dimension_summary(dimension):+    """What any sketch dimension says about itself, for one whose links cannot be read."""+    row = {"type": dimension.objectType}+    try:+        parameter = dimension.parameter+        row.update(name=parameter.name if parameter else None, expression=parameter.expression if parameter else None,+                   driving=dimension.isDriving)+    except (AttributeError, RuntimeError):+        pass+    return row++ def dimension_data(dimension, graph):     dimension, kind = graph_item(dimension, DIMENSION_LINKS)     parameter = dimension.parameter
addin/AdomBridge/commands/mechanical/regular_joints.py+5−3
@@ -5,7 +5,8 @@ import math import adsk.core import adsk.fusion -from .core import concrete, identity, items, ref, require, require_healthy, resolve, same+from . import compute+from .core import concrete, identity, items, ref, require, resolve, same from .geometry import native, owner, root_entity from .joint_state import (geometry_state, matrix_data, origin_data,                           regular_state)@@ -366,8 +367,9 @@ def regular_joint_create(design, value, args):     inp.isFlipped = args.get("flipped", False)     set_motion(inp, args)     joint = named(design.rootComponent.joints.add(inp), args)-    # Fusion solves the joint as it is added; the write's own settle decides on a full recompute-    require_healthy(design, False)+    # Fusion solves the joint as it is added; the write's own settle decides on a full recompute. Faults+    # the model already carried are allowed, as in settle: the joint may not add or change one.+    compute.midway(design)     alignment = initial_alignment(joint, expected["offset"][1], expected["angle"][1], args["type"])     apply_lock(joint, args)     configure_joint(design, joint, args)
addin/AdomBridge/commands/mechanical/transactions.py+56−12
@@ -11,8 +11,8 @@ import uuid import adsk.core  from . import compute-from .core import (MechanicalError, active, current_target, recovery_snapshot,-                   require, require_healthy, same, target)+from .core import (MechanicalError, active, current_target, health, recovery_snapshot,+                   require, resolve, same, target, write_context)  _verified = False _probe_evidence = None@@ -55,7 +55,9 @@ def probe(app, args): def _probe(app, args):     global _verified, _probe_evidence     doc, design, _ = target(app, args, True)-    before = recovery_snapshot(design, require_healthy(design, False))+    # faults the model already carries are its baseline: the probe must leave exactly those+    baseline = health(design, False)+    before = recovery_snapshot(design, baseline)     name = "AdomProbe" + uuid.uuid4().hex     started = False     try:@@ -74,12 +76,12 @@ def _probe(app, args):         parameter = design.userParameters.itemByName(name)         require(parameter is not None and abs(parameter.value - 0.2) < 1e-9, "transaction_probe_failed", "Commit did not preserve the probe parameter.")         require(bool(parameter.deleteMe()), "transaction_probe_failed", "Could not remove committed probe parameter.")-        require_healthy(design)+        compute.restored(design, baseline)         require(equivalent(before, recovery_snapshot(design)), "transaction_probe_failed", "Probe did not restore the semantic witness.")         _verified = True         _probe_evidence = {"abortRemovedParameter": True, "commitPreservedParameter": True,                            "semanticRestoration": True, "applicationVersion": str(getattr(app, "version", "unknown"))}-        return {"target": current_target(app), "transaction": capability()}+        return {"target": current_target(app), "transaction": capability(), "preExistingFaults": compute.summary(baseline)}     except Exception as exc:         _verified = False         recovery = {"verified": False, "strategy": "abort_and_remove_probe_parameter"}@@ -90,7 +92,7 @@ def _probe(app, args):             parameter = design.userParameters.itemByName(name)             if parameter:                 require(parameter.deleteMe(), "recovery_failed", "Probe parameter deletion failed.")-            require_healthy(design)+            compute.restored(design, baseline)             recovery["verified"] = equivalent(before, recovery_snapshot(design))         except Exception as restore_error:             recovery["error"] = str(restore_error)@@ -108,21 +110,63 @@ def _retarget(value, fresh):             _retarget(nested, fresh)  +def _native(entity):+    try:+        return getattr(entity, "nativeObject", None) or entity+    except Exception:+        return entity+++def sketches_named(design, value, args):+    """The sketches a write's references name: a sketch, or the sketch that owns a referenced sketch curve,+    point, constraint or dimension. The write edits or reads these, so their constraint graph must read+    completely before it starts. A profile's sketch is read only for the profile. A reference that does+    not resolve is left for the operation to refuse."""+    found = []+    pending = [item for key, item in args.items() if key != "target"]+    while pending:+        item = pending.pop()+        if isinstance(item, list):+            pending.extend(item)+            continue+        if not isinstance(item, dict):+            continue+        kind = item.get("objectType")+        if not (isinstance(kind, str) and "documentId" in item):+            pending.extend(item.values())+            continue+        name = kind.split("::")[-1]+        if name == "Profile" or not (name.startswith("Sketch") or name.endswith("Constraint")):+            continue+        try:+            entity = resolve(design, value, item)+        except (MechanicalError, RuntimeError, AttributeError):+            continue+        sketch = _native(entity if name == "Sketch" else getattr(entity, "parentSketch", None))+        if sketch is not None and not any(same(sketch, other) for other in found):+            found.append(sketch)+    return found++ 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.")-    before = recovery_snapshot(design, compute.health_before(design, value["documentId"], value["revision"]))+    # the faults the model already carries: the write may not add one or change one (compute.judge)+    baseline = compute.health_before(design, value["documentId"], value["revision"])+    before = recovery_snapshot(design, baseline, sketches_named(design, value, args))+    edits = compute.subjects(design, value, args)     started, committed = False, False     try:         command(app, 'PTransaction.Start "Adom mechanical operation"')         started = True-        serialize = operation(design, value, args)-        require(same(app.activeDocument, doc), "wrong_document", "Active document changed during the operation.")-        state, settled = compute.settle(design, value["documentId"], args)+        with write_context(baseline):+            serialize = operation(design, value, args)+            require(same(app.activeDocument, doc), "wrong_document", "Active document changed during the operation.")+            state, settled = compute.settle(design, value["documentId"], args, baseline, edits)         provisional = current_target(app)         result = serialize(provisional) if callable(serialize) else serialize-        result.update(target=provisional, health=state, transactional=True)+        result.update(target=provisional, health=state, transactional=True, preExistingFaults=compute.summary(baseline, state))         json.dumps(result, allow_nan=False)  # Serialization is validated before commit.         command(app, "PTransaction.Commit")         committed = True@@ -142,7 +186,7 @@ def execute(app, args, operation):             require(same(app.activeDocument, doc), "wrong_document", "Refusing to abort another document's transaction.")             if started:                 command(app, "PTransaction.Abort")-            require_healthy(design)+            compute.restored(design, baseline)             recovery["verified"] = equivalent(before, recovery_snapshot(design))         except Exception as restore_error:             recovery["error"] = str(restore_error)
docs/MECHANICAL.md+26−4
@@ -33,14 +33,20 @@ The mechanical layer is measured on a 4,579-occurrence, 1,249-component producti | `inspect` (full) | 13 s | | resolving one occurrence reference | 6 ms | | `bom` (part numbers cached) | 15 s |+| timeline health walk (689 items) | 14 s |+| recovery witness, bodies already read | 7 s |+| `parameter_create` (a model write without a full recompute) | 46 s |+| `Design.computeAll` | 66 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 guards: a health check before, Fusion's own compute and a health check after, and the recovery witness. The full recompute runs at checkpoints ([Recompute](#recompute)). The witness reads again only the bodies whose revision changed since the last one.+- The witness reads every body the first time, 2,642 bodies in about 300 s on that assembly. A full recompute changes the revision of every body the design computes itself (1,591 there; bodies of referenced designs keep theirs), so the next witness reads those again (about 220 s). The write after a checkpoint, and `transaction_probe`, which recomputes in full and then takes the witness, pay that: the probe there would take about 5 minutes.+- That assembly carries 15 old timeline faults (warnings) and three sketches holding relationships the graph reader cannot read (rectangular pattern constraints, and midpoint constraints linked outside their sketch). Writes are held to the faults already there, and only the sketches a write names must read completely ([Designs that already carry faults](#designs-that-already-carry-faults)).  ## Recompute -Fusion recomputes what an edit touches as the edit is made. Autodesk keeps Compute All for checkpoints: major milestones, significant changes, edits upstream in the timeline that later features reference, and a check before the design leaves Fusion. Writes follow that lead. Each write reads the timeline's health after Fusion's own compute and refuses an unhealthy result (and rolls it back), as before. `Design.computeAll` runs at a checkpoint:+Fusion recomputes what an edit touches as the edit is made. Autodesk keeps Compute All for checkpoints: major milestones, significant changes, edits upstream in the timeline that later features reference, and a check before the design leaves Fusion. Writes follow that lead. Each write reads the timeline's health after Fusion's own compute and refuses (and rolls back) a result that adds a fault or changes one the model already had ([Designs that already carry faults](#designs-that-already-carry-faults)). `Design.computeAll` runs at a checkpoint:  - **Significance.** Writes since the last full recompute add up their weights (`compute.py`):   - 0: metadata, materials and new parameters;@@ -57,6 +63,20 @@ Fusion recomputes what an edit touches as the edit is made. Autodesk keeps Compu  Fusion computes on its main thread either way, so none of this runs in the background; the policy only chooses when. Failure recovery still recomputes in full. While the revision is unchanged, a write's pre-check reuses the health the previous write verified, so consecutive writes walk the timeline once each. Every write reply and `health` carry `compute`: `full`, `reason`, `seconds`, `significance`, `checkpointAt`, `writesSinceFullCompute`, `focus` and `lastFullCompute`. The counters are per document, for the add-in session. +## Designs that already carry faults++A production design can carry timeline faults that nobody is about to fix, such as a plane or sketch whose face was deleted long ago. A write is held to the faults the model carried before it (its baseline), not to a perfect model:++- The baseline is the health the previous write verified at the same revision, otherwise a walk of the timeline: the same walk as before, so it costs no extra walk. Comparing 15 faults takes under 15 µs.+- A fault is the same fault when its timeline item's name, health state and message are the same. Its index can move when an earlier item is deleted.+- The write succeeds when its result adds no fault, changes none, and leaves no fault on what it edits in place: the feature of `feature_edit`, the feature that owns a parameter it sets, the sketch of a sketch edit, or the occurrence, joint, rigid group, joint origin or motion link it edits. Inputs it only reads, such as a profile, an axis or joint geometry, do not count.+- Otherwise it refuses with `unhealthy_model` and rolls back as before. `newFaults` lists the faults it added or changed, and `editedFaults` the faults left on what it edited. Recovery is verified against the same baseline.+- The replies of model writes (the transactional operations, `parameter_set` and `parameter_create`), `transaction_probe` and `export` carry `preExistingFaults`: `count`, the first five `faults`, `more` when there are more, and `cleared` for the faults the write removed (deleted, suppressed or repaired). While old faults remain, `health.healthy` stays false on a write that succeeded.+- A full recompute at a checkpoint is held to the same baseline (`Design.computeAll` succeeds on a model that carries faults; a failed one still refuses). On the 4,579-occurrence assembly the first full recompute after opening showed the same 15 faults. Should one ever show a fault Fusion had not computed since the design opened, `health {recompute:true}` computes the design and the next write takes its baseline from that.+- `transaction_probe` and `export` follow the same rule. With a healthy baseline the rule is the old one: any fault refuses.++Sketches: the recovery witness records every sketch's constraint graph. The sketches a write names, or whose curve, point, constraint or dimension it names, must read completely, or the write refuses with `unsupported_constraint_graph` before it changes anything. A profile's sketch does not count: a feature reads only the profile. Every other sketch enters the witness with what can be read, and a relationship type the reader does not support (a polygon constraint, for example) enters as its type, so it no longer blocks writes elsewhere in the design. `inspect_entity` on such a sketch lists those relationships in `constraintGraph.unreadable` (`complete: false`).+ ## Plan exploded assembly steps  `exploded_view_plan` returns an immutable source-bound instance/home-pose plan,@@ -286,8 +306,10 @@ fully-constrained/fixed/linked/reference observations; it does not invent solver a conflicting-constraint explanation, and it does not recompute or alter the sketch.  Model transaction recovery records sketch constraint and dimension links into their native curve/point-inventory, including offset relationships and curve endpoint links. Unsupported relationship classes,-foreign links or unreadable graph properties fail closed before mutation. A reordered inventory can+inventory, including offset relationships and curve endpoint links. In a sketch the write names (or whose+curve, point, constraint or dimension it names), unsupported relationship classes, foreign links or+unreadable graph properties fail closed before mutation; other sketches enter the witness with what can be+read ([Designs that already carry faults](#designs-that-already-carry-faults)). A reordered inventory can conservatively leave recovery unverified; equal current coordinates alone never establish equal design intent. Readback does not compare entity-token strings to establish these relationships. @@ -402,7 +424,7 @@ Metadata always wins: `makeBuy`, `asPurchased`, `category`, `supplier`, `supplie  Native origin `transform` is component-local; an occurrence-context origin returns its root-space proxy transform. The nested `geometry` frame is separately labeled `native_joint_geometry_api`: Fusion can expose an origin's defining geometry through a placed-instance proxy even for a native origin. Definition edits verify the exact native source and owner; regular-joint attachments additionally require the exact selected occurrence. -A moving-to-rigid redefinition can make an existing Capture Position snapshot empty and unhealthy in Fusion. Such an edit refuses with `causeCode: unhealthy_model`; the response reports whether `PTransaction.Abort` verified restoration. Existing snapshot history is retained. Successful family changes and geometry reattachments require healthy resulting history; the bridge does not retarget or discard snapshots automatically.+A moving-to-rigid redefinition can make an existing Capture Position snapshot empty and unhealthy in Fusion. Such an edit refuses with `causeCode: unhealthy_model`; the response reports whether `PTransaction.Abort` verified restoration. Existing snapshot history is retained. Successful family changes and geometry reattachments require resulting history with no new or changed fault; the bridge does not retarget or discard snapshots automatically.  `motion_link_create/edit/delete` use the native November 2025+ API, with explicit joint degrees of freedom and positive mm/deg ratio magnitudes. `reversed` controls opposite motion. Missing native support fails explicitly. Motion link membership changes require recreating the link. 
tests/test_mechanical_baseline.pyadded+337
@@ -0,0 +1,337 @@+"""Writes on a design that already carries faults, offline: the faults present before a write are its+baseline; a write may not add one, change one or leave one on what it edited. A sketch relationship the+graph reader cannot read refuses only a write that edits or reads that sketch."""+import importlib+from types import SimpleNamespace as NS+import unittest+from unittest.mock import patch++from test_mechanical import Collection, Entity, adsk, c, ops++compute = importlib.import_module(c.__package__ + '.compute')+tx = importlib.import_module(c.__package__ + '.transactions')+rb = importlib.import_module(c.__package__ + '.readback')+++def fault(name, message='lost reference', state=2, index='4'):+    return {'index': index, 'name': name, 'state': state, 'message': message}+++def health(*faults, unknown=(), computed=None):+    return {'healthy': computed is not False and not faults and not unknown, 'computeSucceeded': computed,+            'complete': not unknown, 'faults': list(faults), 'unknown': list(unknown), 'suppressed': [], 'timelineCount': 9}+++OLD = [fault('Fillet7', 'edge lost', index='2'), fault('Plane3', 'face lost', index='5')]+++class Judge(unittest.TestCase):+    def test_a_write_that_adds_nothing_is_allowed_on_a_faulted_model(self):+        compute.judge(health(*OLD), health(*OLD))++    def test_a_new_fault_refuses_and_is_named(self):+        with self.assertRaises(c.MechanicalError) as error:+            compute.judge(health(*OLD, fault('Extrude9', 'no profile', index='8')), health(*OLD))+        self.assertEqual(error.exception.code, 'unhealthy_model')+        self.assertEqual([f['name'] for f in error.exception.details['newFaults']], ['Extrude9'])+        self.assertEqual(error.exception.details['preExistingFaults']['count'], 2)++    def test_a_changed_fault_refuses(self):+        worse = [OLD[0], fault('Plane3', 'face lost', state=1, index='5')]+        with self.assertRaises(c.MechanicalError) as error:+            compute.judge(health(*worse), health(*OLD))+        self.assertEqual(error.exception.details['newFaults'], [worse[1]])+        other = [OLD[0], fault('Plane3', 'another message', index='5')]+        with self.assertRaises(c.MechanicalError):+            compute.judge(health(*other), health(*OLD))++    def test_an_earlier_delete_moves_indexes_without_reading_as_a_change(self):+        moved = [dict(row, index=str(int(row['index']) - 1)) for row in OLD]+        compute.judge(health(*moved), health(*OLD))++    def test_lookalike_faults_count(self):+        two = [fault('Boss1', index='3'), fault('Boss1', index='6')]+        compute.judge(health(*two), health(*two))+        with self.assertRaises(c.MechanicalError) as error:+            compute.judge(health(*two, fault('Boss1', index='9')), health(*two))+        self.assertEqual([f['index'] for f in error.exception.details['newFaults']], ['9'])++    def test_a_cleared_fault_is_allowed_and_reported(self):+        compute.judge(health(OLD[1]), health(*OLD))+        self.assertEqual(compute.summary(health(*OLD), health(OLD[1]))['cleared'], {'count': 1, 'faults': [OLD[0]]})++    def test_unreadable_items_are_faults_too(self):+        lost = {'index': '7', 'name': 'Pattern2', 'error': 'InternalValidationError'}+        compute.judge(health(unknown=[lost]), health(unknown=[lost]))+        with self.assertRaises(c.MechanicalError):+            compute.judge(health(unknown=[lost]), health())++    def test_a_healthy_baseline_keeps_the_old_rule(self):+        with self.assertRaises(c.MechanicalError):+            compute.judge(health(fault('Fillet1')), health())+        with self.assertRaises(c.MechanicalError) as error:+            compute.judge(health(computed=False), health())+        self.assertEqual(str(error.exception), 'Recompute failed.')+        # a failed full recompute refuses on a faulted model too: Fusion's computeAll succeeds there+        with self.assertRaises(c.MechanicalError):+            compute.judge(health(*OLD, computed=False), health(*OLD))++    def test_a_fault_left_on_what_the_write_edits_is_its_own(self):+        feature = Entity(objectType='adsk::fusion::FilletFeature', name='Fillet7')+        natives = [(OLD[0], NS(entity=feature)), (OLD[1], NS(entity=Entity(objectType='adsk::fusion::ConstructionPlane')))]+        compute.judge(health(*OLD), health(*OLD), natives, [Entity(objectType='adsk::fusion::BRepFace')])+        with self.assertRaises(c.MechanicalError) as error:+            compute.judge(health(*OLD), health(*OLD), natives, [feature])+        self.assertEqual(error.exception.details['editedFaults'], [OLD[0]])+        self.assertEqual(str(error.exception), 'The write left a fault on an entity it edited.')++    def test_the_subjects_are_what_a_write_edits_in_place_not_its_inputs(self):+        feature = Entity(objectType='adsk::fusion::FilletFeature', name='Fillet7', entityToken='tok-fillet')+        axis = Entity(objectType='adsk::fusion::ConstructionAxis', name='Axis1', entityToken='tok-axis')+        tokens = {'tok-fillet': feature, 'tok-axis': axis}+        owned = Entity(name='d5', createdBy=feature)+        design = NS(findEntityByToken=lambda token: [tokens[token]] if token in tokens else [],+                    allParameters=Collection([owned, Entity(name='width')]))+        value = {'documentId': 'd', 'revision': 'r'}+        ref = lambda token, kind: dict(value, token=token, objectType='adsk::fusion::' + kind)+        self.assertEqual(compute.subjects(design, value, {'op': 'feature_edit', 'feature': ref('tok-fillet', 'FilletFeature')}), [feature])+        self.assertEqual(compute.subjects(design, value, {'op': 'feature_create', 'axis': ref('tok-axis', 'ConstructionAxis')}), [])+        self.assertEqual(compute.subjects(design, value, {'op': 'feature_edit', 'feature': ref('tok-gone', 'FilletFeature')}), [])+        batch = {'op': 'parameter_batch', 'changes': [{'action': 'set', 'name': 'd5'}, {'action': 'set', 'name': 'width'},+                                                     {'action': 'create', 'name': 'x'}]}+        self.assertEqual(compute.subjects(design, value, batch), [feature])++    def test_a_reply_lists_the_first_few_and_counts_the_rest(self):+        many = [fault('F%d' % i, index=str(i)) for i in range(15)]+        out = compute.summary(health(*many), health(*many))+        self.assertEqual((out['count'], len(out['faults']), out['more']), (15, 5, 10))+        self.assertNotIn('cleared', out)+        self.assertEqual(compute.summary(health()), {'count': 0, 'faults': []})+++class Walk(unittest.TestCase):+    def test_the_walk_hands_back_the_items_behind_its_faults(self):+        class Broken:+            name, isGroup, isSuppressed = 'Pattern2', False, False++            @property+            def healthState(self):+                raise RuntimeError('InternalValidationError')+        bad = NS(name='Fillet7', isGroup=False, isSuppressed=False, healthState=2, errorOrWarningMessage='edge lost')+        good = NS(name='Boss1', isGroup=False, isSuppressed=False, healthState=0)+        design = NS(timeline=Collection([good, bad, Broken()]))+        natives = []+        state = c.health(design, False, natives)+        self.assertEqual(state['faults'], [{'index': '1', 'name': 'Fillet7', 'state': 2, 'message': 'edge lost'}])+        self.assertEqual(state['unknown'], [{'index': '2', 'error': 'InternalValidationError', 'name': 'Pattern2'}])+        self.assertEqual([item for _, item in natives][:1], [bad])+        self.assertEqual(len(natives), 2)++    def test_a_check_inside_a_write_is_held_to_its_baseline(self):+        with patch.object(compute, 'health', lambda design, recompute=False, natives=None: health(*OLD)):+            with c.write_context(health(*OLD)):+                compute.midway(None)+            with c.write_context(health(OLD[0])), self.assertRaises(c.MechanicalError):+                compute.midway(None)+            with self.assertRaises(c.MechanicalError):+                compute.midway(None)  # outside a write the baseline is a healthy model+++class Transactions(unittest.TestCase):+    """The adapter on a faulted model: the faults are the baseline, a breaking write is undone and the+    undo is verified against the same baseline."""+    def setUp(self):+        compute.reset()+        self.state = [health(*OLD)]+        self.reads = []+        self.commands = []+        self.patches = [patch.object(compute, 'health', self.read),+                        patch.object(tx, 'health', self.read),+                        patch.object(tx, '_verified', True),+                        patch.object(tx, 'target', lambda app, args, write=False: ('doc', self.design, {'documentId': 'd', 'revision': 'r0'})),+                        patch.object(tx, 'current_target', lambda app: {'documentId': 'd', 'revision': 'r1'}),+                        patch.object(tx, 'recovery_snapshot', lambda design, health_state=None, strict_sketches=(): {'faults': len(self.state[0]['faults'])}),+                        patch.object(tx, 'same', lambda a, b: True),+                        patch.object(tx, 'command', self.command)]+        self.design = NS(timeline=NS(markerPosition=3, count=3), findEntityByToken=lambda token: [])+        for p in self.patches:+            p.start()++    def tearDown(self):+        for p in self.patches:+            p.stop()++    def read(self, design, recompute=False, natives=None):+        self.reads.append(recompute)+        return dict(self.state[0], computeSucceeded=True if recompute else None)++    def command(self, app, text):+        self.commands.append(text.split()[0])+        if text.startswith('PTransaction.Abort'):+            self.state[0] = health(*OLD)  # the undo puts the model back++    def test_a_write_elsewhere_commits_and_reports_the_faults_already_there(self):+        out = tx.execute(NS(activeDocument='doc'), {'op': 'feature_create', 'target': {}}, lambda design, value, args: {'made': 1})+        self.assertEqual(self.commands, ['PTransaction.Start', 'PTransaction.Commit'])+        self.assertEqual((out['preExistingFaults']['count'], out['health']['healthy']), (2, False))+        # the next write's baseline is what this one verified, faults included, without another walk+        self.assertEqual(compute.health_before(None, 'd', 'r1')['faults'], OLD)++    def test_a_write_that_breaks_something_is_undone_and_the_undo_verified(self):+        def breaks(design, value, args):+            self.state[0] = health(*OLD, fault('Extrude9', 'no profile', index='8'))+            return {'made': 1}+        with self.assertRaises(c.MechanicalError) as error:+            tx.execute(NS(activeDocument='doc'), {'op': 'feature_create', 'target': {}}, breaks)+        details = error.exception.details+        self.assertEqual((error.exception.code, details['causeCode']), ('mutation_failed', 'unhealthy_model'))+        self.assertEqual([f['name'] for f in details['causeDetails']['newFaults']], ['Extrude9'])+        self.assertEqual(self.commands, ['PTransaction.Start', 'PTransaction.Abort'])+        self.assertTrue(details['recovery']['verified'], details['recovery'])++    def test_the_probe_passes_on_a_faulted_model(self):+        params = {}+        design = NS(userParameters=NS(add=lambda name, value, unit, comment: params.setdefault(name, NS(value=0.2, deleteMe=lambda: params.pop(name) is not None)),+                                      itemByName=lambda name: params.get(name)))++        def command(app, text):+            self.commands.append(text.split()[0])+            if text.startswith('PTransaction.Abort'):+                params.clear()+        with patch.object(tx, 'target', lambda app, args, write=False: ('doc', design, {'documentId': 'd', 'revision': 'r0'})), \+                patch.object(tx, 'command', command), patch.object(tx, '_verified', False), \+                patch.object(adsk.core, 'ValueInput', NS(createByString=lambda text: text), create=True):+            out = tx._probe(NS(version='test'), {'target': {}})+            self.assertTrue(out['transaction']['verified'])+            self.assertEqual(out['preExistingFaults']['count'], 2)+++class Sketch(unittest.TestCase):+    """A sketch whose graph holds a relationship the reader does not support."""+    def sketch(self):+        point = lambda x, y: Entity(objectType='adsk::fusion::SketchPoint', geometry=NS(x=x, y=y, z=0), isFixed=False)+        a, b = point(0, 0), point(1, 0)+        line = Entity(objectType='adsk::fusion::SketchLine', isConstruction=False, isFixed=False,+                      geometry=NS(objectType='adsk::core::Line3D', startPoint=a.geometry, endPoint=b.geometry),+                      startSketchPoint=a, endSketchPoint=b)+        horizontal = Entity(objectType='adsk::fusion::HorizontalConstraint', line=line)+        polygon = Entity(objectType='adsk::fusion::PolygonConstraint')+        dimension = Entity(objectType='adsk::fusion::SketchLinearDimension', entityOne=a, entityTwo=b, isDriving=True,+                           value=1.0, orientation=0, parameter=NS(name='d1', expression='10 mm'))+        sketch = Entity(objectType='adsk::fusion::Sketch', name='Hex', isFullyConstrained=False,+                        sketchCurves=Collection([line]), sketchPoints=Collection([a, b]),+                        geometricConstraints=Collection([horizontal]), sketchDimensions=Collection([dimension]))+        return sketch, polygon++    def test_a_readable_sketch_reads_the_same_either_way(self):+        sketch, _ = self.sketch()+        self.assertEqual(rb.sketch_data(sketch), rb.sketch_data(sketch, strict=False))+        self.assertNotIn('constraintGraph', rb.sketch_data(sketch, strict=False))++    def test_strict_refuses_and_lenient_lists_the_relationship(self):+        sketch, polygon = self.sketch()+        sketch.geometricConstraints.values.append(polygon)+        with self.assertRaises(c.MechanicalError) as error:+            rb.sketch_data(sketch)+        self.assertEqual(error.exception.code, 'unsupported_constraint_graph')+        data = rb.sketch_data(sketch, strict=False)+        self.assertEqual(data['constraints'][1]['type'], 'adsk::fusion::PolygonConstraint')+        self.assertEqual(data['constraintGraph']['complete'], False)+        self.assertEqual([row['type'] for row in data['constraintGraph']['unreadable']], ['adsk::fusion::PolygonConstraint'])+        # the readable part of the graph is still there+        self.assertEqual(data['constraints'][0]['links'], {'line': {'collection': 'curves', 'index': 0}})+        self.assertEqual(data['dimensions'][0]['name'], 'd1')++    def test_only_a_sketch_the_write_names_must_read_completely(self):+        sketch, polygon = self.sketch()+        sketch.geometricConstraints.values.append(polygon)+        other, _ = self.sketch()+        component = Entity(objectType='adsk::fusion::Component', name='root', sketches=Collection([sketch, other]),+                           bRepBodies=Collection(), features=Collection())+        design = NS(rootComponent=NS(allOccurrences=Collection()), allComponents=Collection([component]),+                    allParameters=Collection(), timeline=NS(count=0, markerPosition=0))+        with patch.object(c, 'components_by_occurrence', lambda design, occurrences, size: [component]), \+                patch('mechanical_test_commands.mechanical.configurations.snapshot', lambda design, strict=False: {}), \+                patch('mechanical_test_commands.mechanical.assembly_state.component_state', lambda comp: {}):+            snapshot = c.recovery_snapshot(design, health())+            self.assertEqual(snapshot['components'][0][3][0]['constraintGraph']['complete'], False)+            c.recovery_snapshot(design, health(), [other])+            with self.assertRaises(c.MechanicalError) as error:+                c.recovery_snapshot(design, health(), [sketch])+            self.assertEqual(error.exception.code, 'unsupported_constraint_graph')++    def test_reading_a_sketch_lists_what_it_cannot_read_instead_of_refusing(self):+        sketch = Entity(objectType='adsk::fusion::Sketch', name='Hex')+        with patch.object(ops, 'target', return_value=(None, None, {'documentId': 'd', 'revision': 'r'})), \+                patch.object(ops, 'resolve', return_value=sketch), \+                patch.object(ops, 'ref', return_value={'token': 'tok-hex'}), \+                patch.object(rb, 'sketch_data', lambda entity, value=None, strict=True: {'strict': strict}):+            out = ops.inspect_entity(None, {'entity': {}})+        self.assertEqual(out['entity']['strict'], False)++    def test_the_sketches_a_write_names(self):+        sketch, _ = self.sketch()+        line = sketch.sketchCurves.item(0)+        line.parentSketch = sketch+        found = {'tok-sketch': sketch, 'tok-line': line}+        design = NS(findEntityByToken=lambda token: [found[token]] if token in found else [])+        value = {'documentId': 'd', 'revision': 'r'}+        ref = lambda token, kind: dict(value, token=token, objectType='adsk::fusion::' + kind)+        self.assertEqual(tx.sketches_named(design, value, {'sketch': ref('tok-sketch', 'Sketch')}), [sketch])+        self.assertEqual(tx.sketches_named(design, value, {'entities': [ref('tok-line', 'SketchLine')]}), [sketch])+        # a profile's sketch is read only for the profile; other references and the target are skipped+        profile = dict(value, objectType='adsk::fusion::Profile', sketchToken='tok-sketch', profileIndex=0)+        face = ref('tok-face', 'BRepFace')+        self.assertEqual(tx.sketches_named(design, value, {'target': ref('tok-sketch', 'Sketch'), 'profiles': [profile], 'faces': [face]}), [])+        # a reference that does not resolve is left for the operation+        self.assertEqual(tx.sketches_named(design, value, {'sketch': ref('tok-gone', 'Sketch')}), [])+++class Parameters(unittest.TestCase):+    def setUp(self):+        compute.reset()+        self.fillet = Entity(objectType='adsk::fusion::FilletFeature', name='Fillet7')++    def run_set(self, owner, faults_after):+        p = Entity(name='d3', expression='10 mm', unit='mm', createdBy=owner)+        design = NS(allParameters=Collection([p]), unitsManager=NS(isValidExpression=lambda *a: True))+        reads = []++        def read(design, recompute=False, natives=None):+            reads.append(recompute)+            rows = faults_after if p.expression == '12 mm' else OLD+            if natives is not None:+                natives.extend((row, NS(entity=self.fillet if row['name'] == 'Fillet7' else None)) for row in rows)+            return health(*rows, computed=True if recompute else None)+        with patch.object(ops, 'target', return_value=(None, design, {'documentId': 'd', 'revision': 'r'})), \+                patch.object(compute, 'health', read), \+                patch.object(ops, 'recovery_snapshot', side_effect=lambda d, health_state=None: {'expression': p.expression}), \+                patch.object(ops, 'current_target', return_value={'documentId': 'd', 'revision': 'r2'}), \+                patch.object(ops, 'parameter_data', lambda design, parameter: {'name': parameter.name}):+            return ops.parameter_set(None, {'op': 'parameter_set', 'name': 'd3', 'expression': '12 mm'}), p++    def test_a_parameter_change_that_adds_nothing_succeeds(self):+        out, p = self.run_set(Entity(objectType='adsk::fusion::ExtrudeFeature', name='Extrude2'), OLD)+        self.assertEqual((p.expression, out['preExistingFaults']['count']), ('12 mm', 2))++    def test_a_parameter_of_a_faulted_feature_that_stays_faulted_is_refused(self):+        with self.assertRaises(c.MechanicalError) as error:+            self.run_set(self.fillet, OLD)+        self.assertEqual(error.exception.code, 'parameter_edit_failed')+        self.assertEqual(error.exception.details['causeDetails']['editedFaults'], [OLD[0]])+        self.assertTrue(error.exception.details['recovery']['verified'])+++class Export(unittest.TestCase):+    def test_a_faulted_design_exports_and_reports_its_faults(self):+        compute.reset()+        with patch.object(ops, 'target', lambda app, args, write=False, model_write=True: ('doc', None, {'documentId': 'd', 'revision': 'r'})), \+                patch.object(ops, 'current_target', lambda app: {'documentId': 'd', 'revision': 'r'}), \+                patch.object(compute, 'health', lambda design, recompute=False, natives=None: health(*OLD)), \+                patch.object(ops, 'export_design', lambda design, args: {'path': 'out.step'}):+            out = ops.export(None, {'target': {}})+        self.assertEqual((out['path'], out['preExistingFaults']['count']), ('out.step', 2))+++if __name__ == '__main__':+    unittest.main()
tests/test_mechanical_compute.py+21−12
@@ -23,9 +23,13 @@ class Health:     def __init__(self, healthy=True):         self.calls, self.healthy = [], healthy -    def __call__(self, design, recompute=False):+    def __call__(self, design, recompute=False, natives=None):         self.calls.append(recompute)-        return {"healthy": self.healthy, "computeSucceeded": True if recompute else None, "faults": [], "unknown": []}+        faults = [] if self.healthy else [{"index": "3", "name": "Fillet1", "state": 2, "message": "lost edge"}]+        return {"healthy": self.healthy, "computeSucceeded": True if recompute else None, "faults": faults, "unknown": []}+++HEALTHY = {"healthy": True, "faults": [], "unknown": []}   class Weights(unittest.TestCase):@@ -65,7 +69,7 @@ class Policy(unittest.TestCase):         self.patch.stop()      def write(self, op, revision, **args):-        state, info = compute.settle(None, "d", {"op": op, **args})+        state, info = compute.settle(None, "d", {"op": op, **args}, HEALTHY)         return compute.committed("d", revision, state, info)      def test_appends_stay_incremental_until_they_add_up_to_a_checkpoint(self):@@ -93,9 +97,10 @@ class Policy(unittest.TestCase):     def test_an_unhealthy_result_is_refused_with_its_health_and_counts_nothing(self):         self.health.healthy = False         with self.assertRaises(c.MechanicalError) as error:-            compute.settle(None, "d", {"op": "feature_edit", "feature": ref("Lid:1")})+            compute.settle(None, "d", {"op": "feature_edit", "feature": ref("Lid:1")}, HEALTHY)         self.assertEqual(error.exception.code, "unhealthy_model")         self.assertEqual(error.exception.details["compute"]["full"], False)+        self.assertEqual([f["name"] for f in error.exception.details["newFaults"]], ["Fillet1"])         self.assertEqual(compute.status("d")["writesSinceFullCompute"], 0)      def test_health_a_write_verified_is_reused_while_nothing_changed(self):@@ -105,11 +110,13 @@ class Policy(unittest.TestCase):         compute.health_before(None, "d", "someone-else-edited")         self.assertEqual(self.health.calls, [False, False]) -    def test_an_unhealthy_model_is_refused_before_a_write(self):+    def test_faults_a_model_already_carries_are_the_baseline_not_a_refusal(self):         self.health.healthy = False-        with self.assertRaises(c.MechanicalError) as error:-            compute.health_before(None, "d", "r9")-        self.assertEqual(error.exception.code, "unhealthy_model")+        state = compute.health_before(None, "d", "r9")+        self.assertEqual([f["name"] for f in state["faults"]], ["Fillet1"])+        # the same faults after the write: nothing new, so the write settles+        settled, _ = compute.settle(None, "d", {"op": "feature_create"}, state)+        self.assertFalse(settled["healthy"])      def test_an_explicit_checkpoint_starts_the_counters_again(self):         self.write("entity_delete", "r1")@@ -130,7 +137,7 @@ class Writes(unittest.TestCase):                         patch.object(tx, "_verified", True),                         patch.object(tx, "target", lambda app, args, write=False: ("doc", self.design, {"documentId": "d", "revision": self.revision[0]})),                         patch.object(tx, "current_target", lambda app: {"documentId": "d", "revision": self.revision[0]}),-                        patch.object(tx, "recovery_snapshot", lambda design, health_state=None: {}),+                        patch.object(tx, "recovery_snapshot", lambda design, health_state=None, strict_sketches=(): {}),                         patch.object(tx, "same", lambda a, b: True),                         patch.object(tx, "command", lambda app, text: self.commands.append(text.split()[0]))]         self.design = NS(timeline=NS(markerPosition=3, count=3))@@ -163,10 +170,12 @@ class Writes(unittest.TestCase):                 patch.object(ops, "current_target", lambda app: {"documentId": "d", "revision": "r1"}), \                 patch.object(ops, "export_design", lambda design, args: {"path": "out.step"}):             out = ops.export(None, {"target": {}})+            # the edit's verified health is the baseline; the export's full recompute is held to it             self.assertEqual((self.health.calls, out["compute"]["lastFullCompute"]["reason"]), ([True], "export"))-            with patch.object(ops, "require_healthy", lambda design, recompute=True: {"healthy": True}):-                ops.export(None, {"target": {}})-            self.assertEqual(self.health.calls, [True])+            self.assertEqual(out["preExistingFaults"], {"count": 0, "faults": []})+            ops.export(None, {"target": {}})+            # nothing changed since: no recompute, one walk for the baseline+            self.assertEqual(self.health.calls, [True, False])   if __name__ == "__main__":

Reviews

Colby Knox approved · 6d ago

Reviewed. A write held to the faults it started with, anything new or changed still refuses and rolls back, and a healthy design behaves as before. Mechanical tests pass here on your branch. Merging; I'll add the add-in bump and changelog.

Comments

No comments yet.

Log in to comment.