diff --git a/CHANGELOG.md b/CHANGELOG.md index 1706e53..bd6600f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to the Adom Bridge Fusion 360 bridge, newest first. Canonical: https://wiki.adom.inc/adom/fusion-bridge ยท GitHub backup: `adom-inc/fusion-bridge` +## Staged exploded-preview controller (2026-10-06) + +- Add a source candidate for four direct journaled preview writes and live inspection (61 typed operations; 179 public verbs). Exact current-session creation history, native identity and blank-state checks bind a caller-created presentation document. +- Retain independent BRep graphics through finite absolute samples, explicit document activation and exact disposal. Preserve source geometry/joints/visibility, uncertain native effects and unresolved operation history; leave the empty document open for the caller. +- Initialize all bounded native mesh collections before capturing any component revision IDs. Native diagnosis found first child collection access advancing ancestor IDs; retain strict target/history checks and refuse observed document flag changes or unavailable reads without claiming rollback. A separate native function gate passed a fresh clean unsaved blank and cold already-modified local F3D control; saved/cloud-clean multi-component behavior remains unqualified. +- Track presentation-only native revision changes across exact owned graphics effects, bind a bounded blank semantic witness, and preserve immutable creation receipts through a verified disposal/reuse chain. Top-level ownership uses the retained Component's exact graphics inventory; descendant parent checks remain strict. Uncertain deletion attempts cannot be reissued. +- Revised native backend qualification passed on candidate-initialized owned fixtures: 66 instance poses, 55 copied-body geometry observations, eight refusals, two disposals, two archive roundtrips and four exact closures. The first two failures and separate retained-tree reconciliation remain preserved. Installed routing, viewport framing and visible output remain unqualified. Preserve current master's PCB fabrication resource rename. No version bump, automatic camera/foreground action, document close, render, capture or storyboard is added. + ## Unreleased exploded-view planning (2026-10-06) - Add read-only `exploded_view_plan` (56 typed operations; 179 public verbs): exact per-instance home transforms, normalized world/parent/local translation directions, simultaneous moves within ordered steps, instructions and component-linked labels. diff --git a/addin/AdomBridge/commands/mechanical/__init__.py b/addin/AdomBridge/commands/mechanical/__init__.py index 1a3641d..5a9af19 100644 --- a/addin/AdomBridge/commands/mechanical/__init__.py +++ b/addin/AdomBridge/commands/mechanical/__init__.py @@ -13,7 +13,7 @@ from .core import SESSION_ID, UNITS, MechanicalError, active, document_identity, from .operations import OPERATIONS, WRITES from .journal import JournalError, MAX_RECORDS, MAX_DATABASE_BYTES, MAX_RECORD_BYTES, MAX_REQUEST_BYTES, STORAGE_VERSION, canonical, fingerprint from .operation_history import OperationHistory -from . import transactions, sketches, features, assemblies, purchased, bill_of_materials, geometry, sketch_edits, regular_joints, motion_links, configurations, render_scene, exploded_view +from . import transactions, sketches, features, assemblies, purchased, bill_of_materials, geometry, sketch_edits, regular_joints, motion_links, configurations, render_scene, exploded_view, exploded_preview for _name, _handler in {**sketches.OPERATIONS, **features.OPERATIONS, **assemblies.OPERATIONS, **geometry.OPERATIONS, **sketch_edits.OPERATIONS, **regular_joints.OPERATIONS, **motion_links.OPERATIONS}.items(): @@ -25,6 +25,10 @@ OPERATIONS.update(sketch_edits.READS) OPERATIONS.update(configurations.READS) OPERATIONS.update(render_scene.READS) OPERATIONS.update(exploded_view.READS) +# Presentation effects have explicit ownership/disposal, not model PTransaction. +for _name, _handler in {**exploded_preview.WRITES, **exploded_preview.READS}.items(): + OPERATIONS[_name] = lambda app, args, fn=_handler: fn(app, args, _history) +WRITES.update(exploded_preview.WRITES) OPERATIONS.update(purchased.OPERATIONS) OPERATIONS.update(bill_of_materials.OPERATIONS) WRITES.update(purchased.WRITES | bill_of_materials.WRITES) diff --git a/addin/AdomBridge/commands/mechanical/core.py b/addin/AdomBridge/commands/mechanical/core.py index 513c835..80f2bf1 100644 --- a/addin/AdomBridge/commands/mechanical/core.py +++ b/addin/AdomBridge/commands/mechanical/core.py @@ -85,26 +85,96 @@ 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 revision(design): - """Aggregate every component, body, sketch, parameter and occurrence transform. + """Initialize all mesh collections, then capture the unchanged revision graph. - Fail closed when any required API read fails. A root revision alone misses edits - to child definitions. Native revision IDs catch equal-volume topology changes. + 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 - components = [] - for comp in items(design.allComponents): - 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 items(design.rootComponent.allOccurrences)] - params = [[p.name, p.expression, p.unit] for p in items(design.allParameters)] - return digest([components, occurrences, params, design.timeline.count, design.timeline.markerPosition, - configuration_snapshot(design)]) + 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.") + component_handles = [collection.item(index) for index in range(size)] + mesh_collections = [] # Retain all native collection handles through the full digest. + for comp in component_handles: + require(comp is not None and comp.isValid is True and same(comp.parentDesign, design), + "revision_unavailable", "Component does not belong to the exact Design.") + 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 comp in component_handles: + 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 items(design.rootComponent.allOccurrences)] + 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 occurrence_suppression(occurrence): diff --git a/addin/AdomBridge/commands/mechanical/exploded_preview.py b/addin/AdomBridge/commands/mechanical/exploded_preview.py new file mode 100644 index 0000000..4e49cd1 --- /dev/null +++ b/addin/AdomBridge/commands/mechanical/exploded_preview.py @@ -0,0 +1,485 @@ +"""Session-owned public controller for finite exploded presentation graphics. + +Direct synchronous WRITES use the mechanical durable journal, not PTransaction. +History receipts describe past completion; this registry describes live resources. +No document create/close, global Undo, timer, capture or cross-session adoption. +""" +import copy +import hashlib +import json +from pathlib import Path +import time +import uuid + +import adsk.fusion + +from . import core +from . import exploded_preview_prototype as backend +from .core import MechanicalError, SESSION_ID, require, same +from .journal import fingerprint +from .exploded_view import request_arguments + +SCHEMA_VERSION = 1 +BACKEND_SOURCE_DIGEST = hashlib.sha256(Path(backend.__file__).read_bytes()).hexdigest() +MAX_ACTIVE = 4 +MAX_ENTRIES = 128 +MAX_PLAN_BYTES = 2 * 1024 * 1024 +MAX_TOTAL_PLAN_BYTES = 8 * 1024 * 1024 +MAX_TOTAL_BODIES = 512 + + +def bounded_text(value, name, maximum=128): + require(isinstance(value, str) and 0 < len(value) <= maximum, 'invalid_input', name + ' must be a bounded nonempty string.') + return value + + +def exact_target(value): + require(isinstance(value, dict) and set(value) == {'documentId', 'revision'}, 'invalid_input', 'An exact documentId/revision target is required.') + for name in value: + bounded_text(value[name], name, 256) + return copy.deepcopy(value) + + +def flags(doc): + result = (doc.isSaved, doc.isModified) + require(all(type(v) is bool for v in result), 'preview_state_unavailable', 'Document flags are unavailable.') + return result + + +def known_identity(app, document_id): + """Resolve only an already-issued session identity; no revision adoption.""" + backend.main_thread() + matches = [doc for doc, key in core._documents if key == document_id] + require(len(matches) == 1 and matches[0].isValid, 'preview_document_unavailable', 'Exact session document is unavailable.') + doc = matches[0] + require(sum(same(item, doc) for item in backend.bounded(app.documents, 128)) == 1, + 'preview_document_unavailable', 'Exact session document is not uniquely open.') + design = adsk.fusion.Design.cast(doc.products.itemByProductType('DesignProductType')) + require(design is not None and design.isValid and same(design.parentDocument, doc), + 'preview_document_unavailable', 'Exact document has no matching Design.') + require(core.document_identity(doc) == document_id, 'preview_document_unavailable', 'Session document identity differs.') + return doc, design + + +def known_document(app, wanted): + wanted = exact_target(wanted) + doc, design = known_identity(app, wanted['documentId']) + require(core.revision(design) == wanted['revision'], + 'stale_revision', 'Exact document identity/revision changed.') + return doc, design + + +def blank_model(design): + root = design.rootComponent + require(all(backend.count(getattr(root, field), backend.MAX_BODIES) == 0 + for field in ('bRepBodies', 'meshBodies', 'occurrences', 'sketches', 'features')) + and backend.count(design.allComponents, 1) == 1 and same(design.allComponents.item(0), root) + and backend.count(design.allParameters, 0) == 0 and backend.count(design.timeline, 0) == 0 + and backend.count(root.customGraphicsGroups, 0) == 0, + 'preview_not_blank', 'Presentation must be a blank Design with no graphics before reservation.') + + +def retained_json_bytes(*objects): + """Count every distinct retained serialized string, including duplicate content.""" + seen = set() + total = 0 + for obj in objects: + for key, value in vars(obj).items(): + if key.endswith('_json') and isinstance(value, str) and id(value) not in seen: + seen.add(id(value)) + total += len(value.encode('utf-8')) + return total + + +def terminal_record(history, operation_id, operation=None): + record = history.store().lookup(SESSION_ID, operation_id) + require(isinstance(record, dict) and record.get('sessionId') == SESSION_ID + and record.get('operationId') == operation_id and record.get('phase') in ('succeeded', 'failed', 'not_executed'), + 'preview_history_unresolved', 'A coherent current-session terminal operation record is required.') + result = record.get('result') + require(isinstance(result, dict) and result.get('sessionId') == SESSION_ID and result.get('operationId') == operation_id + and type(result.get('success')) is bool and result['success'] == (record['phase'] == 'succeeded'), + 'preview_history_unresolved', 'Terminal record identity/result is incoherent.') + if operation is not None: + require(record.get('operation') == operation and result.get('operation') == operation, + 'preview_ownership', 'Document creation proof is not the required operation.') + return record + + +class Controller: + def __init__(self): + self.epoch = str(uuid.uuid4()) + self.entries = {} + self.claims = {} + + def guard(self, args): + backend.main_thread() + require(args.get('sessionId') == SESSION_ID, 'session_mismatch', 'Preview control requires the current session.') + + def entry(self, app, args): + self.guard(args) + key = bounded_text(args.get('previewId'), 'previewId') + entry = self.entries.get(key) + require(entry is not None and entry['epoch'] == self.epoch and entry['sessionId'] == SESSION_ID, + 'preview_session_expired', 'No live controller capability identifies this preview; history does not rebind native resources.') + require(same(entry['app'], app), 'preview_ownership', 'Preview belongs to another application.') + return entry + + def ownership(self, entry): + backend.main_thread() + require(entry['epoch'] == self.epoch and entry['sessionId'] == SESSION_ID + and self.entries.get(entry['previewId']) is entry + and self.claims.get(entry['target']['documentId']) == entry['previewId'], + 'preview_ownership', 'Exclusive live presentation lease is unavailable.') + doc, design = known_identity(entry['app'], entry['target']['documentId']) + require(same(doc, entry['document']) and same(design, entry['design']) and doc.isSaved is False + and doc.creationId == entry['creationId'], + 'preview_ownership', 'Retained native presentation identity differs.') + return {'success': True, 'ownerId': entry['previewId'], 'sessionId': SESSION_ID, + 'documentId': entry['target']['documentId']} + + def lease(self, entry, cleanup=False): + if entry['lease'] is None: + entry['lease'] = backend.OwnedPresentationLease(entry['app'], entry['document'], entry['design'], + entry['previewId'], lambda: self.ownership(entry), expected_state=entry['initialPresentationState']) + entry['lease'].check(cleanup=cleanup) + return entry['lease'] + + def reserve(self, app, args, history): + self.guard(args) + wanted = exact_target(args.get('target')) + source = exact_target(args.get('sourceTarget')) + require(wanted['documentId'] != source['documentId'], 'preview_ownership', 'Source and presentation must be distinct documents.') + creation_id = bounded_text(args.get('creationOperationId'), 'creationOperationId') + record = terminal_record(history, creation_id, 'new_design') + result = record['result'] + data = result.get('data') + require(record['phase'] == 'succeeded' and isinstance(data, dict) and data.get('target') == wanted + and isinstance(data.get('document'), dict) and data['document'].get('saved') is False, + 'preview_ownership', 'Committed new_design result does not identify this unsaved presentation target.') + creation = bounded_text(data['document'].get('creationId'), 'creationId', 256) + previous = [item for item in self.entries.values() if item['target']['documentId'] == wanted['documentId']] + predecessor = previous[-1] if previous else None + if predecessor is None: + doc, design = known_document(app, wanted) # Initial claim remains strict against real new_design. + else: + require(predecessor['state'] == 'disposed' and self.last_history(predecessor, history)['terminalRecorded'] + and predecessor['priorUnresolved'] is None and predecessor['finalPresentationState'] is not None, + 'preview_history_unresolved', 'Previous presentation use lacks coherent durable verified disposal/release evidence.') + require(predecessor['creationOperationId'] == creation_id and predecessor['creationDigest'] == record['requestDigest'] + and predecessor['target'] == wanted and predecessor['epoch'] == self.epoch, + 'preview_ownership', 'Reuse must preserve its exact original creation proof and controller epoch.') + doc, design = known_identity(app, wanted['documentId']) + require(same(doc, predecessor['document']) and same(design, predecessor['design']) + and same(design.rootComponent, predecessor['lease'].root_component), + 'preview_ownership', 'Disposed presentation native identity changed.') + require(same(app.activeDocument, doc) and doc.creationId == creation and doc.isSaved is False, + 'preview_ownership', 'Claim requires the exact active unsaved document returned by new_design.') + blank_model(design) + initial_state = backend.presentation_observation(doc, design) + require(initial_state['revision'] == wanted['revision'] if predecessor is None + else initial_state == predecessor['finalPresentationState'], + 'stale_revision', 'Presentation changed outside the verified creation/disposal chain.') + source_doc, source_design = known_document(app, source) + source_flags = flags(source_doc) + require(wanted['documentId'] not in self.claims, 'preview_ownership', 'Presentation already has an exclusive preview lease.') + require(len(self.entries) < MAX_ENTRIES and len(self.claims) < MAX_ACTIVE + and sum(e['planBytes'] for e in self.entries.values()) + MAX_PLAN_BYTES <= MAX_TOTAL_PLAN_BYTES + and sum(e['bodyBudget'] for e in self.entries.values()) + backend.MAX_BODIES <= MAX_TOTAL_BODIES, + 'preview_capacity', 'Session preview capacity is full; retained resources are never evicted.') + key = self.epoch + ':' + str(uuid.uuid4()) + entry = {'previewId': key, 'epoch': self.epoch, 'sessionId': SESSION_ID, 'app': app, + 'document': doc, 'design': design, 'target': wanted, 'sourceTarget': source, + 'sourceDocument': source_doc, 'sourceDesign': source_design, 'sourceFlags': source_flags, + 'creationOperationId': creation_id, 'creationDigest': record['requestDigest'], 'creationId': creation, + 'generation': 0, 'state': 'reserved', 'lease': None, 'backend': None, + 'initialPresentationState': initial_state, 'finalPresentationState': None, + 'predecessorPreviewId': predecessor['previewId'] if predecessor is not None else None, + 'planBytes': MAX_PLAN_BYTES, 'bodyBudget': backend.MAX_BODIES, + 'planId': None, 'samplesDigest': None, 'sampleCount': None, 'sampleIndex': None, + 'activationIssued': False, 'factoryMayHaveBeenIssued': False, 'nativeWorkInFlight': False, + 'lastOperationId': args['operationId'], 'lastOperation': args['op'], 'lastRequestDigest': fingerprint(args), 'lastReturned': False, + 'lastExpectedResultDigest': None, + 'lastObserved': None, 'priorUnresolved': None, 'disposition': None, + 'disposalAttempted': False, 'disposalOutcome': 'not_attempted'} + self.entries[key] = entry + self.claims[wanted['documentId']] = key + return entry + + def begin(self, entry, args): + require(not entry['nativeWorkInFlight'], 'preview_in_progress', 'A native preview invocation is still in flight.') + entry['generation'] += 1 + entry['lastOperationId'] = args['operationId'] + entry['lastOperation'] = args['op'] + entry['lastExpectedResultDigest'] = None + entry['lastRequestDigest'] = fingerprint(args) + entry['lastReturned'] = False + entry['nativeWorkInFlight'] = True + + def last_history(self, entry, history): + if entry['lastOperationId'] is None: + return {'terminalRecorded': False, 'reason': 'no_effect_operation'} + try: + record = terminal_record(history, entry['lastOperationId']) + require(record.get('requestDigest') == entry['lastRequestDigest'] + and record['phase'] in ('succeeded', 'failed') and entry['lastExpectedResultDigest'] is not None + and core.digest(record['result']) == entry['lastExpectedResultDigest'], + 'preview_history_unresolved', 'Last operation digest or observed terminal result differs.') + return {'terminalRecorded': True, 'phase': record['phase'], 'operationId': entry['lastOperationId'], + 'requestDigest': entry['lastRequestDigest']} + except Exception as error: + return {'terminalRecorded': False, 'operationId': entry['lastOperationId'], + 'requestDigest': entry['lastRequestDigest'], 'errorCode': getattr(error, 'code', 'preview_history_unresolved'), + 'error': str(error)[:512]} + + def existing(self, app, args, history, disposal=False): + entry = self.entry(app, args) + require(exact_target(args.get('target')) == entry['target'], 'preview_ownership', 'Presentation target differs from its lease.') + require(type(args.get('expectedGeneration')) is int and args['expectedGeneration'] == entry['generation'], + 'preview_generation_conflict', 'Preview generation changed; inspect before another intent.', + currentGeneration=entry['generation']) + require(entry['state'] != 'disposed' and not entry['nativeWorkInFlight'], 'preview_state', 'Preview is disposed or in flight.') + if disposal: + require(not entry['disposalAttempted'], 'preview_disposal_unresolved', + 'A prior deletion attempt remains unresolved; a new operation ID cannot authorize another delete.') + self.lease(entry, cleanup=disposal) + previous = self.last_history(entry, history) + if not previous['terminalRecorded']: + require(disposal and entry['lastReturned'] and entry['lastObserved'] is not None + and entry['backend'] is not None and entry['backend'].root is not None + and entry['backend'].state in ('ready', 'retained_failure'), + 'preview_history_unresolved', 'Prior operation history is unresolved; automatic continuation is disabled.', + priorOperation=previous) + entry['backend']._ownership() # Explicit cleanup exception, never adoption. + previous['observedResultDigest'] = core.digest(entry['lastObserved']) + if not disposal: + require(entry['state'] == 'ready', 'preview_state', 'Only a verified ready preview can be sampled or shown.') + return entry, None if previous['terminalRecorded'] else previous + + def source_check(self, entry): + doc, design = known_document(entry['app'], entry['sourceTarget']) + require(same(doc, entry['sourceDocument']) and same(design, entry['sourceDesign']) + and flags(doc) == entry['sourceFlags'], 'preview_source_changed', 'Source identity/revision/flags changed.') + + def compact(self, entry, scope='operation_completion'): + return {'kind': 'EXPLODED_PREVIEW', 'receiptScope': scope, 'controllerSchemaVersion': SCHEMA_VERSION, + 'backendSourceDigest': BACKEND_SOURCE_DIGEST, 'previewId': entry['previewId'], 'controllerEpoch': self.epoch, + 'sessionId': SESSION_ID, 'presentationTarget': copy.deepcopy(entry['target']), + 'sourceTarget': copy.deepcopy(entry['sourceTarget']), 'creationOperationId': entry['creationOperationId'], + 'presentationTargetScope': 'immutable_creation_anchor', + 'lastVerifiedPresentationRevision': entry['lease'].model_revision if entry['lease'] is not None else entry['initialPresentationState']['revision'], + 'presentationVerificationSequence': entry['lease'].sequence if entry['lease'] is not None else 0, + 'presentationFailureObservation': copy.deepcopy(entry['lease'].failure_observation) if entry['lease'] is not None else None, + 'predecessorPreviewId': entry['predecessorPreviewId'], + 'observedEmptyTarget': {'documentId': entry['target']['documentId'], 'revision': entry['finalPresentationState']['revision']} if entry['finalPresentationState'] is not None else None, + 'planId': entry['planId'], 'samplesDigest': entry['samplesDigest'], 'sampleCount': entry['sampleCount'], + 'state': entry['state'], 'generation': entry['generation'], + 'sampleIndex': entry['sampleIndex'] if entry['state'] == 'ready' else None, + 'lastVerifiedSampleIndex': entry['sampleIndex'], + 'pose': ('sample' if entry['sampleIndex'] is not None else 'home') if entry['state'] == 'ready' else 'unverified', + 'disposalAttempted': entry['disposalAttempted'], 'disposalOutcome': entry['disposalOutcome'], + 'lastOperationId': entry['lastOperationId'], 'nativeWorkInFlight': entry['nativeWorkInFlight'], + 'documentDisposition': 'open_for_caller', 'disposition': entry['disposition'], + 'sourceMoved': False, 'sourceVisibilityWritten': False, 'rollbackClaimed': False, + 'rendered': False, 'storyboardCreated': False, 'visualQualified': False, + 'archivePersistence': 'UNVERIFIED', 'priorOperationUnresolved': entry['priorUnresolved'] is not None, + 'priorUnresolvedOperation': copy.deepcopy(entry['priorUnresolved'])} + + def release(self, entry, disposition): + require(self.claims.get(entry['target']['documentId']) == entry['previewId'], 'preview_ownership', 'Exclusive claim changed.') + require(entry['lease'] is not None, 'preview_ownership', 'Verified presentation lease is required for release.') + entry['finalPresentationState'] = copy.deepcopy(entry['lease'].check()) + del self.claims[entry['target']['documentId']] + entry.update(state='disposed', disposition=disposition, planBytes=0, bodyBudget=0) + entry['backend'] = None # Drop large stored samples/geometry; retain bounded tombstone/native doc proof. + + def no_graphics_release(self, entry): + require(not entry['factoryMayHaveBeenIssued'], 'preview_reconciliation_required', 'Graphics creation may have been issued.') + self.lease(entry) + require(backend.count(entry['design'].rootComponent.customGraphicsGroups, 0) == 0, + 'preview_reconciliation_required', 'No-graphics absence was not verified.') + self.release(entry, 'reservation_released_no_graphics') + + def failure(self, entry, error): + entry['state'] = 'retained_failure' + recovery_error = None + if not entry['factoryMayHaveBeenIssued']: + try: + self.no_graphics_release(entry) + except Exception as cleanup_error: + recovery_error = str(cleanup_error)[:512] + observed = {'errorCode': getattr(error, 'code', 'preview_native_failed'), 'error': str(error)[:2048], + 'effectState': 'possible_graphics' if entry['factoryMayHaveBeenIssued'] else 'activation_only' if entry['activationIssued'] else 'none', + 'preview': self.compact(entry), 'recoveryError': recovery_error} + observed['preview']['nativeWorkInFlight'] = False # Synchronous native effect already returned/threw. + entry['lastObserved'] = observed + entry['lastExpectedResultDigest'] = core.digest({ + 'success': False, 'sessionId': SESSION_ID, 'operationId': entry['lastOperationId'], + 'errorCode': observed['errorCode'], 'error': observed['error'], 'effectState': observed['effectState'], + 'preview': observed['preview'], 'recoveryError': recovery_error, 'rollbackClaimed': False}) + raise MechanicalError(observed['errorCode'], observed['error'], effectState=observed['effectState'], + preview=observed['preview'], recoveryError=recovery_error, rollbackClaimed=False) from error + + def create(self, app, args, history): + self.guard(args) + bounded_text(args.get('operationId'), 'operationId') + bounded_text(args.get('expectedPlanId'), 'expectedPlanId', 64) + planner = copy.deepcopy(args['planner']) + planner.update(op='exploded_view_plan', sessionId=SESSION_ID, target=exact_target(args['sourceTarget'])) + # Validate pure planner mathematics before reserving resources/activation. + request_arguments(planner) + entry = self.reserve(app, args, history) + try: + self.lease(entry) + self.source_check(entry) + self.begin(entry, args) + entry['state'] = 'creating' + entry['activationIssued'] = True + require(entry['sourceDocument'].activate() is True and same(app.activeDocument, entry['sourceDocument']), + 'preview_activation_refused', 'Exact source activation was not acknowledged.') + self.source_check(entry) + prepared = backend.prepare(app, planner, args['expectedPlanId']) + preview = backend.Preview(prepared, entry['lease']) + entry['backend'] = preview # Retain before any subsequent failure or graphics effect. + byte_count = retained_json_bytes(prepared, preview) + require(byte_count <= MAX_PLAN_BYTES, 'preview_capacity', 'Total retained plan/sample JSON exceeds its reserved memory bound.') + entry.update(planId=prepared.plan_id, samplesDigest=preview.samples_digest, + sampleCount=len(json.loads(preview._sample_json)), planBytes=byte_count) + entry['factoryMayHaveBeenIssued'] = True + preview.create() + self.source_check(entry) + entry.update(state='ready', bodyBudget=sum(len(n['graphics']) for n in preview.nodes)) + result = self.compact(entry) + result['nativeWorkInFlight'] = False + entry['lastObserved'] = copy.deepcopy(result) + entry['lastExpectedResultDigest'] = core.digest({ + 'success': True, 'sessionId': SESSION_ID, 'operationId': entry['lastOperationId'], + 'operation': entry['lastOperation'], 'data': result}) + return result + except Exception as error: + self.failure(entry, error) + finally: + entry['nativeWorkInFlight'] = False + entry['lastReturned'] = True + + def action(self, action, app, args, history): + self.guard(args) + bounded_text(args.get('operationId'), 'operationId') + if action == 'sample': + require(type(args.get('sampleIndex')) is int and args['sampleIndex'] >= 0, + 'invalid_input', 'Sample index must be a nonnegative integer.') + entry, unresolved = self.existing(app, args, history, disposal=action == 'dispose') + preview = entry['backend'] + if action == 'sample': + require(args['sampleIndex'] < entry['sampleCount'], 'invalid_input', 'Sample index exceeds the immutable stored samples.') + if action != 'dispose': + self.source_check(entry) + preview._guard(); preview._graphics() + elif preview is not None: + preview._ownership() + if action == 'show': + require(any(same(app.activeDocument, doc) for doc in (entry['sourceDocument'], entry['document'])), + 'preview_activation_refused', 'Another active document intervened; no activation performed.') + self.begin(entry, args) + entry['priorUnresolved'] = unresolved + entry['state'] = 'updating' + try: + if action == 'sample': + preview.sample(args['sampleIndex']) + entry['sampleIndex'] = args['sampleIndex'] + self.source_check(entry) + entry['state'] = 'ready' + elif action == 'show': + preview.activate() + self.source_check(entry) + entry['state'] = 'ready' + elif action == 'dispose': + if preview is None: + self.no_graphics_release(entry) + else: + entry['disposalAttempted'] = True # Before native deletion; never retry uncertain effects. + entry['disposalOutcome'] = 'unknown' + preview.dispose() + entry['disposalOutcome'] = 'verified_absent' + self.release(entry, 'graphics_disposed') + result = self.compact(entry) + result['nativeWorkInFlight'] = False + entry['lastObserved'] = copy.deepcopy(result) + entry['lastExpectedResultDigest'] = core.digest({ + 'success': True, 'sessionId': SESSION_ID, 'operationId': entry['lastOperationId'], + 'operation': entry['lastOperation'], 'data': result}) + return result + except Exception as error: + self.failure(entry, error) + finally: + entry['nativeWorkInFlight'] = False + entry['lastReturned'] = True + + def inspect(self, app, args, history): + entry = self.entry(app, args) + result = self.compact(entry, 'live_observation') + result['observedAt'] = time.time() + result['history'] = self.last_history(entry, history) + result['usable'] = False + result['lastKnownDocumentDisposition'] = result['documentDisposition'] + result['documentDisposition'] = 'unknown_current' + errors = [] + if entry['state'] == 'disposed': + result['receiptScope'] = 'registry_tombstone' + result['nativeResourceRights'] = False + result['nativeObservation'] = 'NOT_PERFORMED' + result['observationComplete'] = False + return result + try: + self.lease(entry, cleanup=entry['state'] == 'retained_failure') + result['presentationOwnershipVerified'] = True + result['documentDisposition'] = 'verified_open_owned' + preview = entry['backend'] + if preview is not None: + preview._ownership() + result['graphicsOwnershipVerified'] = True + preview._graphics() + result['graphicsReadbackVerified'] = True + else: + result['graphicsCount'] = backend.count(entry['design'].rootComponent.customGraphicsGroups, 1) + except Exception as error: + errors.append({'scope': 'presentation', 'code': getattr(error, 'code', 'preview_read_failed'), 'message': str(error)[:512]}) + try: + self.source_check(entry) + if entry['backend'] is not None: + entry['backend'].prepared.check() + result['sourceUnchanged'] = True + except Exception as error: + errors.append({'scope': 'source', 'code': getattr(error, 'code', 'preview_read_failed'), 'message': str(error)[:512]}) + result['errors'] = errors + result['observationComplete'] = not errors + result['usable'] = not errors and entry['state'] == 'ready' and result['history']['terminalRecorded'] and not entry['nativeWorkInFlight'] + return result + + +_controller = Controller() + + +def create(app, args, history): + return _controller.create(app, args, history) + + +def sample(app, args, history): + return _controller.action('sample', app, args, history) + + +def show(app, args, history): + return _controller.action('show', app, args, history) + + +def dispose(app, args, history): + return _controller.action('dispose', app, args, history) + + +def inspect(app, args, history): + return _controller.inspect(app, args, history) + + +WRITES = {'exploded_preview_create': create, 'exploded_preview_sample': sample, + 'exploded_preview_show': show, 'exploded_preview_dispose': dispose} +READS = {'exploded_preview_inspect': inspect} diff --git a/addin/AdomBridge/commands/mechanical/exploded_preview_prototype.py b/addin/AdomBridge/commands/mechanical/exploded_preview_prototype.py new file mode 100644 index 0000000..8066990 --- /dev/null +++ b/addin/AdomBridge/commands/mechanical/exploded_preview_prototype.py @@ -0,0 +1,580 @@ +"""UNREGISTERED prototype: exact-owned, finite CustomGraphics presentation copies. + +No document creation/close, source setters, timers, capture, durable replay or public +operation registration. The native qualification harness supplies an owned blank +presentation lease. All native access, including failed-resource cleanup, stays on +the main thread. Retain the Preview object if create/sample/dispose raises. +""" +import copy +import json +import math +import threading +import uuid + +import adsk.core + +from . import core +from .core import (SESSION_ID, concrete, digest, document_identity, require, + resolve, revision, same, target) +from .exploded_view import IDENTITY, exploded_view_plan + +PROTOTYPE_ONLY = True +MAX_INSTANCES = 64 +MAX_BODIES = 128 +MAX_TOPOLOGY = 4096 +MAX_VERTICES = 16384 +_PREPARED = object() + + +def main_thread(): + require(threading.current_thread() is threading.main_thread(), 'wrong_thread', + 'Preview native access requires the main thread.') + + +def count(collection, limit=MAX_TOPOLOGY): + count = collection.count + require(type(count) is int and 0 <= count <= limit, 'preview_budget', 'Native collection exceeds the preview bound.') + return count + + +def bounded(collection, limit=MAX_TOPOLOGY): + return [concrete(collection.item(i)) for i in range(count(collection, limit))] + + +def finite(value): + require(type(value) in (int, float) and math.isfinite(value) and abs(value) <= 1e12, + 'preview_geometry_invalid', 'Native geometry contains a nonfinite or excessive coordinate.') + return value + + +def point(value): + return [finite(value.x), finite(value.y), finite(value.z)] + + +def body_witness(body, transient): + require(body is not None and body.isValid and body.objectType == 'adsk::fusion::BRepBody' + and body.isTransient is transient and body.isSolid is True, + 'preview_geometry_unsupported', 'Preview requires valid solid BRep bodies with the expected native/copy provenance.') + box = body.boundingBox + low, high = point(box.minPoint), point(box.maxPoint) + require(all(a <= b for a, b in zip(low, high)), 'preview_geometry_invalid', 'Invalid native body bounds.') + volume = finite(body.volume) + require(volume > 0, 'preview_geometry_unsupported', 'Solid BRep volume must be positive.') + return {'volumeCm3': volume, 'boundsCm': [low, high], + 'faceCount': count(body.faces), 'edgeCount': count(body.edges), + 'verticesCm': [point(v.geometry) for v in bounded(body.vertices)]} + + +def equivalent(left, right): + """Only geometry floats use tolerance; identity/counts/booleans remain exact.""" + if type(left) in (int, float) and type(right) in (int, float): + return math.isclose(left, right, rel_tol=1e-10, abs_tol=1e-10) + if type(left) is not type(right): + return False + if isinstance(left, dict): + return left.keys() == right.keys() and all(equivalent(left[k], right[k]) for k in left) + if isinstance(left, list): + return len(left) == len(right) and all(equivalent(a, b) for a, b in zip(left, right)) + return left == right + + +def intended_native_values(values): + require(isinstance(values, list) and len(values) == 16 and all(type(v) in (int, float) and math.isfinite(v) for v in values), + 'preview_plan_invalid', 'Stored sample matrix is invalid.') + values = list(values) + for index in (3, 7, 11): + values[index] /= 10. # Public root/world mm -> native model cm, exactly once. + return values + + +def native_matrix(values): + intended = intended_native_values(values) + result = adsk.core.Matrix3D.create() + require(result is not None and result.setWithArray(list(intended)) is True, + 'preview_transform_refused', 'Native matrix construction failed.') + require(equivalent(list(result.asArray()), intended), + 'preview_transform_refused', 'Native matrix construction altered the intended stored pose.') + return result + + +def matrix_values(entity): + result = list(entity.transform.asArray()) + require(len(result) == 16 and all(type(v) in (int, float) and math.isfinite(v) for v in result), + 'preview_transform_refused', 'Native graphics transform is unavailable.') + return result + + +def source_geometry(design, value, plan): + """Proxy visibility first, native definition geometry second; flat instances.""" + require(len(plan['instances']) <= MAX_INSTANCES, 'preview_budget', 'Preview supports at most 64 physical instances.') + rows = [(None, design.rootComponent, None)] + for row in plan['instances']: + occurrence = resolve(design, value, row['occurrence'], ('Occurrence',)) + component = resolve(design, value, row['component'], ('Component',)) + require(same(occurrence.component, component) and occurrence.fullPathName == row['path'], + 'preview_source_changed', 'Instance/component attachment changed.') + require(occurrence.isVisible is True, 'preview_visibility_unsupported', 'Hidden or effectively hidden occurrences are unsupported.') + timeline = occurrence.timelineObject + require(timeline is None or timeline.isSuppressed is False, + 'preview_visibility_unsupported', 'Suppressed occurrences are unsupported.') + rows.append((row['instanceId'], component, occurrence)) + output, body_count, vertices = [], 0, 0 + for key, component, occurrence in rows: + require(component is not None and component.isValid and same(component.parentDesign, design), + 'preview_source_changed', 'Component is not owned by the source Design.') + require(count(component.meshBodies, MAX_BODIES) == 0, + 'preview_geometry_unsupported', 'Mesh bodies are unsupported; no partial geometry omission is permitted.') + bodies, witnesses = [], [] + for native in bounded(component.bRepBodies, MAX_BODIES): + body_count += 1 + require(body_count <= MAX_BODIES, 'preview_budget', 'Preview exceeds 128 body copies.') + require(native.nativeObject is None and same(native.parentComponent, component), + 'preview_geometry_unsupported', 'Copy inputs must be definition-space native bodies.') + visible = concrete(native.createForAssemblyContext(occurrence)) if occurrence is not None else native + require(visible is not None and visible.isValid and visible.isVisible is True, + 'preview_visibility_unsupported', 'Hidden or effectively hidden bodies are unsupported.') + if occurrence is not None: + require(same(visible.nativeObject, native) and same(visible.assemblyContext, occurrence), + 'preview_source_changed', 'Body proxy does not belong to the exact instance.') + witness = body_witness(native, False) + vertices += len(witness['verticesCm']) + require(vertices <= MAX_VERTICES, 'preview_budget', 'Preview exceeds 16384 copied vertices.') + bodies.append(native); witnesses.append(witness) + output.append({'instanceId': key, 'bodies': bodies, 'witnesses': witnesses}) + require(body_count > 0, 'preview_geometry_unsupported', 'Source has no supported solid bodies.') + return output + + +class Prepared: + """Server-only preparation; callers receive identifiers, never writable poses.""" + def __init__(self, app, args, expected_plan_id): + main_thread() + args = copy.deepcopy(args) + result = exploded_view_plan(app, args) + require(result['planId'] == expected_plan_id, 'preview_plan_mismatch', 'Regenerated plan differs from the expected plan.') + self.app = app + self.document, self.design, value = target(app, args) + self._result_json = json.dumps(result, sort_keys=True, allow_nan=False) + self._target = copy.deepcopy(value) + self._flags = (self.document.isSaved, self.document.isModified) + require(all(type(v) is bool for v in self._flags), 'preview_source_changed', 'Source document flags are unavailable.') + self._geometry = source_geometry(self.design, value, result['plan']) + self._witness = [r['witnesses'] for r in self._geometry] + self._seal = _PREPARED + self.check() + + @property + def plan_id(self): + return json.loads(self._result_json)['planId'] + + def check(self): + main_thread() + result = json.loads(self._result_json) + require(result['plan']['sessionId'] == SESSION_ID, 'session_mismatch', 'Preview belongs to an earlier add-in session.') + require(self.document.isValid and self.design.isValid and same(self.design.parentDocument, self.document) + and sum(same(d, self.document) for d in bounded(self.app.documents, 128)) == 1, + 'preview_source_changed', 'Exact source document is no longer open.') + require(document_identity(self.document) == self._target['documentId'] + and revision(self.design) == self._target['revision'] + and all(type(v) is bool for v in (self.document.isSaved, self.document.isModified)) + and (self.document.isSaved, self.document.isModified) == self._flags, + 'preview_source_changed', 'Source identity, revision or flags changed.') + current = source_geometry(self.design, self._target, result['plan']) + require([r['witnesses'] for r in current] == self._witness, + 'preview_source_changed', 'Source geometry/visibility changed.') + # Refresh handles through the guarded refs, never use a stale body wrapper. + self._geometry = current + + +def prepare(app, planner_args, expected_plan_id): + return Prepared(app, planner_args, expected_plan_id) + + +def all_attributes(entity): + rows = [] + for attribute in bounded(entity.attributes, 256): + row = [attribute.groupName, attribute.name, attribute.value] + require(all(isinstance(v, str) and len(v) <= 4096 for v in row), + 'preview_budget', 'Presentation attribute is unbounded.') + rows.append(row) + return sorted(rows) + + +def origin_witness(root): + """The seven built-in origin entities are outside construction collections.""" + rows = [] + for field, kind in (('xConstructionAxis', 'ConstructionAxis'), ('yConstructionAxis', 'ConstructionAxis'), + ('zConstructionAxis', 'ConstructionAxis'), ('xYConstructionPlane', 'ConstructionPlane'), + ('xZConstructionPlane', 'ConstructionPlane'), ('yZConstructionPlane', 'ConstructionPlane'), + ('originConstructionPoint', 'ConstructionPoint')): + entity = concrete(getattr(root, field)) + require(entity is not None and entity.isValid is True and entity.objectType == 'adsk::fusion::' + kind + and same(entity.component, root) and entity.nativeObject is None and entity.isDeletable is False, + 'preview_not_blank', 'Exact built-in presentation origin is unavailable.') + visibility = [entity.isLightBulbOn, entity.isVisible] + require(all(type(value) is bool for value in visibility), 'preview_not_blank', 'Origin visibility is unavailable.') + geometry = entity.geometry + values = point(geometry) if kind == 'ConstructionPoint' else [point(geometry.origin), + point(geometry.direction if kind == 'ConstructionAxis' else geometry.normal)] + rows.append([field, core.identity(entity), entity.name, visibility, values, all_attributes(entity)]) + return rows + + +def presentation_observation(document, design): + main_thread() + flags = None + try: + flags = [document.isSaved, document.isModified] + return _presentation_observation(document, design, flags) + except Exception as error: + observations = {'beforeFlags': flags, 'afterFlags': None, 'flagReadError': None} + try: + observations['afterFlags'] = [document.isSaved, document.isModified] + except Exception as flag_error: + observations['flagReadError'] = str(flag_error)[:512] + valid = all(isinstance(value, list) and len(value) == 2 and all(type(v) is bool for v in value) + for value in (flags, observations['afterFlags'])) + observations['flagEffect'] = ('unchanged' if flags == observations['afterFlags'] else 'changed') if valid else 'unknown' + details = dict(getattr(error, 'details', {})) + details.update(presentationObservation=observations, rollbackClaimed=False) + raise core.MechanicalError(getattr(error, 'code', 'preview_revision_unavailable'), str(error)[:1024], **details) from error + + +def _presentation_observation(document, design, flags): + """Complete bounded empty-model payload; only the root's opaque revision varies. + + Rebuild the exact current core aggregate for this one-component empty case, + and prove its digest equals the unmodified core revision before and after. + No global monkeypatch, target refresh, or source revision exemption. + """ + from .assembly_state import component_state + from .readback import construction_state + from .configurations import snapshot + require(document.isValid is True and design.isValid is True and same(design.parentDocument, document), + 'preview_ownership', 'Exact presentation document/Design is unavailable.') + require(all(type(v) is bool for v in flags) and flags[0] is False, + 'preview_not_blank', 'Presentation flags must be known and unsaved.') + before = revision(design) + root = design.rootComponent + require(root is not None and root.isValid is True and root.objectType == 'adsk::fusion::Component' + and same(root.parentDesign, design), 'preview_ownership', 'Exact presentation root is unavailable.') + components = bounded(design.allComponents, 1) + require(len(components) == 1 and same(components[0], root), 'preview_not_blank', 'Presentation must have exactly its root.') + empty_fields = ('bRepBodies', 'meshBodies', 'occurrences', 'allOccurrences', 'sketches', 'features', + 'joints', 'asBuiltJoints', 'jointOrigins', 'rigidGroups', 'motionLinks', + 'constructionAxes', 'constructionPlanes', 'constructionPoints', 'modelParameters', + 'canvases', 'decals') + for name in empty_fields: + require(count(getattr(root, name), 0) == 0, 'preview_not_blank', 'Presentation contains unsupported ' + name + '.') + require(count(design.allParameters, 0) == 0 and count(design.timeline, 0) == 0 + and type(design.timeline.markerPosition) is int and design.timeline.markerPosition == 0 + and count(design.snapshots, 0) == 0 and design.snapshots.hasPendingSnapshot is False, + 'preview_not_blank', 'Presentation contains model parameters, history or positions.') + configuration = snapshot(design, strict=True) + require(configuration['complete'] is True and configuration['configured'] is False + and configuration['isConfiguration'] is False, 'preview_not_blank', 'Configured presentations are unsupported.') + attributes = {name: all_attributes(entity) for name, entity in (('document', document), ('design', design), ('root', root))} + require(all(isinstance(value, str) and len(value) <= 4096 for value in (root.name, root.partNumber, root.description)), + 'preview_not_blank', 'Presentation metadata is unavailable or unbounded.') + folders = {} + for name in ('isBodiesFolderLightBulbOn', 'isConstructionFolderLightBulbOn', 'isOriginFolderLightBulbOn', + 'isSketchFolderLightBulbOn', 'isJointsFolderLightBulbOn', 'isJointOriginsFolderLightBulbOn', + 'isCanvasFolderLightBulbOn', 'isDecalFolderLightBulbOn'): + value = getattr(root, name) + require(type(value) is bool, 'preview_not_blank', 'Presentation visibility is unavailable.') + folders[name] = value + native_revision = root.revisionId + require(isinstance(native_revision, str) and 0 < len(native_revision) <= 256, + 'preview_not_blank', 'Native presentation revision is unavailable.') + payload = [[[core.identity(root), native_revision, root.name, [], [], component_state(root), [], construction_state(root)]], + [], [], design.timeline.count, design.timeline.markerPosition, configuration] + require(digest(payload) == before, 'preview_revision_unavailable', 'Empty presentation payload does not match the actual aggregate.') + stable_payload = copy.deepcopy(payload) + stable_payload[0][0][1] = None # The sole classified, opaque native revision slot; never a missing-data sentinel. + witness = {'aggregate': stable_payload, 'attributes': attributes, 'folders': folders, 'origin': origin_witness(root), + 'documentName': document.name, 'creationId': document.creationId, + 'designType': int(design.designType), 'designIntent': int(design.designIntent)} + wire = json.dumps(witness, sort_keys=True, allow_nan=False) + require(len(wire.encode('utf-8')) <= 65536, 'preview_budget', 'Presentation witness exceeds its bound.') + after = revision(design) + final_flags = [document.isSaved, document.isModified] + require(all(type(v) is bool for v in final_flags) and final_flags == flags and after == before, + 'preview_revision_unavailable', 'Presentation observation changed or could not establish a stable revision/flags.') + return {'revision': after, 'nativeRevision': native_revision, 'witnessDigest': digest(witness), 'flags': flags, + 'rootId': core.identity(root)} + + +class OwnedPresentationLease: + """Exact native doc + independent ownership receipt; no name-based selection. + + verify_owner() must return success:true, ownerId, sessionId, documentId after + checking the existing native run registry. This prototype never creates, + registers, archives or closes a document; the qualification harness owns that. + """ + def __init__(self, app, document, design, owner_id, verify_owner, expected_state=None): + main_thread() + require(isinstance(owner_id, str) and 1 <= len(owner_id) <= 128 and callable(verify_owner), + 'preview_ownership', 'A bounded owner ID and exact ownership verifier are required.') + self.app, self.document, self.design = app, document, design + self.root_component = design.rootComponent + self.owner_id, self.verify_owner = owner_id, verify_owner + self.document_id = document_identity(document) + self.session_id = SESSION_ID + self.sequence = 0 + self._effect = None + self.cleanup_state = None + self.failure_observation = None + observed = self.observe() + require(expected_state is None or observed == expected_state, + 'preview_not_blank', 'Presentation changed after reservation.') + self._accept(observed) + + def observe(self): + main_thread() + require(self.session_id == SESSION_ID, 'session_mismatch', 'Presentation lease belongs to another session.') + receipt = self.verify_owner() + require(isinstance(receipt, dict) and set(receipt) == {'success', 'ownerId', 'sessionId', 'documentId'} + and receipt.get('success') is True and receipt.get('ownerId') == self.owner_id + and receipt.get('sessionId') == SESSION_ID and receipt.get('documentId') == self.document_id, + 'preview_ownership', 'Independent exact-document ownership was not verified.') + doc, design = self.document, self.design + require(doc.isValid is True and design.isValid is True and same(design.parentDocument, doc) + and document_identity(doc) == self.document_id and same(design.rootComponent, self.root_component) + and sum(same(d, doc) for d in bounded(self.app.documents, 128)) == 1, + 'preview_ownership', 'Owned presentation document/root is unavailable or differs.') + return presentation_observation(doc, design) + + def _accept(self, observed): + self.verified_state = copy.deepcopy(observed) + self.model_revision = observed['revision'] + + def check(self, cleanup=False): + main_thread() + observed = self.observe() + expected = self.cleanup_state if cleanup and self.cleanup_state is not None else self.verified_state + require(observed == expected, 'preview_not_blank', 'Presentation revision or non-graphics model changed.') + return observed + + def begin_effect(self, cleanup=False): + observed = self.check(cleanup=cleanup) + require(self._effect is None, 'preview_state', 'A presentation effect is already in flight.') + self._effect = copy.deepcopy(observed) + return self._effect + + def _after_effect(self, token): + require(token is self._effect and token is not None, 'preview_state', 'Effect does not belong to the current lease.') + observed = self.observe() + # Full payload digest is already proven by observe(). Only the opaque + # root Component revision slot may differ; flags and every witness field stay exact. + require(all(observed[key] == token[key] for key in ('witnessDigest', 'flags', 'rootId')), + 'preview_not_blank', 'Owned graphics changed non-graphics presentation state.', + presentationBefore=token, presentationAfter=observed, rollbackClaimed=False) + return observed + + def finish_effect(self, token): + observed = self._after_effect(token) + self._accept(observed) + self.sequence += 1 + self.cleanup_state = None + self._effect = None + + def failed_effect(self, token, ownership): + if token is None or self._effect is not token: + return # No effect, or the graphics boundary already completed. + self.cleanup_state = None + try: + ownership() + self.cleanup_state = self._after_effect(token) + self.failure_observation = {'cleanupOnly': True, 'observedRevision': self.cleanup_state['revision']} + except Exception as error: + self.failure_observation = {'cleanupOnly': False, 'error': str(error)[:512], + 'presentationObservation': getattr(error, 'details', {}).get('presentationObservation'), + 'observedState': getattr(error, 'details', {}).get('presentationAfter')} + finally: + self._effect = None + + +class Preview: + """Retain this object on failure: .state/.root identify partial owned resources.""" + def __init__(self, prepared, lease): + main_thread() + require(isinstance(prepared, Prepared) and prepared._seal is _PREPARED and isinstance(lease, OwnedPresentationLease), + 'preview_ownership', 'Use server preparation and an exact-owned presentation lease.') + require(same(prepared.app, lease.app) and not same(prepared.document, lease.document), + 'preview_ownership', 'Source and presentation must be different documents in the same application.') + self.prepared, self.lease = prepared, lease + self.preview_id = str(uuid.uuid4()) + self.root = None + self.nodes = [] + self.state = 'prepared' + self.sample_index = None + self._root_id_written = False + self._delete_issued = False + self._sample_json = json.dumps(json.loads(prepared._result_json)['samples'], sort_keys=True, allow_nan=False) + self.samples_digest = digest(json.loads(self._sample_json)) + + def _guard(self): + main_thread() + self.lease.check() + self.prepared.check() + + def _ownership(self): + require(self.root is not None and self.root.isValid is True + and self.root.objectType == 'adsk::fusion::CustomGraphicsGroup' + and (not self._root_id_written or self.root.id == 'AdomExplodedPreview:' + self.preview_id), + 'preview_ownership', 'Exact retained graphics root is unavailable or changed.') + require(same(self.lease.design.rootComponent, self.lease.root_component), + 'preview_ownership', 'Exact retained presentation root Component changed.') + roots = bounded(self.lease.root_component.customGraphicsGroups, 1) + require(len(roots) == 1 and same(roots[0], self.root), 'preview_ownership', 'Presentation graphics inventory changed.') + children = bounded(self.root, MAX_INSTANCES + 1) + require(len(children) == len(self.nodes), 'preview_ownership', 'Owned child inventory changed.') + for actual, node in zip(children, self.nodes): + group = node['group'] + require(same(actual, group) and group.isValid and same(group.parent, self.root), + 'preview_ownership', 'Exact owned graphics subgroup changed.') + bodies = bounded(group, MAX_BODIES) + require(len(bodies) == len(node['graphics']) and all(same(a, b) and a.isValid and same(a.parent, group) + for a, b in zip(bodies, node['graphics'])), + 'preview_ownership', 'Exact owned graphics body inventory changed.') + + def _graphics(self): + self._ownership() + require(self.root.isSelectable is False and self.root.isChildrenSelectable is False + and self.root.isVisible is True and matrix_values(self.root) == IDENTITY, + 'preview_ownership', 'Owned root graphics properties changed.') + for node in self.nodes: + group = node['group'] + require(group.id == node['id'] and group.isVisible is True + and equivalent(matrix_values(group), node['matrix']), + 'preview_ownership', 'Owned graphics group or last applied transform changed.') + require(len(node['graphics']) == len(node['witnesses']), 'preview_copy_mismatch', 'Incomplete geometry copying.') + for graphic, witness in zip(node['graphics'], node['witnesses']): + require(graphic.isVisible is True and matrix_values(graphic) == IDENTITY + and equivalent(body_witness(graphic.bRepBody, True), witness), + 'preview_copy_mismatch', 'Owned copied geometry changed or copy readback differs.') + + def create(self): + main_thread() + require(self.state == 'prepared', 'preview_state', 'Creation cannot be retried or replayed.') + self._guard() + require(self.lease.design.rootComponent.customGraphicsGroups.count == 0, + 'preview_not_blank', 'Presentation already contains graphics.') + token = self.lease.begin_effect() + self.state = 'creating' + try: + self.root = self.lease.design.rootComponent.customGraphicsGroups.add() + require(self.root is not None and self.root.isValid, 'preview_create_unknown', 'Graphics root creation could not be verified.') + self.root.id = 'AdomExplodedPreview:' + self.preview_id + require(self.root.id == 'AdomExplodedPreview:' + self.preview_id, 'preview_create_unknown', 'Root ownership ID setter was ignored.') + self._root_id_written = True + self.root.isSelectable = False + self.root.isChildrenSelectable = False + result = json.loads(self.prepared._result_json) + homes = {r['instanceId']: r['homeMatrix'] for r in result['plan']['instances']} + for row in self.prepared._geometry: + group = self.root.addGroup() + require(group is not None, 'preview_create_unknown', 'Graphics subgroup creation could not be verified.') + node = {'group': group, 'id': row['instanceId'] or 'fixed-root', 'graphics': [], + 'witnesses': row['witnesses'], 'matrix': list(IDENTITY), 'instanceId': row['instanceId']} + self.nodes.append(node) # Keep exact ownership before ANY native getter/setter. + require(group.isValid, 'preview_create_unknown', 'Returned graphics subgroup is invalid.') + group.id = node['id'] + for body in row['bodies']: + graphic = group.addBRepBody(body) + require(graphic is not None, 'preview_create_unknown', 'Independent BRep graphics creation could not be verified.') + node['graphics'].append(graphic) # Retain before even isValid may throw. + require(graphic.isValid, 'preview_create_unknown', 'Returned graphics body is invalid.') + pose = homes.get(row['instanceId'], IDENTITY) + node['matrix'] = intended_native_values(pose) + group.transform = native_matrix(pose) + self._graphics() + self.lease.finish_effect(token) + self._guard() + self.state = 'ready' + # A new presentation normally became active in the harness. Return to + # the exact source; never steal another intervening active document. + current = self.lease.app.activeDocument + if same(current, self.lease.document): + require(self.prepared.document.activate() is True and same(self.lease.app.activeDocument, self.prepared.document), + 'preview_activation_refused', 'Could not return to the exact source document.') + else: + require(same(current, self.prepared.document), 'preview_activation_refused', 'Another active document intervened; no activation performed.') + self._guard() + return self.receipt() + except Exception: + self.state = 'retained_failure' + self.lease.failed_effect(token, self._ownership) + raise + + def sample(self, index): + main_thread() + samples = json.loads(self._sample_json) + require(type(index) is int and 0 <= index < len(samples), 'invalid_input', 'Sample requires an exact stored integer index.') + require(self.state == 'ready', 'preview_state', 'Sampling requires a verified ready preview.') + self._guard(); self._graphics() + poses = {r['instanceId']: r['matrix'] for r in samples[index]['poses']} + token = self.lease.begin_effect() + self.state = 'sampling' + try: + for node in self.nodes: + pose = poses[node['instanceId']] if node['instanceId'] is not None else IDENTITY + node['matrix'] = intended_native_values(pose) + node['group'].transform = native_matrix(pose) + self._graphics(); self.lease.finish_effect(token); self._guard() + self.sample_index = index + self.state = 'ready' + return self.receipt() + except Exception: + self.state = 'retained_failure' + self.lease.failed_effect(token, self._ownership) + raise + + def activate(self): + main_thread() + require(self.state == 'ready', 'preview_state', 'Only a verified ready preview can be activated.') + self._guard(); self._graphics() + require(any(same(self.lease.app.activeDocument, doc) for doc in (self.prepared.document, self.lease.document)), + 'preview_activation_refused', 'Another active document intervened; no activation performed.') + require(self.lease.document.activate() is True and same(self.lease.app.activeDocument, self.lease.document), + 'preview_activation_refused', 'Presentation activation was not acknowledged.') + self._guard(); self._graphics() + # No foreground/viewport/camera settings or automatic capture. + return self.receipt() + + def dispose(self): + main_thread() + self.lease.check(cleanup=True) # Source may have closed; disposal still requires exact owned presentation. + require(self.state in ('ready', 'retained_failure'), 'preview_state', 'Only retained owned graphics can be disposed.') + # Failed samples may have partial transforms or a stale/closed source. + # Disposal still requires every native descendant to be exactly owned. + self._ownership() + require(not self._delete_issued, 'preview_dispose_unknown', 'An earlier native deletion attempt remains unresolved.') + token = self.lease.begin_effect(cleanup=True) + self.state = 'disposing' + try: + self._delete_issued = True + require(self.root.deleteMe() is True, 'preview_dispose_unknown', 'Owned graphics deletion was not acknowledged.') + require(self.lease.design.rootComponent.customGraphicsGroups.count == 0, + 'preview_dispose_unknown', 'Owned graphics absence could not be verified.') + require(self.root.isValid is False, 'preview_dispose_unknown', 'Deleted retained root remains valid.') + self.lease.finish_effect(token) + self.state = 'disposed' + return self.receipt() + except Exception: + self.state = 'retained_failure' + self.lease._effect = None # Delete outcome cannot authorize another attempt. + self.lease.cleanup_state = None + raise + + def receipt(self): + main_thread() + return {'prototypeOnly': True, 'previewId': self.preview_id, 'planId': self.prepared.plan_id, + 'samplesDigest': self.samples_digest, 'sessionId': self.lease.session_id, + 'sourceTarget': copy.deepcopy(self.prepared._target), 'presentationDocumentId': self.lease.document_id, + 'state': self.state, 'sampleIndex': self.sample_index, 'groupCount': len(self.nodes), + 'bodyCopyCount': sum(len(n['graphics']) for n in self.nodes), + 'sourceMoved': False, 'sourceVisibilityWritten': False, 'storyboardCreated': False, + 'visualQualified': False, 'archivePersistence': 'UNVERIFIED', + 'copyWitness': 'solid volume, bounds, topology counts and indexed vertex coordinates; appearances/textures unqualified', + 'documentCleanup': 'owned caller responsibility; no automatic close or global undo'} diff --git a/addin/AdomBridge/commands/mechanical/schema.json b/addin/AdomBridge/commands/mechanical/schema.json index 7d281d8..5294bbb 100644 --- a/addin/AdomBridge/commands/mechanical/schema.json +++ b/addin/AdomBridge/commands/mechanical/schema.json @@ -6684,5 +6684,310 @@ } }, "description": "Read-only source-bound exploded assembly plan. Normalized translation directions in immutable home world/parent/local axes; simultaneous moves within ordered steps. Descendants inherit ancestor translations once, plus own moves. Bounded deterministic sample matrices and exact home reverse endpoint. No native moves, graphics, BOM quantity changes, render/video or storyboard creation. Complete configuration identity required." + }, + "exploded_preview_create": { + "type": "object", + "required": [ + "op", + "sessionId", + "operationId", + "target", + "sourceTarget", + "creationOperationId", + "expectedPlanId", + "planner" + ], + "properties": { + "sessionId": { + "type": "string" + }, + "target": { + "type": "object", + "required": [ + "documentId", + "revision" + ], + "additionalProperties": false, + "properties": { + "documentId": { + "type": "string" + }, + "revision": { + "type": "string" + } + } + }, + "sourceTarget": { + "type": "object", + "required": [ + "documentId", + "revision" + ], + "additionalProperties": false, + "properties": { + "documentId": { + "type": "string" + }, + "revision": { + "type": "string" + } + } + }, + "creationOperationId": { + "type": "string" + }, + "expectedPlanId": { + "type": "string" + }, + "planner": { + "type": "object", + "required": [ + "unit", + "steps" + ], + "additionalProperties": false, + "properties": { + "unit": { + "type": "string", + "enum": [ + "mm" + ] + }, + "steps": { + "type": "array", + "minItems": 1, + "maxItems": 32, + "items": { + "type": "object", + "required": [ + "durationSeconds", + "moves" + ], + "additionalProperties": false, + "properties": { + "title": { + "type": "string" + }, + "durationSeconds": { + "type": "number", + "exclusiveMinimum": 0, + "maximum": 3600 + }, + "moves": { + "type": "array", + "minItems": 1, + "maxItems": 128, + "items": { + "type": "object", + "required": [ + "occurrence", + "direction", + "distance", + "frame" + ], + "additionalProperties": false, + "properties": { + "occurrence": { + "type": "object", + "description": "Exact guarded occurrence reference from inspect; every repeated/nested instance is distinct." + }, + "direction": { + "type": "array", + "minItems": 3, + "maxItems": 3, + "items": { + "type": "number", + "minimum": -1000000000.0, + "maximum": 1000000000.0 + } + }, + "distance": { + "type": "number", + "exclusiveMinimum": 0, + "maximum": 1000000.0 + }, + "frame": { + "type": "string", + "enum": [ + "world", + "parent", + "local" + ] + } + } + } + }, + "instruction": { + "type": "string" + } + } + } + }, + "sampleTimes": { + "type": "array", + "maxItems": 62, + "items": { + "type": "number", + "minimum": 0, + "maximum": 3600 + } + }, + "playback": { + "type": "string", + "enum": [ + "explode", + "assemble" + ] + } + } + } + }, + "description": "Create finite copied solid-BRep presentation graphics in the exact active blank unsaved Design proven by a committed current-session new_design result. Regenerates the guarded source plan; leaves source active. Exclusive session lease, no source moves/visibility writes, no model transaction, automatic document close, render or capture. Live capability and native backend qualification are separate from durable operation receipts." + }, + "exploded_preview_sample": { + "type": "object", + "required": [ + "op", + "sessionId", + "operationId", + "target", + "previewId", + "expectedGeneration", + "sampleIndex" + ], + "properties": { + "sessionId": { + "type": "string" + }, + "target": { + "type": "object", + "required": [ + "documentId", + "revision" + ], + "additionalProperties": false, + "properties": { + "documentId": { + "type": "string" + }, + "revision": { + "type": "string" + } + } + }, + "previewId": { + "type": "string" + }, + "expectedGeneration": { + "type": "integer", + "minimum": 0, + "maximum": 1000000000 + }, + "sampleIndex": { + "type": "integer", + "minimum": 0, + "maximum": 63 + } + }, + "description": "Apply one server-stored absolute sample to exact-owned graphics; preserve active document. Source must remain unchanged. Requires the current controller capability, presentation target, generation and a durable operation ID. Old-session native resources are never rebound." + }, + "exploded_preview_show": { + "type": "object", + "required": [ + "op", + "sessionId", + "operationId", + "target", + "previewId", + "expectedGeneration" + ], + "properties": { + "sessionId": { + "type": "string" + }, + "target": { + "type": "object", + "required": [ + "documentId", + "revision" + ], + "additionalProperties": false, + "properties": { + "documentId": { + "type": "string" + }, + "revision": { + "type": "string" + } + } + }, + "previewId": { + "type": "string" + }, + "expectedGeneration": { + "type": "integer", + "minimum": 0, + "maximum": 1000000000 + } + }, + "description": "Explicitly activate the exact-owned presentation in Fusion; refuse an intervening foreign active document. No OS foreground, camera change or capture. Requires the current controller capability, presentation target, generation and a durable operation ID. Old-session native resources are never rebound." + }, + "exploded_preview_dispose": { + "type": "object", + "required": [ + "op", + "sessionId", + "operationId", + "target", + "previewId", + "expectedGeneration" + ], + "properties": { + "sessionId": { + "type": "string" + }, + "target": { + "type": "object", + "required": [ + "documentId", + "revision" + ], + "additionalProperties": false, + "properties": { + "documentId": { + "type": "string" + }, + "revision": { + "type": "string" + } + } + }, + "previewId": { + "type": "string" + }, + "expectedGeneration": { + "type": "integer", + "minimum": 0, + "maximum": 1000000000 + } + }, + "description": "Delete only the exact retained graphics tree and leave the blank document open for caller. Supports a narrowly proven cleanup after unresolved terminal storage without rewriting that history; no global Undo or close. Requires the current controller capability, presentation target, generation and a durable operation ID. Old-session native resources are never rebound." + }, + "exploded_preview_inspect": { + "type": "object", + "required": [ + "op", + "sessionId", + "previewId" + ], + "properties": { + "sessionId": { + "type": "string" + }, + "previewId": { + "type": "string" + } + }, + "description": "Main-thread live preview ownership/geometry/history observation. Cached operation receipts do not establish current graphics liveness. Old-session history remains separately readable through mech_operation_status; no native reference adoption or lifecycle repair is provided here." } } diff --git a/contracts/describe.json b/contracts/describe.json index b6f7600..b9ca382 100644 --- a/contracts/describe.json +++ b/contracts/describe.json @@ -2502,6 +2502,311 @@ ], "type": "object" }, + "exploded_preview_create": { + "description": "Create finite copied solid-BRep presentation graphics in the exact active blank unsaved Design proven by a committed current-session new_design result. Regenerates the guarded source plan; leaves source active. Exclusive session lease, no source moves/visibility writes, no model transaction, automatic document close, render or capture. Live capability and native backend qualification are separate from durable operation receipts.", + "properties": { + "creationOperationId": { + "type": "string" + }, + "expectedPlanId": { + "type": "string" + }, + "planner": { + "additionalProperties": false, + "properties": { + "playback": { + "enum": [ + "explode", + "assemble" + ], + "type": "string" + }, + "sampleTimes": { + "items": { + "maximum": 3600, + "minimum": 0, + "type": "number" + }, + "maxItems": 62, + "type": "array" + }, + "steps": { + "items": { + "additionalProperties": false, + "properties": { + "durationSeconds": { + "exclusiveMinimum": 0, + "maximum": 3600, + "type": "number" + }, + "instruction": { + "type": "string" + }, + "moves": { + "items": { + "additionalProperties": false, + "properties": { + "direction": { + "items": { + "maximum": 1000000000.0, + "minimum": -1000000000.0, + "type": "number" + }, + "maxItems": 3, + "minItems": 3, + "type": "array" + }, + "distance": { + "exclusiveMinimum": 0, + "maximum": 1000000.0, + "type": "number" + }, + "frame": { + "enum": [ + "world", + "parent", + "local" + ], + "type": "string" + }, + "occurrence": { + "description": "Exact guarded occurrence reference from inspect; every repeated/nested instance is distinct.", + "type": "object" + } + }, + "required": [ + "occurrence", + "direction", + "distance", + "frame" + ], + "type": "object" + }, + "maxItems": 128, + "minItems": 1, + "type": "array" + }, + "title": { + "type": "string" + } + }, + "required": [ + "durationSeconds", + "moves" + ], + "type": "object" + }, + "maxItems": 32, + "minItems": 1, + "type": "array" + }, + "unit": { + "enum": [ + "mm" + ], + "type": "string" + } + }, + "required": [ + "unit", + "steps" + ], + "type": "object" + }, + "sessionId": { + "type": "string" + }, + "sourceTarget": { + "additionalProperties": false, + "properties": { + "documentId": { + "type": "string" + }, + "revision": { + "type": "string" + } + }, + "required": [ + "documentId", + "revision" + ], + "type": "object" + }, + "target": { + "additionalProperties": false, + "properties": { + "documentId": { + "type": "string" + }, + "revision": { + "type": "string" + } + }, + "required": [ + "documentId", + "revision" + ], + "type": "object" + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "sourceTarget", + "creationOperationId", + "expectedPlanId", + "planner" + ], + "type": "object" + }, + "exploded_preview_dispose": { + "description": "Delete only the exact retained graphics tree and leave the blank document open for caller. Supports a narrowly proven cleanup after unresolved terminal storage without rewriting that history; no global Undo or close. Requires the current controller capability, presentation target, generation and a durable operation ID. Old-session native resources are never rebound.", + "properties": { + "expectedGeneration": { + "maximum": 1000000000, + "minimum": 0, + "type": "integer" + }, + "previewId": { + "type": "string" + }, + "sessionId": { + "type": "string" + }, + "target": { + "additionalProperties": false, + "properties": { + "documentId": { + "type": "string" + }, + "revision": { + "type": "string" + } + }, + "required": [ + "documentId", + "revision" + ], + "type": "object" + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "previewId", + "expectedGeneration" + ], + "type": "object" + }, + "exploded_preview_inspect": { + "description": "Main-thread live preview ownership/geometry/history observation. Cached operation receipts do not establish current graphics liveness. Old-session history remains separately readable through mech_operation_status; no native reference adoption or lifecycle repair is provided here.", + "properties": { + "previewId": { + "type": "string" + }, + "sessionId": { + "type": "string" + } + }, + "required": [ + "op", + "sessionId", + "previewId" + ], + "type": "object" + }, + "exploded_preview_sample": { + "description": "Apply one server-stored absolute sample to exact-owned graphics; preserve active document. Source must remain unchanged. Requires the current controller capability, presentation target, generation and a durable operation ID. Old-session native resources are never rebound.", + "properties": { + "expectedGeneration": { + "maximum": 1000000000, + "minimum": 0, + "type": "integer" + }, + "previewId": { + "type": "string" + }, + "sampleIndex": { + "maximum": 63, + "minimum": 0, + "type": "integer" + }, + "sessionId": { + "type": "string" + }, + "target": { + "additionalProperties": false, + "properties": { + "documentId": { + "type": "string" + }, + "revision": { + "type": "string" + } + }, + "required": [ + "documentId", + "revision" + ], + "type": "object" + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "previewId", + "expectedGeneration", + "sampleIndex" + ], + "type": "object" + }, + "exploded_preview_show": { + "description": "Explicitly activate the exact-owned presentation in Fusion; refuse an intervening foreign active document. No OS foreground, camera change or capture. Requires the current controller capability, presentation target, generation and a durable operation ID. Old-session native resources are never rebound.", + "properties": { + "expectedGeneration": { + "maximum": 1000000000, + "minimum": 0, + "type": "integer" + }, + "previewId": { + "type": "string" + }, + "sessionId": { + "type": "string" + }, + "target": { + "additionalProperties": false, + "properties": { + "documentId": { + "type": "string" + }, + "revision": { + "type": "string" + } + }, + "required": [ + "documentId", + "revision" + ], + "type": "object" + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "previewId", + "expectedGeneration" + ], + "type": "object" + }, "exploded_view_plan": { "description": "Read-only source-bound exploded assembly plan. Normalized translation directions in immutable home world/parent/local axes; simultaneous moves within ordered steps. Descendants inherit ancestor translations once, plus own moves. Bounded deterministic sample matrices and exact home reverse endpoint. No native moves, graphics, BOM quantity changes, render/video or storyboard creation. Complete configuration identity required.", "properties": { @@ -8854,7 +9159,7 @@ "op": "inspect" }, "input": { - "op": "required enum: inspect|inspect_entity|new_design|parameter_set|parameter_create|health|export|transaction_probe|sketch_create|sketch_geometry|sketch_constraint|sketch_dimension|sketch_project|sketch_edit|plane_create|feature_create|feature_edit|entity_delete|parameter_batch|component_create|occurrence_insert|occurrence_update|occurrence_delete|component_metadata|joint_create|joint_edit|joint_delete|interference|bom|bom_export|cad_import|mcmaster_discover|mcmaster_open|material_list|material_assign|rigid_group_create|rigid_group_edit|rigid_group_delete|assembly_properties|geometry_query|measure|sketch_curve_edit|sketch_offset|construction_axis|sketch_diagnostics|joint_origin_create|joint_origin_edit|joint_origin_delete|regular_joint_create|regular_joint_edit|motion_link_create|motion_link_edit|motion_link_delete|configuration_inspect|render_scene_inspect|exploded_view_plan", + "op": "required enum: inspect|inspect_entity|new_design|parameter_set|parameter_create|health|export|transaction_probe|sketch_create|sketch_geometry|sketch_constraint|sketch_dimension|sketch_project|sketch_edit|plane_create|feature_create|feature_edit|entity_delete|parameter_batch|component_create|occurrence_insert|occurrence_update|occurrence_delete|component_metadata|joint_create|joint_edit|joint_delete|interference|bom|bom_export|cad_import|mcmaster_discover|mcmaster_open|material_list|material_assign|rigid_group_create|rigid_group_edit|rigid_group_delete|assembly_properties|geometry_query|measure|sketch_curve_edit|sketch_offset|construction_axis|sketch_diagnostics|joint_origin_create|joint_origin_edit|joint_origin_delete|regular_joint_create|regular_joint_edit|motion_link_create|motion_link_edit|motion_link_delete|configuration_inspect|render_scene_inspect|exploded_view_plan|exploded_preview_create|exploded_preview_sample|exploded_preview_show|exploded_preview_dispose|exploded_preview_inspect", "operationId": "required unique write ID; reuse unchanged for retry", "operationSchemas": { "assembly_properties": { @@ -9228,6 +9533,311 @@ ], "type": "object" }, + "exploded_preview_create": { + "description": "Create finite copied solid-BRep presentation graphics in the exact active blank unsaved Design proven by a committed current-session new_design result. Regenerates the guarded source plan; leaves source active. Exclusive session lease, no source moves/visibility writes, no model transaction, automatic document close, render or capture. Live capability and native backend qualification are separate from durable operation receipts.", + "properties": { + "creationOperationId": { + "type": "string" + }, + "expectedPlanId": { + "type": "string" + }, + "planner": { + "additionalProperties": false, + "properties": { + "playback": { + "enum": [ + "explode", + "assemble" + ], + "type": "string" + }, + "sampleTimes": { + "items": { + "maximum": 3600, + "minimum": 0, + "type": "number" + }, + "maxItems": 62, + "type": "array" + }, + "steps": { + "items": { + "additionalProperties": false, + "properties": { + "durationSeconds": { + "exclusiveMinimum": 0, + "maximum": 3600, + "type": "number" + }, + "instruction": { + "type": "string" + }, + "moves": { + "items": { + "additionalProperties": false, + "properties": { + "direction": { + "items": { + "maximum": 1000000000.0, + "minimum": -1000000000.0, + "type": "number" + }, + "maxItems": 3, + "minItems": 3, + "type": "array" + }, + "distance": { + "exclusiveMinimum": 0, + "maximum": 1000000.0, + "type": "number" + }, + "frame": { + "enum": [ + "world", + "parent", + "local" + ], + "type": "string" + }, + "occurrence": { + "description": "Exact guarded occurrence reference from inspect; every repeated/nested instance is distinct.", + "type": "object" + } + }, + "required": [ + "occurrence", + "direction", + "distance", + "frame" + ], + "type": "object" + }, + "maxItems": 128, + "minItems": 1, + "type": "array" + }, + "title": { + "type": "string" + } + }, + "required": [ + "durationSeconds", + "moves" + ], + "type": "object" + }, + "maxItems": 32, + "minItems": 1, + "type": "array" + }, + "unit": { + "enum": [ + "mm" + ], + "type": "string" + } + }, + "required": [ + "unit", + "steps" + ], + "type": "object" + }, + "sessionId": { + "type": "string" + }, + "sourceTarget": { + "additionalProperties": false, + "properties": { + "documentId": { + "type": "string" + }, + "revision": { + "type": "string" + } + }, + "required": [ + "documentId", + "revision" + ], + "type": "object" + }, + "target": { + "additionalProperties": false, + "properties": { + "documentId": { + "type": "string" + }, + "revision": { + "type": "string" + } + }, + "required": [ + "documentId", + "revision" + ], + "type": "object" + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "sourceTarget", + "creationOperationId", + "expectedPlanId", + "planner" + ], + "type": "object" + }, + "exploded_preview_dispose": { + "description": "Delete only the exact retained graphics tree and leave the blank document open for caller. Supports a narrowly proven cleanup after unresolved terminal storage without rewriting that history; no global Undo or close. Requires the current controller capability, presentation target, generation and a durable operation ID. Old-session native resources are never rebound.", + "properties": { + "expectedGeneration": { + "maximum": 1000000000, + "minimum": 0, + "type": "integer" + }, + "previewId": { + "type": "string" + }, + "sessionId": { + "type": "string" + }, + "target": { + "additionalProperties": false, + "properties": { + "documentId": { + "type": "string" + }, + "revision": { + "type": "string" + } + }, + "required": [ + "documentId", + "revision" + ], + "type": "object" + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "previewId", + "expectedGeneration" + ], + "type": "object" + }, + "exploded_preview_inspect": { + "description": "Main-thread live preview ownership/geometry/history observation. Cached operation receipts do not establish current graphics liveness. Old-session history remains separately readable through mech_operation_status; no native reference adoption or lifecycle repair is provided here.", + "properties": { + "previewId": { + "type": "string" + }, + "sessionId": { + "type": "string" + } + }, + "required": [ + "op", + "sessionId", + "previewId" + ], + "type": "object" + }, + "exploded_preview_sample": { + "description": "Apply one server-stored absolute sample to exact-owned graphics; preserve active document. Source must remain unchanged. Requires the current controller capability, presentation target, generation and a durable operation ID. Old-session native resources are never rebound.", + "properties": { + "expectedGeneration": { + "maximum": 1000000000, + "minimum": 0, + "type": "integer" + }, + "previewId": { + "type": "string" + }, + "sampleIndex": { + "maximum": 63, + "minimum": 0, + "type": "integer" + }, + "sessionId": { + "type": "string" + }, + "target": { + "additionalProperties": false, + "properties": { + "documentId": { + "type": "string" + }, + "revision": { + "type": "string" + } + }, + "required": [ + "documentId", + "revision" + ], + "type": "object" + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "previewId", + "expectedGeneration", + "sampleIndex" + ], + "type": "object" + }, + "exploded_preview_show": { + "description": "Explicitly activate the exact-owned presentation in Fusion; refuse an intervening foreign active document. No OS foreground, camera change or capture. Requires the current controller capability, presentation target, generation and a durable operation ID. Old-session native resources are never rebound.", + "properties": { + "expectedGeneration": { + "maximum": 1000000000, + "minimum": 0, + "type": "integer" + }, + "previewId": { + "type": "string" + }, + "sessionId": { + "type": "string" + }, + "target": { + "additionalProperties": false, + "properties": { + "documentId": { + "type": "string" + }, + "revision": { + "type": "string" + } + }, + "required": [ + "documentId", + "revision" + ], + "type": "object" + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "previewId", + "expectedGeneration" + ], + "type": "object" + }, "exploded_view_plan": { "description": "Read-only source-bound exploded assembly plan. Normalized translation directions in immutable home world/parent/local axes; simultaneous moves within ordered steps. Descendants inherit ancestor translations once, plus own moves. Bounded deterministic sample matrices and exact home reverse endpoint. No native moves, graphics, BOM quantity changes, render/video or storyboard creation. Complete configuration identity required.", "properties": { diff --git a/docs/EXPLODED-PREVIEWS.md b/docs/EXPLODED-PREVIEWS.md new file mode 100644 index 0000000..c890a85 --- /dev/null +++ b/docs/EXPLODED-PREVIEWS.md @@ -0,0 +1,93 @@ +# Finite exploded-preview controller candidate + +This source candidate adds `exploded_preview_create`, `exploded_preview_sample`, `exploded_preview_show`, `exploded_preview_dispose` and `exploded_preview_inspect` under `fusion_mechanical` (61 typed operations, still 179 public verbs). Its revised native backend passed on candidate-initialized owned fixtures in Fusion 2705.1.30: 11 graphics observations, 66 instance poses, 55 copied-body geometry observations, eight refusals and two exact disposals. The source assembly, joints, BOM and document flags were preserved; two F3D archive roundtrips and four exact document closures passed. The **installed public controller route and visual output remain unqualified**. The original two backend failures and their separate reconciliation evidence remain preserved. This no-image test qualifies native module behavior, not installed journal/creation-history routing or rendered output. See [qualification evidence](mechanical-verification/exploded-preview/README.md). + +The backend copies solid BRep geometry into a separate blank presentation Design. Its flat graphics groups use the planner's root/world transforms, converted from mm to cm once. Source occurrences, bodies, joints, grounding and visibility are never written. The prototype's exact native lease/copy contract is documented in [EXPLODED-PREVIEW.md](prototypes/EXPLODED-PREVIEW.md). + +The shared revision reader initializes every bounded component mesh collection before capturing any native component revision ID. Native diagnosis on modified fixtures found that a first child collection acquisition can also advance ancestor IDs. All original revision fields and exact stale-target checks remain; old targets and committed creation receipts are never rewritten. This affects ordinary mechanical target issuance as well as preview preparation. + +The reader records the exact parent document's saved/modified flags before initialization, after it and after hashing. Invalid ownership, collection/read failures or changed flags refuse with bounded observations and `rollbackClaimed: false`; errors also attempt flag readback and preserve unknown observations. A changed dirty flag is never reset or hidden. A separate native function gate passed a fresh clean unsaved blank and a cold, already-modified local F3D control, with unchanged flags and exact cleanup. Saved/cloud-clean multi-component preservation remains unqualified. That gate applies to the shared revision reader, not this revised graphics lifecycle. + +## Caller workflow + +1. Inspect the source, retain its exact target and occurrence references, and call `exploded_view_plan` with explicit steps, frames and finite sample times. Retain the original planner inputs and returned plan ID. +2. Explicitly create a blank presentation with current-session typed `new_design`, without a box. Keep its operation ID and returned target. The new blank document must remain active, unsaved, unedited and free of graphics. +3. Call `exploded_preview_create` with those exact identities. The server verifies the authoritative committed creation result, reserves an exclusive session lease, activates the exact source inside Fusion, regenerates the plan, and creates presentation copies at home. Successful creation leaves the source active. +4. Apply finite stored sample indices with fresh operation IDs/current generation. `exploded_preview_show` explicitly activates the owned presentation. It refuses to switch away from an unrelated active document. Sampling itself leaves the active document unchanged. +5. Explicitly dispose the owned graphics. The empty presentation document remains open for the caller. No automatic save, close, global Undo or cleanup by document name is provided. + +```json +{ + "op": "exploded_preview_create", + "sessionId": "", + "operationId": "", + "target": {"documentId": "", "revision": ""}, + "creationOperationId": "", + "sourceTarget": {"documentId": "", "revision": ""}, + "expectedPlanId": "", + "planner": { + "unit": "mm", + "steps": [{"durationSeconds": 2, "moves": [{ + "occurrence": "", + "direction": [0, 0, 1], "distance": 20, "frame": "world" + }]}], + "sampleTimes": [1], + "playback": "explode" + } +} +``` + +The occurrence placeholder must be a complete guarded object, not its name or a string. `planner` contains the same unit/steps/sampleTimes/playback inputs used for planning; session and source target come from the guarded outer request. No client matrices or replacement sample arrays are accepted. Initial creation is the home pose, including when the stored samples use reverse playback. + +Sample/show/dispose requests use the stored presentation target, `previewId`, `expectedGeneration`, current session and a fresh operation ID. Sampling also requires a strict integer `sampleIndex` into the server's stored samples. For example: + +```json +{ + "op": "exploded_preview_sample", + "sessionId": "", "operationId": "", + "target": {"documentId": "", "revision": ""}, + "previewId": "", "expectedGeneration": 1, + "sampleIndex": 1 +} +``` + +A successful new effect advances the generation. Concurrent or stale intents with an old generation refuse. Repeating an index with a fresh ID/current generation applies that absolute sample; repeating the same operation ID and identical arguments retrieves its immutable completion receipt without executing again. + +## Ownership and status + +The server requires a coherent, committed current-session `new_design` result and its exact returned document ID, native creation ID and original revision. That proof is combined with the still-open native object in the session identity registry, exact blank state, and an exclusive in-memory lease. A client success receipt, document name, label attribute or creation ID alone cannot establish ownership. The journal's execution-observed document for `new_design` is the document active before creation; it is not the newly created result document. + +The request's presentation target remains the **immutable creation anchor** throughout this lease and any authorized reuse. `presentationTargetScope: immutable_creation_anchor` makes that distinction explicit. `lastVerifiedPresentationRevision` and `presentationVerificationSequence` describe the separate private presentation verification chain. Native observation showed that each owned graphics sample and deletion can advance only the root Component's opaque revision ID, including reapplying an unchanged sample. The candidate brackets these effects on the main thread: it first requires the exact previous private revision, then verifies the exact owned tree (or acknowledged deletion, empty inventory and invalidated retained root), unchanged flags and unchanged non-graphics witness before advancing its private expectation. It never changes the original creation result, source target, shared revision algorithm or operation history. + +The bounded presentation witness reconstructs the exact empty-model core aggregate and checks its digest against the unchanged global reader before and after observation. It excludes only the measured opaque root revision slot from its private semantic comparison. Configuration, model/history inventories, metadata, all-group document/design/root attributes, folder visibility and the seven origin entities' identities, geometry, visibility and attributes remain bound. This is a narrowly specified blank-model witness, not a complete application/view/render-settings witness. Unrelated revision changes outside an owned effect still refuse even when that semantic witness is identical. + +A successful disposal returns `observedEmptyTarget` as its completion-time empty-model observation. Reuse still submits the immutable creation anchor and requires the same live document/root and controller epoch, a coherent committed predecessor disposal/release, no unresolved prior operation, and exact equality with the predecessor's retained final revision/witness. Merely observing an empty document or matching a historical textual ID cannot authorize reuse. The disposed entry remains a tombstone; observation does not recreate resource rights. + +`exploded_preview_inspect` takes the current session and preview ID. It performs main-thread live reads, reports the current generation, graphics/source observations and the last operation's coherent terminal-history status. It is not a busy-safe/offline endpoint. `mech_operation_status` remains the direct observational route for durable status while native work is busy or unavailable. Never recover a timeout by replaying a mutation with a new ID. + +Operation results use `receiptScope: operation_completion`; live inspection uses `live_observation`. Replaying successful create after disposal returns the old completion receipt and never recreates graphics. The live capability is separate from historical success. A failed partial sample reports `pose: unverified` and null current sample index; its last verified index is identified separately. Disposed entries return an explicitly registry-only tombstone: native observation is not performed, current document disposition is unknown, and no live resource rights are asserted even if that document has since closed or been reused. + +## Failures and bounded lifetime + +Creation can reserve at most four live/failed previews, 128 total session entries, eight MiB of retained serialized plan/sample JSON and 512 acknowledged body copies. Per-preview native topology/visibility bounds still apply. Capacity failures never evict retained resources. Every retained serialized string is counted, including separate sample copies. Disposed tombstones are bounded; a full session refuses further allocation. + +A failure before the graphics factory is ever invoked may release a reservation only after exact owned blank/zero-graphics readback. Any activation already performed stays recorded. Unknown factory effects or unidentified descendants retain the lease and exact handles. Failure does not imply a transaction rollback. + +Sample/show require an unchanged source. An explicit first disposal can clean up exact-owned graphics after source closure, a partial sample, or failed terminal-history persistence. For the persistence exception, its own new admission must be durable and the prior synchronous native handler must have actually returned with no work still in flight, the same epoch/generation, and complete exact root/descendant ownership. The previous unknown operation history stays unchanged. A returned partial sample may establish a separately recorded cleanup-only revision after exact tree and non-graphics verification; it never becomes a ready pose or authorizes more sampling. An unreadable/changed witness or unknown descendant retains the failure without adopting the latest revision. + +A false/throwing delete or failed absence readback latches an uncertain deletion. A fresh operation ID or generation cannot authorize a second delete. The document/resources remain for explicit reconciliation. If deletion succeeds but its terminal persistence fails, the observed deletion remains distinct from a durable completion; no automatic replay, replacement create or close follows. + +Session restart loses native lease capabilities. Old-session journal records remain readable but cannot authorize adopting, sampling or deleting surviving graphics by a textual ID. No startup sweep or automatic document closure occurs. + +## Usability and coverage limits + +`show` only activates the document. Its default camera may not frame a large or widely exploded assembly. Automatic framing, camera changes, fit/refresh behavior and visual acceptance are unqualified; they require a separate explicit action and purposeful visual gate. Numeric planner matrices remain available, but this controller does not yet return qualified world-space graphics bounds. Activation is not evidence that every copy is visible on screen. + +No material/texture fidelity, full-scene witness, graphics F3D persistence, native storyboard, rendered still/video, trails or collision-free disassembly is claimed. Hidden/suppressed instances or bodies, meshes and nonsolid BRep are refused by the backend rather than silently omitted. The no-image native gate established copied geometry, transforms and exact cleanup; installed public routing and a purposeful visual checkpoint are separate remaining gates. + +Offline checks: + +```sh +python3 -m unittest discover -s tests -p test_exploded_preview_controller.py -v +python3 -m unittest discover -s tests -p test_exploded_preview_prototype.py -v +``` diff --git a/docs/EXPLODED-VIEWS.md b/docs/EXPLODED-VIEWS.md index 857a9e8..9ea77b3 100644 --- a/docs/EXPLODED-VIEWS.md +++ b/docs/EXPLODED-VIEWS.md @@ -9,6 +9,8 @@ an exploded viewport, graphics, trails, a storyboard or video. Those presentatio backends remain a core animation requirement, and must preserve the source assembly's joints, grounding, positions and configuration. +A separate [finite preview controller candidate](EXPLODED-PREVIEWS.md) implements the next source slice; native graphics and installed routing are still unqualified. It does not change the numeric plannerโ€™s accepted behavior. + ## Current planning contract Call `fusion_mechanical` with this operation, the current `sessionId`, an exact diff --git a/docs/MECHANICAL.md b/docs/MECHANICAL.md index c0292ae..e1b0bd3 100644 --- a/docs/MECHANICAL.md +++ b/docs/MECHANICAL.md @@ -37,7 +37,7 @@ move occurrences, suppress joints, create graphics, change BOM quantities, creat a storyboard, render images or export video. Native module-function qualification passed six instances across 60 poses, source/joint/BOM preservation and F3D reopening; installed public routing remains untested. See [the evidence](build-2026-10-06-exploded-plans.md). -Exploded previews and presentation backends are still planned. +A [finite exploded-preview controller candidate](EXPLODED-PREVIEWS.md) now exists in source (61 typed operations): independent copied graphics, explicit finite samples/show/dispose, and session-owned presentation leases. Its native backend and installed public routing remain unqualified; the first backend attempt stopped before graphics creation. Camera framing and visible-output acceptance are separate future gates. ## Inspect render settings and prepare a detached camera diff --git a/docs/prototypes/EXPLODED-PREVIEW.md b/docs/prototypes/EXPLODED-PREVIEW.md new file mode 100644 index 0000000..9f82a1a --- /dev/null +++ b/docs/prototypes/EXPLODED-PREVIEW.md @@ -0,0 +1,72 @@ +# Exploded presentation geometry prototype + +This is an **unregistered native backend**, not an installed/public operation. It creates independent solid BRep display geometry in a separately owned blank Design, applies finite stored samples, and deletes only its exact graphics tree. It does not move source occurrences, joints or bodies. The separate staged controller exposes 61 typed operations in total; this module itself is not registered as a handler. Release versions remain unchanged. The revised backend passed native qualification on candidate-initialized owned fixtures; installed public routing and visual output remain unqualified. + +## Interface and ownership + +The native qualification harness creates and registers a fresh unnamed presentation Design using its existing exact-document ownership infrastructure. Preparation runs while the source is active; thereafter source checks use the retained exact source Document/Design, session identity, revision, flags, fresh entity resolution and visibility/geometry witnesses. This deliberately supports an inactive source without weakening `core.target`, which still requires an active exact target during preparation. + +```python +from .exploded_preview_prototype import prepare, OwnedPresentationLease, Preview + +prepared = prepare(app, original_planner_args, expected_plan_id) +lease = OwnedPresentationLease(app, owned_doc, owned_design, run_id, verify_owner) +preview = Preview(prepared, lease) # Retain this object BEFORE create can raise. +created = preview.create() # Native geometry copied at its home pose. +preview.sample(1) # Exact integer index into server-generated samples. +preview.activate() # Explicit Fusion-document activation; no OS focus call. +preview.sample(2) # Finite call, no timer or playback loop. +disposed = preview.dispose() # Deletes graphics; never closes a document. +``` + +`verify_owner()` must independently recheck the exact native registry object/run marker and return exactly `{success: true, ownerId, sessionId, documentId}`. Any error, false status, extra ambiguous envelope, foreign identity, saved document or nonblank model refuses. The lease must belong to the same application and a different document from the source. Source documents are never selected by name. + +The harness remains responsible for document creation, registration, archive policy and exact cleanup. The prototype never creates/closes/saves a document or performs global Undo. A blank Design means one root component, no BRep/mesh bodies, occurrences, sketches, features, parameters or timeline entries. Its non-graphics model and saved/modified flags must remain unchanged. A private expected presentation revision is advanced only across a verified owned graphics effect; all unbracketed revision changes refuse. Creation also requires no existing custom graphics; subsequent calls require the exact owned graphics inventory. + +The top-level ownership proof is complete unique API-equal membership of the retained root Component's `customGraphicsGroups`, plus the retained valid group type/ID. Native diagnosis found the top group's `parent` getter reporting another CustomGraphicsGroup, so no Component-parent assumption is made there. Every retained subgroup and body still requires its exact parent and collection identity. + +A separate native diagnostic on the retained failed fixture measured only the root Component's opaque `revisionId` changing on each finite sample (including sample 0 repeated) and deletion. The source correction uses strict pre/post effect boundaries and a bounded blank-model witness. It rebuilds the unchanged core aggregate, proves the actual digest, and permits only that one opaque slot to differ after exact graphics readback. The witness additionally binds document/design/root attributes, folder visibility and the built-in origin entities. No source or global revision check is relaxed. Newly bracketed create/sample/dispose behavior and these added getters remain pending native qualification; original failed campaigns are not rewritten. + +By default, successful creation returns to the exact source document if the presentation is still active. It refuses to activate away from an intervening foreign active document. Explicit `activate()` accepts only the source or owned presentation as the current document and verifies activation. Sampling performs no document activation. There is no viewport/camera assignment, foreground call, timer, capture, image upload or Shotlog integration. + +## Copy and frame contract + +Preparation regenerates the plan on the server from its original guarded arguments and compares `planId`. The object retains private JSON for that result and its finite samples. Samples have a separate digest because playback/sample times are not part of the planner's plan ID. No interface accepts client matrices, client geometry or replacement samples. + +One graphics root contains **flat sibling** groups for the fixed root-owned bodies and each physical occurrence, including empty assembly nodes. A body's input is its component-definition native BRep, never an occurrence proxy. `CustomGraphicsGroup.addBRepBody` creates the independent display copy; the instance subgroup receives the planner's root/world transform once. Only translation slots 3/7/11 are converted from millimetres to native centimetres. Body and top-level root graphics transforms stay identity. This avoids applying an ancestor's transform twice to nested or repeated instances. + +Copies are checked through the returned transient `CustomGraphicsBRepBody.bRepBody`: positive solid volume (cmยณ), local bounds (cm), face/edge counts and corresponding vertex coordinates (cm). Group transforms are read back after every finite sample. These observations supplement the native copying API contract; they do not prove appearance, material or texture fidelity, nor provide a complete shape-equivalence algorithm. Both root selection and child selection are disabled and verified. + +Supported scope is at most 64 physical instances, 128 solid BRep copies, 4,096 faces/edges/vertices per body collection and 16,384 total copied vertices. All physical source occurrences and all contextual source bodies must be effectively visible. Suppression, hidden components/bodies, any instantiated/root mesh body, nonsolid BRep, invalid context or unavailable required getter refuses the entire preparation. No unsupported body is silently omitted. Empty assembly components are retained as groups. Sketches, construction geometry, decals, point clouds, existing custom graphics and other non-BRep scene objects are outside this explicitly **solid BRep presentation** scope; there is no full-scene completeness claim. + +Effective visibility is checked on exact occurrence/body proxies before using the native body as copy input. Transient copies are never queried for `BRepBody.isVisible`, which the API does not support. Source geometry and revision are rechecked before/after effects. Source visibility and suppression are never changed to improve a preview. + +## Failure, disposal and lifetime + +The `Preview` object retains its root immediately after creation, each subgroup before further setters, and each acknowledged body copy. A failed create/sample becomes `retained_failure`; it cannot be replayed or sampled again. Explicit disposal remains available after source staleness/closure or a partially applied sample, provided every graphics descendant is still exactly one of the retained owned objects. A returned partial effect can record a cleanup-only revision only after exact descendant ownership and unchanged non-graphics/flag proof. The last verified ready revision stays distinct, and sampling remains disabled. Unknown factory effects, unknown descendants or changed/unreadable witnesses cannot be adopted merely by rereading them. This permits disposal without requiring a successful source read. + +Null/throwing native creation may leave unknown geometry. If native child inventory contains an unacknowledged object, the prototype refuses automatic disposal and retains the owned presentation for independent reconciliation. It never deletes a group discovered merely by name/id. `deleteMe` must return true and the exact root collection must then be empty. A false/throwing delete or failed absence readback retains ownership evidence and permanently latches that attempt; calling dispose again cannot issue a second native deletion. An uncertain disappearance needs separate native reconciliation. Document closure is always the owned caller's separate responsibility. + +The lease and samples are session-local, process-memory capabilities. There is no restart rebind, automatic recovery or durable graphics replay. Closing/changing the source invalidates sampling. No F3D persistence, native storyboard, render, animation video, trails, text labels, collision-free disassembly or visual acceptance is claimed. `visualQualified` is always false in prototype receipts. The next native gate must demonstrate actual viewport geometry separately. + +## Finite qualification gate + +Use the already-qualified six-instance repeated/nested/rotated planner fixture. Expected presentation: one owned root, seven flat groups, five independent BRep copies and an otherwise blank model. Check home, midpoint, final, reverse-home and out-of-order/repeated stored samples against independent matrix/geometry oracles. Check both parent and child translations are applied once, each copied body's local geometry equals its native definition, and no source API entity is moved or hidden. + +Recheck the source's full model/joint/BOM graph and document flags before/after creation, sampling and disposal. Check ignored setters, ownership mismatch, invalid sample indices, source closure/staleness and partial creation/sample faults retain the appropriate ownership evidence. Archive/reopen the source normally. After verified graphics disposal, independently witness the empty presentation model before its exact-owned archive/reopen/closure. A disposed empty F3D does **not** establish graphics persistence. Any undecidable effect retains the exact document; no global Undo or blind cleanup. + +The finite runner passed on Fusion 2705.1.30: 11 graphics observations, 66 instance poses, 55 copied-body geometry observations, eight refusals and two disposals. Its source/presentation F3D roundtrips, four exact closures and restoration of the original active FloorPlan document passed. The runner initialized only the owned fixtures with the candidate revision reader before issuing fresh targets; the installed public controller was not invoked. See [qualification evidence](../mechanical-verification/exploded-preview/README.md). A later explicit qualification may use one meaningful targeted visual observation; no automatic capture or feed is part of the backend. + +## Primary API references + +- [Independent BRep copy creation](https://help.autodesk.com/cloudhelp/ENU/Fusion-360-API/files/fusion_CustomGraphicsGroup_addBRepBody.htm) and [custom graphics overview, including bodies from another Design](https://help.autodesk.com/cloudhelp/ENU/Fusion-360-API/files/CustomGraphics_UM.htm). +- [Group ownership, transform and selection controls](https://help.autodesk.com/cloudhelp/ENU/Fusion-360-API/files/fusion_CustomGraphicsGroup.htm), [transform property](https://help.autodesk.com/cloudhelp/ENU/Fusion-360-API/files/fusion_CustomGraphicsEntity_transform.htm) and [Boolean deletion acknowledgement](https://help.autodesk.com/cloudhelp/ENU/Fusion-360-API/files/fusion_CustomGraphicsGroup_deleteMe.htm). +- [Returned transient display body](https://help.autodesk.com/cloudhelp/ENU/Fusion-360-API/files/fusion_CustomGraphicsBRepBody_bRepBody.htm), [body volume units](https://help.autodesk.com/cloudhelp/ENU/Fusion-360-API/files/fusion_BRepBody_volume.htm), [contextual occurrence visibility](https://help.autodesk.com/cloudhelp/ENU/Fusion-360-API/files/fusion_Occurrence_isVisible.htm) and [effective nontransient body visibility](https://help.autodesk.com/cloudhelp/ENU/Fusion-360-API/files/fusion_BRepBody_isVisible.htm). +- [Opaque component revision IDs](https://help.autodesk.com/cloudhelp/ENU/Fusion-360-API/files/fusion_Component_revisionId.htm) and [top-level custom graphics collection](https://help.autodesk.com/cloudhelp/ENU/Fusion-360-API/files/fusion_Component_customGraphicsGroups.htm). +- [Explicit document activation](https://help.autodesk.com/cloudhelp/ENU/Fusion-360-API/files/core_Document_activate.htm). [Document close](https://help.autodesk.com/cloudhelp/ENU/Fusion-360-API/files/core_Document_close.htm) remains outside this module and must follow the existing ownership harness. + +Offline regression command: + +```sh +python3 -m unittest discover -s tests -p test_exploded_preview_prototype.py -v +``` diff --git a/tests/test_exploded_preview_controller.py b/tests/test_exploded_preview_controller.py new file mode 100644 index 0000000..77cc0a6 --- /dev/null +++ b/tests/test_exploded_preview_controller.py @@ -0,0 +1,396 @@ +"""Real dispatcher/journal + fake native graphics: no host or native execution.""" +import copy +import importlib +import json +import threading +from types import SimpleNamespace as NS +import unittest +from unittest.mock import patch +from test_mechanical import m, c, Entity, isolated_history +import test_exploded_preview_prototype as native_fixture +Graphics = native_fixture.Graphics +body = native_fixture.body + +controller = importlib.import_module('mechanical_test_commands.mechanical.exploded_preview') +journal = importlib.import_module('mechanical_test_commands.mechanical.journal') +p = controller.backend + + +class PublicPreview(unittest.TestCase): + def setUp(self): + self.native=native_fixture.PreviewTests('test_flat_definition_copies_and_world_translation_applied_once') + self.native.setUp();self.addCleanup(self.native.doCleanups) + self.f=self.native.f;self.app=self.native.app;self.doc=self.native.doc;self.design=self.native.design + self.root=self.native.root + isolated_history(self) + self.h=m._history + self.control=controller.Controller() + for item in [patch.object(controller,'_controller',self.control), + patch.object(c,'document_identity',side_effect=lambda doc:doc.doc_id), + patch.object(c,'revision',side_effect=lambda design:design.rev)]: + item.start();self.addCleanup(item.stop) + self.f.doc.creationId='native-source';self.doc.creationId='native-presentation' + self.f.doc.products=NS(itemByProductType=lambda name:self.f.design) + self.doc.products=NS(itemByProductType=lambda name:self.design) + c._documents[:]=[(self.f.doc,'source'),(self.doc,'presentation')] + self.target={'documentId':'presentation','revision':self.design.rev} + self.creation={'op':'new_design','sessionId':m.SESSION_ID,'operationId':'document-created'} + self.created_data={'target':self.target,'document':{'creationId':'native-presentation','saved':False}} + with patch.dict(m.OPERATIONS,new_design=lambda app,args:copy.deepcopy(self.created_data)): + made=m.handle_mechanical(self.app,self.creation) + self.assertTrue(made['success']) + self.app.activeDocument=self.doc + self.create_args={'op':'exploded_preview_create','sessionId':m.SESSION_ID,'operationId':'preview-created', + 'target':self.target,'sourceTarget':self.f.value,'creationOperationId':'document-created', + 'expectedPlanId':self.native.generated['planId'], + 'planner':{key:copy.deepcopy(value) for key,value in self.native.args.items() if key in ('unit','steps','sampleTimes','playback')}} + self.number=0 + def call(self,args):return m.handle_mechanical(self.app,copy.deepcopy(args)) + def create(self): + result=self.call(self.create_args) + self.assertTrue(result['success'],result) + self.key=result['data']['previewId'] + return result + def args(self,op,**extra): + self.number+=1 + entry=self.control.entries[self.key] + return {'op':'exploded_preview_'+op,'sessionId':m.SESSION_ID,'operationId':'action-'+str(self.number), + 'target':self.target,'previewId':self.key,'expectedGeneration':entry['generation'],**extra} + def inspect(self):return self.call({'op':'exploded_preview_inspect','sessionId':m.SESSION_ID,'previewId':self.key}) + + def test_native_revision_effects_preserve_creation_receipt_and_disposal_reuse_chain(self): + self.native.enable_revision_effects() + creation_record = copy.deepcopy(self.h.store().lookup(m.SESSION_ID, 'document-created')) + original_target = copy.deepcopy(self.target) + created = self.create(); first_key = self.key + self.assertNotEqual(created['data']['lastVerifiedPresentationRevision'], original_target['revision']) + for index in (2, 0, 1): + result = self.call(self.args('sample', sampleIndex=index)) + self.assertTrue(result['success'], result) + self.assertEqual(result['data']['presentationTarget'], original_target) + self.assertEqual(result['data']['lastVerifiedPresentationRevision'], self.design.rev) + disposed = self.call(self.args('dispose')) + self.assertTrue(disposed['success'], disposed) + self.assertEqual(disposed['data']['observedEmptyTarget']['revision'], self.design.rev) + self.app.activeDocument = self.doc + reused = self.call({**self.create_args, 'operationId': 'reuse-after-dispose'}) + self.assertTrue(reused['success'], reused) + self.assertEqual(reused['data']['predecessorPreviewId'], first_key) + self.assertEqual(reused['data']['presentationTarget'], original_target) + self.assertEqual(self.h.store().lookup(m.SESSION_ID, 'document-created'), creation_record) + self.assertEqual(self.call(self.create_args)['data'], created['data']) + + def test_same_empty_model_with_unowned_revision_does_not_authorize_reuse(self): + self.native.enable_revision_effects() + self.create() + disposed = self.call(self.args('dispose')) + self.assertTrue(disposed['success'], disposed) + self.root.revisionId = 'foreign-revision-after-dispose' + self.app.activeDocument = self.doc + refused = self.call({**self.create_args, 'operationId': 'reuse-stale-disposed'}) + self.assertEqual(refused['errorCode'], 'stale_revision') + self.assertEqual(self.root.customGraphicsGroups.count, 0) + self.assertEqual(len(self.control.entries), 1) + + def test_first_claim_still_requires_original_creation_revision(self): + self.root.revisionId = 'intervening-edit' + refused = self.call(self.create_args) + self.assertEqual(refused['errorCode'], 'stale_revision') + self.assertEqual(self.control.entries, {}) + self.assertEqual(self.root.customGraphicsGroups.count, 0) + + def test_partial_sample_revision_cleanup_preserves_last_verified_pose_and_history(self): + self.native.enable_revision_effects() + self.create() + sampled = self.call(self.args('sample', sampleIndex=1)) + self.assertTrue(sampled['success'], sampled) + entry = self.control.entries[self.key] + entry['backend'].nodes[2]['group'].fail_transform = True + request = self.args('sample', sampleIndex=2) + failed = self.call(request) + self.assertFalse(failed['success']) + self.assertEqual(failed['preview']['pose'], 'unverified') + self.assertIsNone(failed['preview']['sampleIndex']) + self.assertEqual(failed['preview']['lastVerifiedSampleIndex'], 1) + self.assertTrue(failed['preview']['presentationFailureObservation']['cleanupOnly']) + failure_record = copy.deepcopy(self.h.store().lookup(m.SESSION_ID, request['operationId'])) + disposed = self.call(self.args('dispose')) + self.assertTrue(disposed['success'], disposed) + self.assertEqual(self.h.store().lookup(m.SESSION_ID, request['operationId']), failure_record) + self.assertEqual(self.root.customGraphicsGroups.count, 0) + + def test_public_lifecycle_and_historical_replay_after_disposal(self): + created=self.create();self.assertIs(self.app.activeDocument,self.f.doc) + self.assertEqual(created['data']['generation'],1);self.assertEqual(self.root.customGraphicsGroups.count,1) + request=self.args('sample',sampleIndex=2) + sampled=self.call(request);self.assertTrue(sampled['success'],sampled) + self.assertEqual(sampled['data']['generation'],2) + replay=self.call(request);self.assertTrue(replay['replayed']);self.assertEqual(replay['data'],sampled['data']) + shown=self.call(self.args('show'));self.assertTrue(shown['success'],shown);self.assertIs(self.app.activeDocument,self.doc) + current=self.inspect()['data'];self.assertTrue(current['usable']);self.assertEqual(current['receiptScope'],'live_observation') + self.assertTrue(current['history']['terminalRecorded']) + disposed=self.call(self.args('dispose'));self.assertTrue(disposed['success'],disposed) + self.assertEqual(disposed['data']['state'],'disposed');self.assertEqual(self.root.customGraphicsGroups.count,0) + self.assertTrue(self.doc.isValid);self.assertEqual(disposed['data']['documentDisposition'],'open_for_caller') + replay_create=self.call(self.create_args);self.assertTrue(replay_create['replayed']) + self.assertEqual(replay_create['data'],created['data']);self.assertEqual(self.root.customGraphicsGroups.count,0) + live=self.inspect()['data'];self.assertEqual(live['state'],'disposed');self.assertFalse(live['nativeResourceRights']) + self.assertEqual(self.call(self.args('dispose'))['errorCode'],'preview_state') + + def test_conflicting_digest_and_stale_generation_do_not_write(self): + self.create();entry=self.control.entries[self.key];before=copy.deepcopy(entry['backend'].receipt()) + conflict=self.call({**self.create_args,'expectedPlanId':'changed'}) + self.assertEqual(conflict['errorCode'],'operation_conflict');self.assertEqual(conflict['outcome'],'not_executed') + stale=self.call(self.args('sample',sampleIndex=1,expectedGeneration=0)) + self.assertEqual(stale['errorCode'],'preview_generation_conflict') + self.assertEqual(entry['backend'].receipt(),before);self.assertEqual(entry['generation'],1) + + def test_failed_durable_admission_has_no_activation_reservation_or_graphics(self): + with patch.object(self.h.store(),'admit',side_effect=journal.JournalError('operation_storage_failed','disk fault')): + result=self.call(self.create_args) + self.assertFalse(result['success']);self.assertEqual(result['outcome'],'unknown') + self.assertEqual(self.control.entries,{});self.assertIs(self.app.activeDocument,self.doc) + self.assertEqual(self.root.customGraphicsGroups.count,0) + + def test_executing_duplicate_never_enters_controller(self): + descriptor,reply=self.h.admit(self.create_args,'first-dispatch');self.assertIsNone(reply) + self.assertIsNone(self.h.start(descriptor,self.create_args,{'documentId':'presentation'})) + result=self.call(self.create_args) + self.assertEqual(result['errorCode'],'operation_in_progress');self.assertEqual(self.control.entries,{}) + self.assertEqual(self.root.customGraphicsGroups.count,0) + + def test_creation_history_is_server_proof_not_observed_previous_document(self): + record=self.h.store().lookup(m.SESSION_ID,'document-created') + self.assertEqual(record['observedDocument']['documentId'],'owned-offline-document') + created=self.create();self.assertTrue(created['success']) + self.assertNotEqual(record['observedDocument']['documentId'],created['data']['presentationTarget']['documentId']) + + def test_foreign_creation_identity_saved_nonblank_and_stale_targets_refuse(self): + variants=[{'creationOperationId':'unknown'}, {'target':{'documentId':'foreign','revision':'blank'}}, + {'sourceTarget':{'documentId':'source','revision':'old'}}, {'sourceTarget':self.target}] + for index,change in enumerate(variants): + result=self.call({**self.create_args,'operationId':'refusal-'+str(index),**change}) + self.assertFalse(result['success']);self.assertEqual(self.control.entries,{}) + self.doc.creationId='different' + self.assertFalse(self.call({**self.create_args,'operationId':'wrong-native-id'})['success']) + self.doc.creationId='native-presentation';self.doc.isSaved=True + self.assertFalse(self.call({**self.create_args,'operationId':'saved'})['success']) + self.doc.isSaved=False;self.root.bRepBodies.values.append(body()) + self.assertFalse(self.call({**self.create_args,'operationId':'nonblank'})['success']) + self.assertEqual(self.control.entries,{});self.assertEqual(self.root.customGraphicsGroups.count,0) + + def test_wrong_expected_plan_after_activation_releases_only_known_no_graphics_reservation(self): + result=self.call({**self.create_args,'expectedPlanId':'wrong'}) + self.assertFalse(result['success']);self.assertEqual(result['errorCode'],'preview_plan_mismatch') + self.assertEqual(result['effectState'],'activation_only');self.assertFalse(result['rollbackClaimed']) + entry=next(iter(self.control.entries.values())) + self.assertEqual(entry['state'],'disposed');self.assertEqual(entry['generation'],1) + self.assertEqual(entry['disposition'],'reservation_released_no_graphics') + self.assertFalse(entry['factoryMayHaveBeenIssued']);self.assertEqual(self.control.claims,{}) + self.assertIs(self.app.activeDocument,self.f.doc);self.assertEqual(self.root.customGraphicsGroups.count,0) + self.app.activeDocument=self.doc + self.create_args['operationId']='fresh-create' + self.create() + + def test_unknown_graphics_creation_does_not_use_zero_count_release(self): + with patch.object(p.Preview,'create',side_effect=RuntimeError('factory outcome unknown')): + result=self.call(self.create_args) + self.assertFalse(result['success']);entry=next(iter(self.control.entries.values())) + self.assertEqual(entry['state'],'retained_failure');self.assertTrue(entry['factoryMayHaveBeenIssued']) + self.assertEqual(len(self.control.claims),1);self.assertEqual(self.root.customGraphicsGroups.count,0) + self.key=entry['previewId'] + result=self.call(self.args('dispose'));self.assertFalse(result['success']);self.assertEqual(len(self.control.claims),1) + + def test_partial_sample_retains_then_disposes_after_source_closes(self): + self.create();entry=self.control.entries[self.key] + entry['backend'].nodes[2]['group'].fail_transform=True + result=self.call(self.args('sample',sampleIndex=2)) + self.assertFalse(result['success']);self.assertEqual(entry['state'],'retained_failure') + self.assertFalse(entry['nativeWorkInFlight']);self.assertTrue(entry['lastReturned']) + self.f.doc.isValid=False;self.app.documents.values.remove(self.f.doc) + disposed=self.call(self.args('dispose'));self.assertTrue(disposed['success'],disposed) + self.assertEqual(self.root.customGraphicsGroups.count,0);self.assertTrue(self.doc.isValid) + + def test_terminal_storage_failure_allows_only_explicit_exact_owned_disposal(self): + store=self.h.store();real=store.transition + def fail_finish(*args,**kwargs): + if args[3]=='succeeded' and args[1]=='preview-created':raise journal.JournalError('operation_storage_failed','lost terminal commit') + return real(*args,**kwargs) + with patch.object(store,'transition',side_effect=fail_finish):result=self.call(self.create_args) + self.assertEqual(result['errorCode'],'operation_reconciliation_required') + self.key=next(iter(self.control.entries));entry=self.control.entries[self.key] + self.assertTrue(entry['lastReturned']);self.assertEqual(self.root.customGraphicsGroups.count,1) + original=store.lookup(m.SESSION_ID,'preview-created') + self.assertEqual(original['phase'],'executing') + self.assertFalse(self.inspect()['data']['usable']) + self.assertEqual(self.call(self.args('sample',sampleIndex=1))['errorCode'],'preview_history_unresolved') + self.assertEqual(self.call(self.args('show'))['errorCode'],'preview_history_unresolved') + disposed=self.call(self.args('dispose'));self.assertTrue(disposed['success'],disposed) + self.assertTrue(disposed['data']['priorOperationUnresolved']) + self.assertEqual(disposed['data']['priorUnresolvedOperation']['operationId'],'preview-created') + self.assertIn('observedResultDigest',disposed['data']['priorUnresolvedOperation']) + self.assertEqual(store.lookup(m.SESSION_ID,'preview-created'),original) + self.assertEqual(self.root.customGraphicsGroups.count,0) + again=self.call(self.create_args);self.assertFalse(again['success']);self.assertEqual(self.root.customGraphicsGroups.count,0) + + def test_unknown_history_cannot_authorize_disposal_of_inflight_or_foreign_descendant(self): + self.create();entry=self.control.entries[self.key] + entry['nativeWorkInFlight']=True + self.assertFalse(self.call(self.args('dispose'))['success']) + entry['nativeWorkInFlight']=False;entry['backend'].root.values.append(Graphics(entry['backend'].root)) + self.assertFalse(self.call(self.args('dispose'))['success']);self.assertEqual(self.root.customGraphicsGroups.count,1) + + def test_dispose_commit_failure_preserves_unknown_and_blocks_replacement_create(self): + self.create();request=self.args('dispose');store=self.h.store();real=store.transition + def fail(*args,**kwargs): + if args[3]=='succeeded' and args[1]==request['operationId']:raise journal.JournalError('operation_storage_failed','terminal fault') + return real(*args,**kwargs) + with patch.object(store,'transition',side_effect=fail):result=self.call(request) + self.assertEqual(result['errorCode'],'operation_reconciliation_required');self.assertEqual(self.root.customGraphicsGroups.count,0) + self.assertFalse(self.inspect()['data']['history']['terminalRecorded']) + self.app.activeDocument=self.doc + result=self.call({**self.create_args,'operationId':'replacement'}) + self.assertEqual(result['errorCode'],'preview_history_unresolved');self.assertEqual(self.root.customGraphicsGroups.count,0) + self.assertFalse(self.call(request)['success']) # No second delete. + + def test_invalid_indices_foreign_active_doc_and_capacity_fail_without_effect(self): + with patch.object(controller,'MAX_ACTIVE',0): + result=self.call({**self.create_args,'operationId':'capacity'}) + self.assertEqual(result['errorCode'],'preview_capacity');self.assertEqual(self.control.entries,{}) + self.create();entry=self.control.entries[self.key];generation=entry['generation'] + for value in (-1,True,1.5,64,3): + result=self.call(self.args('sample',sampleIndex=value));self.assertFalse(result['success']) + self.assertEqual(entry['generation'],generation) + foreign=Entity();self.app.activeDocument=foreign + result=self.call(self.args('show'));self.assertEqual(result['errorCode'],'preview_activation_refused') + self.assertIs(self.app.activeDocument,foreign);self.assertEqual(entry['generation'],generation) + + def test_restart_does_not_rebind_registry_or_replay_native_effects(self): + original=self.create();key=self.key + with patch.object(controller,'_controller',controller.Controller()): + result=self.inspect();self.assertEqual(result['errorCode'],'preview_session_expired') + replay=self.call(self.create_args);self.assertTrue(replay['replayed']);self.assertEqual(replay['data'],original['data']) + self.assertEqual(self.root.customGraphicsGroups.count,1) + self.assertEqual(self.call(self.args('dispose'))['errorCode'],'preview_session_expired') + self.assertIn(key,self.control.entries) + + def test_worker_and_old_session_guard_before_native_getters(self): + class Trap: + def __getattribute__(self,key):raise AssertionError('native access '+key) + errors=[] + def run(): + for method,args in [(self.control.create,{}),(self.control.inspect,{}), + (lambda app,args,h:self.control.action('dispose',app,args,h),{})]: + try:method(Trap(),args,self.h) + except Exception as error:errors.append(error) + worker=threading.Thread(target=run);worker.start();worker.join() + self.assertEqual(len(errors),3);self.assertTrue(all(getattr(e,'code',None)=='wrong_thread' for e in errors)) + result=self.call({**self.create_args,'sessionId':'old'}) + self.assertEqual(result['errorCode'],'session_mismatch');self.assertEqual(self.control.entries,{}) + + def test_uncertain_delete_is_never_reissued_with_fresh_ids_or_lost_terminal_storage(self): + for mode in ('false','throw','deleted_readback_failed','false_finish_failure'): + with self.subTest(mode=mode): + self.app.activeDocument=self.doc + self.create_args['operationId']='create-'+mode + self.create();entry=self.control.entries[self.key];group=entry['backend'].root + calls=[];original=group.deleteMe + def deletion(): + calls.append(1) + if mode=='throw':raise RuntimeError('delete outcome unknown') + if mode=='deleted_readback_failed': + result=original();self.design.rev='changed-after-delete';return result + return False + store=self.h.store();transition=store.transition + request=self.args('dispose') + def finish(*args,**kwargs): + if mode=='false_finish_failure' and args[1]==request['operationId'] and args[3]=='failed': + raise journal.JournalError('operation_storage_failed','lost failed terminal') + return transition(*args,**kwargs) + with patch.object(group,'deleteMe',side_effect=deletion),patch.object(store,'transition',side_effect=finish): + failed=self.call(request) + self.assertFalse(failed['success']) + again=self.call(self.args('dispose')) + self.assertEqual(again['errorCode'],'preview_disposal_unresolved') + self.assertEqual(len(calls),1) + self.assertEqual(entry['disposalOutcome'],'unknown') + # Offline fixture reset only, never production cleanup/retry. + self.root.customGraphicsGroups.values.clear();self.design.rev='blank' + self.control=controller.Controller();controller._controller=self.control + + def test_combined_retained_json_budget_refuses_before_any_graphics_factory(self): + prepared=self.native.prepared();lease=self.native.lease();preview=p.Preview(prepared,lease) + individual=max(len(prepared._result_json.encode()),len(preview._sample_json.encode())) + combined=controller.retained_json_bytes(prepared,preview) + self.assertGreater(combined,individual) + with patch.object(controller,'MAX_PLAN_BYTES',individual),patch.object(p.Preview,'create') as native_create: + result=self.call(self.create_args) + self.assertEqual(result['errorCode'],'preview_capacity');native_create.assert_not_called() + entry=next(iter(self.control.entries.values())) + self.assertEqual(entry['state'],'disposed');self.assertEqual(entry['planBytes'],0) + self.assertEqual(self.control.claims,{}) + self.assertEqual(self.root.customGraphicsGroups.count,0) + aliased=NS(_a_json='same-string') + aliased._b_json=aliased._a_json + self.assertEqual(controller.retained_json_bytes(aliased),len('same-string')) + + def test_partial_later_sample_reports_unverified_pose_with_last_good_index(self): + self.create();self.assertTrue(self.call(self.args('sample',sampleIndex=1))['success']) + entry=self.control.entries[self.key];entry['backend'].nodes[2]['group'].fail_transform=True + failed=self.call(self.args('sample',sampleIndex=2)) + self.assertFalse(failed['success']);receipt=failed['preview'] + self.assertEqual(receipt['pose'],'unverified');self.assertIsNone(receipt['sampleIndex']) + self.assertEqual(receipt['lastVerifiedSampleIndex'],1) + live=self.inspect()['data'];self.assertEqual(live['pose'],'unverified') + self.assertFalse(live['usable']) + + def test_disposed_inspection_is_explicit_tombstone_after_close_or_document_reuse(self): + self.create();self.assertTrue(self.call(self.args('dispose'))['success']) + old_key=self.key + self.doc.isValid=False;self.app.documents.values.remove(self.doc) + result=self.inspect()['data'] + self.assertEqual(result['receiptScope'],'registry_tombstone') + self.assertEqual(result['nativeObservation'],'NOT_PERFORMED') + self.assertFalse(result['observationComplete']);self.assertEqual(result['documentDisposition'],'unknown_current') + self.assertEqual(result['lastKnownDocumentDisposition'],'open_for_caller') + self.doc.isValid=True;self.app.documents.values.append(self.doc);self.app.activeDocument=self.doc + self.create_args['operationId']='new-preview-same-doc';self.create() + self.assertEqual(self.root.customGraphicsGroups.count,1) + result=self.call({'op':'exploded_preview_inspect','sessionId':m.SESSION_ID,'previewId':old_key})['data'] + self.assertEqual(result['receiptScope'],'registry_tombstone');self.assertFalse(result['nativeResourceRights']) + self.assertEqual(self.root.customGraphicsGroups.count,1) + + def test_two_admitted_generation_claims_apply_only_first_sample(self): + self.create() + first=self.args('sample',sampleIndex=1);second=self.args('sample',sampleIndex=2) + a,reply=self.h.admit(first,'first');self.assertIsNone(reply) + b,reply=self.h.admit(second,'second');self.assertIsNone(reply) + result=m.handle_mechanical(self.app,first,admission=a);self.assertTrue(result['success'],result) + rejected=m.handle_mechanical(self.app,second,admission=b) + self.assertEqual(rejected['errorCode'],'preview_generation_conflict') + entry=self.control.entries[self.key] + self.assertEqual(entry['generation'],2);self.assertEqual(entry['sampleIndex'],1) + self.assertEqual(self.h.store().lookup(m.SESSION_ID,second['operationId'])['phase'],'failed') + + def test_contradictory_terminal_result_cannot_make_live_preview_usable(self): + self.create();lookup=self.h.store().lookup + def conflicting(session,operation): + record=lookup(session,operation) + if operation=='preview-created':record['result']['data']['generation']=999 + return record + with patch.object(self.h.store(),'lookup',side_effect=conflicting): + result=self.inspect()['data'] + self.assertFalse(result['usable']);self.assertFalse(result['history']['terminalRecorded']) + rejected=self.call(self.args('sample',sampleIndex=1)) + self.assertEqual(rejected['errorCode'],'preview_history_unresolved') + self.assertEqual(self.control.entries[self.key]['generation'],1) + + def test_controller_is_direct_write_and_all_schemas_present(self): + self.assertEqual(len(m.OPERATIONS),61);self.assertEqual(set(m.OPERATIONS),set(m.SCHEMA)) + for name in controller.WRITES:self.assertIn(name,m.WRITES) + self.assertNotIn('exploded_preview_inspect',m.WRITES) + with patch.object(m.transactions,'execute',side_effect=AssertionError('PTransaction must not wrap presentation effects')): + self.create();self.assertTrue(self.call(self.args('dispose'))['success']) + + +if __name__=='__main__':unittest.main() diff --git a/tests/test_exploded_preview_prototype.py b/tests/test_exploded_preview_prototype.py new file mode 100644 index 0000000..ac47d0b --- /dev/null +++ b/tests/test_exploded_preview_prototype.py @@ -0,0 +1,510 @@ +"""Offline native-adapter fault tests; no Fusion, graphics display or host access.""" +import copy +import importlib +import threading +from types import SimpleNamespace as NS +import unittest +from unittest.mock import patch +from test_mechanical import c, Entity, Collection, MeshCollection +from test_mechanical_exploded_view import fixture as assembly_fixture, pose + +p = importlib.import_module('mechanical_test_commands.mechanical.exploded_preview_prototype') +plan = importlib.import_module('mechanical_test_commands.mechanical.exploded_view') +real_revision = c.revision + + +def enrich_blank(document, design, root): + """Native-like blank data for the actual core aggregate and presentation witness.""" + document.attributes = Collection() + document.name = 'Untitled' + document.creationId = 'native-presentation' + design.attributes = Collection() + design.designType = design.designIntent = 0 + design.isConfiguredDesign = design.isConfiguration = False + design.configurationRowId = '' + design.timeline.markerPosition = 0 + design.snapshots = Collection() + design.snapshots.hasPendingSnapshot = False + for name in ('allOccurrences', 'joints', 'asBuiltJoints', 'jointOrigins', 'rigidGroups', 'motionLinks', + 'constructionAxes', 'constructionPlanes', 'constructionPoints', 'modelParameters', 'canvases', 'decals'): + if not hasattr(root, name): + setattr(root, name, Collection()) + for name in ('isBodiesFolderLightBulbOn', 'isConstructionFolderLightBulbOn', 'isOriginFolderLightBulbOn', + 'isSketchFolderLightBulbOn', 'isJointsFolderLightBulbOn', 'isJointOriginsFolderLightBulbOn', + 'isCanvasFolderLightBulbOn', 'isDecalFolderLightBulbOn'): + setattr(root, name, True) + root.attributes = Collection() + root.name = 'Presentation' + root.partNumber = root.description = '' + root.material = None + for field, kind, vector in ( + ('xConstructionAxis', 'ConstructionAxis', (1., 0., 0.)), + ('yConstructionAxis', 'ConstructionAxis', (0., 1., 0.)), + ('zConstructionAxis', 'ConstructionAxis', (0., 0., 1.)), + ('xYConstructionPlane', 'ConstructionPlane', (0., 0., 1.)), + ('xZConstructionPlane', 'ConstructionPlane', (0., 1., 0.)), + ('yZConstructionPlane', 'ConstructionPlane', (1., 0., 0.)), + ('originConstructionPoint', 'ConstructionPoint', None)): + xyz = NS(x=0., y=0., z=0.) + geometry = xyz if vector is None else NS(origin=xyz, direction=NS(x=vector[0], y=vector[1], z=vector[2]), normal=NS(x=vector[0], y=vector[1], z=vector[2])) + setattr(root, field, Entity(objectType='adsk::fusion::' + kind, name=field, component=root, + nativeObject=None, isDeletable=False, isLightBulbOn=False, isVisible=False, + geometry=geometry, attributes=Collection())) + + +class PresentationDesign(Entity): + @property + def rev(self): + return self.revision_override if self.revision_override is not None else real_revision(self) + @rev.setter + def rev(self, value): + self.revision_override = None if value == 'blank' else value + + +class Matrix: + def __init__(self, values=None): self.values = list(values or p.IDENTITY) + def setWithArray(self, values): self.values = list(values); return True + def asArray(self): return list(self.values) + + +def body(component=None, transient=False): + points = [(0.,0.,0.), (1.,0.,0.), (0.,1.,0.), (0.,0.,1.)] + result = Entity(objectType='adsk::fusion::BRepBody', isTransient=transient, isSolid=True, + parentComponent=component, nativeObject=None, isVisible=True, volume=1/6, + boundingBox=NS(minPoint=NS(x=0.,y=0.,z=0.), maxPoint=NS(x=1.,y=1.,z=1.)), + faces=Collection([object()]*4), edges=Collection([object()]*6), + vertices=Collection([Entity(objectType="adsk::fusion::BRepVertex",geometry=NS(x=x,y=y,z=z)) for x,y,z in points])) + def proxy(occ): + return Entity(objectType='adsk::fusion::BRepBody', nativeObject=result, assemblyContext=occ, + isVisible=getattr(occ,'bodyVisible',True)) + result.createForAssemblyContext=proxy + return result + + +class Graphics(Collection): + def __init__(self, parent=None, root_collection=None): + super().__init__(); self.parent=parent; self.root_collection=root_collection + self.isValid=True; self.id=''; self.isVisible=True; self.isSelectable=True; self.isChildrenSelectable=True + self.transform=Matrix(); self.objectType='adsk::fusion::CustomGraphicsGroup' + self.fail_transform=False; self.delete_ack=True; self.ignore_delete=False; self.null_body=False + self.ignore_transform=False; self._transform=Matrix() + @property + def transform(self): return self._transform + @transform.setter + def transform(self, value): + if getattr(self,'fail_transform',False):raise RuntimeError('native transform refused') + if not getattr(self,'ignore_transform',False):self._transform=Matrix(value.asArray()) + self.effect() + def effect(self): + if self.root_collection is not None: + if self.root_collection.on_effect is not None:self.root_collection.on_effect() + elif isinstance(self.parent, Graphics):self.parent.effect() + def addGroup(self): + group=Graphics(self);self.values.append(group);self.effect();return group + def addBRepBody(self,native): + if self.null_body:return None + copied=copy.deepcopy(native);copied.isTransient=True;copied.parentComponent=None + graphic=Entity(objectType='adsk::fusion::CustomGraphicsBRepBody',parent=self,isVisible=True, + transform=Matrix(),bRepBody=copied) + self.values.append(graphic);self.effect();return graphic + def deleteMe(self): + if self.delete_ack and not self.ignore_delete: + self.root_collection.values.remove(self);self.isValid=False;self.effect() + return self.delete_ack + + +class Roots(Collection): + def __init__(self,component):super().__init__();self.component=component;self.fail=False;self.on_effect=None + def add(self): + if self.fail:return None + value=Graphics(self.component,self);self.values.append(value);value.effect();return value + + +class PreviewTests(unittest.TestCase): + def setUp(self): + c._documents.clear();c._entities.clear();self.f=assembly_fixture() + for comp in (self.f.root,self.f.shared,self.f.pin): + comp.meshBodies=Collection();comp.bRepBodies=Collection([body(comp)]) + for occ in self.f.root.allOccurrences.values: + occ.isVisible=True;occ.timelineObject=NS(isSuppressed=False) + self.f.design.parentDocument=self.f.doc + self.f.design.rev='source-rev' + self.f.doc.doc_id='source';self.f.doc.objectType='adsk::core::Document' + self.f.value={'documentId':'source','revision':'source-rev'} + presentation_doc=Entity(objectType='adsk::core::Document',isSaved=False,isModified=False,doc_id='presentation') + root=Entity(objectType='adsk::fusion::Component',bRepBodies=Collection(),meshBodies=MeshCollection(), + occurrences=Collection(),sketches=Collection(),features=Collection(),revisionId='blank') + root.customGraphicsGroups=Roots(root) + design=PresentationDesign(parentDocument=presentation_doc,rootComponent=root,allComponents=Collection([root]), + allParameters=Collection(),timeline=NS(count=0),revision_override=None) + root.parentDesign=design + enrich_blank(presentation_doc, design, root) + self.doc,self.design,self.root=presentation_doc,design,root + self.app=NS(documents=Collection([self.f.doc,self.doc]),activeDocument=self.f.doc) + def activate(doc):self.app.activeDocument=doc;return True + self.f.doc.activate=lambda:activate(self.f.doc);self.doc.activate=lambda:activate(self.doc) + patches=[patch.object(p,'document_identity',side_effect=lambda d:d.doc_id), + patch.object(p,'revision',side_effect=lambda d:d.rev), + patch.object(p,'target',return_value=(self.f.doc,self.f.design,self.f.value)), + patch.object(plan,'target',return_value=(self.f.doc,self.f.design,self.f.value)), + patch.object(plan,'configuration_snapshot',return_value={'configured':False,'isConfiguration':False,'configurationRowId':'','table':None,'complete':True}), + patch.object(p.adsk.core,'Matrix3D',NS(create=Matrix),create=True)] + for item in patches:item.start();self.addCleanup(item.stop) + self.args={'op':'exploded_view_plan','sessionId':p.SESSION_ID,'target':self.f.value,'unit':'mm', + 'sampleTimes':[.5], 'steps':[{'durationSeconds':1,'moves':[ + {'occurrence':c.ref(self.f.a,self.f.value),'direction':[1,0,0],'distance':10,'frame':'local'}, + {'occurrence':c.ref(self.f.ap,self.f.value),'direction':[1,0,0],'distance':5,'frame':'parent'}]}]} + self.generated=plan.exploded_view_plan(self.app,self.args) + self.receipt={'success':True,'ownerId':'native-owned-run','sessionId':p.SESSION_ID,'documentId':'presentation'} + def prepared(self):return p.prepare(self.app,self.args,self.generated['planId']) + def lease(self):return p.OwnedPresentationLease(self.app,self.doc,self.design,'native-owned-run',lambda:copy.deepcopy(self.receipt)) + def preview(self):return p.Preview(self.prepared(),self.lease()) + + def enable_revision_effects(self): + effects = [] + def changed(): + effects.append(1) + self.root.revisionId = 'owned-graphics-' + str(len(effects)) + self.root.customGraphicsGroups.on_effect = changed + return effects + + def test_owned_effects_advance_only_private_presentation_revision(self): + effects = self.enable_revision_effects() + preview = self.preview() + initial = copy.deepcopy(preview.lease.verified_state) + preview.create() + for sequence, index in enumerate((0, 2, 0), 2): + before = preview.lease.model_revision + preview.sample(index) + self.assertNotEqual(preview.lease.model_revision, before) + self.assertEqual(preview.lease.model_revision, real_revision(self.design)) + self.assertEqual(preview.lease.sequence, sequence) + self.assertEqual(preview.lease.verified_state['witnessDigest'], initial['witnessDigest']) + before_show = preview.lease.model_revision + preview.activate() + self.assertEqual(preview.lease.model_revision, before_show) + preview.dispose() + self.assertEqual(preview.lease.sequence, 5) + self.assertEqual(preview.lease.model_revision, real_revision(self.design)) + self.assertNotEqual(preview.lease.model_revision, initial['revision']) + self.assertEqual(self.f.design.rev, 'source-rev') + self.assertGreater(len(effects), 5) + + def test_native_top_group_parent_shape_does_not_replace_inventory_proof(self): + preview = self.preview() + preview.create() + preview.root.parent = Graphics() # Measured Fusion top-level getter shape. + preview.sample(2) + exact = preview.root + foreign = Graphics(self.root, self.root.customGraphicsGroups) + self.root.customGraphicsGroups.values[0] = foreign + with self.assertRaises(c.MechanicalError): + preview.dispose() + self.assertTrue(exact.isValid) + self.assertTrue(foreign.isValid) + + def test_unbracketed_opaque_revision_change_is_never_adopted(self): + preview = self.preview(); preview.create() + verified = copy.deepcopy(preview.lease.verified_state) + self.root.revisionId = 'unrelated-edit' + self.assertEqual(p.presentation_observation(self.doc, self.design)['witnessDigest'], verified['witnessDigest']) + for operation in (lambda: preview.sample(0), preview.activate, preview.dispose): + with self.assertRaises(c.MechanicalError):operation() + self.assertEqual(preview.lease.verified_state, verified) + self.assertFalse(preview._delete_issued) + + def test_same_count_external_attributes_and_origin_changes_refuse(self): + preview = self.preview(); preview.create() + for owner in (self.doc, self.design, self.root, self.root.originConstructionPoint): + with self.subTest(owner=type(owner).__name__): + before = real_revision(self.design) + owner.attributes.values.append(Entity(objectType='adsk::core::Attribute', groupName='AnotherOwner', name='note', value='changed')) + if owner is not self.root:self.assertEqual(real_revision(self.design), before) + with self.assertRaises(c.MechanicalError):preview.sample(0) + owner.attributes.values.clear() + self.root.xConstructionAxis.geometry.direction.x = .5 + with self.assertRaises(c.MechanicalError):preview.dispose() + self.assertFalse(preview._delete_issued) + + def test_mid_effect_metadata_change_cannot_be_rebased_or_cleaned_up(self): + self.enable_revision_effects() + preview = self.preview(); preview.create() + previous = copy.deepcopy(preview.lease.verified_state) + original = Graphics.transform.fset + def corrupt(group, matrix): + original(group, matrix) + self.doc.attributes.values[:] = [Entity(objectType='adsk::core::Attribute', groupName='user', name='note', value='unrelated change')] + with patch.object(Graphics, 'transform', property(Graphics.transform.fget, corrupt)), self.assertRaises(c.MechanicalError): + preview.sample(2) + self.assertEqual(preview.lease.verified_state, previous) + self.assertIsNone(preview.lease.cleanup_state) + self.assertFalse(preview.lease.failure_observation['cleanupOnly']) + with self.assertRaises(c.MechanicalError):preview.dispose() + self.assertFalse(preview._delete_issued) + + def test_partial_effect_revision_is_cleanup_only_and_never_ready(self): + self.enable_revision_effects() + preview = self.preview(); preview.create(); preview.sample(1) + last_good = copy.deepcopy(preview.lease.verified_state) + preview.nodes[2]['group'].fail_transform = True + with self.assertRaises(RuntimeError):preview.sample(2) + self.assertEqual(preview.lease.verified_state, last_good) + self.assertNotEqual(preview.lease.cleanup_state['revision'], last_good['revision']) + self.assertTrue(preview.lease.failure_observation['cleanupOnly']) + with self.assertRaises(c.MechanicalError):preview.lease.check() + self.assertEqual(preview.lease.check(cleanup=True), preview.lease.cleanup_state) + self.f.doc.isValid = False + self.app.documents.values.remove(self.f.doc) + self.assertEqual(preview.dispose()['state'], 'disposed') + self.assertEqual(preview.lease.model_revision, real_revision(self.design)) + + def test_changed_flags_during_owned_effect_are_observed_not_reset(self): + preview = self.preview(); preview.create() + before = preview.lease.model_revision + self.root.customGraphicsGroups.on_effect = lambda: setattr(self.doc, 'isModified', True) + with self.assertRaises(c.MechanicalError) as caught:preview.sample(2) + self.assertEqual(caught.exception.details['presentationAfter']['flags'], [False, True]) + self.assertEqual(caught.exception.details['presentationBefore']['flags'], [False, False]) + self.assertFalse(caught.exception.details['rollbackClaimed']) + self.assertTrue(self.doc.isModified) + self.assertEqual(preview.lease.model_revision, before) + with self.assertRaises(c.MechanicalError):preview.dispose() + + def test_throwing_witness_getter_retains_primary_error_and_flag_change(self): + class ThrowingAttributes: + @property + def count(inner): + self.doc.isModified = True + raise RuntimeError('attribute inventory failed after dirtying') + self.doc.attributes = ThrowingAttributes() + with self.assertRaises(c.MechanicalError) as caught:p.presentation_observation(self.doc, self.design) + self.assertIn('attribute inventory failed', str(caught.exception)) + self.assertEqual(caught.exception.details['presentationObservation']['flagEffect'], 'changed') + self.assertFalse(caught.exception.details['rollbackClaimed']) + + def test_bounded_full_aggregate_and_strict_blank_witness(self): + original = p.presentation_observation(self.doc, self.design) + self.design.rev = 'different-aggregate' + with self.assertRaises(c.MechanicalError) as caught:p.presentation_observation(self.doc, self.design) + self.assertEqual(caught.exception.code, 'preview_revision_unavailable') + self.design.rev = 'blank' + for owner, name, value in ((self.doc, 'isModified', 0), (self.root, 'constructionPoints', Collection([object()])), + (self.design, 'isConfiguredDesign', True), (self.doc, 'attributes', NS(count=257)), + (self.root, 'isOriginFolderLightBulbOn', 1)): + previous = getattr(owner, name) + setattr(owner, name, value) + with self.subTest(name=name), self.assertRaises(c.MechanicalError):p.presentation_observation(self.doc, self.design) + setattr(owner, name, previous) + self.assertEqual(p.presentation_observation(self.doc, self.design), original) + + def test_flat_definition_copies_and_world_translation_applied_once(self): + preview=self.preview();self.app.activeDocument=self.doc + receipt=preview.create() + self.assertIs(self.app.activeDocument,self.f.doc) + self.assertEqual(receipt['bodyCopyCount'],5) + self.assertFalse(preview.root.isSelectable);self.assertFalse(preview.root.isChildrenSelectable) + self.assertEqual(len(preview.root.values),5) + by_key={r['instanceId']:r for r in self.generated['plan']['instances']} + for node in preview.nodes: + self.assertIs(node['group'].parent,preview.root) + self.assertEqual(p.matrix_values(node['graphics'][0]),p.IDENTITY) + self.assertTrue(node['graphics'][0].bRepBody.isTransient) + if node['instanceId'] is not None: + expected=list(by_key[node['instanceId']]['homeMatrix']) + for i in (3,7,11):expected[i]/=10 + self.assertEqual(p.matrix_values(node['group']),expected) + self.assertEqual(self.f.doc.isModified,True);self.assertEqual(self.f.design.rev,'source-rev') + + def test_sample_uses_server_owned_matrices_with_preview_active(self): + preview=self.preview();preview.create();preview.activate() + self.args['steps'][0]['moves'][0]['distance']=99999 + self.generated['samples'][-1]['poses'][0]['matrix'][3]=999999 + receipt=preview.sample(2) + self.assertIs(self.app.activeDocument,self.doc);self.assertEqual(receipt['sampleIndex'],2) + pin=next(r for r in self.generated['plan']['instances'] if r['path']=='Assembly:1+Pin:1') + group=next(n['group'] for n in preview.nodes if n['instanceId']==pin['instanceId']) + self.assertEqual([p.matrix_values(group)[i] for i in (3,7,11)],[10.,2.5,0.]) + preview.sample(0) + self.assertEqual([p.matrix_values(group)[i] for i in (3,7,11)],[10.,1.,0.]) + for index in (True,-1,3,1.,{'matrix':p.IDENTITY}): + with self.assertRaises(c.MechanicalError):preview.sample(index) + + def test_copy_is_independent_and_no_transient_visibility_read(self): + preview=self.preview();preview.create() + copied=preview.nodes[0]['graphics'][0].bRepBody + del copied.isVisible + preview.sample(1) # Effective visibility is checked only on source/native proxies. + self.f.root.bRepBodies.item(0).volume=2 + self.assertEqual(copied.volume,1/6) + with self.assertRaises(c.MechanicalError):preview.sample(2) + self.assertEqual(preview.state,'ready') + self.assertEqual(preview.dispose()['state'],'disposed') # Stale source must not trap owned cleanup. + self.assertTrue(self.doc.isValid) # No document close. + + def test_mesh_hidden_suppressed_surface_and_context_mismatch_refuse_before_graphics(self): + cases=[(self.f.pin,'meshBodies',Collection([object()])),(self.f.ap,'isVisible',False), + (self.f.ap,'bodyVisible',False),(self.f.ap,'timelineObject',NS(isSuppressed=True)), + (self.f.pin.bRepBodies.item(0),'isSolid',False), + (self.f.pin.bRepBodies.item(0),'nativeObject',object())] + for obj,key,value in cases: + with self.subTest(key=key): + had=hasattr(obj,key);old=getattr(obj,key,None);setattr(obj,key,value) + with self.assertRaises(c.MechanicalError):self.prepared() + setattr(obj,key,old) if had else delattr(obj,key) + self.assertEqual(self.root.customGraphicsGroups.count,0) + native=self.f.pin.bRepBodies.item(0);native.createForAssemblyContext=lambda o:Entity(objectType='adsk::fusion::BRepBody',isVisible=True,nativeObject=native,assemblyContext=self.f.b) + with self.assertRaises(c.MechanicalError):self.prepared() + + def test_ownership_semantic_error_foreign_id_saved_or_nonblank_refuses(self): + for change in ({'success':False},{'documentId':'other'},{'sessionId':'old'}, {'data':{'success':False}}): + old=copy.deepcopy(self.receipt);self.receipt.update(change) + with self.assertRaises(c.MechanicalError):self.lease() + self.receipt=old + self.doc.isSaved=True + with self.assertRaises(c.MechanicalError):self.lease() + self.doc.isSaved=False;self.root.bRepBodies.values.append(body()) + with self.assertRaises(c.MechanicalError):self.lease() + + def test_worker_guard_precedes_native_getters_at_all_entries(self): + class Trap: + def __getattribute__(self,key):raise AssertionError('native getter '+key) + errors=[] + def run(): + calls=[lambda:p.prepare(Trap(),{},'x'),lambda:p.OwnedPresentationLease(Trap(),Trap(),Trap(),'x',lambda:{}), + lambda:p.Preview(Trap(),Trap()),lambda:p.Preview.create(Trap()),lambda:p.Preview.sample(Trap(),0), + lambda:p.Preview.activate(Trap()),lambda:p.Preview.dispose(Trap())] + for call in calls: + try:call() + except Exception as exc:errors.append(exc) + thread=threading.Thread(target=run);thread.start();thread.join() + self.assertEqual(len(errors),7) + self.assertTrue(all(isinstance(e,c.MechanicalError) and e.code=='wrong_thread' for e in errors)) + + def test_wrong_plan_stale_revision_or_changed_flags_refuses_before_copy(self): + with self.assertRaises(c.MechanicalError):p.prepare(self.app,self.args,'foreign-plan') + prepared=self.prepared();self.f.design.rev='changed' + with self.assertRaises(c.MechanicalError):p.Preview(prepared,self.lease()).create() + self.f.design.rev='source-rev';self.f.doc.isModified=False + with self.assertRaises(c.MechanicalError):p.Preview(prepared,self.lease()).create() + self.assertEqual(self.root.customGraphicsGroups.count,0) + + def test_partial_sample_retained_then_exact_dispose_without_source(self): + preview=self.preview();preview.create();preview.nodes[2]['group'].fail_transform=True + with self.assertRaises(RuntimeError):preview.sample(2) + self.assertEqual(preview.state,'retained_failure') + self.assertNotEqual(p.matrix_values(preview.nodes[1]['group']),p.IDENTITY) + with self.assertRaises(c.MechanicalError):preview.sample(2) + self.f.doc.isValid=False;self.app.documents.values.remove(self.f.doc) + self.assertEqual(preview.dispose()['state'],'disposed') + self.assertEqual(self.root.customGraphicsGroups.count,0) + + def test_ignored_setter_and_corrupt_copy_fail_readback(self): + preview=self.preview();preview.create();preview.nodes[1]['group'].ignore_transform=True + with self.assertRaises(c.MechanicalError):preview.sample(2) + self.assertEqual(preview.state,'retained_failure');preview.dispose() + preview=self.preview();preview.create();preview.nodes[1]['graphics'][0].bRepBody.volume=99 + with self.assertRaises(c.MechanicalError):preview.sample(1) + preview.dispose() + + def test_partial_creation_keeps_exact_root_for_disposal(self): + preview=self.preview() + original=Graphics.addBRepBody + counter=[] + def throwing(group,native): + counter.append(1) + if len(counter)==2:raise RuntimeError('copy refused') + return original(group,native) + with patch.object(Graphics,'addBRepBody',throwing),self.assertRaises(RuntimeError):preview.create() + self.assertIsNotNone(preview.root);self.assertEqual(preview.state,'retained_failure') + self.assertEqual(preview.dispose()['state'],'disposed') + + def test_unacknowledged_added_child_prevents_blind_cleanup(self): + preview=self.preview() + def null_after_add(group): + group.values.append(Graphics(group));return None + with patch.object(Graphics,'addGroup',null_after_add),self.assertRaises(c.MechanicalError):preview.create() + with self.assertRaises(c.MechanicalError):preview.dispose() + self.assertEqual(self.root.customGraphicsGroups.count,1) + + def test_foreign_graphics_or_lease_prevents_deletion(self): + preview=self.preview();preview.create();preview.root.values.append(Graphics(preview.root)) + with self.assertRaises(c.MechanicalError):preview.dispose() + preview.root.values.pop();self.receipt['success']=False + with self.assertRaises(c.MechanicalError):preview.dispose() + self.assertTrue(preview.root.isValid) + + def test_false_delete_or_missing_absence_proof_retains_exact_refs(self): + for ignored in (False,True): + preview=self.preview();preview.create() + preview.root.delete_ack=ignored;preview.root.ignore_delete=ignored + with self.assertRaises(c.MechanicalError):preview.dispose() + self.assertEqual(preview.state,'retained_failure');self.assertTrue(preview.root.isValid) + preview.root.delete_ack=True;preview.root.ignore_delete=False + with self.assertRaises(c.MechanicalError):preview.dispose() + self.assertTrue(preview.root.isValid) + self.root.customGraphicsGroups.values.clear() # Isolated fake reset; no second native deletion. + + def test_foreign_active_document_is_never_activated_away(self): + preview=self.preview();preview.create();foreign=Entity();self.app.activeDocument=foreign + with self.assertRaises(c.MechanicalError):preview.activate() + self.assertIs(self.app.activeDocument,foreign) + preview.dispose() + preview=self.preview() + with self.assertRaises(c.MechanicalError):preview.create() + self.assertIs(self.app.activeDocument,foreign) + preview.dispose() + + def test_bounds_and_nonfinite_source_refuse_without_native_graphics(self): + self.f.pin.bRepBodies.item(0).boundingBox.maxPoint.x=float('nan') + with self.assertRaises(c.MechanicalError):self.prepared() + self.f.pin.bRepBodies.item(0).boundingBox.maxPoint.x=1. + with patch.object(p,'MAX_BODIES',2),self.assertRaises(c.MechanicalError):self.prepared() + with patch.object(p,'MAX_VERTICES',3),self.assertRaises(c.MechanicalError):self.prepared() + self.assertEqual(self.root.customGraphicsGroups.count,0) + + def test_acknowledged_but_corrupt_native_matrix_factory_cannot_become_expectation(self): + preview=self.preview();preview.create() + def corrupt(matrix, values): + matrix.values=list(values) + for i in (3,7,11):matrix.values[i]=0. + # Also corrupt caller input: expectations must not alias this list. + for i in (3,7,11):values[i]=0. + return True + with patch.object(Matrix,'setWithArray',corrupt),self.assertRaises(c.MechanicalError) as error: + preview.sample(2) + self.assertEqual(error.exception.code,'preview_transform_refused') + self.assertIsNone(preview.sample_index);self.assertEqual(preview.state,'retained_failure') + preview.dispose() + preview=self.preview() + with patch.object(Matrix,'setWithArray',corrupt),self.assertRaises(c.MechanicalError):preview.create() + self.assertEqual(preview.state,'retained_failure');preview.dispose() + + def test_nonnull_child_and_body_handles_retained_before_throwing_validity_getter(self): + class Once: + def __init__(self, wrapped):object.__setattr__(self,'wrapped',wrapped);object.__setattr__(self,'failed',False) + def __getattr__(self,key): + if key=='isValid' and not self.failed: + object.__setattr__(self,'failed',True);raise RuntimeError('validity getter failed once') + return getattr(self.wrapped,key) + def __setattr__(self,key,value):setattr(self.wrapped,key,value) + for method in ('addGroup','addBRepBody'): + with self.subTest(method=method): + original=getattr(Graphics,method) + def wrapped(group,*args): + value=Once(original(group,*args));group.values[-1]=value;return value + preview=self.preview() + with patch.object(Graphics,method,wrapped),self.assertRaises(RuntimeError):preview.create() + self.assertEqual(preview.state,'retained_failure') + self.assertEqual(len(preview.nodes),1) + if method=='addBRepBody':self.assertEqual(len(preview.nodes[0]['graphics']),1) + self.assertEqual(preview.dispose()['state'],'disposed') + self.assertEqual(self.root.customGraphicsGroups.count,0) + + def test_not_registered_as_public_or_durable_operation(self): + from test_mechanical import m + self.assertTrue(p.PROTOTYPE_ONLY) + self.assertFalse(any(getattr(handler, '__module__', None) == p.__name__ for handler in m.OPERATIONS.values())) + self.assertFalse(hasattr(p,'READS'));self.assertFalse(hasattr(p,'WRITES')) + + +if __name__=='__main__':unittest.main() diff --git a/tests/test_mechanical.py b/tests/test_mechanical.py index 316d82d..958f4e7 100644 --- a/tests/test_mechanical.py +++ b/tests/test_mechanical.py @@ -38,10 +38,15 @@ class Entity: def __init__(self, **kwargs): self.__dict__.update(isValid=True, **kwargs) +class MeshCollection(Collection): + isValid = True + objectType = 'adsk::fusion::MeshBodies' + + def component(name, volume=0): props=NS(volume=volume,area=volume*6,mass=volume*0.002,centerOfMass=NS(x=1,y=2,z=3)) body=Entity(getPhysicalProperties=lambda a:props) - return Entity(name=name,revisionId='r1',bRepBodies=Collection([body] if volume else []),sketches=Collection(),occurrences=Collection(),allOccurrences=Collection(),partNumber='',description='',material=None) + return Entity(name=name,revisionId='r1',meshBodies=MeshCollection(),bRepBodies=Collection([body] if volume else []),sketches=Collection(),occurrences=Collection(),allOccurrences=Collection(),partNumber='',description='',material=None) def isolated_history(testcase): journal = importlib.import_module('mechanical_test_commands.mechanical.journal') @@ -65,15 +70,17 @@ class Safety(unittest.TestCase): def test_child_revision_changes_target_even_when_root_unchanged(self): root=component('root');child=component('child') - design=NS(allComponents=Collection([root,child]),rootComponent=NS(allOccurrences=Collection()),allParameters=Collection(),timeline=NS(count=0,markerPosition=0)) + design=NS(isValid=True,parentDocument=Entity(isSaved=False,isModified=True),allComponents=Collection([root,child]),rootComponent=NS(allOccurrences=Collection()),allParameters=Collection(),timeline=NS(count=0,markerPosition=0)) + root.parentDesign=child.parentDesign=design before=c.revision(design);child.revisionId='r2';self.assertNotEqual(c.revision(design),before) def test_occurrence_suppression_comes_from_timeline_not_occurrence(self): root=component('root');child=component('child') occurrence=Entity(component=child,transform2=NS(asArray=lambda:[1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1]), timelineObject=NS(isSuppressed=False),isGrounded=False) - design=NS(allComponents=Collection([root,child]),rootComponent=NS(allOccurrences=Collection([occurrence])), + design=NS(isValid=True,parentDocument=Entity(isSaved=False,isModified=True),allComponents=Collection([root,child]),rootComponent=NS(allOccurrences=Collection([occurrence])), allParameters=Collection(),timeline=NS(count=0,markerPosition=0)) + root.parentDesign=child.parentDesign=design before=c.revision(design); occurrence.timelineObject.isSuppressed=True self.assertNotEqual(c.revision(design),before) occurrence.timelineObject=None; self.assertIsNone(c.occurrence_suppression(occurrence)) diff --git a/tests/test_mechanical_configurations.py b/tests/test_mechanical_configurations.py index d81ff18..6fd21d1 100644 --- a/tests/test_mechanical_configurations.py +++ b/tests/test_mechanical_configurations.py @@ -8,7 +8,7 @@ from types import SimpleNamespace as NS import unittest from unittest.mock import patch -from test_mechanical import adsk, c, m, Entity, Collection, component +from test_mechanical import adsk, c, m, Entity, Collection, MeshCollection, component config = importlib.import_module('mechanical_test_commands.mechanical.configurations') bom = importlib.import_module('mechanical_test_commands.mechanical.bill_of_materials') @@ -32,7 +32,7 @@ class Configurations(unittest.TestCase): def fixture(self, kind='ConfigurationParameterColumn', text=False): self.root = self.native('Component', name='Bracket', partNumber='PN-001', description='fixture', entityToken='root', - revisionId='same', bRepBodies=Collection(), sketches=Collection(), features=Collection(), + revisionId='same', meshBodies=MeshCollection(), bRepBodies=Collection(), sketches=Collection(), features=Collection(), occurrences=Collection(), allOccurrences=Collection(), material=None) self.timeline = Collection(); self.timeline.markerPosition = 0 self.design = NS(isValid=True, isConfiguredDesign=True,isConfiguration=False,configurationRowId="", rootComponent=self.root, allComponents=Collection([self.root]), @@ -76,7 +76,7 @@ class Configurations(unittest.TestCase): for index, cell in enumerate(self.cells): cell.value = 'PN-' + str(index) self.column.getCellByRowId = lambda row_id: self.cells[next(i for i, r in enumerate(self.rows) if r.id == row_id)] self.table.columns = Collection([self.column]); self.design.configurationTopTable = self.table - self.doc = Entity(name='Untitled'); self.design.parentDocument = self.doc + self.doc = Entity(name='Untitled', isSaved=False, isModified=True); self.design.parentDocument = self.doc self.app = NS(activeDocument=self.doc, activeProduct=self.design) return self.design diff --git a/tests/test_mechanical_exploded_view.py b/tests/test_mechanical_exploded_view.py index 82ee7be..c1182aa 100644 --- a/tests/test_mechanical_exploded_view.py +++ b/tests/test_mechanical_exploded_view.py @@ -210,7 +210,7 @@ class Exploded(unittest.TestCase): self.assertEqual(error.exception.code,'configuration_scope_unsupported') def test_real_registration_read_schema_and_immutable_sampling(self): - self.assertEqual(len(m.SCHEMA),56);self.assertNotIn('exploded_view_plan',m.WRITES) + self.assertEqual(len(m.SCHEMA),61);self.assertNotIn('exploded_view_plan',m.WRITES) frozen,write=m.prepare_request(self.args());self.assertFalse(write) result=self.read();snapshot={k:result['plan'][k] for k in ('instances','rootBodyGroup')} before=copy.deepcopy(snapshot) diff --git a/tests/test_mechanical_revision.py b/tests/test_mechanical_revision.py new file mode 100644 index 0000000..f9a7451 --- /dev/null +++ b/tests/test_mechanical_revision.py @@ -0,0 +1,274 @@ +"""Actual revision/target/new-design code with lazy native getter fault models.""" +import importlib +import threading +import copy +from types import SimpleNamespace as NS +import unittest +from unittest.mock import patch + +from test_mechanical import adsk, c, m, Collection, MeshCollection, Entity, isolated_history + +assembly = importlib.import_module(c.__package__ + '.assembly_state') +readback = importlib.import_module(c.__package__ + '.readback') +configurations = importlib.import_module(c.__package__ + '.configurations') + + +class LazyComponent(Entity): + def __init__(self, name, events, document, ancestors=()): + super().__init__(name=name, objectType='adsk::fusion::Component', entityToken='token-' + name, + bRepBodies=Collection(), sketches=Collection(), features=Collection(), + occurrences=Collection(), allOccurrences=Collection(), constructionAxes=Collection(), + joints=Collection(), asBuiltJoints=Collection(), rigidGroups=Collection(), jointOrigins=Collection()) + self.events, self.document, self.ancestors = events, document, ancestors + self.version, self.initialized, self.raise_mesh, self.dirty_on_mesh = 0, False, False, False + self.throw_after_dirty = False + self.meshes = MeshCollection() + + @property + def meshBodies(self): + self.events.append(('mesh', self.name)) + if self.raise_mesh: + raise RuntimeError('injected mesh collection acquisition failure') + if not self.initialized: + self.initialized = True + for component in (self,) + self.ancestors: + component.version += 1 + if self.dirty_on_mesh: + self.document.isModified = True + if self.throw_after_dirty: + raise RuntimeError('injected getter threw after initialization/dirtying') + return self.meshes + + @property + def revisionId(self): + self.events.append(('revision', self.name)) + return str(self.version) + + +class RevisionInitialization(unittest.TestCase): + def setUp(self): + c._documents.clear() + c._entities.clear() + self.events = [] + self.doc = Entity(objectType='adsk::core::Document', name='Owned saved source', creationId='owned-native-id', isSaved=True, isModified=False) + self.root = LazyComponent('root', self.events, self.doc) + self.carrier = LazyComponent('Carrier', self.events, self.doc, (self.root,)) + self.part = LazyComponent('Part', self.events, self.doc, (self.carrier, self.root)) + self.components = [self.root, self.carrier, self.part] + self.design = Entity(parentDocument=self.doc, allComponents=Collection(self.components), + rootComponent=self.root, allParameters=Collection(), + timeline=NS(count=0, markerPosition=0)) + for component in self.components: + component.parentDesign = self.design + self.app = NS(activeDocument=self.doc, activeProduct=self.design) + for change in (patch.object(assembly, 'component_state', return_value={}), + patch.object(readback, 'construction_state', return_value={}), + patch.object(configurations, 'snapshot', return_value={'complete': True, 'configured': False, 'isConfiguration': False})): + change.start() + self.addCleanup(change.stop) + + def test_all_components_initialized_before_any_revision_snapshot(self): + c.revision(self.design) + before_first_revision = self.events[:next(i for i, row in enumerate(self.events) if row[0] == 'revision')] + self.assertEqual({row[1] for row in before_first_revision if row[0] == 'mesh'}, {'root', 'Carrier', 'Part'}) + + def test_current_target_survives_later_mesh_inventory(self): + issued = c.current_target(self.app) + for component in self.components: + self.assertEqual(component.meshBodies.count, 0) + self.assertEqual(c.current_target(self.app), issued) + + def test_child_first_acquisition_cannot_invalidate_earlier_ancestor_hash(self): + first = c.revision(self.design) + self.part.meshBodies + self.assertEqual(c.revision(self.design), first) + self.assertGreater(self.root.version, self.carrier.version) + self.assertGreater(self.carrier.version, self.part.version) + + def test_real_later_native_revision_still_refuses_stale_target(self): + target = c.current_target(self.app) + self.part.version += 10 + with self.assertRaises(c.MechanicalError) as error: + c.target(self.app, {'target': target}) + self.assertEqual(error.exception.code, 'stale_revision') + + def test_mesh_acquisition_error_prevents_revision_issuance(self): + self.part.raise_mesh = True + with self.assertRaises(Exception): + c.revision(self.design) + self.assertFalse(any(row[0] == 'revision' for row in self.events)) + + def test_saved_clean_flag_transition_refuses_without_reset(self): + self.part.dirty_on_mesh = True + with self.assertRaises(c.MechanicalError) as error: + c.revision(self.design) + self.assertTrue(self.doc.isModified) + self.assertTrue(self.doc.isSaved) + self.assertEqual(error.exception.code, 'revision_document_changed') + self.assertEqual(error.exception.details['revisionObservation']['documentFlagEffect'], 'changed') + self.assertEqual(error.exception.details['revisionObservation']['changedFlags'], ['isModified']) + self.assertFalse(error.exception.details['rollbackClaimed']) + + def test_clean_saved_source_unchanged_and_inactive_ownership_supported(self): + self.app.activeDocument = Entity(isSaved=False, isModified=True) + first = c.revision(self.design) + self.assertEqual(c.revision(self.design), first) + self.assertEqual((self.doc.isSaved, self.doc.isModified), (True, False)) + self.assertTrue(all(component.initialized for component in self.components)) + + def test_throwing_getter_still_reports_changed_flags(self): + self.part.dirty_on_mesh = self.part.throw_after_dirty = True + with self.assertRaises(c.MechanicalError) as error: + c.revision(self.design) + self.assertIn('threw after initialization', str(error.exception)) + observation = error.exception.details['revisionObservation'] + self.assertEqual(observation['documentFlagEffect'], 'changed') + self.assertEqual(observation['changedFlags'], ['isModified']) + self.assertTrue(observation['observations'][-1]['isModified']) + self.assertFalse(error.exception.details['rollbackClaimed']) + self.assertFalse(any(row[0] == 'revision' for row in self.events)) + + def test_flag_read_failure_reports_unknown_instead_of_unchanged(self): + class Document(Entity): + @property + def isModified(self): + raise RuntimeError('native flags unavailable') + self.doc = Document(isSaved=True) + self.design.parentDocument = self.doc + with self.assertRaises(c.MechanicalError) as error: + c.revision(self.design) + observation = error.exception.details['revisionObservation'] + self.assertEqual(observation['documentFlagEffect'], 'unknown') + self.assertFalse(observation['observations'][0]['complete']) + self.assertEqual(self.events, []) + + def test_nonboolean_flags_refuse_before_collections(self): + self.doc.isModified = 0 + with self.assertRaises(c.MechanicalError) as error: + c.revision(self.design) + self.assertEqual(error.exception.details['revisionObservation']['documentFlagEffect'], 'unknown') + self.assertEqual(self.events, []) + + def test_mesh_count_failure_is_not_skipped(self): + class Meshes(MeshCollection): + @property + def count(self): + raise RuntimeError('native count unavailable') + self.part.meshes = Meshes() + with self.assertRaises(c.MechanicalError) as error: + c.revision(self.design) + self.assertIn('native count unavailable', str(error.exception)) + self.assertEqual(error.exception.details['revisionObservation']['documentFlagEffect'], 'unchanged') + self.assertFalse(any(row[0] == 'revision' for row in self.events)) + + def test_malformed_or_excessive_mesh_counts_refuse(self): + for count in (-1, True, 0.0, c.MAX_ENTITIES + 1): + with self.subTest(count=count): + self.part.meshes = NS(count=count, isValid=True, objectType='adsk::fusion::MeshBodies') + self.events.clear() + with self.assertRaises(c.MechanicalError) as error: + c.revision(self.design) + self.assertEqual(error.exception.code, 'inspection_budget') + self.assertFalse(any(row[0] == 'revision' for row in self.events)) + + def test_foreign_component_owner_refuses_without_mesh_access(self): + self.root.parentDesign = Entity() + with self.assertRaises(c.MechanicalError) as error: + c.revision(self.design) + self.assertEqual(error.exception.code, 'revision_unavailable') + self.assertEqual(self.events, []) + + def test_invalid_or_malformed_mesh_collection_never_counts_as_empty(self): + for meshes in (None, NS(count=0, isValid=False, objectType='adsk::fusion::MeshBodies'), + NS(count=0, isValid=True, objectType='adsk::fusion::BRepBodies'), NS(count=0)): + with self.subTest(meshes=meshes): + self.part.meshes = meshes + self.events.clear() + with self.assertRaises(c.MechanicalError) as error: + c.revision(self.design) + self.assertEqual(error.exception.code, 'revision_unavailable') + self.assertFalse(any(row[0] == 'revision' for row in self.events)) + + def test_initialization_does_not_reject_existing_mesh_or_enumerate_mesh_geometry(self): + self.part.meshes = NS(count=1, isValid=True, objectType='adsk::fusion::MeshBodies', + item=lambda index: self.fail('Mesh geometry need not be materialized')) + self.assertEqual(c.revision(self.design), c.revision(self.design)) + + def test_actual_new_design_journal_target_survives_blank_check_and_lease(self): + isolated_history(self) + source_target = c.current_target(self.app) + source_doc, source_design = self.doc, self.design + document = Entity(objectType='adsk::core::Document', name='Untitled', isSaved=False, isModified=True, creationId='created-blank') + root = LazyComponent('blank', self.events, document) + root.customGraphicsGroups = Collection() + root.attributes = Collection() + def add_attribute(group, name, value): + root.attributes.values.append(Entity(objectType='adsk::core::Attribute', groupName=group, name=name, value=value)) + root.version += 1 + root.attributes.add = add_attribute + design = Entity(parentDocument=document, allComponents=Collection([root]), rootComponent=root, + allParameters=Collection(), timeline=Collection(), computeAll=lambda: True) + design.timeline.markerPosition = 0 + root.parentDesign = design + from test_exploded_preview_prototype import enrich_blank + enrich_blank(document, design, root) + root.attributes.add = add_attribute + document.products = NS(itemByProductType=lambda name: design) + source_doc.products = NS(itemByProductType=lambda name: source_design) + documents = Collection([source_doc]) + def add_document(kind): + documents.values.append(document) + self.app.activeDocument, self.app.activeProduct = document, design + return document + documents.add = add_document + self.app.documents = documents + tx = importlib.import_module(c.__package__ + '.transactions') + controller = importlib.import_module(c.__package__ + '.exploded_preview') + with patch.object(adsk.core, 'DocumentTypes', NS(FusionDesignDocumentType=0), create=True), \ + patch.object(adsk.fusion, 'DesignTypes', NS(ParametricDesignType=0), create=True), \ + patch.object(tx, 'capability', return_value={'verified': True}): + request = {'op': 'new_design', 'sessionId': m.SESSION_ID, 'operationId': 'real-creation', 'name': 'Preview fixture'} + created = m.handle_mechanical(self.app, request) + self.assertTrue(created['success'], created) + saved_record = copy.deepcopy(m._history.store().lookup(m.SESSION_ID, 'real-creation')) + original_target = copy.deepcopy(created['data']['target']) + self.assertTrue(root.initialized) + registry = controller.Controller() + entry = registry.reserve(self.app, {'operationId': 'preview-create', 'op': 'exploded_preview_create', + 'sessionId': m.SESSION_ID, 'target': original_target, + 'sourceTarget': source_target, 'creationOperationId': 'real-creation'}, m._history) + registry.lease(entry).check() + controller.blank_model(design) + self.assertEqual(c.current_target(self.app), original_target) + self.assertEqual(m._history.store().lookup(m.SESSION_ID, 'real-creation'), saved_record) + root.version += 1 + with self.assertRaises(c.MechanicalError) as error: + registry.lease(entry) + self.assertEqual(error.exception.code, 'preview_not_blank') + + def test_component_budget_fails_before_any_mesh_getter(self): + self.design.allComponents = NS(count=c.MAX_ENTITIES + 1, item=lambda index: self.fail('Budget must precede item')) + with self.assertRaises(c.MechanicalError) as error: + c.revision(self.design) + self.assertEqual(error.exception.code, 'inspection_budget') + self.assertEqual(self.events, []) + + def test_worker_refuses_before_any_native_component_getter(self): + errors = [] + def run(): + try: + c.revision(self.design) + except Exception as error: + errors.append(error) + thread = threading.Thread(target=run) + thread.start() + thread.join(2) + self.assertFalse(thread.is_alive()) + self.assertEqual(len(errors), 1) + self.assertIsInstance(errors[0], c.MechanicalError) + self.assertEqual(errors[0].code, 'wrong_thread') + self.assertEqual(self.events, []) + + +if __name__ == '__main__': + unittest.main(verbosity=2)