master
Drew Owens Typed reads for assembly planning (assembly_layout, ray_cast) e6d8a3a 6d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662
"""Main-thread mechanical identity, revisions, references and health.

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
import threading
import uuid

import adsk.core
import adsk.fusion

SESSION_ID = str(uuid.uuid4())
_documents = []
_entities = []
MAX_ENTITIES = 20000
UNITS = {"length": "mm", "angle": "deg", "mass": "kg", "area": "mm2", "volume": "mm3"}


class MechanicalError(Exception):
    def __init__(self, code, message, **details):
        super().__init__(message)
        self.code, self.details = code, details


def require(condition, code, message, **details):
    if not condition:
        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)]


def same(a, b):
    return a is b or a == b


def concrete(entity):
    """Cast Base-returning Fusion getters before reading subclass context/state."""
    if entity is None:
        return None
    cls = getattr(adsk.fusion, entity.objectType.split("::")[-1], None)
    if cls is not None and hasattr(cls, "cast"):
        entity = cls.cast(entity)
        require(entity is not None, "unsupported_entity_wrapper", "Could not cast the advertised Fusion entity type.")
    return entity


# Lookup indexes over _entities. A lookup used to compare against every entity registered this
# session, so a 1,249-component assembly (about 10,000 identities) made the revision guard run for
# 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


def _identity_bucket(entity):
    """Type plus name and owner (an occurrence's full path): same-named entities share a bucket and
    are told apart by native equality, as before."""
    kind = getattr(entity, "objectType", "")
    try:
        if kind.endswith("::Occurrence"):
            return (kind, entity.fullPathName)
        owner = getattr(entity, "parentComponent", None) or getattr(entity, "parentSketch", None) or getattr(entity, "body", None)
        return (kind, getattr(entity, "name", None), getattr(owner, "name", None))
    except Exception:
        return (kind,)


def _identity_token(entity):
    try:
        return (entity.objectType, entity.entityToken)
    except Exception:
        return None


def identity(entity):
    """Object equality, never name or token equality. Retained for this session only. The candidates
    are the entity's bucket, then (for one renamed since) its token's; an entity whose name and token
    both changed gets a new identity, which reads as a changed revision, never a false match."""
    if not _entities and (_identity_buckets or _identity_tokens):
        _identity_buckets.clear()  # the registry was reset
        _identity_tokens.clear()
        _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.")
    for previous, key in _documents:
        if previous.isValid and same(previous, doc):
            return key
    key = SESSION_ID + ":" + str(uuid.uuid4())
    _documents.append((doc, key))
    return key


def active(app):
    require(threading.current_thread() is threading.main_thread(), "wrong_thread", "Mechanical API requires the Fusion main thread.")
    doc = app.activeDocument
    design = adsk.fusion.Design.cast(app.activeProduct)
    require(doc and design, "no_design", "The active product must be a Fusion Design.")
    require(same(design.parentDocument, doc), "wrong_document", "Active product and document disagree.")
    return doc, design, document_identity(doc)


def digest(value):
    return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(",", ":"), allow_nan=False).encode()).hexdigest()


def _revision_document_observation(design, document):
    """Bounded, independently readable facts even when one native getter fails."""
    result = {"complete": True, "errors": []}
    for field, getter in (
        ("designValid", lambda: design.isValid),
        ("documentValid", lambda: document.isValid),
        ("ownerVerified", lambda: same(design.parentDocument, document)),
        ("isSaved", lambda: document.isSaved),
        ("isModified", lambda: document.isModified),
    ):
        try:
            value = getter()
            require(type(value) is bool, "revision_unavailable", "Native document observation is not boolean.")
            result[field] = value
        except Exception as error:
            result[field] = None
            result["complete"] = False
            result["errors"].append({"field": field, "error": str(error)[:512]})
    return result


def reference_version(component):
    """A component inserted by reference: its source document's identity and version, which is what
    changes when someone edits it there (the edit arrives here as a new version)."""
    try:
        data_file = component.parentDesign.parentDocument.dataFile
        return {"dataFileId": data_file.id, "version": data_file.versionNumber} if data_file else {"dataFileId": None}
    except Exception as error:
        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.

    Fusion can advance ancestor revision IDs on the first child meshBodies read.
    All components must be initialized before ANY component revisionId is sampled.
    Native IDs still detect true equal-volume/topology changes; no target is rebased.
    """
    require(threading.current_thread() is threading.main_thread(), "wrong_thread", "Mechanical revision requires the Fusion main thread.")
    from .assembly_state import component_state, occurrence_state
    from .readback import construction_state, feature_data
    from .configurations import snapshot as configuration_snapshot
    document, observations = None, []

    def observe(stage):
        state = _revision_document_observation(design, document)
        observations.append({"stage": stage, **state})
        return state

    def checked(state, before=None):
        require(state["complete"] and all(state[key] is True for key in ("designValid", "documentValid", "ownerVerified")),
                "revision_unavailable", "Exact Design/document ownership or flags are unavailable.")
        if before is not None:
            require(all(state[key] == before[key] for key in ("isSaved", "isModified")),
                    "revision_document_changed", "Document saved/modified flags changed during revision observation.")

    try:
        document = design.parentDocument
        before = observe("before_initialization")
        checked(before)
        collection = design.allComponents
        size = collection.count
        require(type(size) is int and 0 <= size <= MAX_ENTITIES, "inspection_budget", "Component collection exceeds inspection budget.")
        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.
        # Requiring every component to belong to this Design refused every assembly built from
        # referenced sub-assemblies (2026-10-28: a production cell built from 23 referenced designs).
        referenced = set()
        for index, comp in enumerate(component_handles):
            require(comp is not None and comp.isValid is True, "revision_unavailable", "Component handle is invalid.")
            if not same(comp.parentDesign, design):
                # only a non-root component from another saved document is a reference
                source = getattr(comp.parentDesign, "parentDocument", None)
                require(not same(comp, design.rootComponent) and source is not None and getattr(source, "dataFile", None) is not None,
                        "revision_unavailable", "Component does not belong to the exact Design.")
                referenced.add(index)
                continue
            meshes = comp.meshBodies
            mesh_collections.append(meshes)  # Retain before count/validation can fail.
            require(meshes is not None and meshes.isValid is True and meshes.objectType == "adsk::fusion::MeshBodies",
                    "revision_unavailable", "Native mesh collection is invalid or has an unexpected type.")
            mesh_count = meshes.count
            require(type(mesh_count) is int and 0 <= mesh_count <= MAX_ENTITIES,
                    "inspection_budget", "Mesh collection exceeds inspection budget.")
        checked(observe("after_initialization"), before)
        components = []
        for index, comp in enumerate(component_handles):
            if index in referenced:
                components.append([identity(comp), "referenced", comp.name, reference_version(comp)])
                continue
            components.append([identity(comp), str(comp.revisionId), comp.name,
                               [[identity(b), str(b.revisionId)] for b in items(comp.bRepBodies)],
                               [[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 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)])
        checked(observe("after_digest"), before)
        return result
    except Exception as error:
        observe("after_failure")
        initial = observations[0] if observations and observations[0]["stage"] == "before_initialization" else None
        changed = sorted({key for state in observations for key in ("isSaved", "isModified")
                          if initial is not None and type(initial.get(key)) is bool and type(state.get(key)) is bool
                          and state[key] != initial[key]})
        effect = "changed" if changed else "unchanged" if initial is not None and all(state["complete"] for state in observations) else "unknown"
        details = dict(getattr(error, "details", {}))
        details.update(revisionObservation={"documentFlagEffect": effect, "changedFlags": changed,
                                            "observations": observations}, rollbackClaimed=False)
        raise MechanicalError(getattr(error, "code", "revision_unavailable"), str(error)[:1024], **details) from error


def inside_reference(occurrence):
    """Is this occurrence inside a design inserted by reference (it or an ancestor is the reference)?"""
    node = occurrence
    while node is not None:
        if getattr(node, "isReferencedComponent", False):
            return True
        node = getattr(node, "assemblyContext", None)
    return False


def occurrence_suppression(occurrence):
    """Suppressed in this design's timeline; None when it has no timeline object here. An occurrence
    inside a referenced design has none in this document (Fusion raises InternalValidationError, seen
    on a production cell): its suppression is decided in its source document."""
    try:
        timeline = occurrence.timelineObject
    except RuntimeError:
        if inside_reference(occurrence):
            return None
        element = pattern_element(occurrence)
        if element is not None:
            return bool(element.isSuppressed)
        raise
    return bool(timeline.isSuppressed) if timeline is not None else None


def pattern_element(occurrence):
    """The pattern element that made an occurrence, or None. A component pattern's copies have no
    timeline object of their own (Fusion raises InternalValidationError); the element carries their
    suppression. Looked up only on that error, in the owning component's patterns."""
    try:
        context = getattr(occurrence, "assemblyContext", None)
        owner = context.component if context is not None else occurrence.component.parentDesign.rootComponent
        path = occurrence.fullPathName
        groups = (owner.features.rectangularPatternFeatures, owner.features.circularPatternFeatures)
    except (AttributeError, RuntimeError):
        return None
    for group in groups:
        for i in range(group.count):
            elements = group.item(i).patternElements
            for j in range(elements.count):
                element = elements.item(j)
                if any(o.fullPathName == path for o in element.occurrences):
                    return element
    return None


def target(app, args, write=False, model_write=True):
    doc, design, key = active(app)
    wanted = args.get("target")
    require(isinstance(wanted, dict), "target_required", "Pass target from mechanical inspect.")
    require(wanted.get("documentId") == key, "wrong_document", "Target is not the active document.", activeDocumentId=key)
    if write:
        from .configurations import snapshot as configuration_snapshot
        configuration = configuration_snapshot(design, strict=True)
        if model_write:
            require(not configuration["configured"], "configuration_mutation_unverified",
                    "Configured-design model writes remain disabled until native configuration recovery and archive gates pass; inspection and current-row BOM/export remain available.")
    rev = revision(design)
    if write or "revision" in wanted:
        require(wanted.get("revision") == rev, "stale_revision", "The model changed; inspect it before a new operation.", currentRevision=rev)
    return doc, design, {"documentId": key, "revision": rev}


def read_target(app, args):
    """target() for a read that only needs the guard: the target's document must be the active one, and
    a revision, when the target carries one, must be current. The revision digest costs about 5 s on a
    4,579-occurrence assembly, so a read not asked to check one does not compute it, and its target then
    carries no revision."""
    _, design, key = active(app)
    wanted = args.get("target")
    require(isinstance(wanted, dict), "target_required", "Pass target from mechanical inspect.")
    require(wanted.get("documentId") == key, "wrong_document", "Target is not the active document.", activeDocumentId=key)
    if "revision" in wanted:
        return target(app, args)
    return app.activeDocument, design, {"documentId": key}


def current_target(app):
    doc, design, key = active(app)
    return {"documentId": key, "revision": revision(design)}


ORIGIN_PLANES = {"xy": "xYConstructionPlane", "xz": "xZConstructionPlane", "yz": "yZConstructionPlane"}


def origin_plane_key(entity):
    """'xy', 'xz' or 'yz' when entity is one of its component's three origin planes, else None."""
    if getattr(entity, "objectType", "") != "adsk::fusion::ConstructionPlane":
        return None
    comp = getattr(entity, "component", None) or getattr(entity, "parent", None)
    for key, attr in ORIGIN_PLANES.items():
        if comp is not None and same(getattr(comp, attr, None), entity):
            return key
    return None


def ref(entity, target_value, occurrence=None):
    entity = concrete(entity)
    if entity.objectType == "adsk::fusion::Profile":
        from .readback import profile_ref
        sketch = entity.parentSketch
        matches = [index for index, profile in enumerate(items(sketch.profiles)) if same(profile, entity)]
        require(len(matches) == 1, "ambiguous_reference", "Profile must belong uniquely to its sketch.")
        return profile_ref(sketch, entity, matches[0], target_value)
    is_occurrence = entity.objectType.endswith("::Occurrence")
    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": 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.
        # Bind this explicit root reference to its live API identity instead.
        result["rootComponentId"] = identity(native)
    plane = origin_plane_key(native)
    if plane:
        # an origin plane resolves through its component, not its token, so a base plane is a
        # reference like any other plane (construction_axis two_planes, sketch_create, a split tool)
        result["originPlane"] = plane
        result["component"] = ref(native.component, target_value)
    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:
        require(len(candidates) == 1, "ambiguous_context", "Occurrence path must identify one instance.")
        return candidates[0]
    # Native definition references have a path relative to their owner. Only
    # retain that context when the token and saved path both resolve exactly.
    require(entity.fullPathName == path, "invalid_context", "Occurrence path changed.")
    return entity


def resolve(design, target_value, value, allowed=()):
    require(isinstance(value, dict), "invalid_reference", "An entity reference object is required.")
    require(value.get("documentId") == target_value["documentId"] and value.get("revision") == target_value["revision"],
            "stale_reference", "Reference is from another document or revision.")
    if value.get("objectType") == "adsk::fusion::Profile":
        # Bind profile selection to a uniquely
        # resolved sketch, this exact aggregate revision and geometric witness.
        from .readback import profile_signature
        require(not allowed or "Profile" in allowed, "wrong_entity_type", "Operation does not accept profiles.")
        require(isinstance(value.get("sketchToken"), str), "invalid_reference", "Profile sketch token is required.")
        sketches = design.findEntityByToken(value["sketchToken"])
        require(len(sketches) == 1 and sketches[0].objectType.endswith("::Sketch"), "ambiguous_reference", "Profile sketch must resolve uniquely.")
        index = value.get("profileIndex")
        require(isinstance(index, int) and not isinstance(index, bool) and 0 <= index < sketches[0].profiles.count,
                "invalid_reference", "Profile index is no longer valid.")
        profile = sketches[0].profiles.item(index)
        require(digest(profile_signature(profile)) == value.get("fingerprint"), "stale_reference", "Profile geometry changed.")
        return profile
    if value.get("originPlane") is not None:
        require(value.get("objectType") == "adsk::fusion::ConstructionPlane" and value["originPlane"] in ORIGIN_PLANES,
                "invalid_reference", "An origin plane reference names xy, xz or yz.")
        require(not allowed or "ConstructionPlane" in allowed, "wrong_entity_type", "Operation does not accept this entity type.")
        comp = resolve(design, target_value, value.get("component"), ("Component",))
        entity = getattr(comp, ORIGIN_PLANES[value["originPlane"]])
        require(entity is not None and entity.isValid, "invalid_reference", "Origin plane is not available.")
        return entity
    if "rootComponentId" in value:
        entity = design.rootComponent
        require(value.get("objectType") == "adsk::fusion::Component"
                and value["rootComponentId"] == identity(entity)
                and not value.get("occurrenceToken") and not value.get("occurrencePath"),
                "invalid_reference", "Root reference does not identify this active design's root component.")
    else:
        require(isinstance(value.get("token"), str) and value["token"], "invalid_reference", "Reference token is missing.")
        matches = design.findEntityByToken(value["token"])
        require(len(matches) <= MAX_ENTITIES, "inspection_budget", "Token lookup exceeds inspection budget.")
        if len(matches) > 1 and value.get("objectType") == "adsk::fusion::Component":
            # Repeated definitions can return duplicate API wrappers. Only
            # actual Component equality makes them one entity; face splits and
            # occurrence instances retain the strict one-result rule below.
            first = matches[0]
            for candidate in matches:
                require(candidate.isValid and candidate.objectType == "adsk::fusion::Component"
                        and same(first, candidate), "ambiguous_reference",
                        "Component token resolves to distinct or invalid entities.", matchCount=len(matches))
            matches = [first]
        require(len(matches) == 1, "ambiguous_reference", "Token must resolve to exactly one live entity.", matchCount=len(matches))
        entity = concrete(matches[0])
    require(entity.isValid and entity.objectType == value.get("objectType"), "invalid_reference", "Entity type or validity changed.")
    require(not allowed or entity.objectType.split("::")[-1] in allowed, "wrong_entity_type", "Operation does not accept this entity type.")
    if entity.objectType.endswith("::Occurrence") and value.get("occurrencePath"):
        entity = occurrence_context(design, entity, value["occurrencePath"])
    if value.get("occurrenceToken"):
        contexts = design.findEntityByToken(value["occurrenceToken"])
        require(len(contexts) == 1, "ambiguous_context", "Occurrence context must resolve uniquely.")
        context = occurrence_context(design, contexts[0], value.get("occurrencePath"))
        owner = getattr(entity, "parentComponent", None)
        if owner is None and hasattr(entity, "component"):
            owner = entity.component
        if owner is None and hasattr(entity, "parentSketch"):
            owner = entity.parentSketch.parentComponent
        if owner is None and hasattr(entity, "body"):
            owner = entity.body.parentComponent
        require(owner and same(owner, context.component), "invalid_context", "Entity is not owned by this occurrence component.")
        entity = entity.createForAssemblyContext(context)
        require(entity and entity.isValid, "invalid_context", "Could not create occurrence-context proxy.")
    return entity


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())
    faults, unknown, suppressed = [], [], []
    pending = [(str(index), item) for index, item in enumerate(items(design.timeline))]
    while pending:
        index, item = pending.pop(0)
        try:
            if getattr(item, "isGroup", False):
                group = adsk.fusion.TimelineGroup.cast(item)
                pending.extend((index + "." + str(i), child) for i, child in enumerate(items(group)))
                continue
            if getattr(item, "isSuppressed", False):
                suppressed.append({"index": index, "name": item.name})
                continue
            state = item.healthState
            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:
            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}


def require_healthy(design, recompute=True):
    state = health(design, recompute)
    require(state["healthy"], "unhealthy_model", "Recompute or feature health check failed.", health=state)
    return state


def point_mm(point):
    return [point.x * 10, point.y * 10, point.z * 10]


def body_data(body):
    pp = body.physicalProperties
    box = body.boundingBox
    return {"name": body.name, "volume": pp.volume * 1000, "area": pp.area * 100,
            "mass": pp.mass, "centerOfMass": point_mm(pp.centerOfMass),
            "bounds": {"min": point_mm(box.minPoint), "max": point_mm(box.maxPoint)},
            "faces": body.faces.count, "edges": body.edges.count, "isSolid": body.isSolid,
            "space": "component", "units": UNITS}


def parameter_data(design, parameter):
    unit = parameter.unit
    # Fusion numeric values always use its internal units (cm/rad/kg), regardless
    # of the display unit. Conversion occurs here, never in individual handlers.
    value, normalized = None, unit
    for canonical in ("mm", "deg", "kg", ""):
        try:
            if design.unitsManager.isValidExpression("1 " + canonical, unit):
                value = design.unitsManager.convert(parameter.value, "internalUnits", canonical)
                normalized = canonical or "unitless"
                break
        except Exception:
            continue
    row = {"name": parameter.name, "expression": parameter.expression,
           "value": value, "unit": normalized, "displayUnit": unit}
    # which feature (or sketch dimension, or joint) owns a model parameter, so feature_edit can be
    # pointed at it without diffing parameter lists; a user parameter has no owner
    owner = None
    try:
        created_by = getattr(parameter, "createdBy", None)
        if created_by is not None:
            owner = {"name": getattr(created_by, "name", None), "objectType": created_by.objectType}
            role = getattr(parameter, "role", None)
            if role:
                owner["role"] = role
    except Exception:
        owner = None
    row["kind"] = "model" if parameter.objectType.endswith("::ModelParameter") else "user"
    row["owner"] = owner
    return row


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). 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
    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, 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],
            "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):
    require(isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value), "invalid_input", name + " must be a finite number.")
    require(not positive or value > 0, "invalid_input", name + " must be positive.")
    return float(value)