diff --git a/CHANGELOG.md b/CHANGELOG.md index bccbdef..4a91600 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,139 @@ 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` +## Unreleased integration (2026-10-06) + +- Integrate the mechanical foundation with current master while preserving newer routing and electronics workflows. Regenerate the combined 179-verb contract; retain all 54 typed mechanical operations. +- Keep master's release metadata and separate add-in version unchanged. Historical native evidence stays tied to its original source; merged-source native acceptance remains pending. See docs/integration-2026-10-06.md. +- Prioritize exploded views and ordered assembly instructions for the next animation checkpoint, with BOM-linked labels, trails and reversible presentation poses that preserve source joints. This remains planned work. + +## 1.12.0-dev.8 (2026-10-05) + +- Persist typed mechanical admission, dispatch claims, exact document identity, request digest and terminal results in a bounded app-owned SQLite journal. Same-session duplicates never redispatch; old-session history is readable without native replay. +- Report interrupted work and uncertain persistence explicitly. Preserve observed execution across duplicate-event faults, recover hot SQLite journals, and refuse corrupt, incompatible, unsafe or exhausted storage before dispatch. +- Route operation-status reads directly to the add-in without launch/restart, window etiquette, modal handling or timeout remediation; unavailable history remains unknown. Bind client reconciliation to immutable request digests and coherent terminal identity/durability. +- Add real subprocess-crash, concurrent claim, lost-acknowledgement, and loopback HTTP/CustomEvent regressions for fast replies, false delivery returns, lost waiters and storage failures. Native durability/installer gates remain required before release. +- Give each loaded add-in copy its own event registration and enforce an exclusive local listener. Losing copies release only their own resources; failed or repeated startup cannot replace a live copy's event or leave a bound socket behind. Native event-delivery acceptance remains a separate gate. +- Preserve a bounded callback waiter when native `fireCustomEvent` returns false: Fusion 2705.1.30 has delivered callbacks despite that return. Confirm completion from the callback and arbitrate expired queued work against claimed execution through the durable journal; never infer nonexecution from the Boolean alone. +- Flush a newly staged SQLite journal through a writable handle on Windows; read-only handles fail `fsync` with `EBADF`. Flush errors still prevent publication and dispatch. +- Report a conflicting submitted payload as not executed only when the admission check proves an existing different request digest. Preserve the original operation record; unrelated storage and execution uncertainty still reports unknown. +- Keep Ralph capture selective: model/API state, logs, operation records and archive hashes are the default evidence. Continuous screenshot feeds and automatic Shotlog draining are disabled; select and deduplicate any visual evidence before upload. + +## 1.12.0-dev.7 (2026-10-05) + +- Add read-only `configuration_inspect` (54 typed operations): ordered native table/row/column identities, numeric/text cells, controlled-source graph, explicit/effective suppression and active row. No configuration activation or lifecycle writes are enabled. +- Witness the native reserved test row separately from ordinary configurations and include its cells in revision checks. Preserve per-field diagnostics when unsaved/offline property values are unavailable. Native conversion survived an acknowledged Abort in the owned probe; configured model writes remain disabled, and complete configured archive recovery remains unverified. +- Include inactive configuration cells and source relationships in stale-target and recovery witnesses. Unknown columns, themes, preview hierarchies and inserted configurations are explicit incomplete scopes; model mutations refuse them. Configured-design model writes also remain gated until native configuration recovery/archive acceptance, including indirect edits through parameters or features. +- Stamp current-active-row BOM JSON and both CSV modes with configuration and exact document/revision provenance. Visibility remains independent of engineering quantity; unsupported configuration scope cannot produce a purportedly complete BOM. +- Add offline configuration graph, preflight, typed-cell, wrapper-lifetime and CSV regressions. Native configured-table readback and persistence remain release gates. + +## 1.12.0-dev.6 (2026-10-02) + +- Add eight guarded operations (53 total): geometry-solving regular-joint create/redefine, reusable joint-origin lifecycle, and native motion-link lifecycle. Preserve the existing as-built and joint drive/limit operations. +- Verify attachment identities and native parameter readback; restore the exact timeline marker in finally for historical redefinition. Expand stale/recovery witnesses to joint geometry, origin frames and orientation sources, motion-link members/ratios, and timeline position. +- Reacquire attachment sources after native recompute invalidates retained geometry wrappers. Preserve graph identity through token lookup followed by fresh API equality, and report native joint-transform provenance without applying occurrence transforms twice. +- Handle rigid locks using measured native semantics: refuse `locked:true`, skip the unsupported false setter, and verify the implicit false state after creation or redefinition. Motion-link ratio edits may re-solve member positions; later drive checks use the post-edit pose. +- Add focused offline graph, repeated-instance, grounded-placement, unit, failed-setter and timeline-restoration regressions. Native solver, motion-link behavior and archive persistence remain required release gates. + + +## 1.12.0-dev.5 (2026-10-05) + +- Add six typed everyday-modeling operations (45 total): body-scoped geometry queries with explicit + frames and ambiguity refusal, native distance/angle measurements with witness points, sketch + trim/extend/split/break, signed parameter-driven offsets through `addOffset2`, construction axes from + existing geometry, and native sketch constraint/health diagnostics. +- Curve edits return replacement references, reject silent no-ops and require explicit consent for + trimming an entire curve away. Root-space child geometry requires its exact occurrence context; + cross-component axes require an explicit creation occurrence when authored outside the root. +- Recovery witnesses now include spline control data, constraint/dimension graph links, offset + relationships and construction axes; unreadable graphs refuse mutation. Selected-curve shape + changes are checked separately so unrelated sketch changes cannot hide a refused curve edit. + Offline boundary checks cover frames, selection cardinality, witness consistency, failed/no-op edits + and signed offset readback. Native application acceptance passed on Windows Fusion 2705.1.11: + 16 groups, 254 assertions, 32 exact owned closures and 16 reviewed viewport captures. +- Handle native component-space measurement limitations with temporary geometry copies; preserve + and label degenerate parallel-angle witness points using independent direction verification. + Native in-place extension is accepted only after the selected curve's persisted change is verified. +- Add the maintained everyday Ralph runner with independently measured topology, constraint graphs, + archive reopening and campaign-aware recovery. Recovery recomputes aggregate check counts while + preserving historical failures. Exact embedded catalog checks reject stale cached binaries before + packaging; installed payload hashes and live native catalogs are verified separately. + +## 1.12.0-dev.4 (2026-10-02) + +- Add a maintained native Ralph campaign for bracket, assembly and optional local supplier fixtures, + archive round trips, numerical geometry/BOM witnesses, failure cleanup and image-hash review. + Offline gates record source and embedded-asset hashes with individual build/test logs. Live-feed + capture verifies actual PNG pixels and Shotlog transfer without declaring an unreviewed visual pass. +- The legacy verb runner defaults to an explicit read-only argument allowlist. Manual and fixture + workflows remain separately reported; `--only` cannot authorize them. Production board name + workflows, preference changes, name-based document polling and automatic mutation retries are disabled. +- Preserve raw relay/native envelopes and reject semantic errors beneath transport success. Negative + cases require exact refusal contracts; infrastructure failures never count as guard coverage. + Timed-out typed operations can be reconciled through their matching session/operation ledger only. +- Each run has unique reports, caller identity and scratch ownership. Reusable campaign helpers persist + exact document IDs, verified native checkpoints and file ownership; cleanup runs in `finally`, reports + failures and refuses changed identities. Foreground behavior remains explicitly unverified without + timestamped, attributed transitions. Preference restoration preserves concurrent user changes. +- The add-in HTTP endpoint validates bounded request framing, bodies and timeouts, and enforces an + absolute request deadline. Duplicate callbacks cannot replace completed results; diagnostic watchdogs + no longer release another request's main-thread lock. Real socket and offline harness regressions cover + malformed requests, callback interleavings, contradictory responses, unsafe retries and cleanup guards. + +## 1.12.0-dev.3 (2026-10-02) + +- Assembly checkpoint adds 20 typed operations (39 total): internal components and independent copies, + nested occurrence placement/grounding, seven native as-built joint motions with limits and drive + readback, rigid groups, interference without creating bodies, materials and assembly physical + properties including center of mass. Existing design intent is preserved; internal creation requires Hybrid. +- Import downloaded STEP/SAT supplier CAD into a dedicated component, validate an optional expected + SHA-256, and persist supplier/part/source/hash metadata. Discover or explicitly launch Fusion's native + McMaster UI. Browser selection/download remains interactive; no cloud save is implied. +- Structured instance BOM and flat identity-grouped quantities include purchased-part provenance; + both CSV modes publish atomically. Purchased collapse/exclusion remains separate from physical mass. + Root bodies and repeated nested bodies count once per instance, with traversal budgets/cycle guards. +- Revision/recovery witnesses now include component metadata/materials, occurrence grounding, + joint motion/limits and rigid groups. Nested occurrence references preserve their instance path. + Model recovery excludes files, cloud effects and interactive command completion. +- Offline coverage checks nested quantities/mass, purchased collapse/exclusion, hidden cycles, + supplier hash guards, joint limit conversions and CSV publication boundaries. Independent live + verification passed all seven joint motions, placement/reuse, native McMaster launch, supplier CAD, + purchasing/physical BOM and archive persistence. Captured positions are checked in every affected + root assembly context after compute; BOM physical totals use explicit high calculation accuracy. + See docs/build-2026-10-02.md for the 815 retained native checks, source audit and coverage limits. + +## 1.12.0-dev.2 (2026-10-02) + +- Typed parametric modeling adds sketch primitives, driving dimensions and geometric constraints, + sketch edits/projections, construction planes and solid feature creation. Feature operations cover + extrude/cut, revolve, hole, fillet, chamfer, shell, loft, sweep, combine, split, move, mirror, + rectangular/circular patterns, draft and straight threads through runtime-checked Fusion APIs. +- A runtime probe verifies PTransaction abort and commit behavior before model mutations are enabled. + Failed operations report semantic recovery checks. Atomic parameter batches support create/set/delete; + feature edits support naming, suppression and owned model-parameter expressions. Independent feature + and sketch-geometry deletion uses the same health and transaction gate. +- Inspection returns sketch curves/points/profiles and detailed face/edge geometry with guarded references. + Profile references bind a sketch, current revision, index and geometric fingerprint. Health walks + timeline groups and reports intentionally suppressed items separately. +- Operation status and capability requests bypass the main-thread queue and native bridge busy gate. + The ledger uses a short lock around metadata snapshots; no lock is held over Fusion API work. +- The Autodesk desktop MCP example now uses its supported activeCommand query. Shipping schemas remain + explicit about runtime API availability and the boundary between model transactions and external files. + +## 1.12.0-dev.1 (2026-10-02) + +- Development checkpoint: `fusion_mechanical` adds typed create/inspect/parameter/health/export operations, + document and revision guards, occurrence-context references and a session operation ledger. Discover + schemas with `fusion_mech_capabilities`; query timed-out work with `fusion_mech_operation_status`. +- Parameter failures restore their previous expression and report semantic recovery checks. New designs + stay unnamed to prevent save dialogs during cleanup. Generic atomic modeling batches are not promised. +- Physical properties include root-owned bodies; distinct same-named component definitions stay separate + in mass reports and BOMs. BOMs count bodies and children, preserve explicit empty kit rules, and report CSV failures. +- Exports validate capabilities and stage output before atomic publication. Existing files survive export + failures; postpublication cleanup errors remain successful publications with warnings. STL uses mm; + STL/3MF never launch a print utility or silently produce one file per body. +- Register HTTP result waiters before firing Fusion events, preventing fast commands from being dropped as stale. + ## 1.11.9 (2026-09-29) - `fusion_aps_signin` always opens a browser. With no remembered browser profile it passed an EMPTY profile name, ab diff --git a/README.md b/README.md index 9ee6f14..bdd016f 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ Short version below, full detail in each guide. | [3D view, export and STEP colorization](docs/3d-viewer-and-export.md) | The 3D PCB, the native viewport frame in `fusion_state`, camera motion with `fusion_view`, every export format, web-grade GLB (16 MB to ~465 KB), and recoloring a grey imported STEP. | | [Electronics library manager](docs/electronics-libraries.md) | Symbols, IPC footprints and real 3D packages, generated rather than hand-drawn, explicit 3D `placement`, plus how to verify a part before shipping it. | | [Multi-part library import](docs/MULTIPART_LIBRARY_IMPORT.md) | How a zip of `.lbr` + STEPs becomes a Library Manager library with rendering 3D on every part: cloud `.f3d` urns, per-part minting (and the wrong-footprint trap it avoids), proof, and what the user sees. With screenshots. | +| [Mechanical design](docs/MECHANICAL.md) | Development 1.12: guarded parametric parts, constrained sketches, assemblies/joints, McMaster CAD, material/mass and flat/structured mechanical BOM. [Build evidence](docs/build-2026-10-02.md) and [feature/gap inventory](docs/mechanical-comparison/README.md). | | [Mechanical BOM and physical properties](docs/mechanical-bom.md) | Kit-aware assembly BOM that matches Fusion's Manage to BOM, and per-part volume/mass for costing. | | [Sign-in, sessions and updates](docs/sign-in-and-updates.md) | Multi-profile Autodesk sign-in, SSO without passwords, one login covering APS, the `needs_signin` short-circuit, `restartPolicy`, and silent auto-updates. | | [Autodesk's Fusion MCP server](docs/fusion-mcp-server.md) | Proxy Autodesk + Anthropic's local MCP server (text-to-CAD, Electronics object model) so a cloud AI can reach it, and turn it on for the user. | diff --git a/SKILL.md b/SKILL.md index 4f5d767..93a502c 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,7 +1,7 @@ --- name: fusion-bridge public: true -description: "Launch and drive Fusion 360 on the user's laptop from this container via the adom-bridge CLI. Includes FAST cloud-file search via APS (Autodesk Platform Services) — search/browse/open the team's Fusion cloud designs in seconds (set up once per company, each user signs in once, never charged). Plus open .f3d/.f3z designs, open cloud files, close documents, dismiss recovery dialogs, capture Fusion window screenshots, send keys + click, run the add-in (STEP/GLB/.lbr import-export, BOM/parameters). Trigger words: fusion, Fusion 360, fast cloud search, fusion_aps_search, search fusion files, find a design, browse cloud files, fusion_aps_open, aps, open fusion, launch fusion, open design, f3d, f3z, import STEP, export STEP, export GLB, export lbr, Fusion electronics, Fusion PCB, BOM from Fusion, Fusion parameters, Fusion API, dismiss recovery, Fusion screenshot, fusion_click, fusion_send_key, fusion bridge, close fusion." +description: "Launch and drive Fusion 360 on the user's laptop from this container via the adom-bridge CLI. Includes FAST cloud-file search via APS (Autodesk Platform Services) — search/browse/open the team's Fusion cloud designs in seconds (set up once per company, each user signs in once, never charged). Plus open .f3d/.f3z designs, open cloud files, close documents, dismiss recovery dialogs, capture Fusion window screenshots, send keys + click, run the add-in (STEP/GLB/.lbr import-export, BOM/parameters). Mechanical development builds add guarded sketches/solids, assemblies/joints, McMaster CAD and structured BOM. Trigger words: fusion, Fusion 360, fast cloud search, fusion_aps_search, search fusion files, find a design, browse cloud files, fusion_aps_open, aps, open fusion, launch fusion, open design, f3d, f3z, import STEP, export STEP, export GLB, export lbr, Fusion electronics, Fusion PCB, BOM from Fusion, Fusion parameters, Fusion API, dismiss recovery, Fusion screenshot, fusion_click, fusion_send_key, fusion bridge, close fusion." --- # adom-bridge — Fusion 360 bridge @@ -17,7 +17,17 @@ description: "Launch and drive Fusion 360 on the user's laptop from this contain > wiki page (`adom-wiki settings get autodesk-aps`). The **fusion-aps-org-setup** skill shows the two calls a new box needs, and the one-time > admin setup for an org that has none. Never copy it from another machine's config file. -All commands dispatch to the Fusion Python bridge. From this container: +Application commands reach the in-process Fusion add-in through the bridge. From this container: + +## Mechanical design (1.12 development builds) + +Discover `fusion_mech_capabilities {}` before constructing typed requests. Use `fusion_mechanical {"op":"inspect"}` for the active document target and entity references; pass references unchanged and refresh them after edits. Existing-document writes require the current document/revision, session ID and a unique operation ID. Probe transactions before transactional writes on an existing document; `new_design` probes automatically when needed. After a timeout, query `fusion_mech_operation_status` before deciding whether to retry, and reuse the exact original ID/payload only within the same session. + +The typed surface covers sketches/constraints/dimensions, common solid features, parameter/feature edits, internal Hybrid assemblies, occurrence placement, as-built joints/rigid groups, interference, material/physical properties and safe exports. Inspect actual geometry and units after edits; API availability alone is not evidence of a completed model. + +For McMaster, `mcmaster_open` launches the interactive native picker; inspect after insertion/cancellation. For a downloaded supplier model, `cad_import` ingests STEP/SAT with optional expected SHA-256 and explicit provenance. `bom` and `bom_export` support flat/structured purchasing quantities, purchased-subassembly policy and CSV. Native F3D preserves supplier attributes; neutral STEP does not promise that persistence. + +Read [the mechanical guide](docs/MECHANICAL.md) for exact contracts, [verification evidence](docs/build-2026-10-02.md) for tested behavior and [remaining gaps](docs/mechanical-comparison/REMAINING-GAPS.md) before promising additional capabilities. Arbitrary scripts do not inherit the typed recovery contract. The comparison includes Autodesk Compute, but this package does not authenticate or configure that cloud server automatically. ## Open or create a schematic (one verb) diff --git a/addin/AdomBridge/AdomBridge.py b/addin/AdomBridge/AdomBridge.py index d04486e..08f899b 100644 --- a/addin/AdomBridge/AdomBridge.py +++ b/addin/AdomBridge/AdomBridge.py @@ -11,6 +11,8 @@ import traceback import time import json import os +import errno +import uuid # Module-level references kept alive for the add-in lifetime _app = None @@ -20,6 +22,8 @@ _start_error_shown = False _custom_event = None _event_handler = None _http_thread = None +_serving = False +_custom_event_id = None CUSTOM_EVENT_ID = "AdomBridgeCommandEvent" @@ -45,18 +49,27 @@ class CommandEventHandler(adsk.core.CustomEventHandler): request_id = info["request_id"] command = info["command"] cmd_args = info.get("args", {}) + admission = info.get("mechanicalAdmission") if command == "mechanical" else None # Staleness check: if the HTTP waiter already timed out and # bailed, skip the work entirely. Saves main-thread time on # heavy exports whose caller already gave up. if not http_server.is_request_alive(request_id): + if admission is not None: + from .commands.mechanical import not_executed + not_executed(admission, {"success": False, "errorCode": "request_expired_before_execution", + "error": "HTTP waiter expired before the native handler started; this event was skipped."}) adsk.core.Application.log( f"AdomBridge: skipping stale request {request_id[:8]} ({command}) — HTTP waiter gone" ) return try: - result = dispatch_command(_app, command, cmd_args) + if admission is not None: + from .commands.mechanical import handle_mechanical + result = handle_mechanical(_app, cmd_args, admission=admission) + else: + result = dispatch_command(_app, command, cmd_args) except Exception as e: result = {"success": False, "error": f"Command error: {e}\n{traceback.format_exc()}"} @@ -68,30 +81,49 @@ class CommandEventHandler(adsk.core.CustomEventHandler): def run(context): """Called by Fusion 360 when the add-in starts.""" - global _app, _ui, _custom_event, _event_handler, _http_thread + global _app, _ui, _custom_event, _event_handler, _http_thread, _serving, _custom_event_id + + # Fusion may repeat run on the same loaded module. Do not replace a live + # listener's app/event globals or register a second handler. + if _serving: + if _http_thread is not None and _http_thread.is_alive(): + return + adsk.core.Application.log("Adom Bridge listener stopped unexpectedly; stop this copy before restarting it.") + return + if _custom_event is not None: + adsk.core.Application.log("Adom Bridge still owns a previous event registration; stop this copy before restarting it.") + return try: _app = adsk.core.Application.get() _ui = _app.userInterface # Register the custom event for thread marshaling - _custom_event = _app.registerCustomEvent(CUSTOM_EVENT_ID) + _custom_event_id = CUSTOM_EVENT_ID + "-" + uuid.uuid4().hex + _custom_event = _app.registerCustomEvent(_custom_event_id) + if _custom_event is None: + raise RuntimeError("Fusion refused this copy's unique CustomEvent registration.") _event_handler = CommandEventHandler() - _custom_event.add(_event_handler) + if not _custom_event.add(_event_handler): + raise RuntimeError("Fusion refused the CustomEvent handler registration.") # Start the HTTP server on a background daemon thread from . import http_server try: - _http_thread = http_server.start(CUSTOM_EVENT_ID, _app) - except OSError: + _http_thread = http_server.start(_custom_event_id, _app) + except OSError as exc: + if exc.errno != errno.EADDRINUSE and getattr(exc, "winerror", None) != 10048: + raise # Port 8774 already bound: a SIBLING copy of this add-in is serving. install() # writes every known AddIns location and newer Fusion builds scan more than one # (seen live on #69: FusionAddins AND API/AddIns both loaded), so a second copy # binding the port is normal operation, not an error the user can act on. # Stand down quietly; the serving copy has the job. (Colby, PR #62) + stop(context) adsk.core.Application.log( - "Adom Bridge add-in: port 8774 already served by a sibling copy; this copy is standing down") + "Adom Bridge add-in: port 8774 is already occupied; this copy is standing down") return + _serving = True # Log to the Text Commands palette (non-blocking) adsk.core.Application.log(f"Adom Bridge add-in started (port 8774)") @@ -111,6 +143,7 @@ def run(context): # once per user action (issue #69, Colby, 2026-08-28). After the first box, log. global _start_error_shown tb = traceback.format_exc() + stop(context) here = os.path.dirname(os.path.abspath(__file__)) # Leave a marker the BRIDGE reads (fusion_readiness / fusion_addin_status): it knows # which AddIns dir is broken and re-syncs it, so the fix is one bridge start away. @@ -141,25 +174,40 @@ def run(context): def stop(context): """Called by Fusion 360 when the add-in stops.""" - global _app, _ui, _custom_event, _event_handler, _http_thread + global _custom_event, _event_handler, _http_thread, _serving, _custom_event_id try: # Stop HTTP server - from . import http_server - http_server.stop() try: - from .commands import activity_palette - activity_palette.teardown() - except Exception: - pass - - # Unregister custom event - if _app and _custom_event: - _app.unregisterCustomEvent(CUSTOM_EVENT_ID) - - _custom_event = None - _event_handler = None - _http_thread = None + from . import http_server + http_server.stop() + finally: + # A losing sibling has never owned this globally named palette. + if _serving: + try: + from .commands import activity_palette + activity_palette.teardown() + except Exception: + pass + released = _custom_event is None + try: + if _custom_event is not None and _event_handler is not None: + try: + _custom_event.remove(_event_handler) + finally: + if _app is not None and _custom_event_id is not None: + released = bool(_app.unregisterCustomEvent(_custom_event_id)) + if not released: + raise RuntimeError("Fusion refused owned CustomEvent cleanup; registration retained for stop retry.") + elif _custom_event is not None and _app is not None and _custom_event_id is not None: + released = bool(_app.unregisterCustomEvent(_custom_event_id)) + if not released: + raise RuntimeError("Fusion refused owned CustomEvent cleanup; registration retained for stop retry.") + finally: + if released: + _custom_event = _event_handler = _custom_event_id = None + _http_thread = None + _serving = False except Exception: # NEVER a modal from stop. Fusion re-fires the lifecycle on ordinary events, so diff --git a/addin/AdomBridge/commands/__init__.py b/addin/AdomBridge/commands/__init__.py index 0674369..7ab0009 100644 --- a/addin/AdomBridge/commands/__init__.py +++ b/addin/AdomBridge/commands/__init__.py @@ -65,8 +65,12 @@ from .silkscreen_capture import handle_take_silkscreen_screenshot from .modeling import handle_run_modeling_script from .assembly_bom import handle_assembly_bom from .physical_properties import handle_physical_properties +from .mechanical import handle_mechanical, capabilities, operation_status COMMAND_HANDLERS = { + "mechanical": handle_mechanical, + "mech_capabilities": capabilities, + "mech_operation_status": operation_status, "activity_banner": handle_activity_banner, # App state "get_app_state": handle_get_app_state, diff --git a/addin/AdomBridge/commands/assembly_bom.py b/addin/AdomBridge/commands/assembly_bom.py index d436573..3feb13c 100644 --- a/addin/AdomBridge/commands/assembly_bom.py +++ b/addin/AdomBridge/commands/assembly_bom.py @@ -1,18 +1,15 @@ -"""Structured, kit-aware mechanical assembly BOM (mirrors Fusion's Manage -> BOM). - -The electronics `export_bom` only works on an open PCB board. This is its mechanical -counterpart: it walks the active Design assembly and returns a purchasing BOM that -respects the component hierarchy. It recurses through organizational subassemblies but -counts a physical part or a purchased kit/unit ONCE, so hardware modeled INSIDE a kit -(for example a "... with fasteners" bracket, or a bearing/pulley unit) is not -double-counted the way a flat allOccurrences walk would count it. -""" - +"""Mechanical BOM grouped by resolved component identity, never display name.""" import csv import time +import tempfile +from pathlib import Path import adsk.core import adsk.fusion +from .mechanical.core import identity, items +from .mechanical.exporting import publish_staged +from .mechanical.assembly_state import metadata +from .physical_properties import own_properties def _material_name(comp): @@ -21,196 +18,125 @@ def _material_name(comp): return comp.material.name except Exception: pass - try: - if comp.bRepBodies.count > 0 and comp.bRepBodies.item(0).material: - return comp.bRepBodies.item(0).material.name - except Exception: - pass return "" def _attr(comp, name): - try: - v = getattr(comp, name, "") or "" - return v.strip() - except Exception: - return "" + return (getattr(comp, name, "") or "").strip() def handle_assembly_bom(app: adsk.core.Application, args: dict) -> dict: - """Return a structured, kit-aware parts list for the active mechanical design. - - args: - treatAsUnit optional list of name substrings whose subassemblies are counted once - and NOT exploded (default: ["with fasteners"]). - exclude optional list of top-level component-name prefixes to skip entirely. - includePhysicalProperties optional bool -- add volume_cm3 / mass_kg per line. - outputPath optional str -- also write a CSV to this Windows path. - """ design = adsk.fusion.Design.cast(app.activeProduct) if not design: - return {"success": False, "error": "No active Fusion Design.", - "_hint": "Open a mechanical design in the Design workspace, then retry."} - + return {"success": False, "error": "No active Fusion Design."} args = args or {} - # Walk budget (issue #659, found by the daily verb suite): an electronics 3D PCB carries an - # occurrence per pad, so the walk that is instant on mechanical assemblies grinds for minutes, - # blows the relay window, and blocks the add-in main thread for every later verb. Refuse FAST - # with a redirect instead: the mechanical walk is the wrong tool for a board anyway. - budget = float(args.get("maxSeconds", 20)) - deadline = time.time() + budget - try: - occ_total = design.rootComponent.allOccurrences.count - except Exception: - occ_total = -1 - if occ_total > 3000: - return {"success": False, "errorCode": "bom_walk_budget_exceeded", - "occurrences": occ_total, - "error": "This design has %d occurrences - walking it would block Fusion for minutes." % occ_total, - "_hint": "This looks like an electronics 3D PCB (an occurrence per pad), and the mechanical " - "BOM walk is the wrong tool for a board: use fusion_export_bom for the electronics " - "BOM. For a genuinely huge MECHANICAL assembly, re-run with maxSeconds raised " - "(e.g. {\"maxSeconds\": 120}) and expect a long wait."} - kit = [k.lower() for k in (args.get("treatAsUnit") or ["with fasteners", "with fastener"])] + # Preserve the electronics fast-refusal gate: a 3D PCB contains occurrences + # per pad and is not a suitable input for this mechanical purchasing BOM. + if design.rootComponent.allOccurrences.count > 3000: + return {"success": False, "errorCode": "bom_walk_budget_exceeded", "complete": False, + "error": "Mechanical BOM occurrence budget exceeded; use fusion_export_bom for a PCB.", + "occurrences": design.rootComponent.allOccurrences.count} + raw_kit = args.get("treatAsUnit") + kit = [k.lower() for k in (["with fasteners", "with fastener"] if raw_kit is None else raw_kit)] exclude = tuple(args.get("exclude") or []) include_pp = bool(args.get("includePhysicalProperties", False)) + deadline = time.monotonic() + min(max(float(args.get("maxSeconds", 20)), 0.1), 300) + parts, collapsed, errors = {}, {}, [] + + def add(comp, path, rule=None): + key = identity(comp) + if key in parts: + parts[key]["quantity"] += 1 + parts[key]["occurrencePaths"].append(path) + return + row = {"componentId": key, "componentName": comp.name, + "partNumber": _attr(comp, "partNumber"), "description": _attr(comp, "description"), + "material": _material_name(comp), "quantity": 1, "bodies": comp.bRepBodies.count, + "occurrencePaths": [path], "scope": "component_subtree" if rule else "direct_component_bodies"} + provenance = metadata(comp) + row.update({name: provenance[name] for name in ("supplier", "supplierPartNumber", "sourceUrl", "sha256", "asPurchased") if name in provenance}) + if include_pp: + try: + accuracy = adsk.fusion.CalculationAccuracy.LowCalculationAccuracy + if rule: + pp = comp.getPhysicalProperties(accuracy) + row.update(volume_cm3=pp.volume, mass_kg=pp.mass) + else: + row.update({k: v for k, v in own_properties(comp, accuracy).items() if k in ("volume_cm3", "mass_kg")}) + except Exception as exc: + errors.append({"componentId": key, "error": str(exc)}) + row.update(volume_cm3=None, mass_kg=None) + parts[key] = row - def kit_rule(comp): - n = comp.name.lower() - for k in kit: - if k in n: - return k - return None - - def is_leaf(comp): - if kit_rule(comp) is not None: - return True - return comp.bRepBodies.count > 0 # a physical part is a leaf - - parts = {} - # Transparency (issue #25, Oliver + John's call): every heuristic collapse is REPORTED so a - # name-match on someone's own "bearing test jig" is a visible, correctable decision instead of - # silently vanishing its internals from the BOM. collapsed = {unitName: {rule, quantity}}. - collapsed = {} - # Attribution for the inverse advisory: which DESCENDED subassembly each leaf came from, so a - # purchased-looking unit that was exploded (240 identical bearing balls) can be flagged. - sub_stats = {} # subassembly name -> {part name -> instances} - sub_stack = [] - - class _BudgetExceeded(Exception): - pass - + visited = 0 def walk(occs): - for i in range(occs.count): - if time.time() > deadline: - raise _BudgetExceeded() - occ = occs.item(i) + nonlocal visited + for occ in items(occs): + visited += 1 + if time.monotonic() > deadline or visited > 20000: + raise TimeoutError("BOM walk budget exceeded.") comp = occ.component - name = comp.name - if any(name.startswith(e) for e in exclude): + provenance = metadata(comp) + if provenance.get("excludeFromBom", False): continue + if any(comp.name.startswith(e) for e in exclude): + continue + rule = next((k for k in kit if k in comp.name.lower()), None) + if raw_kit is None and provenance.get("asPurchased", False): + rule = "asPurchased metadata" children = occ.childOccurrences - has_children = children is not None and children.count > 0 - if is_leaf(comp): - rule = kit_rule(comp) - if rule is not None and has_children: - c = collapsed.setdefault(name, {"unit": name, "matchedRule": rule, "quantity": 0}) - c["quantity"] += 1 - if sub_stack and rule is None: - stats = sub_stats.setdefault(sub_stack[-1], {}) - stats[name] = stats.get(name, 0) + 1 - row = parts.get(name) - if row is None: - row = { - "componentName": name, - "partNumber": _attr(comp, "partNumber"), - "description": _attr(comp, "description"), - "material": _material_name(comp), - "quantity": 1, - "bodies": comp.bRepBodies.count, - } - if include_pp: - try: - pp = comp.getPhysicalProperties( - adsk.fusion.CalculationAccuracy.LowCalculationAccuracy) - row["volume_cm3"] = round(pp.volume, 4) - row["mass_kg"] = round(pp.mass, 6) - except Exception: - row["volume_cm3"] = None - row["mass_kg"] = None - parts[name] = row - else: - row["quantity"] += 1 - elif has_children: - sub_stack.append(name) + if comp.bRepBodies.count or rule: + add(comp, occ.fullPathName, rule) + if rule and children.count: + row = collapsed.setdefault(identity(comp), {"componentId": identity(comp), "unit": comp.name, "matchedRule": rule, "quantity": 0}) + row["quantity"] += 1 + elif children.count: + # A component may own bodies AND children. Count both instead of + # treating every component containing a body as a terminal leaf. walk(children) - sub_stack.pop() try: - walk(design.rootComponent.occurrences) - except _BudgetExceeded: - return {"success": False, "errorCode": "bom_walk_budget_exceeded", - "occurrences": occ_total, "budgetSeconds": budget, - "partialParts": len(parts), - "error": "BOM walk exceeded its %ds budget (%d part types found so far)." % (budget, len(parts)), - "_hint": "On an electronics board use fusion_export_bom instead; on a huge mechanical " - "assembly re-run with a larger maxSeconds."} + root = design.rootComponent + if root.bRepBodies.count: + add(root, "") + walk(root.occurrences) + except Exception as exc: + return {"success": False, "errorCode": "bom_walk_budget_exceeded" if isinstance(exc, TimeoutError) else "bom_walk_failed", "error": str(exc), + "complete": False, "partialParts": len(parts)} rows = list(parts.values()) - - doc_name = design.rootComponent.name - try: - if design.parentDocument: - doc_name = design.parentDocument.name - except Exception: - pass - - # Inverse advisory (Oliver, PR #38 intent): a DESCENDED subassembly dominated by many - # identical small parts walks and quacks like a purchased unit that should be one BOM line. - UNDER_COLLAPSE_MIN = 12 - possible_under = [] - for sub, stats in sub_stats.items(): - part, n = max(stats.items(), key=lambda kv: kv[1]) - if n >= UNDER_COLLAPSE_MIN: - possible_under.append({ - "subassembly": sub, "dominantPart": part, "instances": n, - "why": "descended subassembly dominated by %d identical parts; if it is a purchased unit, add a matching substring to treatAsUnit and it becomes one line" % n}) - - out = { - "success": True, - "design": doc_name, - "partCount": len(rows), - "totalInstances": sum(r["quantity"] for r in rows), - "treatAsUnit": kit, - "collapsedUnits": sorted(collapsed.values(), key=lambda c: -c["quantity"]), - "possibleUnderCollapse": sorted(possible_under, key=lambda a: -a["instances"]), - "parts": rows, - } - if collapsed or possible_under: - bits = [] - if collapsed: - bits.append("%d subassembly type(s) were COLLAPSED to one line each by treatAsUnit name-matching (see collapsedUnits); if any is one of YOUR designs rather than a purchased unit, re-run with an explicit treatAsUnit list (or []) to explode it" % len(collapsed)) - if possible_under: - bits.append("%d descended subassembly(ies) look like purchased units (see possibleUnderCollapse); add a matching substring to treatAsUnit to collapse them" % len(possible_under)) - out["_hint"] = ". ".join(bits) + "." - - - outpath = args.get("outputPath") - if outpath: + out = {"success": not errors, "complete": not errors, "design": design.parentDocument.name, + "partCount": len(rows), "totalInstances": sum(r["quantity"] for r in rows), + "treatAsUnit": kit, "collapsedUnits": list(collapsed.values()), + "possibleUnderCollapse": [], "parts": rows, "errors": errors} + if collapsed: + out["_hint"] = "Named purchased-unit rules collapsed subassemblies; inspect collapsedUnits. Pass treatAsUnit:[] to explode all children." + if args.get("outputPath") and out["success"]: try: - cols = ["Part Number", "Part Name", "Description", "Material", "Quantity"] - if include_pp: - cols += ["Volume cm3", "Mass kg"] - with open(outpath, "w", newline="", encoding="utf-8") as f: - w = csv.writer(f) - w.writerow(cols) - for r in rows: - line = [r["partNumber"], r["componentName"], r["description"], - r["material"], r["quantity"]] + destination = Path(args["outputPath"]) + if not destination.is_absolute() or destination.suffix.lower() != ".csv": + raise ValueError("outputPath must be an absolute .csv path.") + if destination.is_symlink() or destination.is_dir(): + raise ValueError("Destination must be a regular file path.") + destination.parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix=".adom-bom-", dir=str(destination.parent)) as folder: + staged = Path(folder) / "bom.csv" + with staged.open("w", newline="", encoding="utf-8") as stream: + writer = csv.writer(stream) + columns = ["Component ID", "Part Number", "Part Name", "Description", "Material", "Quantity", "Supplier", "Supplier Part Number", "Source URL", "SHA256"] if include_pp: - line += [r.get("volume_cm3"), r.get("mass_kg")] - w.writerow(line) - out["outputPath"] = outpath - except Exception as e: - out["csvError"] = str(e) - + columns += ["Volume cm3", "Mass kg"] + writer.writerow(columns) + for row in rows: + line = [row["componentId"], row["partNumber"], row["componentName"], row["description"], row["material"], row["quantity"]] + line += [row.get(name, "") for name in ("supplier", "supplierPartNumber", "sourceUrl", "sha256")] + if include_pp: + line += [row.get("volume_cm3"), row.get("mass_kg")] + # CSV intended for spreadsheet use: text cannot become a formula. + writer.writerow([("'" + cell if cell[:1] in "=+-@" else cell) if isinstance(cell, str) and cell else cell for cell in line]) + out.update(publish_staged(staged, destination, args.get("overwrite", True))) + except Exception as exc: + if out.get("published"): + out["cleanupWarning"] = str(exc) + else: + out.update(success=False, csvError=str(exc), errorCode="bom_csv_failed", error=str(exc)) return out diff --git a/addin/AdomBridge/commands/cloud_documents.py b/addin/AdomBridge/commands/cloud_documents.py index ba3c19c..837ca76 100644 --- a/addin/AdomBridge/commands/cloud_documents.py +++ b/addin/AdomBridge/commands/cloud_documents.py @@ -1484,98 +1484,14 @@ def handle_search_cloud_files(app: adsk.core.Application, args: dict) -> dict: def handle_export_cloud_file(app: adsk.core.Application, args: dict) -> dict: - """Export the active Fusion document to a local file for transfer. - - Exports the currently open document as STEP, STL, F3D, or other format - to a local file path. The file can then be pulled back to Docker via pull_file. - - Args: - args: Dict with: - - outputPath: Local file path to export to (required). - - format: Export format (optional, default "step"). - Supported: "step", "stl", "f3d", "iges", "sat", "smt". - """ - output_path = args.get("outputPath", "").strip() - if not output_path: - return {"success": False, "error": "No outputPath specified."} - - output_path = output_path.replace("\\", "/") + """Compatibility entry point using the safe shared single-file exporter.""" + from .export import _export fmt = args.get("format", "step").lower().strip() - - doc = app.activeDocument - if not doc: - return { - "success": False, - "error": "No active document to export.", - "_hint": "Call fusion_open_cloud_file to open a file, then retry.", - } - - # Get the design product - design = None - try: - design = adsk.fusion.Design.cast(app.activeProduct) - except Exception: - pass - - if not design: - return { - "success": False, - "error": "Active document is not a Fusion Design. Cannot export.", - "_hint": "Call fusion_activate_workspace with target='design' or open a Fusion Design document first, then retry.", - "data": {"hint": "Open a design document first, or switch to the Design workspace."}, - } - - # Ensure output directory exists - out_dir = os.path.dirname(output_path) - if out_dir and not os.path.exists(out_dir): - os.makedirs(out_dir, exist_ok=True) - - root = design.rootComponent - export_mgr = design.exportManager - - try: - if fmt in ("step", "stp"): - options = export_mgr.createSTEPExportOptions(output_path, root) - export_mgr.execute(options) - elif fmt == "stl": - options = export_mgr.createSTLExportOptions(root, output_path) - export_mgr.execute(options) - elif fmt in ("iges", "igs"): - options = export_mgr.createIGESExportOptions(output_path, root) - export_mgr.execute(options) - elif fmt == "sat": - options = export_mgr.createSATExportOptions(output_path, root) - export_mgr.execute(options) - elif fmt == "smt": - options = export_mgr.createSMTExportOptions(output_path, root) - export_mgr.execute(options) - elif fmt == "f3d": - options = export_mgr.createFusionArchiveExportOptions(output_path) - export_mgr.execute(options) - else: - return { - "success": False, - "error": f"Unsupported format: {fmt}", - "data": {"supportedFormats": ["step", "stl", "f3d", "iges", "sat", "smt"]}, - } - except Exception as e: - return {"success": False, "error": f"Export failed: {e}"} - - if not os.path.exists(output_path): - return {"success": False, "error": f"Export file was not created at {output_path}"} - - size = os.path.getsize(output_path) - - return { - "success": True, - "output": f"Exported '{doc.name}' as {fmt.upper()} to {output_path} ({size} bytes)", - "data": { - "outputPath": output_path, - "format": fmt, - "fileSize": size, - "documentName": doc.name, - }, - } + fmt = {"stp": "step", "igs": "iges"}.get(fmt, fmt) + out = _export(app, args, fmt) + if out.get("success"): + out["data"]["documentName"] = app.activeDocument.name + return out def handle_walk_cloud_tree(app: adsk.core.Application, args: dict) -> dict: diff --git a/addin/AdomBridge/commands/export.py b/addin/AdomBridge/commands/export.py index 13bb891..632ee1c 100644 --- a/addin/AdomBridge/commands/export.py +++ b/addin/AdomBridge/commands/export.py @@ -1,693 +1,48 @@ -"""Export commands — STEP, STL, 3MF, F3D, FBX. +"""Legacy export verbs sharing the guarded single-file staging implementation. -All 3D export commands require an active Fusion Design (3D PCB view or a CAD -design document). Switch to 3D PCB view with fusion_show_3d_board before -exporting from an electronics project. +Legacy callers retain implicit overwrite behavior. The typed mechanical exporter +requires an explicit overwrite:true. Both preserve existing bytes on failure. """ - import adsk.core import adsk.fusion -import os -import time -from pathlib import Path - - -# Shared hint constants — reused across every export handler. -_HINT_NO_OUTPUT_PATH = ( - "Provide an outputPath arg like {\"outputPath\": \"C:\\\\path\\\\out.step\"}. " - "The path must include the file extension matching the export format." -) -_HINT_FORMAT_UNAVAILABLE = ( - "This format isn't available in this Fusion build. " - "Use fusion_export_step or fusion_export_stl as a fallback." -) -_HINT_EXPORT_FAILED = ( - "If this persists, call fusion_screenshot_fusion to check for a blocking dialog, " - "then fusion_send_key with 'escape' or 'enter' to dismiss it. " - "Wrong-workspace errors: call fusion_show_3d_board first." -) -_HINT_FILE_LOCKED = ( - "Close the file in any viewer (Blender, Windows Explorer preview, etc.), " - "or call fusion_close_document first if it's open in Fusion, then retry." -) +from .mechanical.exporting import export_design +from .mechanical.core import MechanicalError -def _get_active_design(app: adsk.core.Application): - """Get the active Fusion design, or None.""" - product = app.activeProduct - if product and product.productType == "DesignProductType": - return adsk.fusion.Design.cast(product) - return None +def _get_active_design(app): + return adsk.fusion.Design.cast(app.activeProduct) def _no_design_error(): - """Standard error when no active design is available.""" - return { - "success": False, - "error": "No active design open in Fusion 360.", - "_hint": "If you're in PCB Editor (2D board), call fusion_show_3d_board to switch to 3D view. Otherwise call fusion_open_cloud_file to open a Design document, then retry.", - "data": { - "hint": "3D exports require a Design product. If you're in PCB Editor (2D board), " - "switch to 3D view first with fusion_show_3d_board.", - "recoverySteps": [ - "Run fusion_show_3d_board to switch to 3D PCB view", - "Or open a .f3d design document", - "Then retry the export command", - ], - }, - } - - -def _export_success(fmt: str, output_path: str, extra_msg: str = "") -> dict: - """Standard success response for 3D exports.""" - size = os.path.getsize(output_path) if os.path.exists(output_path) else 0 - return { - "success": True, - "output": f"Exported {fmt} to {output_path}", - "message": f"{fmt} file exported ({size // 1024} KB). " - f"{extra_msg}" - "Use pull_file to transfer to Docker.", - "data": { - "outputPath": output_path, - "format": fmt.lower(), - "fileSizeKB": size // 1024, - "nextSteps": [ - "Use pull_file to transfer the file back to Docker", - "Export other formats: fusion_export_step, fusion_export_stl, fusion_export_3mf, fusion_export_f3d, fusion_export_fbx", - ], - }, - } - - -def handle_export_step(app: adsk.core.Application, args: dict) -> dict: - """Export the active design as a STEP file (.step). - - Industry-standard CAD interchange format. Best for mechanical CAD - interoperability (SolidWorks, CATIA, Creo, etc.). - - Args: - outputPath: Full path for the output .step file (required). - """ - output_path = args.get("outputPath", "") - if not output_path: - return {"success": False, "error": "No outputPath specified", "_hint": _HINT_NO_OUTPUT_PATH} - - design = _get_active_design(app) - if not design: - return _no_design_error() - - Path(output_path).parent.mkdir(parents=True, exist_ok=True) - - export_mgr = design.exportManager - options = export_mgr.createSTEPExportOptions(output_path) - result = export_mgr.execute(options) - - if result: - return _export_success("STEP", output_path, - "Industry-standard CAD interchange — opens in any CAD tool. ") - return {"success": False, "error": "STEP export failed", "_hint": _HINT_EXPORT_FAILED} - - -def handle_export_stl(app: adsk.core.Application, args: dict) -> dict: - """Export the active design as an STL file (.stl). - - Mesh-based format for 3D printing and visualization. - - Args: - outputPath: Full path for the output .stl file (required). - refinement: "low", "medium", or "high" (default: "medium"). - """ - output_path = args.get("outputPath", "") - if not output_path: - return {"success": False, "error": "No outputPath specified", "_hint": _HINT_NO_OUTPUT_PATH} - - design = _get_active_design(app) - if not design: - return _no_design_error() - - Path(output_path).parent.mkdir(parents=True, exist_ok=True) - - root = design.rootComponent - export_mgr = design.exportManager - options = export_mgr.createSTLExportOptions(root, output_path) - - refinement = args.get("refinement", "medium").lower() - refinement_map = { - "low": adsk.fusion.MeshRefinementSettings.MeshRefinementLow, - "medium": adsk.fusion.MeshRefinementSettings.MeshRefinementMedium, - "high": adsk.fusion.MeshRefinementSettings.MeshRefinementHigh, - } - options.meshRefinement = refinement_map.get( - refinement, adsk.fusion.MeshRefinementSettings.MeshRefinementMedium - ) - - result = export_mgr.execute(options) - - if result: - return _export_success("STL", output_path, - f"Mesh refinement: {refinement}. Good for 3D printing. ") - return {"success": False, "error": "STL export failed", "_hint": _HINT_EXPORT_FAILED} - - -def handle_export_3mf(app: adsk.core.Application, args: dict) -> dict: - """Export the active design as a 3MF file (.3mf). - - Modern 3D printing format — supports color, materials, and multi-body. - - Args: - outputPath: Full path for the output .3mf file (required). - """ - output_path = args.get("outputPath", "") - if not output_path: - return {"success": False, "error": "No outputPath specified", "_hint": _HINT_NO_OUTPUT_PATH} - - design = _get_active_design(app) - if not design: - return _no_design_error() - - Path(output_path).parent.mkdir(parents=True, exist_ok=True) - - root = design.rootComponent - export_mgr = design.exportManager - options = export_mgr.createC3MFExportOptions(root, output_path) - result = export_mgr.execute(options) + return {"success": False, "errorCode": "no_design", "error": "No active Fusion Design.", + "_hint": "Open a Design or switch the electronics project to its 3D view."} - if result: - return _export_success("3MF", output_path, - "Modern 3D printing format with color/material support. ") - return {"success": False, "error": "3MF export failed", "_hint": _HINT_EXPORT_FAILED} - - -def handle_export_f3d(app: adsk.core.Application, args: dict) -> dict: - """Export the active design as a Fusion 360 archive (.f3d). - - Native Fusion format — preserves all parametric features, sketches, - timeline history, and component references. Best for archiving or - sharing with other Fusion 360 users. - - Args: - outputPath: Full path for the output .f3d file (required). - """ - output_path = args.get("outputPath", "") - if not output_path: - return {"success": False, "error": "No outputPath specified", "_hint": _HINT_NO_OUTPUT_PATH} - - design = _get_active_design(app) - if not design: - return _no_design_error() - - Path(output_path).parent.mkdir(parents=True, exist_ok=True) - - export_mgr = design.exportManager - options = export_mgr.createFusionArchiveExportOptions(output_path) - result = export_mgr.execute(options) - - if result: - return _export_success("F3D", output_path, - "Fusion 360 native archive — preserves all features and history. ") - return {"success": False, "error": "F3D export failed", "_hint": _HINT_EXPORT_FAILED} - - -def handle_export_usdz(app: adsk.core.Application, args: dict) -> dict: - """Export the active design as a USDZ file (.usdz). - - Universal Scene Description (USD) — Pixar's scene-graph format adopted - by Apple, NVIDIA, and Autodesk. Preserves: - - Full component hierarchy (board, copper layers, soldermask, packages) - - PBR materials and colors - - Named nodes for toggling parts on/off in viewers - - Best format for digital twins, AR (Apple Quick Look), and GLB conversion - via Blender. The component hierarchy maps directly to toggleable nodes - in GLB viewers. - - Pipeline: Fusion → USDZ → Blender → GLB (or view USDZ directly on iOS) - - Args: - outputPath: Full path for the output .usdz file (required). - """ - output_path = args.get("outputPath", "") - if not output_path: - return {"success": False, "error": "No outputPath specified", "_hint": _HINT_NO_OUTPUT_PATH} +def _export(app, args, fmt): design = _get_active_design(app) if not design: return _no_design_error() - - # Ensure .usdz extension - if not output_path.lower().endswith(('.usdz', '.usd', '.usda', '.usdc')): - output_path = str(Path(output_path).with_suffix('.usdz')) - - Path(output_path).parent.mkdir(parents=True, exist_ok=True) - - # Pre-delete to avoid overwrite dialogs - if os.path.exists(output_path): - try: - os.remove(output_path) - except PermissionError: - return { - "success": False, - "error": f"Cannot overwrite {output_path} — file is locked.", - "_hint": _HINT_FILE_LOCKED, - "data": { - "hint": "Close the file in any viewer and retry.", - "outputPath": output_path, - }, - } - - export_mgr = design.exportManager - try: - options = export_mgr.createUSDExportOptions(output_path) - except AttributeError: - return { - "success": False, - "error": "USDZ export not available in this Fusion 360 version.", - "_hint": _HINT_FORMAT_UNAVAILABLE, - "data": { - "hint": "createUSDExportOptions requires a recent Fusion 360 build. " - "Export as STEP instead and convert via Blender.", - "recoverySteps": [ - "Export STEP: fusion_export_step (then convert in Blender)", - "Export OBJ: fusion_export_fbx (falls back to OBJ)", - ], - }, - } - - result = export_mgr.execute(options) - - if result and os.path.exists(output_path): - size = os.path.getsize(output_path) - - # Get component hierarchy for the response - components = [] - try: - root = design.rootComponent - for i in range(root.occurrences.count): - occ = root.occurrences.item(i) - components.append(occ.name) - except Exception: - pass - - return { - "success": True, - "output": f"Exported USDZ to {output_path}", - "message": f"USDZ exported ({size // 1024} KB) with full component hierarchy. " - f"Contains {len(components)} top-level components that map to " - f"toggleable nodes in GLB viewers. " - f"Use pull_file to transfer to Docker.", - "data": { - "outputPath": output_path, - "format": "usdz", - "fileSizeKB": size // 1024, - "components": components, - "hint": "USDZ preserves PBR materials and scene hierarchy. " - "Each component (Board, copper layers, soldermask, Packages) " - "becomes a separate toggleable node in the GLB.", - "nextSteps": [ - "Use pull_file to transfer the USDZ to Docker", - "Convert to GLB: blender --background --python-expr " - "\"import bpy; bpy.ops.wm.read_factory_settings(use_empty=True); " - "bpy.ops.wm.usd_import(filepath='input.usdz'); " - "bpy.ops.export_scene.gltf(filepath='output.glb')\"", - "Or view directly on iOS/macOS — USDZ is Apple's native AR format", - "Or import into Unity/Unreal Engine for interactive 3D", - ], - }, - } - - return {"success": False, "error": "USDZ export failed", "_hint": _HINT_EXPORT_FAILED} - - -def handle_export_fbx(app: adsk.core.Application, args: dict) -> dict: - """Export the active design as an FBX file (.fbx). - - Autodesk FBX format — widely used for 3D interchange with Blender, - Unity, Unreal Engine, and other DCC tools. - - NOTE: FBX export is not available in current Fusion 360 builds via the API. - This command will fail clearly — use fusion_export_usdz or fusion_export_step - instead. No silent fallbacks. - - Args: - outputPath: Full path for the output .fbx file (required). - """ - output_path = args.get("outputPath", "") - if not output_path: - return {"success": False, "error": "No outputPath specified", "_hint": _HINT_NO_OUTPUT_PATH} - - design = _get_active_design(app) - if not design: - return _no_design_error() - - Path(output_path).parent.mkdir(parents=True, exist_ok=True) - - # Pre-delete to avoid overwrite dialogs - if os.path.exists(output_path): - try: - os.remove(output_path) - except PermissionError: - return { - "success": False, - "error": f"Cannot overwrite {output_path} — file is locked.", - "_hint": _HINT_FILE_LOCKED, - "data": { - "hint": "The file may be open in another application. Close it and retry.", - "outputPath": output_path, - }, - } - - # Try FBX via exportManager API - export_mgr = design.exportManager try: - options = export_mgr.createFBXExportOptions(output_path) - result = export_mgr.execute(options) - if result and os.path.exists(output_path): - return _export_success("FBX", output_path, - "Convert to GLB via Blender: " - "blender --background --python-expr " - "\"import bpy; bpy.ops.import_scene.fbx(filepath=''); " - "bpy.ops.export_scene.gltf(filepath='')\" ") - except (AttributeError, Exception): - pass # createFBXExportOptions doesn't exist in this Fusion build + data = export_design(design, {**args, "format": fmt, "overwrite": args.get("overwrite", True)}) + data["fileSizeKB"] = data["fileSize"] // 1024 + return {"success": True, "data": data, + "output": "Exported " + fmt.upper() + " to " + data["outputPath"]} + except MechanicalError as exc: + return {"success": False, "errorCode": exc.code, "error": str(exc), **exc.details} + except Exception as exc: + return {"success": False, "errorCode": "export_failed", "error": str(exc), + "destinationPreserved": True} - # FBX not available — fail clearly, no fallbacks - return { - "success": False, - "error": "FBX export not available in this Fusion 360 build. " - "Use fusion_export_usdz or fusion_export_step instead.", - "_hint": _HINT_FORMAT_UNAVAILABLE, - "data": { - "hint": "FBX is not supported via the Fusion API in current builds. " - "Use fusion_export_usdz (best for Blender/GLB pipeline, preserves " - "PBR materials and scene hierarchy) or fusion_export_step " - "(highest geometric fidelity).", - "alternatives": ["fusion_export_usdz", "fusion_export_step", "fusion_export_stl"], - }, - } - - -def handle_export_dxf(app: adsk.core.Application, args: dict) -> dict: - """Export the active design as a DXF file (.dxf). - - AutoCAD DXF (Drawing Exchange Format) — universal 2D/3D CAD interchange. - Widely supported by all CAD tools, laser cutters, CNC routers, and - PCB fabrication houses. - - Args: - outputPath: Full path for the output .dxf file (required). - """ - output_path = args.get("outputPath", "") - if not output_path: - return {"success": False, "error": "No outputPath specified", "_hint": _HINT_NO_OUTPUT_PATH} - - design = _get_active_design(app) - if not design: - return _no_design_error() - - Path(output_path).parent.mkdir(parents=True, exist_ok=True) - - if os.path.exists(output_path): - try: - os.remove(output_path) - except PermissionError: - return { - "success": False, - "error": f"Cannot overwrite {output_path} — file is locked.", - "_hint": _HINT_FILE_LOCKED, - "data": {"outputPath": output_path}, - } - - export_mgr = design.exportManager - try: - options = export_mgr.createDXFExportOptions(output_path) - result = export_mgr.execute(options) - if result and os.path.exists(output_path): - return _export_success("DXF", output_path, - "Universal CAD interchange — opens in AutoCAD, LibreCAD, etc. ") - except (AttributeError, Exception): - pass - - return { - "success": False, - "error": "DXF export failed or not available in this Fusion 360 build.", - "_hint": _HINT_FORMAT_UNAVAILABLE, - "data": { - "hint": "Try fusion_export_step for CAD interchange instead.", - "alternatives": ["fusion_export_step", "fusion_export_dwg"], - }, - } - - -def handle_export_dwg(app: adsk.core.Application, args: dict) -> dict: - """Export the active design as a DWG file (.dwg). - - AutoCAD native format — the industry standard for 2D/3D technical - drawings. Best for sharing with AutoCAD, BricsCAD, DraftSight users. - - Args: - outputPath: Full path for the output .dwg file (required). - """ - output_path = args.get("outputPath", "") - if not output_path: - return {"success": False, "error": "No outputPath specified", "_hint": _HINT_NO_OUTPUT_PATH} - - design = _get_active_design(app) - if not design: - return _no_design_error() - - Path(output_path).parent.mkdir(parents=True, exist_ok=True) - - if os.path.exists(output_path): - try: - os.remove(output_path) - except PermissionError: - return { - "success": False, - "error": f"Cannot overwrite {output_path} — file is locked.", - "_hint": _HINT_FILE_LOCKED, - "data": {"outputPath": output_path}, - } - - export_mgr = design.exportManager - try: - options = export_mgr.createDWGExportOptions(output_path) - result = export_mgr.execute(options) - if result and os.path.exists(output_path): - return _export_success("DWG", output_path, - "AutoCAD native format — opens in AutoCAD, BricsCAD, etc. ") - except (AttributeError, Exception): - pass - - return { - "success": False, - "error": "DWG export failed or not available in this Fusion 360 build.", - "_hint": _HINT_FORMAT_UNAVAILABLE, - "data": { - "hint": "Try fusion_export_dxf or fusion_export_step instead.", - "alternatives": ["fusion_export_dxf", "fusion_export_step"], - }, - } - - -def handle_export_iges(app: adsk.core.Application, args: dict) -> dict: - """Export the active design as an IGES file (.igs/.iges). - - Legacy CAD interchange format. Still used by some older CAD systems - and manufacturing tools. STEP is preferred for modern workflows. - - Args: - outputPath: Full path for the output .igs file (required). - """ - output_path = args.get("outputPath", "") - if not output_path: - return {"success": False, "error": "No outputPath specified", "_hint": _HINT_NO_OUTPUT_PATH} - - design = _get_active_design(app) - if not design: - return _no_design_error() - - Path(output_path).parent.mkdir(parents=True, exist_ok=True) - - if os.path.exists(output_path): - try: - os.remove(output_path) - except PermissionError: - return { - "success": False, - "error": f"Cannot overwrite {output_path} — file is locked.", - "_hint": _HINT_FILE_LOCKED, - "data": {"outputPath": output_path}, - } - - export_mgr = design.exportManager - try: - options = export_mgr.createIGESExportOptions(output_path) - result = export_mgr.execute(options) - if result and os.path.exists(output_path): - return _export_success("IGES", output_path, - "Legacy CAD interchange — STEP is preferred for modern use. ") - except (AttributeError, Exception): - pass - - return { - "success": False, - "error": "IGES export failed or not available in this Fusion 360 build.", - "_hint": _HINT_FORMAT_UNAVAILABLE, - "data": { - "hint": "Try fusion_export_step instead (modern replacement for IGES).", - "alternatives": ["fusion_export_step"], - }, - } - - -def handle_export_obj(app: adsk.core.Application, args: dict) -> dict: - """Export the active design as an OBJ file (.obj). - - Wavefront OBJ — simple mesh format widely supported by 3D viewers, - game engines, and DCC tools. No materials or hierarchy. - - Args: - outputPath: Full path for the output .obj file (required). - """ - output_path = args.get("outputPath", "") - if not output_path: - return {"success": False, "error": "No outputPath specified", "_hint": _HINT_NO_OUTPUT_PATH} - - design = _get_active_design(app) - if not design: - return _no_design_error() - - Path(output_path).parent.mkdir(parents=True, exist_ok=True) - - if os.path.exists(output_path): - try: - os.remove(output_path) - except PermissionError: - return { - "success": False, - "error": f"Cannot overwrite {output_path} — file is locked.", - "_hint": _HINT_FILE_LOCKED, - "data": {"outputPath": output_path}, - } - - export_mgr = design.exportManager - try: - options = export_mgr.createOBJExportOptions(output_path) - result = export_mgr.execute(options) - if result and os.path.exists(output_path): - return _export_success("OBJ", output_path, - "Simple mesh format — opens in Blender, Unity, etc. ") - except (AttributeError, Exception): - pass - - return { - "success": False, - "error": "OBJ export failed or not available in this Fusion 360 build.", - "_hint": _HINT_FORMAT_UNAVAILABLE, - "data": { - "hint": "Try fusion_export_stl or fusion_export_usdz instead.", - "alternatives": ["fusion_export_stl", "fusion_export_usdz"], - }, - } - - -def handle_export_sat(app: adsk.core.Application, args: dict) -> dict: - """Export the active design as a SAT file (.sat). - - ACIS SAT — solid modeling kernel format. Used by SolidWorks, - SpaceClaim, and other ACIS-based CAD tools. - - Args: - outputPath: Full path for the output .sat file (required). - """ - output_path = args.get("outputPath", "") - if not output_path: - return {"success": False, "error": "No outputPath specified", "_hint": _HINT_NO_OUTPUT_PATH} - - design = _get_active_design(app) - if not design: - return _no_design_error() - - Path(output_path).parent.mkdir(parents=True, exist_ok=True) - - if os.path.exists(output_path): - try: - os.remove(output_path) - except PermissionError: - return { - "success": False, - "error": f"Cannot overwrite {output_path} — file is locked.", - "_hint": _HINT_FILE_LOCKED, - "data": {"outputPath": output_path}, - } - - export_mgr = design.exportManager - try: - options = export_mgr.createSATExportOptions(output_path) - result = export_mgr.execute(options) - if result and os.path.exists(output_path): - return _export_success("SAT", output_path, - "ACIS solid modeling format — opens in SolidWorks, SpaceClaim. ") - except (AttributeError, Exception): - pass - - return { - "success": False, - "error": "SAT export failed or not available in this Fusion 360 build.", - "_hint": _HINT_FORMAT_UNAVAILABLE, - "data": { - "hint": "Try fusion_export_step instead.", - "alternatives": ["fusion_export_step"], - }, - } - - -def handle_export_skp(app: adsk.core.Application, args: dict) -> dict: - """Export the active design as a SketchUp file (.skp). - - SketchUp format — popular for architectural visualization and - simple 3D modeling. - - Args: - outputPath: Full path for the output .skp file (required). - """ - output_path = args.get("outputPath", "") - if not output_path: - return {"success": False, "error": "No outputPath specified", "_hint": _HINT_NO_OUTPUT_PATH} - - design = _get_active_design(app) - if not design: - return _no_design_error() - - Path(output_path).parent.mkdir(parents=True, exist_ok=True) - - if os.path.exists(output_path): - try: - os.remove(output_path) - except PermissionError: - return { - "success": False, - "error": f"Cannot overwrite {output_path} — file is locked.", - "_hint": _HINT_FILE_LOCKED, - "data": {"outputPath": output_path}, - } - - export_mgr = design.exportManager - try: - options = export_mgr.createSKPExportOptions(output_path) - result = export_mgr.execute(options) - if result and os.path.exists(output_path): - return _export_success("SKP", output_path, - "SketchUp format — opens in SketchUp, Trimble viewers. ") - except (AttributeError, Exception): - pass - return { - "success": False, - "error": "SketchUp export failed or not available in this Fusion 360 build.", - "_hint": _HINT_FORMAT_UNAVAILABLE, - "data": { - "hint": "Try fusion_export_step or fusion_export_stl instead.", - "alternatives": ["fusion_export_step", "fusion_export_stl"], - }, - } +def handle_export_step(app, args): return _export(app, args, "step") +def handle_export_stl(app, args): return _export(app, args, "stl") +def handle_export_3mf(app, args): return _export(app, args, "3mf") +def handle_export_f3d(app, args): return _export(app, args, "f3d") +def handle_export_usdz(app, args): return _export(app, args, "usdz") +def handle_export_fbx(app, args): return _export(app, args, "fbx") +def handle_export_dxf(app, args): return _export(app, args, "dxf") +def handle_export_dwg(app, args): return _export(app, args, "dwg") +def handle_export_iges(app, args): return _export(app, args, "iges") +def handle_export_obj(app, args): return _export(app, args, "obj") +def handle_export_sat(app, args): return _export(app, args, "sat") +def handle_export_skp(app, args): return _export(app, args, "skp") diff --git a/addin/AdomBridge/commands/mechanical/__init__.py b/addin/AdomBridge/commands/mechanical/__init__.py new file mode 100644 index 0000000..da54e1e --- /dev/null +++ b/addin/AdomBridge/commands/mechanical/__init__.py @@ -0,0 +1,195 @@ +"""Guarded synchronous operations with durable admission and history. + +No native work is replayed across add-in sessions. Plain-data status/admission +can run on HTTP threads; document observation and handlers remain on main. +""" +import json +import math +from pathlib import Path +import threading +import uuid + +from .core import SESSION_ID, UNITS, MechanicalError, active, document_identity, require +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 + +for _name, _handler in {**sketches.OPERATIONS, **features.OPERATIONS, **assemblies.OPERATIONS, + **geometry.OPERATIONS, **sketch_edits.OPERATIONS, **regular_joints.OPERATIONS, **motion_links.OPERATIONS}.items(): + OPERATIONS[_name] = lambda app, args, fn=_handler: transactions.execute(app, args, fn) + WRITES.add(_name) +OPERATIONS.update(assemblies.READS) +OPERATIONS.update(geometry.READS) +OPERATIONS.update(sketch_edits.READS) +OPERATIONS.update(configurations.READS) +OPERATIONS.update(purchased.OPERATIONS) +OPERATIONS.update(bill_of_materials.OPERATIONS) +WRITES.update(purchased.WRITES | bill_of_materials.WRITES) +OPERATIONS["transaction_probe"] = transactions.probe +WRITES.add("transaction_probe") + +SOURCE_VERSION = json.loads((Path(__file__).resolve().parents[2] / "AdomBridge.manifest").read_text(encoding="utf-8"))["version"] +_history = OperationHistory(SESSION_ID, SOURCE_VERSION) +SCHEMA = json.loads((Path(__file__).with_name("schema.json")).read_text(encoding="utf-8")) + + +def validate(value, schema, path="args"): + if "oneOf" in schema: + variants = schema["oneOf"] + # Discriminator errors should explain the selected operation/primitive, + # rather than returning a wall of errors from unrelated variants. + if isinstance(value, dict): + for discriminator in ("type", "action"): + if discriminator in value: + matching = [variant for variant in variants if value[discriminator] in + variant.get("properties", {}).get(discriminator, {}).get("enum", [])] + if len(matching) == 1: + validate(value, matching[0], path) + variants = [] + break + if variants: + successes = 0 + for variant in variants: + try: + validate(value, variant, path) + successes += 1 + except MechanicalError: + pass + require(successes == 1, "invalid_input", path + " must match exactly one supported schema variant") + kinds = {"object": dict, "string": str, "boolean": bool, "number": (int, float), "integer": int, "array": list} + kind = schema.get("type") + if kind: + require(isinstance(value, kinds[kind]) and not (kind in ("number", "integer") and isinstance(value, bool)), + "invalid_input", path + " must be " + kind) + if kind in ("number", "integer"): + require(math.isfinite(value), "invalid_input", path + " must be finite") + if "exclusiveMinimum" in schema: + require(value > schema["exclusiveMinimum"], "invalid_input", path + " is too small") + if "minimum" in schema: + require(value >= schema["minimum"], "invalid_input", path + " is too small") + if "maximum" in schema: + require(value <= schema["maximum"], "invalid_input", path + " is too large") + if kind == "array": + require(schema.get("minItems", 0) <= len(value) <= schema.get("maxItems", 10000), "invalid_input", path + " has an invalid item count") + for index, item in enumerate(value): + validate(item, schema.get("items", {}), path + "[" + str(index) + "]") + if "enum" in schema: + require(value in schema["enum"], "invalid_input", path + " has an unsupported value") + if kind == "object": + require(len(value) >= schema.get("minProperties", 0), "invalid_input", path + " requires at least one property") + for key in schema.get("required", []): + require(key in value, "invalid_input", path + "." + key + " is required") + props = schema.get("properties", {}) + if schema.get("additionalProperties") is False: + require(not (set(value) - set(props)), "invalid_input", path + " has unknown fields") + for key in value.keys() & props.keys(): + validate(value[key], props[key], path + "." + key) + if isinstance(schema.get("additionalProperties"), dict): + for key in value.keys() - props.keys(): + validate(value[key], schema["additionalProperties"], path + "." + key) + + +def capabilities(app, args): + return {"success": True, "sessionId": SESSION_ID, "schemaVersion": 1, "units": UNITS, + "operations": SCHEMA, "transactions": {"genericAtomicBatch": False, "typedModelOperations": transactions.capability(), + "parameterRecovery": "compensating expression restore with semantic readback", + "newDesignRecovery": "discard only the new unsaved document", + "externalEffects": "exports publish atomically; model recovery does not undo files"}, + "idempotency": {"scope": "durable history keyed by add-in session and operation ID", "capacity": MAX_RECORDS, + "storageVersion": STORAGE_VERSION, "databaseByteLimit": MAX_DATABASE_BYTES, + "recordByteLimit": MAX_RECORD_BYTES, "admission": "FULL SQLite commit before native dispatch", + "restartBehavior": "old-session history is readable; native execution never replays across sessions"}} + + +def operation_status(app, args): + return _history.status(args) + + +def error_result(error): + if isinstance(error, MechanicalError): + return {"success": False, "sessionId": SESSION_ID, "errorCode": error.code, + "error": str(error), **error.details} + if isinstance(error, JournalError): + return {"success": False, "sessionId": SESSION_ID, "errorCode": error.code, "error": str(error)} + return {"success": False, "sessionId": SESSION_ID, "errorCode": "mechanical_failed", + "error": str(error), "recovery": {"verified": False, "strategy": "none"}} + + +def prepare_request(args): + """Validate and freeze user input without touching any adsk object.""" + require(isinstance(args, dict), "invalid_input", "Arguments must be an object.") + # Authorization remains in the bridge envelope. Time budgets and caller + # annotations cannot alter the canonical user request or its retry digest. + args = {key: value for key, value in args.items() if key not in ("caller", "_callerBudgetMs")} + op = args.get("op") + require(isinstance(op, str) and op in OPERATIONS, "unsupported_operation", "Discover supported operations with fusion_mech_capabilities.") + validate(args, SCHEMA[op]) + allowed = set(SCHEMA[op]["properties"]) | {"op", "sessionId", "operationId", "target"} + require(not (set(args) - allowed), "invalid_input", "Unknown operation arguments: " + ", ".join(sorted(set(args) - allowed))) + write = op in WRITES or (op == "health" and args.get("recompute", False)) + if write: + require(args.get("sessionId") == SESSION_ID, "session_mismatch", "Old-session history is readable, but native execution requires the current add-in session.") + require(isinstance(args.get("operationId"), str) and 1 <= len(args["operationId"]) <= 128, + "operation_id_required", "Every write needs a unique operationId; retries must preserve the exact request.") + return json.loads(canonical(args, MAX_REQUEST_BYTES)), write + + +def admit_for_dispatch(args, dispatch_id): + """HTTP-safe durable admission. Return frozen args, descriptor, early reply.""" + try: + args, write = prepare_request(args) + if not write: + return args, None, None + descriptor, result = _history.admit(args, dispatch_id) + return args, descriptor, result + except Exception as error: + return None, None, error_result(error) + + +def observed_document(app): + require(threading.current_thread() is threading.main_thread(), "wrong_thread", "Document observation requires the Fusion main thread.") + doc = app.activeDocument + if doc is None: + return None + data_file = doc.dataFile + return {"documentId": document_identity(doc), "creationId": doc.creationId, + "dataFileId": data_file.id if data_file is not None else None} + + +def not_executed(admission, result): + return _history.not_executed(admission, result) + + +def handle_mechanical(app, args, admission=None): + try: + args, write = prepare_request(args) + except Exception as error: + return not_executed(admission, error_result(error)) + if write: + if admission is None: + admission, reply = _history.admit(args, str(uuid.uuid4())) + if reply is not None: + return reply + # Admission must already be durable before observing an active document. + try: + require(admission.get("sessionId") == SESSION_ID and admission.get("operationId") == args["operationId"] + and admission.get("requestDigest") == fingerprint(args), "operation_conflict", + "Dispatch identity does not match the current canonical request.") + document = observed_document(app) + except Exception as error: + return not_executed(admission, error_result(error)) + reply = _history.start(admission, args, document) + if reply is not None: + return reply + elif admission is not None: + return not_executed(admission, error_result(MechanicalError("operation_conflict", "A write admission cannot dispatch a read operation."))) + try: + data = OPERATIONS[args["op"]](app, args) + result = {"success": True, "sessionId": SESSION_ID, "operation": args["op"], "data": data} + except Exception as error: + result = error_result(error) + if write: + result["operationId"] = args["operationId"] + return _history.finish(admission, result) + return result diff --git a/addin/AdomBridge/commands/mechanical/assemblies.py b/addin/AdomBridge/commands/mechanical/assemblies.py new file mode 100644 index 0000000..df34515 --- /dev/null +++ b/addin/AdomBridge/commands/mechanical/assemblies.py @@ -0,0 +1,458 @@ +"""Typed component definitions, placements, joint motion and interference.""" +import math + +import adsk.core +import adsk.fusion + +from .core import (identity, items, number, point_mm, ref, require, resolve, same, target) +from .assembly_state import GROUP, MOTION_SCALES, joint_data, joint_motion, metadata, rigid_group_data +from .sketches import collection, component, named + + +def hybrid(design): + enum = getattr(adsk.fusion, "DesignIntentTypes", None) + require(enum is not None and hasattr(design, "designIntent"), "unsupported_capability", "This operation requires an inspectable Hybrid design intent.") + require(design.designIntent == enum.HybridDesignIntentType, "hybrid_design_required", "Internal components require Hybrid design intent. Create a new Hybrid design; existing intent is never changed implicitly.") + + +def transform(spec=None): + spec = spec or {} + matrix = adsk.core.Matrix3D.create() + angle = number(spec.get("angle", 0), "angle") + axis = spec.get("axis", [0, 0, 1]) + vector = adsk.core.Vector3D.create(*axis) + require(vector.length > 1e-9, "invalid_input", "Rotation axis must be nonzero.") + require(matrix.setToRotation(math.radians(angle), vector, adsk.core.Point3D.create(0, 0, 0)), "invalid_transform", "Fusion refused rotation transform.") + matrix.translation = adsk.core.Vector3D.create(*[number(n, "position") / 10 for n in spec.get("position", [0, 0, 0])]) + return matrix + + +def occurrence_data(occ, value): + matrix = list(occ.transform2.asArray()) + for index in (3, 7, 11): + matrix[index] *= 10 + return {"ref": ref(occ, value), "component": {"ref": ref(occ.component, value), "id": identity(occ.component), **metadata(occ.component)}, + "path": occ.fullPathName, "transform": matrix, "transformLayout": "row-major", "translationUnit": "mm", + "space": "assembly_context", "grounded": occ.isGrounded, "groundedToParent": occ.isGroundToParent} + + +def require_transform(actual, expected, message): + require_transform_values(list(actual.asArray()), list(expected.asArray()), message) + + +def require_transform_values(actual_values, expected_values, message): + require(len(actual_values) == len(expected_values) == 16 + and all(math.isfinite(a) and math.isfinite(b) and abs(a-b) < 1e-8 + for a, b in zip(actual_values, expected_values)), "placement_refused", message) + + +def created_occurrence_contexts(design, parent, occurrence): + """Find placed root proxies without reading a definition-local token.""" + if same(parent, design.rootComponent): + return [occurrence] + native = getattr(occurrence, "nativeObject", None) or occurrence + all_occurrences = items(design.rootComponent.allOccurrences) + contexts = [o for o in all_occurrences if same(getattr(o, "nativeObject", None) or o, native)] + if not contexts: + parents = [o for o in all_occurrences if same(o.component, parent)] + contexts = [native.createForAssemblyContext(o) for o in parents] + require(contexts and all(o is not None and o.isValid for o in contexts), "occurrence_context_failed", "Could not obtain placed root-context instances for the new occurrence.") + return contexts + + +def context_placements(design, parent, contexts, expected): + local_values = list(expected.asArray()) + require_transform_values(local_values, local_values, "Requested transform must be a complete finite matrix.") + if same(parent, design.rootComponent): + return [(context, local_values) for context in contexts] + parents_by_path = {} + for candidate in items(design.rootComponent.allOccurrences): + if same(candidate.component, parent): + parents_by_path.setdefault(candidate.fullPathName, []).append(candidate) + placements, seen_parents = [], set() + for context in contexts: + parent_context = context.assemblyContext + parent_matches = parents_by_path.get(parent_context.fullPathName, []) if parent_context is not None else [] + require(parent_context is not None and same(parent_context.component, parent) + and len(parent_matches) == 1 and same(parent_matches[0], parent_context), + "occurrence_context_failed", "New occurrence's parent must identify an exact root-context instance.") + require(parent_context.fullPathName not in seen_parents, "occurrence_context_failed", "New occurrence has duplicate parent contexts.") + seen_parents.add(parent_context.fullPathName) + parent_values = list(parent_context.transform2.asArray()) + require_transform_values(parent_values, parent_values, "Parent transform must be a complete finite matrix.") + # Row-major matrices: parent root pose composed with requested local pose. + world_values = [sum(parent_values[row*4+k] * local_values[k*4+column] for k in range(4)) + for row in range(4) for column in range(4)] + placements.append((context, world_values)) + require(seen_parents == set(parents_by_path), "occurrence_context_failed", "New occurrence must resolve in every placed parent instance.") + return placements + + +def place_created_occurrence(design, parent, occurrence, expected): + # Paste New may leave a pending pose and reused parent instances can inherit + # the source pose. Transform overrides must use root proxies, never the local + # occurrence. Apply every instance and capture once before shared recompute. + contexts = created_occurrence_contexts(design, parent, occurrence) + for context, values in context_placements(design, parent, contexts, expected): + matrix = expected + if not same(parent, design.rootComponent): + matrix = adsk.core.Matrix3D.create() + require(matrix.setWithArray(values), "placement_failed", "Could not construct the requested root-context placement.") + context.transform2 = matrix + require_transform_values(list(context.transform2.asArray()), values, "Fusion refused the requested occurrence transform.") + captured = False + if design.snapshots.hasPendingSnapshot: + require(design.snapshots.add() is not None, "placement_failed", "Could not capture the new occurrence positions.") + captured = True + return captured + + +def created_occurrence_result(design, parent, occurrence, value, expected=None): + """Return and verify every placed instance in root assembly coordinates. + + The local transform can retain its base pose even when captured positions + are correct. Verify the computed root proxies, each against its exact parent. + """ + contexts = created_occurrence_contexts(design, parent, occurrence) + if expected is not None: + for context, values in context_placements(design, parent, contexts, expected): + require_transform_values(list(context.transform2.asArray()), values, + "Recompute did not retain the requested placement in every parent instance.") + rows = [occurrence_data(o, value) for o in contexts] + return {"occurrence": rows[0], "affectedOccurrences": rows, + "placementSpace": "parent_component", "referenceSpace": "root_assembly", + "placementScope": "all_current_parent_instances", "editScope": "parent_component_definition"} + + +def parent_component(design, value, args): + return component(design, value, {"component": args["parent"]} if "parent" in args else {}) + + +def component_create(design, value, args): + hybrid(design) + parent = parent_component(design, value, args) + expected = transform(args.get("transform")) + occ = parent.occurrences.addNewComponent(expected) + require(occ and occ.isValid, "creation_failed", "Component creation failed.") + named(occ.component, args) + if args.get("metadata"): + apply_metadata(occ.component, args["metadata"]) + captured = place_created_occurrence(design, parent, occ, expected) + return lambda fresh: {**created_occurrence_result(design, parent, occ, fresh, expected), "positionCaptured": captured} + + +def occurrence_insert(design, value, args): + hybrid(design) + parent = parent_component(design, value, args) + comp = resolve(design, value, args["component"], ("Component",)) + require(not same(comp, design.rootComponent), "invalid_component", "Root component cannot be instanced.") + # Avoid cyclic containment before asking the kernel to insert. + descendants = [comp] + [o.component for o in items(comp.allOccurrences)] + require(not any(same(parent, c) for c in descendants), "cyclic_assembly", "A component cannot contain itself or its ancestor.") + method = parent.occurrences.addNewComponentCopy if args.get("independentCopy", False) else parent.occurrences.addExistingComponent + expected = transform(args.get("transform")) + occ = method(comp, expected) + require(occ and occ.isValid, "creation_failed", "Occurrence insertion failed.") + captured = place_created_occurrence(design, parent, occ, expected) + return lambda fresh: {**created_occurrence_result(design, parent, occ, fresh, expected), "positionCaptured": captured} + + +def occurrence_update(design, value, args): + require(any(k in args for k in ("transform", "grounded", "groundedToParent")), "invalid_input", "Occurrence update requires a placement or grounding change.") + occ = resolve(design, value, args["occurrence"], ("Occurrence",)) + if "grounded" in args: + occ.isGrounded = args["grounded"] + if "groundedToParent" in args: + occ.isGroundToParent = args["groundedToParent"] + if "transform" in args: + require(not occ.isGrounded and not occ.isGroundToParent, "grounded_occurrence", "Unground the occurrence explicitly before moving it.") + expected = transform(args["transform"]) + occ.transform2 = expected + actual = occ.transform2.asArray() + require(all(abs(a-b) < 1e-8 for a,b in zip(actual, expected.asArray())), "placement_refused", "Constraints prevented the requested occurrence transform.") + if design.snapshots.hasPendingSnapshot: + require(design.snapshots.add() is not None, "placement_failed", "Could not capture occurrence position in the timeline.") + require("grounded" not in args or occ.isGrounded == args["grounded"], "grounding_refused", "Grounding did not match the request.") + require("groundedToParent" not in args or occ.isGroundToParent == args["groundedToParent"], "grounding_refused", "Parent grounding did not match the request.") + def result(fresh): + if "transform" in args: + expected = transform(args["transform"]) + require(all(abs(a-b) < 1e-8 for a,b in zip(occ.transform2.asArray(), expected.asArray())), + "placement_refused", "Recompute did not retain the requested occurrence transform.") + require("grounded" not in args or occ.isGrounded == args["grounded"], "grounding_refused", "Recompute did not retain grounding.") + require("groundedToParent" not in args or occ.isGroundToParent == args["groundedToParent"], "grounding_refused", "Recompute did not retain parent grounding.") + return {"occurrence": occurrence_data(occ, fresh)} + return result + + +def occurrence_delete(design, value, args): + occ = resolve(design, value, args["occurrence"], ("Occurrence",)) + path = occ.fullPathName + require(occ.deleteMe(), "delete_failed", "Fusion refused occurrence deletion.") + return {"deletedPath": path} + + +def apply_metadata(comp, changes): + for key in ("name", "partNumber", "description"): + if key in changes: + setattr(comp, key, changes[key]) + import json + for key in changes.keys() - {"name", "partNumber", "description"}: + require(comp.attributes.add(GROUP, key, json.dumps(changes[key], sort_keys=True)) is not None, "metadata_failed", "Could not persist " + key) + + +def component_metadata(design, value, args): + comp = component(design, value, args) + apply_metadata(comp, args["changes"]) + return lambda fresh: {"component": {"ref": ref(comp, fresh), **metadata(comp)}} + + +def require_root_context(design, entity): + context = entity if entity.objectType.endswith("::Occurrence") else getattr(entity, "assemblyContext", None) + if context is not None: + require(any(same(o, context) and o.fullPathName == context.fullPathName for o in items(design.rootComponent.allOccurrences)), + "root_context_required", "Assembly analysis and joints require a root-context occurrence reference from inspect.") + else: + owner = getattr(entity, "parentComponent", None) or getattr(entity, "component", None) + if hasattr(entity, "body"): + owner = entity.body.parentComponent + if hasattr(entity, "parentSketch"): + owner = entity.parentSketch.parentComponent + require(owner is not None and same(owner, design.rootComponent), "occurrence_context_required", "Non-root geometry requires a specific occurrence-context reference.") + + +def joint_geometry(design, value, raw): + entity = resolve(design, value, raw["entity"], ("SketchPoint", "ConstructionPoint", "BRepVertex", "BRepEdge", "BRepFace")) + require_root_context(design, entity) + kind = entity.objectType.split("::")[-1] + default = "middle" if kind == "BRepFace" and entity.geometry.objectType.endswith(("::Cylinder", "::Cone")) else "center" + key = getattr(adsk.fusion.JointKeyPointTypes, {"center": "CenterKeyPoint", "start": "StartKeyPoint", "middle": "MiddleKeyPoint", "end": "EndKeyPoint"}[raw.get("keyPoint", default)]) + if kind in ("SketchPoint", "ConstructionPoint", "BRepVertex"): + result = adsk.fusion.JointGeometry.createByPoint(entity) + elif kind == "BRepEdge": + result = adsk.fusion.JointGeometry.createByCurve(entity, key) + elif entity.geometry.objectType.endswith("::Plane"): + result = adsk.fusion.JointGeometry.createByPlanarFace(entity, None, key) + else: + result = adsk.fusion.JointGeometry.createByNonPlanarFace(entity, key) + require(result is not None, "invalid_joint_geometry", "Fusion could not infer the joint origin.") + return result + + +def set_limits(motion, name, values, scale): + require(hasattr(motion, name), "wrong_joint_type", "Joint does not support " + name) + limits = getattr(motion, name) + minimum = values.get("minimum", limits.minimumValue * scale) + maximum = values.get("maximum", limits.maximumValue * scale) + minimum_enabled = values.get("minimumEnabled", "minimum" in values or limits.isMinimumValueEnabled) + maximum_enabled = values.get("maximumEnabled", "maximum" in values or limits.isMaximumValueEnabled) + require(not (minimum_enabled and maximum_enabled) or minimum <= maximum, "invalid_limits", "Minimum joint limit must not exceed maximum.") + rest = values.get("rest", limits.restValue * scale) + rest_enabled = values.get("restEnabled", "rest" in values or limits.isRestValueEnabled) + require(not rest_enabled or (not minimum_enabled or rest >= minimum) and (not maximum_enabled or rest <= maximum), "invalid_limits", "Rest position must lie within enabled limits.") + limits.isMinimumValueEnabled = False + limits.isMaximumValueEnabled = False + for key in ("minimum", "maximum", "rest"): + if key in values: + setattr(limits, key + "Value", number(values[key], key) / scale) + limits.isMinimumValueEnabled = minimum_enabled + limits.isMaximumValueEnabled = maximum_enabled + limits.isRestValueEnabled = rest_enabled + + +def configure_joint(design, joint, args): + if "name" in args: + joint.name = args["name"] + if "suppressed" in args: + joint.isSuppressed = args["suppressed"] + motion = joint_motion(joint) + for prefix, scale in MOTION_SCALES.items(): + if prefix + "Limits" in args: + set_limits(motion, prefix + "Limits", args[prefix + "Limits"], scale) + if prefix + "Value" in args: + require(hasattr(motion, prefix + "Value"), "wrong_joint_type", "Joint does not support " + prefix) + number_value = number(args[prefix + "Value"], prefix + "Value") / scale + limits = getattr(motion, prefix + "Limits") + require(not limits.isMinimumValueEnabled or number_value >= limits.minimumValue, "joint_limit_exceeded", "Drive value is below minimum.") + require(not limits.isMaximumValueEnabled or number_value <= limits.maximumValue, "joint_limit_exceeded", "Drive value is above maximum.") + setattr(motion, prefix + "Value", number_value) + # Joint value and suppression setters alter pending assembly state. Capture + # once, after all requested changes, so computeAll replays that state instead + # of resetting it. Rest values and limit flags stay intact. + captured = False + if ("suppressed" in args or any(prefix + "Value" in args for prefix in MOTION_SCALES)) and design.snapshots.hasPendingSnapshot: + require(design.snapshots.add() is not None, "joint_capture_failed", "Could not capture the edited assembly state.") + captured = True + return captured + + +def joint_result(joint, value, args): + result = joint_data(joint, value) + for field in ("name", "suppressed"): + if field in args: + require(result[field] == args[field], "joint_edit_refused", "Joint did not retain requested " + field + ".") + for prefix in MOTION_SCALES: + for suffix in ("Value", "Limits"): + key = prefix + suffix + if key in args: + require(key in result, "joint_readback_failed", "Joint motion readback is unavailable.") + if suffix == "Value": + require(math.isclose(result[key], args[key], rel_tol=1e-8, abs_tol=1e-6), "joint_drive_refused", "Joint did not reach the requested drive value.") + else: + expected_limits = dict(args[key]) + for field in ("minimum", "maximum", "rest"): + if field in expected_limits and field + "Enabled" not in expected_limits: + expected_limits[field + "Enabled"] = True + for field, expected in expected_limits.items(): + actual = result[key][field] + require(actual == expected if isinstance(expected, bool) else math.isclose(actual, expected, rel_tol=1e-8, abs_tol=1e-6), "joint_limits_refused", "Joint limit did not read back: " + field) + return {"joint": result} + + +def rigid_group_result(group, value, args): + result = rigid_group_data(group, value) + for field in ("name", "suppressed"): + if field in args: + require(result[field] == args[field], "rigid_group_edit_refused", "Rigid group did not retain requested " + field + ".") + return {"rigidGroup": result} + + +def joint_create(design, value, args): + first, second = [resolve(design, value, args[key], ("Occurrence",)) for key in ("occurrenceOne", "occurrenceTwo")] + require(not same(first, second), "invalid_joint", "Joint requires two distinct occurrences.") + for occurrence in (first, second): + require_root_context(design, occurrence) + geometry = joint_geometry(design, value, args["geometry"]) if args["type"] != "rigid" else None + group = design.rootComponent.asBuiltJoints + inp = group.createInput(first, second, geometry) + method = getattr(inp, {"rigid": "setAsRigidJointMotion", "revolute": "setAsRevoluteJointMotion", "slider": "setAsSliderJointMotion", "cylindrical": "setAsCylindricalJointMotion", "pin_slot": "setAsPinSlotJointMotion", "planar": "setAsPlanarJointMotion", "ball": "setAsBallJointMotion"}[args["type"]]) + direction = getattr(adsk.fusion.JointDirections, args.get("axis", "z").upper() + "AxisJointDirection") + if args["type"] == "rigid": + ok = method() + elif args["type"] == "ball": + ok = method(adsk.fusion.JointDirections.ZAxisJointDirection, adsk.fusion.JointDirections.XAxisJointDirection) + elif args["type"] == "pin_slot": + require(args.get("axis", "z") != args.get("slideAxis", "x"), "invalid_joint_axes", "Pin-slot rotation and slide directions must differ.") + ok = method(direction, getattr(adsk.fusion.JointDirections, args.get("slideAxis", "x").upper() + "AxisJointDirection")) + else: + ok = method(direction) + require(ok, "joint_failed", "Joint motion definition failed.") + joint = group.add(inp) + require(joint and joint.isValid, "joint_failed", "As-built joint creation failed.") + captured = configure_joint(design, joint, args) + return lambda fresh: {**joint_result(joint, fresh, args), "positionCaptured": captured} + + +def joint_edit(design, value, args): + require(any(k in args for k in ("name", "suppressed")) or any(prefix + suffix in args for prefix in MOTION_SCALES for suffix in ("Limits", "Value")), "invalid_input", "Joint edit requires a change.") + joint = resolve(design, value, args["joint"], ("Joint", "AsBuiltJoint")) + from .motion_links import coupling_snapshot, verify_coupling + driven = any(prefix + "Value" in args for prefix in MOTION_SCALES) + coupling = coupling_snapshot(design) if driven else [] + captured = configure_joint(design, joint, args) + return lambda fresh: {**joint_result(joint, fresh, args), "positionCaptured": captured, + "motionLinksVerified": verify_coupling(coupling)} + + +def joint_delete(design, value, args): + joint = resolve(design, value, args["joint"], ("Joint", "AsBuiltJoint")) + name = joint.name + require(joint.deleteMe(), "delete_failed", "Fusion refused joint deletion.") + return {"deletedJoint": name} + + +def rigid_group_create(design, value, args): + occurrences = [resolve(design, value, raw, ("Occurrence",)) for raw in args["occurrences"]] + require(len({identity(o) for o in occurrences}) == len(occurrences), "duplicate_selection", "Rigid group occurrences must be distinct.") + for occurrence in occurrences: + require_root_context(design, occurrence) + group = design.rootComponent.rigidGroups.add(collection(occurrences), args.get("includeChildren", True)) + named(group, args) + return lambda fresh: rigid_group_result(group, fresh, args) + + +def rigid_group_edit(design, value, args): + require("name" in args or "suppressed" in args, "invalid_input", "Rigid group edit requires a change.") + group = resolve(design, value, args["group"], ("RigidGroup",)) + if "name" in args: + group.name = args["name"] + if "suppressed" in args: + group.isSuppressed = args["suppressed"] + captured = False + if "suppressed" in args and design.snapshots.hasPendingSnapshot: + require(design.snapshots.add() is not None, "rigid_group_capture_failed", "Could not capture the edited rigid group state.") + captured = True + return lambda fresh: {**rigid_group_result(group, fresh, args), "positionCaptured": captured} + + +def rigid_group_delete(design, value, args): + group = resolve(design, value, args["group"], ("RigidGroup",)) + name = group.name + require(group.deleteMe(), "delete_failed", "Fusion refused rigid group deletion.") + return {"deletedRigidGroup": name} + + +def assembly_properties(app, args): + _, design, value = target(app, args) + entity = resolve(design, value, args["entity"], ("Component", "Occurrence", "BRepBody")) if args.get("entity") else design.rootComponent + props = entity.getPhysicalProperties(adsk.fusion.CalculationAccuracy.HighCalculationAccuracy) + volume = props.volume * 1000 + is_occurrence = entity.objectType.endswith("::Occurrence") + return {"target": value, "entity": ref(entity, value), "mass": props.mass, "volume": volume, + "area": props.area * 100, "centerOfMass": point_mm(props.centerOfMass), + "averageDensity": props.mass / volume if volume else None, + "units": {"mass": "kg", "volume": "mm3", "area": "mm2", "centerOfMass": "mm", "averageDensity": "kg/mm3"}, + "scope": "body" if entity.objectType.endswith("::BRepBody") else "component_subtree", + "space": "assembly_context" if is_occurrence or getattr(entity, "assemblyContext", None) else "component", + "materialBasis": "Fusion assigned/default material; supplier provenance does not certify modeled material density"} + + +def interference(app, args): + _, design, value = target(app, args) + entities = [resolve(design, value, r, ("Occurrence", "BRepBody")) for r in args["entities"]] + selected_bodies = set() + walked = 0 + def body_instances(entity, depth=0): + nonlocal walked + walked += 1 + require(depth <= 100 and walked <= 20000, "inspection_budget", "Interference assembly exceeds traversal budget.") + if entity.objectType.endswith("::BRepBody"): + native = getattr(entity, "nativeObject", None) or entity + context = getattr(entity, "assemblyContext", None) + return {(identity(native), context.fullPathName if context else "")} + result = set() + for body in items(entity.bRepBodies): + walked += 1 + require(walked <= 20000, "inspection_budget", "Interference assembly exceeds shared body traversal budget.") + native = getattr(body, "nativeObject", None) or body + result.add((identity(native), entity.fullPathName)) + for child in items(entity.childOccurrences): + result.update(body_instances(child, depth + 1)) + require(len(result) <= 20000, "inspection_budget", "Interference assembly exceeds body budget.") + return result + for index, entity in enumerate(entities): + require(not any(same(entity, earlier) for earlier in entities[:index]), "duplicate_selection", "Interference entities must be distinct.") + require_root_context(design, entity) + instances = body_instances(entity) + require(not selected_bodies.intersection(instances), "overlapping_selection", "Selections include the same physical body instance more than once; choose disjoint occurrences/bodies.") + selected_bodies.update(instances) + require(len(selected_bodies) <= 20000, "inspection_budget", "Interference selection exceeds physical body instance budget.") + inp = design.createInterferenceInput(collection(entities)) + inp.areCoincidentFacesIncluded = args.get("includeTouching", False) + results = design.analyzeInterference(inp) + require(results is not None, "interference_failed", "Fusion did not return an interference result.") + rows = [] + for result in items(results): + body = result.interferenceBody + volume = body.volume * 1000 if body else 0.0 + rows.append({"entityOne": ref(result.entityOne, value), "entityTwo": ref(result.entityTwo, value), + "volume": volume, "classification": "overlap" if volume > args.get("volumeTolerance", 1e-6) else "touching"}) + return {"target": value, "complete": True, "results": rows, "volumeUnit": "mm3", "bodiesCreated": False} + + +OPERATIONS = {"component_create": component_create, "occurrence_insert": occurrence_insert, + "occurrence_update": occurrence_update, "occurrence_delete": occurrence_delete, + "component_metadata": component_metadata, "joint_create": joint_create, "joint_edit": joint_edit, + "joint_delete": joint_delete, "rigid_group_create": rigid_group_create, + "rigid_group_edit": rigid_group_edit, "rigid_group_delete": rigid_group_delete} +READS = {"interference": interference, "assembly_properties": assembly_properties} diff --git a/addin/AdomBridge/commands/mechanical/assembly_state.py b/addin/AdomBridge/commands/mechanical/assembly_state.py new file mode 100644 index 0000000..f8dfa24 --- /dev/null +++ b/addin/AdomBridge/commands/mechanical/assembly_state.py @@ -0,0 +1,108 @@ +"""Assembly readback shared by inspection, stale guards and recovery witnesses.""" +import json +import math +import adsk.fusion + +from .core import concrete, identity, items, ref, require + +GROUP = "AdomMechanical" +MOTION_SCALES = {"rotation": 180 / math.pi, "slide": 10, "primarySlide": 10, "secondarySlide": 10, + "pitch": 180 / math.pi, "roll": 180 / math.pi, "yaw": 180 / math.pi} + + +def attributes(entity): + attrs = getattr(entity, "attributes", None) + return {attr.name: attr.value for attr in items(attrs) if attr.groupName == GROUP} if attrs else {} + + +def metadata(component): + raw = attributes(component) + result = {"name": component.name, "partNumber": getattr(component, "partNumber", ""), + "description": getattr(component, "description", ""), "attributes": raw} + for name in ("supplier", "supplierPartNumber", "sourceUrl", "sourceFile", "sha256", "asPurchased", "excludeFromBom"): + if name in raw: + result[name] = json.loads(raw[name]) + material = getattr(component, "material", None) + result["material"] = {"id": material.id, "name": material.name} if material else None + return result + + +def limits_data(limits, scale): + return {"minimumEnabled": limits.isMinimumValueEnabled, "minimum": limits.minimumValue * scale, + "maximumEnabled": limits.isMaximumValueEnabled, "maximum": limits.maximumValue * scale, + "restEnabled": limits.isRestValueEnabled, "rest": limits.restValue * scale} + + +def joint_motion(joint): + joint = concrete(joint) + base = joint.jointMotion + require(base is not None, "unsupported_inferred_joint", "Inferred joint motion is unavailable; redefine it in Fusion before typed edits.") + kind = base.objectType.split("::")[-1] + require(kind in ("RigidJointMotion", "RevoluteJointMotion", "SliderJointMotion", "CylindricalJointMotion", + "PinSlotJointMotion", "PlanarJointMotion", "BallJointMotion"), "unsupported_joint_motion", "Cannot inspect joint motion type: " + kind) + cls = getattr(adsk.fusion, kind, None) + require(cls is not None, "unsupported_joint_motion", "Fusion motion wrapper is unavailable: " + kind) + motion = cls.cast(base) + require(motion is not None, "unsupported_joint_motion", "Fusion could not cast joint motion: " + kind) + return motion + + +def joint_data(joint, value=None): + joint = concrete(joint) + regular = joint.objectType.endswith("::Joint") + motion = joint_motion(joint) + result = {"name": joint.name, "type": joint.objectType, "motionType": motion.objectType, + "suppressed": joint.isSuppressed, + "occurrenceOneId": identity(joint.occurrenceOne) if joint.occurrenceOne else None, + "occurrenceTwoId": identity(joint.occurrenceTwo) if joint.occurrenceTwo else None} + for name, scale in MOTION_SCALES.items(): + if hasattr(motion, name + "Limits"): + result[name + "Limits"] = limits_data(getattr(motion, name + "Limits"), scale) + if hasattr(motion, name + "Value"): + result[name + "Value"] = getattr(motion, name + "Value") * scale + for name in ("rotationAxisVector", "slideDirectionVector", "normalDirectionVector", "primarySlideDirectionVector", "secondarySlideDirectionVector", "pitchDirectionVector", "yawDirectionVector", "rollDirectionVector"): + if hasattr(motion, name): + vector = getattr(motion, name) + result[name] = [vector.x, vector.y, vector.z] if vector else None + from .joint_state import motion_axes_state + result["axes"] = motion_axes_state(motion, value) + if hasattr(joint, "transform"): + matrix = joint.transform + result["transform"] = list(matrix.asArray()) if matrix else None + if regular and matrix: + for index in (3, 7, 11): + result["transform"][index] *= 10 + if regular: + from .joint_state import regular_state + result.update(regular_state(joint, value)) + if value: + result["ref"] = ref(joint, value) + result["units"] = {"length": "mm", "angle": "deg", "transformTranslation": "mm" if regular else "cm"} + return result + + +def rigid_group_data(group, value=None): + result = {"name": group.name, "suppressed": group.isSuppressed, + "occurrenceIds": [identity(o) for o in items(group.occurrences)]} + if value: + result["ref"] = ref(group, value) + result["occurrenceRefs"] = [ref(o, value) for o in items(group.occurrences)] + return result + + +def component_state(comp): + result = metadata(comp) + result["joints"] = [joint_data(joint) for name in ("joints", "asBuiltJoints") + for joint in items(getattr(comp, name))] if hasattr(comp, "joints") else [] + result["rigidGroups"] = [rigid_group_data(group) for group in items(comp.rigidGroups)] if hasattr(comp, "rigidGroups") else [] + result["bodyMaterials"] = [{"id": body.material.id, "name": body.material.name} if body.material else None + for body in items(comp.bRepBodies)] if all(hasattr(b, "material") for b in items(comp.bRepBodies)) else [] + from .joint_state import component_joint_state + result.update(component_joint_state(comp)) + return result + + +def occurrence_state(occurrence): + return {"name": getattr(occurrence, "name", None), "grounded": occurrence.isGrounded, + "groundedToParent": getattr(occurrence, "isGroundToParent", None), + "attributes": attributes(occurrence)} diff --git a/addin/AdomBridge/commands/mechanical/bill_of_materials.py b/addin/AdomBridge/commands/mechanical/bill_of_materials.py new file mode 100644 index 0000000..baf64a6 --- /dev/null +++ b/addin/AdomBridge/commands/mechanical/bill_of_materials.py @@ -0,0 +1,141 @@ +"""Definition identity, nested instance quantities, purchased collapse and CSV.""" +import csv +from pathlib import Path +import tempfile +import adsk.fusion + +from .assembly_state import metadata +from .core import identity, items, occurrence_suppression, ref, require, target +from .exporting import publish_staged +from .configurations import provenance, snapshot as configuration_snapshot, source_link + + +def generate(design, value, args): + configuration = configuration_snapshot(design, strict=True) + table = configuration["table"] + columns = {column["id"]: column for column in table["columns"]} if table else {} + suppressed = [columns[cell["columnId"]]["source"] for cell in table["cells"] + if cell["rowId"] == table["activeRowId"] and cell["type"] == "ConfigurationSuppressCell" + and cell["isEffectivelySuppressed"]] if table else [] + def suppression(occurrence): + if occurrence_suppression(occurrence) is True: + return "suppressed" + for source in suppressed: + if source["objectType"].endswith("::Occurrence"): + candidate = source_link(design, occurrence) + if candidate["entityId"] == source["entityId"] and (source["occurrenceId"] is None or candidate["occurrenceId"] == source["occurrenceId"]): + return "configuration_suppressed" + return None + parts, instances, excluded = {}, [], [] + physical = {"mass": 0.0, "volume": 0.0, "bodyInstances": 0} + accuracy = adsk.fusion.CalculationAccuracy.HighCalculationAccuracy + budget = {"visited": 0} + def visit(comp, depth, ancestry): + budget["visited"] += 1 + require(depth <= 100 and budget["visited"] <= 20000, "bom_budget", "Assembly exceeds combined purchasing/physical traversal budget.") + key = identity(comp) + require(key not in ancestry, "cyclic_assembly", "Assembly contains cyclic component containment.") + return key + # Physical totals are independent of purchasing collapse. Count direct bodies + # once per physical instance, including root bodies and repeated children. + def physical_walk(comp, occurrence=None, depth=0, ancestry=()): + key = visit(comp, depth, ancestry) + bodies = items(occurrence.bRepBodies if occurrence else comp.bRepBodies) + for body in bodies: + props = body.getPhysicalProperties(accuracy) + physical["mass"] += props.mass + physical["volume"] += props.volume * 1000 + physical["bodyInstances"] += 1 + children = items(occurrence.childOccurrences if occurrence else comp.occurrences) + for child in children: + if suppression(child) is None: + physical_walk(child.component, child, depth + 1, ancestry + (key,)) + + def walk(comp, occurrence=None, parent_path=None, depth=0, ancestry=()): + key = visit(comp, depth, ancestry) + path = occurrence.fullPathName if occurrence else "" + data = metadata(comp) + reason = suppression(occurrence) if occurrence else None + if reason: + excluded.append({"path": path, "reason": reason}) + return + if data.get("excludeFromBom", False): + excluded.append({"path": path, "reason": "excludeFromBom"}) + return + children = items(occurrence.childOccurrences if occurrence else comp.occurrences) + purchased = bool(data.get("asPurchased", False)) and not args.get("explodePurchased", False) + row = {"path": path, "parentPath": parent_path, "depth": depth, "componentId": key, "metadata": data, + "bodyCount": comp.bRepBodies.count, "childCount": len(children), "asPurchased": purchased, + "componentRef": ref(comp, value), "occurrenceRef": ref(occurrence, value) if occurrence else None} + instances.append(row) + if comp.bRepBodies.count or purchased: + if key not in parts: + props = comp.getPhysicalProperties(accuracy) if purchased else None + direct_properties = [] if purchased else [b.getPhysicalProperties(accuracy) for b in items(comp.bRepBodies)] + parts[key] = {"componentId": key, **data, "quantity": 0, "occurrencePaths": [], + "scope": "component_subtree" if purchased else "direct_component_bodies", + "unitMass": props.mass if props else sum(p.mass for p in direct_properties), + "unitVolume": props.volume * 1000 if props else sum(p.volume * 1000 for p in direct_properties)} + parts[key]["quantity"] += 1 + parts[key]["occurrencePaths"].append(path) + if not purchased: + for child in children: + walk(child.component, child, path, depth + 1, ancestry + (key,)) + + walk(design.rootComponent) + physical_walk(design.rootComponent) + rows = list(parts.values()) + return {"target": value, "complete": True, "configuration": provenance(configuration), + "configurationSuppression": [cell for cell in table["cells"] if cell["rowId"] == table["activeRowId"] and cell["type"] == "ConfigurationSuppressCell"] if table else [], + "parts": rows, "instances": instances, "excluded": excluded, + "partCount": len(rows), "totalPurchasingInstances": sum(r["quantity"] for r in rows), + "physical": {**physical, "scope": "all unsuppressed direct-body instances; BOM exclusions do not remove physical mass", + "materialBasis": "Fusion assigned/default material; supplier provenance does not certify modeled material density"}, + "units": {"mass": "kg", "volume": "mm3"}, "calculationAccuracy": "high", + "identityScope": "running add-in session; supplier metadata persists in native archives"} + + +def bom(app, args): + _, design, value = target(app, args) + return generate(design, value, args) + + +def bom_export(app, args): + _, design, value = target(app, args, True, model_write=False) + destination = Path(args["filename"]) + require(destination.is_absolute() and destination.suffix.lower() == ".csv" and not destination.is_symlink() and not destination.is_dir(), "invalid_destination", "BOM destination must be an absolute regular .csv path.") + require(args.get("overwrite", False) or not destination.exists(), "destination_exists", "Pass overwrite:true to replace an existing CSV.") + result = generate(design, value, args) + destination.parent.mkdir(parents=True, exist_ok=True) + published = {} + try: + with tempfile.TemporaryDirectory(prefix=".adom-bom-", dir=str(destination.parent)) as folder: + staged = Path(folder) / "bom.csv" + keys = ["componentId", "partNumber", "name", "description", "quantity", "supplier", "supplierPartNumber", "sourceUrl", "sha256", "unitMass", "unitVolume", "scope"] + rows = result["parts"] + if args.get("mode", "flat") == "structured": + keys = ["path", "parentPath", "depth", "componentId", "name", "partNumber", "description", "quantity", "bodyCount", "childCount", "asPurchased", "supplier", "supplierPartNumber", "sourceUrl", "sha256"] + rows = [{**row["metadata"], **row, "quantity": 1} for row in result["instances"]] + configuration = result["configuration"] + context = {"documentId": value["documentId"], "revision": value["revision"], + "configurationTableId": configuration["tableId"], "configurationRowId": configuration["rowId"], + "configurationRowName": configuration["rowName"]} + keys += list(context) + rows = [{**row, **context} for row in rows] + with staged.open("w", encoding="utf-8", newline="") as stream: + writer = csv.writer(stream) + writer.writerow(keys) + for part in rows: + values = [part.get(key, "") for key in keys] + writer.writerow(["'" + v if isinstance(v, str) and v[:1] in ("=", "+", "-", "@") else v for v in values]) + published.update(publish_staged(staged, destination, args.get("overwrite", False))) + except Exception as exc: + if not published.get("published"): + raise + published["cleanupWarning"] = str(exc) + return {"target": value, "partCount": result["partCount"], "mode": args.get("mode", "flat"), + "calculationAccuracy": result["calculationAccuracy"], "configuration": result["configuration"], **published} + + +OPERATIONS = {"bom": bom, "bom_export": bom_export} +WRITES = {"bom_export"} diff --git a/addin/AdomBridge/commands/mechanical/configurations.py b/addin/AdomBridge/commands/mechanical/configurations.py new file mode 100644 index 0000000..c8b7a20 --- /dev/null +++ b/addin/AdomBridge/commands/mechanical/configurations.py @@ -0,0 +1,326 @@ +"""Read-only configuration graph; incomplete scope never authorizes a write. + +Table IDs are native stable identifiers. Controlled sources use fresh API equality, +with tokens used only to re-resolve wrappers invalidated by recompute. No method in +this module converts a design, activates a row, or writes a configuration cell. +""" +import math + +import adsk.core +import adsk.fusion + +from .core import MAX_ENTITIES, MechanicalError, identity, items, require, same + +MAX_ROWS, MAX_COLUMNS, MAX_CELLS = 128, 128, 4096 +_sources = [] +CELL_TYPES = { + "ConfigurationParameterColumn": "ConfigurationParameterCell", + "ConfigurationSuppressColumn": "ConfigurationSuppressCell", + "ConfigurationVisibilityColumn": "ConfigurationVisibilityCell", + "ConfigurationPropertyColumn": "ConfigurationPropertyCell", +} + + +def concrete(entity): + require(entity is not None and entity.isValid, "configuration_read_failed", "Configuration object is missing or invalid.") + namespace = adsk.core if entity.objectType.startswith("adsk::core::") else adsk.fusion + cls = getattr(namespace, entity.objectType.split("::")[-1], None) + require(cls is not None and hasattr(cls, "cast"), "configuration_read_failed", "Concrete configuration wrapper is unavailable.", objectType=entity.objectType) + result = cls.cast(entity) + require(result is not None and result.isValid, "configuration_read_failed", "Concrete configuration cast failed.") + return result + + +def bounded(collection, maximum): + require(0 <= collection.count <= maximum, "configuration_budget", "Configuration inspection budget exceeded.") + return [concrete(collection.item(index)) for index in range(collection.count)] + + +def native_id(entity): + value = entity.id + require(isinstance(value, str) and bool(value), "configuration_read_failed", "Native configuration ID is missing.") + return value + + +def flag(value): + require(type(value) is bool, "configuration_read_failed", "Native configuration flag is not a Boolean.") + return value + + +def source_link(design, entity): + entity = concrete(entity) + context = getattr(entity, "assemblyContext", None) + native = concrete(getattr(entity, "nativeObject", None) or entity) + kind = native.objectType.split("::")[-1] + owner_scope = "component" + if kind.endswith("Parameter"): + require(any(same(native, p) for p in items(design.allParameters)), "configuration_source_mismatch", "Controlled parameter is outside this design.") + if kind == "UserParameter": + require(same(native.design, design), "configuration_source_mismatch", "User parameter belongs to another design.") + owner, owner_scope = design, "design" + else: + require(kind == "ModelParameter", "configuration_scope_unsupported", "Only user and model parameter ownership is supported.") + owner = concrete(native.component) + require(any(same(owner, c) for c in items(design.allComponents)), "configuration_source_mismatch", "Model parameter component is outside this design.") + else: + from .geometry import owner as component_owner + owner = native if kind == "Component" else component_owner(native) + if kind == "Occurrence": + owner = native.component + require(owner is not None and any(same(owner, c) for c in items(design.allComponents)), + "configuration_source_mismatch", "Controlled source owner is outside this design.") + key = None + token = getattr(native, "entityToken", None) + if token: + for saved in _sources: + if not saved["design"].isValid or not same(saved["design"], design) or saved["type"] != native.objectType: + continue + matches = design.findEntityByToken(saved["token"]) + require(len(matches) <= MAX_ENTITIES, "configuration_budget", "Configuration source lookup exceeds budget.") + if any(candidate.isValid and same(candidate, native) for candidate in matches): + require(all(candidate.isValid and candidate.objectType == native.objectType and same(candidate, native) for candidate in matches), + "configuration_source_mismatch", "Controlled source token resolves ambiguously.") + key = saved["id"] + break + if key is None: + key = identity(native) + if token: + require(len(_sources) < MAX_ENTITIES, "identity_budget", "Configuration source identity budget exhausted.") + _sources.append({"design": design, "type": native.objectType, "token": token, "id": key}) + return {"entityId": key, "objectType": native.objectType, "ownerId": identity(owner), "ownerScope": owner_scope, + "occurrenceId": identity(context) if context is not None else None} + + +def diagnostic_read(result, field, reader): + """Retain stable table cells when a preview guard is unavailable. + + The missing guard remains an explicit read error, so strict preflight and + recovery cannot infer that unavailable preview state is absent. + """ + try: + return reader() + except Exception as error: + result["readErrors"].append({"code": error.code if isinstance(error, MechanicalError) else "configuration_read_failed", + "field": field, "message": str(error)}) + return {"unavailable": True} + + +def child_ids(row): + children = row.childRows + require(len(children) <= MAX_ROWS, "configuration_budget", "Configuration hierarchy exceeds budget.") + return [native_id(concrete(child)) for child in children] + + +def property_link(design, prop): + prop = concrete(prop) + group = concrete(prop.parent) + require(group.objectType == "adsk::core::PropertyGroup", "configuration_scope_unsupported", "Only component property groups are supported.") + owner = concrete(group.parent) + require(owner.objectType == "adsk::fusion::Component", "configuration_scope_unsupported", "Property must belong to a component.") + return {"id": native_id(prop), "name": prop.name, "objectType": prop.objectType, + "groupId": native_id(group), "owner": source_link(design, owner), "readOnly": flag(prop.isReadOnly)} + + +def numeric_cell(design, cell, unit): + value = cell.value + require(isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value), + "configuration_read_failed", "Configuration numeric value is not finite.") + scales = {"mm": 10.0, "deg": 180.0 / math.pi, "kg": 1.0, "mm2": 100.0, "mm3": 1000.0, "": 1.0} + # A bare number is valid in almost any supplied default unit; it does not + # establish dimensionlessness. Fusion documents an empty native unit for it. + canonical = "" if unit == "" else next((candidate for candidate in scales if candidate + and design.unitsManager.isValidExpression("1 " + candidate, unit)), None) + require(canonical is not None, "configuration_scope_unsupported", + "Configuration numeric units must be length, angle, mass, area, volume or unitless.", displayUnit=unit) + result = {"internalValue": value, "internalUnitSystem": "Fusion database units (cm, rad, kg)", "displayUnit": unit} + # convert returns -1 on ExpressionError, which is also a legitimate signed + # cell value. Probe a positive basis against the known database scale, then + # calculate signed values with that verified linear scale. Temperature and + # other unsupported families never enter a speculative conversion fallback. + scale = scales[canonical] + basis = design.unitsManager.convert(1.0, "internalUnits", canonical) + require(isinstance(basis, (int, float)) and not isinstance(basis, bool) and math.isfinite(basis) + and basis > 0 and math.isclose(basis, scale, rel_tol=1e-12, abs_tol=1e-12), + "configuration_read_failed", "Configuration unit conversion basis failed validation.", unit=canonical or "unitless") + converted = value * scale + require(math.isfinite(converted), "configuration_read_failed", "Converted configuration value is not finite.") + result.update(value=converted, unit=canonical or "unitless") + return result + + +def read_table(design, result): + table = concrete(design.configurationTopTable) + rows, columns = bounded(table.rows, MAX_ROWS), bounded(table.columns, MAX_COLUMNS) + require(len(rows) * len(columns) <= MAX_CELLS, "configuration_budget", "Configuration cell budget exceeded.") + require(rows and len({native_id(row) for row in rows}) == len(rows), "configuration_read_failed", "Missing rows or duplicate configuration row IDs.") + require(len({native_id(column) for column in columns}) == len(columns), "configuration_read_failed", "Duplicate configuration column IDs.") + active = concrete(table.activeRow) + require(any(same(active, row) for row in rows), "configuration_read_failed", "Active row is outside ordinary rows.") + data = {"id": native_id(table), "name": table.name, "activeRowId": native_id(active), + "activeRowName": active.name, "rows": [], "testRow": None, "columns": [], "cells": []} + result["table"] = data + custom = diagnostic_read(result, "top.hasCustomColumns", lambda: flag(table.hasCustomColumns)) + def read_test_row(): + row = table.testRow + if row is None: + return None + row = concrete(row) + require(all(not same(row, normal) and native_id(row) != native_id(normal) for normal in rows), + "configuration_read_failed", "Test row overlaps an ordinary row.") + return row + test_row = diagnostic_read(result, "top.testRow", read_test_row) + test_id = native_id(test_row) if test_row is not None and not isinstance(test_row, dict) else test_row + result["preview"] = {"hasCustomColumns": custom, "testRowId": test_id} + if custom is True: + result["unsupported"].append("custom columns") + all_rows = rows + ([test_row] if isinstance(test_id, str) else []) + require(len(all_rows) <= MAX_ROWS and len(all_rows) * len(columns) <= MAX_CELLS, + "configuration_budget", "Configuration table including its test row exceeds budget.") + themes = bounded(table.customThemeTables, MAX_COLUMNS) + for name in ("appearanceTable", "materialTable", "plasticRuleTable", "sheetMetalRuleTable"): + theme = concrete(getattr(table, name)) + if theme.columns.count: + themes.append(theme) + result["themeTables"] = [{"id": native_id(t), "objectType": t.objectType, + "rowCount": t.rows.count, "columnCount": t.columns.count} for t in themes] + if themes: + result["unsupported"].append("theme-table contents") + for row in all_rows: + require(same(row.parentTable, table), "configuration_read_failed", "Configuration row belongs to another table.") + def parent_id(): + parent = row.parentRow + return native_id(concrete(parent)) if parent is not None else None + hierarchy = {"parentId": diagnostic_read(result, "row." + row.id + ".parentRow", parent_id), + "childIds": diagnostic_read(result, "row." + row.id + ".childRows", lambda: child_ids(row)), + "isTestRow": diagnostic_read(result, "row." + row.id + ".isTestRow", lambda: flag(row.isTestRow))} + reserved = isinstance(test_id, str) and same(row, test_row) + if isinstance(hierarchy["parentId"], str) or (isinstance(hierarchy["childIds"], list) and hierarchy["childIds"]): + result["unsupported"].append("hierarchical row: " + row.id) + if type(hierarchy["isTestRow"]) is bool and hierarchy["isTestRow"] is not reserved: + result["unsupported"].append("test-row flag disagrees with native table membership: " + row.id) + row_data = {"id": row.id, "name": row.name, "index": row.index, "hierarchy": hierarchy, + "scope": "test" if reserved else "ordinary"} + if reserved: + data["testRow"] = row_data + else: + data["rows"].append(row_data) + for column in columns: + require(same(column.parentTable, table), "configuration_read_failed", "Configuration column belongs to another table.") + kind = column.objectType.split("::")[-1] + count_label = "column." + column.id + ".rowCount" + def row_count(): + count = column.rowCount + require(type(count) is int and 0 <= count <= MAX_ROWS, "configuration_read_failed", "Invalid column row count.") + return count + metadata = {"id": column.id, "index": column.index, "title": column.title, "type": kind, + "rowCount": diagnostic_read(result, count_label, row_count)} + data["columns"].append(metadata) + if type(metadata["rowCount"]) is int: + diagnostic_read(result, count_label, + lambda: require(metadata["rowCount"] == len(all_rows), "configuration_read_failed", + "Column row count differs from the ordinary plus reserved test-row inventory.")) + if kind not in CELL_TYPES: + metadata["supported"] = False + result["unsupported"].append("column type: " + kind) + continue + label = "column." + column.id + if kind == "ConfigurationParameterColumn": + metadata.update(source=diagnostic_read(result, label + ".source", lambda: source_link(design, column.parameter)), + displayUnit=diagnostic_read(result, label + ".displayUnit", lambda: column.parameter.unit)) + elif kind == "ConfigurationSuppressColumn": + metadata["source"] = diagnostic_read(result, label + ".source", lambda: source_link(design, column.feature)) + elif kind == "ConfigurationVisibilityColumn": + metadata["source"] = diagnostic_read(result, label + ".source", lambda: source_link(design, column.entity)) + else: + metadata["property"] = diagnostic_read(result, label + ".property", lambda: property_link(design, column.parentProperty)) + for row in all_rows: + label = "cell." + row.id + "." + column.id + entry = {"rowId": row.id, "columnId": column.id, "type": CELL_TYPES[kind], + "scope": "test" if isinstance(test_id, str) and same(row, test_row) else "ordinary"} + data["cells"].append(entry) + def addressed_cell(): + cell = concrete(column.getCellByRowId(row.id)) + require(cell.objectType == "adsk::fusion::" + CELL_TYPES[kind], "configuration_read_failed", "Cell type disagrees with its column.") + require(same(concrete(cell.parentRow), row) and same(concrete(cell.parentColumn), column), + "configuration_read_failed", "Cell address readback mismatch.") + return cell + cell = diagnostic_read(result, label + ".address", addressed_cell) + if isinstance(cell, dict): + entry["unavailable"] = True + continue + if kind == "ConfigurationParameterColumn": + entry.update(expression=diagnostic_read(result, label + ".expression", lambda: cell.expression), + valueType=diagnostic_read(result, label + ".valueType", lambda: int(cell.valueType))) + if entry["valueType"] == adsk.fusion.ParameterValueTypes.NumericParameterValueType: + measured = diagnostic_read(result, label + ".numeric", lambda: numeric_cell(design, cell, column.parameter.unit)) + if measured.get("unavailable"): + entry["numeric"] = measured + else: + entry.update(measured) + elif entry["valueType"] == adsk.fusion.ParameterValueTypes.TextParameterValueType: + entry["textValue"] = diagnostic_read(result, label + ".textValue", lambda: cell.textValue) + elif not isinstance(entry["valueType"], dict): + diagnostic_read(result, label + ".valueType", lambda: require(False, "configuration_read_failed", "Unknown parameter-cell value type.")) + elif kind == "ConfigurationSuppressColumn": + entry.update(isSuppressed=diagnostic_read(result, label + ".isSuppressed", lambda: flag(cell.isSuppressed)), + isEffectivelySuppressed=diagnostic_read(result, label + ".isEffectivelySuppressed", lambda: flag(cell.isEffectivelySuppressed))) + elif kind == "ConfigurationVisibilityColumn": + entry["isVisible"] = diagnostic_read(result, label + ".isVisible", lambda: flag(cell.isVisible)) + else: + def property_value(): + value = cell.value + require(isinstance(value, str), "configuration_scope_unsupported", "Only textual component property cells are supported.") + return value + entry["value"] = diagnostic_read(result, label + ".value", property_value) + + +def snapshot(design, strict=False): + result = {"configured": None, "isConfiguration": None, "configurationRowId": None, + "complete": False, "table": None, "configuredOccurrences": [], + "unsupported": [], "readErrors": [], "configurationWritesEnabled": False, + "scope": "loaded Design ordinary and reserved test rows; no activation or recompute"} + try: + result["configured"] = flag(design.isConfiguredDesign) + result["isConfiguration"] = flag(design.isConfiguration) + row_id = design.configurationRowId + require(isinstance(row_id, str), "configuration_read_failed", "Native design configuration row ID is not a string.") + result["configurationRowId"] = row_id + require(bool(row_id) == result["isConfiguration"], "configuration_read_failed", "Native design configuration flag and row ID disagree.") + if result["isConfiguration"]: + result["unsupported"].append("generated single-configuration design: source-table identity and durable lifecycle are unsupported") + if result["configured"]: + read_table(design, result) + # A standard design can still contain externally configured occurrences. + for occurrence in items(design.rootComponent.allOccurrences): + if flag(occurrence.isConfiguration): + row = concrete(occurrence.configurationRow) + data_file = occurrence.configuredDataFile + require(data_file is not None and data_file.isValid, "configuration_read_failed", "Configured occurrence source file is unavailable.") + result["configuredOccurrences"].append({"occurrenceId": identity(occurrence), "path": occurrence.fullPathName, + "tableId": native_id(concrete(row.parentTable)), "rowId": native_id(row), "rowName": row.name, + "sourceFileId": data_file.id, "sourceVersion": data_file.versionNumber}) + if result["configuredOccurrences"]: + result["unsupported"].append("configured inserted occurrences: source-table witness and cloud lifecycle are deferred") + except Exception as error: + result["readErrors"].append({"code": error.code if isinstance(error, MechanicalError) else "configuration_read_failed", "message": str(error)}) + result["complete"] = not result["unsupported"] and not result["readErrors"] + if strict: + require(result["complete"], "configuration_scope_unsupported", "A complete supported configuration witness is required before model mutation or complete BOM export.", configuration=result) + return result + + +def provenance(graph): + table = graph["table"] + return {"configured": graph["configured"], "isConfiguration": graph["isConfiguration"], + "tableId": table["id"] if table else None, + "rowId": graph["configurationRowId"] if graph["isConfiguration"] else table["activeRowId"] if table else None, "rowName": table["activeRowName"] if table else None, + "scope": "current active configuration only", "graphComplete": graph["complete"]} + + +def inspect(app, args): + from .core import target + _, design, value = target(app, args) + return {"target": value, "configuration": snapshot(design)} + + +READS = {"configuration_inspect": inspect} diff --git a/addin/AdomBridge/commands/mechanical/core.py b/addin/AdomBridge/commands/mechanical/core.py new file mode 100644 index 0000000..513c835 --- /dev/null +++ b/addin/AdomBridge/commands/mechanical/core.py @@ -0,0 +1,317 @@ +"""Main-thread mechanical identity, revisions, references and health. + +Session IDs are capabilities for this running add-in, not persistent CAD IDs. Tokens +are only resolver inputs: they are never compared to decide entity identity. +""" +import hashlib +import json +import math +import threading +import uuid + +import adsk.core +import adsk.fusion + +SESSION_ID = str(uuid.uuid4()) +_documents = [] +_entities = [] +MAX_ENTITIES = 20000 +UNITS = {"length": "mm", "angle": "deg", "mass": "kg", "area": "mm2", "volume": "mm3"} + + +class MechanicalError(Exception): + def __init__(self, code, message, **details): + super().__init__(message) + self.code, self.details = code, details + + +def require(condition, code, message, **details): + if not condition: + raise MechanicalError(code, message, **details) + + +def items(collection): + require(collection.count <= MAX_ENTITIES, "inspection_budget", "Collection exceeds inspection budget.") + return [collection.item(i) for i in range(collection.count)] + + +def same(a, b): + return a is b or a == b + + +def concrete(entity): + """Cast Base-returning Fusion getters before reading subclass context/state.""" + if entity is None: + return None + cls = getattr(adsk.fusion, entity.objectType.split("::")[-1], None) + if cls is not None and hasattr(cls, "cast"): + entity = cls.cast(entity) + require(entity is not None, "unsupported_entity_wrapper", "Could not cast the advertised Fusion entity type.") + return entity + + +def identity(entity): + """Object equality, never name or token equality. Retained for this session only.""" + for obj, key in _entities: + if obj.isValid and same(obj, entity): + return key + require(len(_entities) < MAX_ENTITIES, "identity_budget", "Session entity budget exhausted.") + key = str(uuid.uuid4()) + _entities.append((entity, key)) + return key + + +def document_identity(doc): + require(threading.current_thread() is threading.main_thread(), "wrong_thread", "Document identity requires the Fusion main thread.") + require(doc is not None and doc.isValid, "wrong_document", "Document identity requires a live document.") + for previous, key in _documents: + if previous.isValid and same(previous, doc): + return key + key = SESSION_ID + ":" + str(uuid.uuid4()) + _documents.append((doc, key)) + return key + + +def active(app): + require(threading.current_thread() is threading.main_thread(), "wrong_thread", "Mechanical API requires the Fusion main thread.") + doc = app.activeDocument + design = adsk.fusion.Design.cast(app.activeProduct) + require(doc and design, "no_design", "The active product must be a Fusion Design.") + require(same(design.parentDocument, doc), "wrong_document", "Active product and document disagree.") + return doc, design, document_identity(doc) + + +def digest(value): + return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(",", ":"), allow_nan=False).encode()).hexdigest() + + +def revision(design): + """Aggregate every component, body, sketch, parameter and occurrence transform. + + 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. + """ + 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)]) + + +def occurrence_suppression(occurrence): + timeline = occurrence.timelineObject + return bool(timeline.isSuppressed) if timeline is not None else None + + +def target(app, args, write=False, model_write=True): + doc, design, key = active(app) + wanted = args.get("target") + require(isinstance(wanted, dict), "target_required", "Pass target from mechanical inspect.") + require(wanted.get("documentId") == key, "wrong_document", "Target is not the active document.", activeDocumentId=key) + if write: + from .configurations import snapshot as configuration_snapshot + configuration = configuration_snapshot(design, strict=True) + if model_write: + require(not configuration["configured"], "configuration_mutation_unverified", + "Configured-design model writes remain disabled until native configuration recovery and archive gates pass; inspection and current-row BOM/export remain available.") + rev = revision(design) + if write or "revision" in wanted: + require(wanted.get("revision") == rev, "stale_revision", "The model changed; inspect it before a new operation.", currentRevision=rev) + return doc, design, {"documentId": key, "revision": rev} + + +def current_target(app): + doc, design, key = active(app) + return {"documentId": key, "revision": revision(design)} + + +def ref(entity, target_value, occurrence=None): + entity = concrete(entity) + if entity.objectType == "adsk::fusion::Profile": + from .readback import profile_ref + sketch = entity.parentSketch + matches = [index for index, profile in enumerate(items(sketch.profiles)) if same(profile, entity)] + require(len(matches) == 1, "ambiguous_reference", "Profile must belong uniquely to its sketch.") + return profile_ref(sketch, entity, matches[0], target_value) + is_occurrence = entity.objectType.endswith("::Occurrence") + native = entity if is_occurrence else (getattr(entity, "nativeObject", None) or entity) + context = None if is_occurrence else (occurrence or getattr(entity, "assemblyContext", None)) + result = {"documentId": target_value["documentId"], "revision": target_value["revision"], + "token": native.entityToken, "objectType": native.objectType, + "occurrenceToken": context.entityToken if context else None, + "occurrencePath": context.fullPathName if context else (entity.fullPathName if is_occurrence else None)} + if native.objectType == "adsk::fusion::Component" and same(native, native.parentDesign.rootComponent): + # Fusion can emit a root token that findEntityByToken cannot resolve. + # Bind this explicit root reference to its live API identity instead. + result["rootComponentId"] = identity(native) + return result + + +def occurrence_context(design, entity, path): + entity = concrete(entity) + require(entity.objectType.endswith("::Occurrence"), "invalid_context", "Context must resolve to an occurrence.") + native = getattr(entity, "nativeObject", None) or entity + candidates = [o for o in items(design.rootComponent.allOccurrences) + if o.fullPathName == path and same(getattr(o, "nativeObject", None) or o, native)] + if candidates: + require(len(candidates) == 1, "ambiguous_context", "Occurrence path must identify one instance.") + return candidates[0] + # Native definition references have a path relative to their owner. Only + # retain that context when the token and saved path both resolve exactly. + require(entity.fullPathName == path, "invalid_context", "Occurrence path changed.") + return entity + + +def resolve(design, target_value, value, allowed=()): + require(isinstance(value, dict), "invalid_reference", "An entity reference object is required.") + require(value.get("documentId") == target_value["documentId"] and value.get("revision") == target_value["revision"], + "stale_reference", "Reference is from another document or revision.") + if value.get("objectType") == "adsk::fusion::Profile": + # Bind profile selection to a uniquely + # resolved sketch, this exact aggregate revision and geometric witness. + from .readback import profile_signature + require(not allowed or "Profile" in allowed, "wrong_entity_type", "Operation does not accept profiles.") + require(isinstance(value.get("sketchToken"), str), "invalid_reference", "Profile sketch token is required.") + sketches = design.findEntityByToken(value["sketchToken"]) + require(len(sketches) == 1 and sketches[0].objectType.endswith("::Sketch"), "ambiguous_reference", "Profile sketch must resolve uniquely.") + index = value.get("profileIndex") + require(isinstance(index, int) and not isinstance(index, bool) and 0 <= index < sketches[0].profiles.count, + "invalid_reference", "Profile index is no longer valid.") + profile = sketches[0].profiles.item(index) + require(digest(profile_signature(profile)) == value.get("fingerprint"), "stale_reference", "Profile geometry changed.") + return profile + if "rootComponentId" in value: + entity = design.rootComponent + require(value.get("objectType") == "adsk::fusion::Component" + and value["rootComponentId"] == identity(entity) + and not value.get("occurrenceToken") and not value.get("occurrencePath"), + "invalid_reference", "Root reference does not identify this active design's root component.") + else: + require(isinstance(value.get("token"), str) and value["token"], "invalid_reference", "Reference token is missing.") + matches = design.findEntityByToken(value["token"]) + require(len(matches) <= MAX_ENTITIES, "inspection_budget", "Token lookup exceeds inspection budget.") + if len(matches) > 1 and value.get("objectType") == "adsk::fusion::Component": + # Repeated definitions can return duplicate API wrappers. Only + # actual Component equality makes them one entity; face splits and + # occurrence instances retain the strict one-result rule below. + first = matches[0] + for candidate in matches: + require(candidate.isValid and candidate.objectType == "adsk::fusion::Component" + and same(first, candidate), "ambiguous_reference", + "Component token resolves to distinct or invalid entities.", matchCount=len(matches)) + matches = [first] + require(len(matches) == 1, "ambiguous_reference", "Token must resolve to exactly one live entity.", matchCount=len(matches)) + entity = concrete(matches[0]) + require(entity.isValid and entity.objectType == value.get("objectType"), "invalid_reference", "Entity type or validity changed.") + require(not allowed or entity.objectType.split("::")[-1] in allowed, "wrong_entity_type", "Operation does not accept this entity type.") + if entity.objectType.endswith("::Occurrence") and value.get("occurrencePath"): + entity = occurrence_context(design, entity, value["occurrencePath"]) + if value.get("occurrenceToken"): + contexts = design.findEntityByToken(value["occurrenceToken"]) + require(len(contexts) == 1, "ambiguous_context", "Occurrence context must resolve uniquely.") + context = occurrence_context(design, contexts[0], value.get("occurrencePath")) + owner = getattr(entity, "parentComponent", None) + if owner is None and hasattr(entity, "component"): + owner = entity.component + if owner is None and hasattr(entity, "parentSketch"): + owner = entity.parentSketch.parentComponent + if owner is None and hasattr(entity, "body"): + owner = entity.body.parentComponent + require(owner and same(owner, context.component), "invalid_context", "Entity is not owned by this occurrence component.") + entity = entity.createForAssemblyContext(context) + require(entity and entity.isValid, "invalid_context", "Could not create occurrence-context proxy.") + return entity + + +def health(design, recompute=False): + computed = None + if recompute: + computed = bool(design.computeAll()) + faults, unknown, suppressed = [], [], [] + pending = [(str(index), item) for index, item in enumerate(items(design.timeline))] + while pending: + index, item = pending.pop(0) + try: + if getattr(item, "isGroup", False): + group = adsk.fusion.TimelineGroup.cast(item) + pending.extend((index + "." + str(i), child) for i, child in enumerate(items(group))) + continue + if getattr(item, "isSuppressed", False): + suppressed.append({"index": index, "name": item.name}) + continue + state = item.healthState + if state != adsk.fusion.FeatureHealthStates.HealthyFeatureHealthState: + faults.append({"index": index, "name": item.name, "state": int(state), + "message": item.errorOrWarningMessage}) + except Exception as exc: + unknown.append({"index": index, "error": str(exc)}) + return {"healthy": computed is not False and not faults and not unknown, + "computeSucceeded": computed, "complete": not unknown, "faults": faults, + "unknown": unknown, "suppressed": suppressed, "timelineCount": design.timeline.count} + + +def require_healthy(design, recompute=True): + state = health(design, recompute) + require(state["healthy"], "unhealthy_model", "Recompute or feature health check failed.", health=state) + return state + + +def point_mm(point): + return [point.x * 10, point.y * 10, point.z * 10] + + +def body_data(body): + pp = body.physicalProperties + box = body.boundingBox + return {"name": body.name, "volume": pp.volume * 1000, "area": pp.area * 100, + "mass": pp.mass, "centerOfMass": point_mm(pp.centerOfMass), + "bounds": {"min": point_mm(box.minPoint), "max": point_mm(box.maxPoint)}, + "faces": body.faces.count, "edges": body.edges.count, "isSolid": body.isSolid, + "space": "component", "units": UNITS} + + +def parameter_data(design, parameter): + unit = parameter.unit + # Fusion numeric values always use its internal units (cm/rad/kg), regardless + # of the display unit. Conversion occurs here, never in individual handlers. + value, normalized = None, unit + for canonical in ("mm", "deg", "kg", ""): + try: + if design.unitsManager.isValidExpression("1 " + canonical, unit): + value = design.unitsManager.convert(parameter.value, "internalUnits", canonical) + normalized = canonical or "unitless" + break + except Exception: + continue + return {"name": parameter.name, "expression": parameter.expression, + "value": value, "unit": normalized, "displayUnit": unit} + + +def recovery_snapshot(design): + """Semantic recovery witness; native revision IDs legitimately change on undo.""" + from .readback import construction_state, feature_data, sketch_data, topology_witness + from .assembly_state import component_state, occurrence_state + from .configurations import snapshot as configuration_snapshot + configuration = configuration_snapshot(design, strict=True) + return {"configuration": configuration, + "parameters": [[p.name, p.expression, p.unit] for p in items(design.allParameters)], + "components": [[identity(c), c.name, [topology_witness(b) for b in items(c.bRepBodies)], + [sketch_data(s) for s in items(c.sketches)], + [feature_data(f, order=i) for i, f in enumerate(items(c.features))], component_state(c), construction_state(c)] for c in items(design.allComponents)], + "occurrences": [[identity(o), list(o.transform2.asArray()), occurrence_state(o)] for o in items(design.rootComponent.allOccurrences)], + "timeline": design.timeline.count, "timelineMarker": design.timeline.markerPosition, "health": health(design)} + + +def number(value, name, positive=False): + require(isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value), "invalid_input", name + " must be a finite number.") + require(not positive or value > 0, "invalid_input", name + " must be positive.") + return float(value) diff --git a/addin/AdomBridge/commands/mechanical/exporting.py b/addin/AdomBridge/commands/mechanical/exporting.py new file mode 100644 index 0000000..9cbf46e --- /dev/null +++ b/addin/AdomBridge/commands/mechanical/exporting.py @@ -0,0 +1,93 @@ +"""Single-file exports: preflight, isolated staging, verified atomic publication.""" +import hashlib +import os +from pathlib import Path +import tempfile + +import adsk.fusion + +from .core import MechanicalError, require + +FORMATS = { + "step": ("createSTEPExportOptions", (".step", ".stp"), "file"), + "stl": ("createSTLExportOptions", (".stl",), "entity"), + "3mf": ("createC3MFExportOptions", (".3mf",), "entity"), + "f3d": ("createFusionArchiveExportOptions", (".f3d",), "file"), + "iges": ("createIGESExportOptions", (".igs", ".iges"), "file"), + "sat": ("createSATExportOptions", (".sat",), "file"), + "smt": ("createSMTExportOptions", (".smt",), "file"), + "usdz": ("createUSDExportOptions", (".usdz",), "file"), + "fbx": ("createFBXExportOptions", (".fbx",), "file"), + "skp": ("createSketchUpExportOptions", (".skp",), "file"), +} + + +def publish_staged(staged, destination, overwrite): + require(staged.is_file() and staged.stat().st_size > 0, "empty_export", "Exporter produced no nonempty file.") + size = staged.stat().st_size + sha = hashlib.sha256(staged.read_bytes()).hexdigest() + result = {"outputPath": str(destination), "fileSize": size, "sha256": sha, + "published": True, "overwrite": overwrite} + if overwrite: + os.replace(str(staged), str(destination)) + else: + # Atomic exclusive publication. Checking exists then replace has a race that + # could overwrite another writer's file. A hardlink never replaces it. + os.link(str(staged), str(destination)) + try: + staged.unlink() + except OSError as exc: + # The hardlink is the publication commit point. A leftover staging + # link is cleanup work, not a failed export or preserved destination. + result["cleanupWarning"] = str(exc) + return result + + +def export_design(design, args): + fmt = args.get("format", "step").lower() + require(fmt in FORMATS, "unsupported_format", "Single-file export format is unavailable. Sketch DXF requires an explicit sketch; OBJ sidecars need a bundle exporter.") + method, suffixes, mode = FORMATS[fmt] + manager = design.exportManager + factory = getattr(manager, method, None) + require(callable(factory), "unsupported_capability", method + " is unavailable in this Fusion build.") + raw = args.get("outputPath") + require(isinstance(raw, str) and raw, "invalid_path", "outputPath is required.") + destination = Path(raw) + require(destination.is_absolute(), "invalid_path", "outputPath must be absolute on the Fusion host.") + require(destination.suffix.lower() in suffixes, "extension_mismatch", "File extension does not match requested format.") + overwrite = args.get("overwrite", False) + require(isinstance(overwrite, bool), "invalid_input", "overwrite must be a boolean.") + require(not destination.exists() or overwrite, "destination_exists", "Destination exists; pass overwrite:true to atomically replace it.") + require(not destination.is_symlink() and not destination.is_dir(), "invalid_path", "Destination must be a regular file path.") + destination.parent.mkdir(parents=True, exist_ok=True) + # Entire unique directory prevents the exporter seeing an existing file and + # raising an overwrite dialog. Same filesystem permits atomic publication. + out = None + try: + with tempfile.TemporaryDirectory(prefix=".adom-export-", dir=str(destination.parent)) as folder: + staged = Path(folder) / ("model" + destination.suffix) + options = factory(design.rootComponent, str(staged)) if mode == "entity" else factory(str(staged)) + require(options is not None, "export_preflight_failed", "Exporter did not create options.") + if hasattr(options, "isViewFit"): + options.isViewFit = False + if fmt in ("stl", "3mf"): + options.sendToPrintUtility = False + options.isOneFilePerBody = False + if fmt == "stl": + refinement = args.get("refinement", "medium") + settings = {"low": "MeshRefinementLow", "medium": "MeshRefinementMedium", "high": "MeshRefinementHigh"} + require(refinement in settings, "invalid_input", "refinement must be low, medium or high.") + options.meshRefinement = getattr(adsk.fusion.MeshRefinementSettings, settings[refinement]) + # STL has no embedded unit declaration; request millimetres explicitly. + require(hasattr(options, "unitType"), "unsupported_capability", "STL millimetre unit selection is unavailable.") + options.unitType = adsk.fusion.DistanceUnits.MillimeterDistanceUnits + require(bool(manager.execute(options)), "export_failed", "Fusion exporter returned false; destination was preserved.") + out = publish_staged(staged, destination, overwrite) + out.update({"format": fmt, "externalEffect": "file_published", "modelRollbackApplies": False}) + if fmt == "stl": + out["lengthUnit"] = "mm" + except Exception as exc: + if out is None: + raise + out["cleanupWarning"] = str(exc) + return out diff --git a/addin/AdomBridge/commands/mechanical/features.py b/addin/AdomBridge/commands/mechanical/features.py new file mode 100644 index 0000000..8ad73c9 --- /dev/null +++ b/addin/AdomBridge/commands/mechanical/features.py @@ -0,0 +1,238 @@ +"""Solid feature operations; selection always resolves against the guarded model.""" +import math + +import adsk.core +import adsk.fusion + +from .core import items, parameter_data, ref, require, resolve, same +from .readback import feature_data, topology_data +from .sketches import axis, collection, component, named, plane, point, quantity + + +def owner(entity): + if hasattr(entity, "body"): + return entity.body.parentComponent + if hasattr(entity, "parentComponent"): + return entity.parentComponent + if hasattr(entity, "parentSketch"): + return entity.parentSketch.parentComponent + return None + + +def selected(design, value, args, name, types, comp): + raw = args.get(name, []) + require(isinstance(raw, list) and raw, "invalid_selection", name + " requires at least one reference.") + result = [resolve(design, value, r, types) for r in raw] + for entity in result: + require(getattr(entity, "assemblyContext", None) is None, "definition_reference_required", "Feature authoring requires native definition references.") + require(same(owner(entity), comp), "wrong_component", "Selected entity belongs to another component; pass its component reference.") + return result + + +def operation(value): + return getattr(adsk.fusion.FeatureOperations, {"new_body": "NewBodyFeatureOperation", "join": "JoinFeatureOperation", + "cut": "CutFeatureOperation", "intersect": "IntersectFeatureOperation"}[value]) + + +def factory(features, name): + result = getattr(features, name, None) + require(result is not None, "unsupported_capability", name + " is unavailable in this Fusion build.") + return result + + +def feature_create(design, value, args): + comp = component(design, value, args) + features, kind = comp.features, args["type"] + q = lambda key, unit="mm", positive=True: quantity(design, args[key], unit, positive) + select = lambda key, types: selected(design, value, args, key, types, comp) + operation_name = args.get("operation", "new_body") + if operation_name == "join" and kind in ("extrude", "revolve", "loft", "sweep"): + require(comp.bRepBodies.count == 1, "ambiguous_join", "Join requires one existing body in the component; create a new body then use combine for explicit multi-body selection.") + if kind == "extrude": + profiles = select("profiles", ("Profile",)) + group = factory(features, "extrudeFeatures") + inp = group.createInput(collection(profiles), operation(operation_name)) + if args.get("direction", "positive") == "symmetric": + require(inp.setSymmetricExtent(q("distance"), True, quantity(design, args.get("taper", 0), "deg")), "invalid_extent", "Symmetric extrusion extent was refused.") + else: + extent = adsk.fusion.DistanceExtentDefinition.create(q("distance")) + direction = getattr(adsk.fusion.ExtentDirections, "NegativeExtentDirection" if args.get("direction") == "negative" else "PositiveExtentDirection") + require(inp.setOneSideExtent(extent, direction, quantity(design, args.get("taper", 0), "deg")), "invalid_extent", "Extrusion extent was refused.") + if operation_name in ("cut", "intersect"): + inp.participantBodies = select("bodies", ("BRepBody",)) + elif kind == "revolve": + profiles = select("profiles", ("Profile",)) + group = factory(features, "revolveFeatures") + inp = group.createInput(collection(profiles), axis(design, value, comp, args["axis"]), operation(operation_name)) + require(inp.setAngleExtent(False, q("angle", "deg")), "invalid_extent", "Revolve angle was refused.") + if operation_name in ("cut", "intersect"): + inp.participantBodies = select("bodies", ("BRepBody",)) + elif kind == "fillet": + group = factory(features, "filletFeatures") + inp = group.createInput() + inp.edgeSetInputs.addConstantRadiusEdgeSet(collection(select("edges", ("BRepEdge",))), q("radius"), args.get("tangentChain", False)) + elif kind == "chamfer": + group = factory(features, "chamferFeatures") + require(hasattr(group, "createInput2"), "unsupported_capability", "Modern chamfer input API unavailable.") + inp = group.createInput2() + inp.chamferEdgeSets.addEqualDistanceChamferEdgeSet(collection(select("edges", ("BRepEdge",))), q("distance"), args.get("tangentChain", False)) + elif kind == "shell": + group = factory(features, "shellFeatures") + inp = group.createInput(collection(select("faces", ("BRepFace",))), args.get("tangentChain", False)) + inp.insideThickness = q("thickness") + elif kind == "hole": + group = factory(features, "holeFeatures") + inp = group.createSimpleInput(q("diameter")) + require(inp.setPositionBySketchPoints(collection(select("points", ("SketchPoint",)))), "hole_position_failed", "Hole positions were refused.") + require(inp.setDistanceExtent(q("depth")), "invalid_extent", "Hole depth was refused.") + inp.participantBodies = select("bodies", ("BRepBody",)) + elif kind == "loft": + group = factory(features, "loftFeatures") + inp = group.createInput(operation(operation_name)) + profiles = select("profiles", ("Profile",)) + require(len(profiles) >= 2, "invalid_selection", "Loft requires at least two profiles.") + for profile in profiles: + inp.loftSections.add(profile) + inp.isSolid = True + if operation_name in ("cut", "intersect"): + inp.participantBodies = select("bodies", ("BRepBody",)) + elif kind == "sweep": + group = factory(features, "sweepFeatures") + profiles = select("profiles", ("Profile",)) + require(len(profiles) == 1, "invalid_selection", "Sweep requires exactly one profile.") + path = adsk.fusion.Path.create(collection(select("path", ("SketchLine", "SketchArc", "SketchFittedSpline", "BRepEdge"))), adsk.fusion.ChainedCurveOptions.noChainedCurves) + require(path is not None, "invalid_path", "Sweep path must form a connected curve chain.") + inp = group.createInput(profiles[0], path, operation(operation_name)) + if operation_name in ("cut", "intersect"): + inp.participantBodies = select("bodies", ("BRepBody",)) + elif kind == "combine": + group = factory(features, "combineFeatures") + target_body = resolve(design, value, args["body"], ("BRepBody",)) + require(getattr(target_body, "assemblyContext", None) is None, "definition_reference_required", "Combine requires a native target body reference.") + require(same(owner(target_body), comp), "wrong_component", "Target body is in another component.") + tools = select("tools", ("BRepBody",)) + require(not any(same(target_body, tool) for tool in tools), "invalid_selection", "Target cannot also be a tool body.") + inp = group.createInput(target_body, collection(tools)) + require(operation_name in ("join", "cut", "intersect"), "invalid_input", "Combine operation must be join, cut or intersect.") + inp.operation = operation(operation_name) + inp.isKeepToolBodies = args.get("keepTools", True) + elif kind == "split": + group = factory(features, "splitBodyFeatures") + bodies = select("bodies", ("BRepBody",)) + require(len(bodies) == 1, "invalid_selection", "Split requires one body.") + tool = plane(design, value, comp, args["plane"]) + inp = group.createInput(bodies[0], tool, args.get("extend", True)) + elif kind == "move": + group = factory(features, "moveFeatures") + require(hasattr(group, "createInput2"), "unsupported_capability", "Modern move input API unavailable.") + inp = group.createInput2(collection(select("bodies", ("BRepBody",)))) + matrix = adsk.core.Matrix3D.create() + if args.get("angle", 0): + vector = args.get("axis", [0, 0, 1]) + rotation_axis = adsk.core.Vector3D.create(*vector) + require(rotation_axis.length > 1e-9, "invalid_input", "Rotation axis must be nonzero.") + angle_value = design.unitsManager.evaluateExpression(str(args["angle"]) + " deg", "deg") + require(matrix.setToRotation(angle_value, rotation_axis, point(args.get("origin", [0, 0, 0]))), "invalid_transform", "Rotation matrix creation failed.") + translation = point(args.get("translation", [0, 0, 0])) + existing = matrix.translation + matrix.translation = adsk.core.Vector3D.create(existing.x + translation.x, existing.y + translation.y, existing.z + translation.z) + require(inp.defineAsFreeMove(matrix), "invalid_transform", "Move transform was refused.") + elif kind == "mirror": + group = factory(features, "mirrorFeatures") + inp = group.createInput(collection(select("bodies", ("BRepBody",))), plane(design, value, comp, args["plane"])) + inp.isCombine = False + elif kind == "rectangular_pattern": + if any(key in args for key in ("axis2", "count2", "spacing2")): + require("count2" in args and "spacing2" in args, "invalid_input", "Second pattern direction requires count2 and spacing2; axis2 defaults to y.") + group = factory(features, "rectangularPatternFeatures") + inp = group.createInput(collection(select("bodies", ("BRepBody",))), axis(design, value, comp, args["axis"]), + q("count", ""), q("spacing"), adsk.fusion.PatternDistanceType.SpacingPatternDistanceType) + inp.quantityTwo = quantity(design, 1, "", True) + inp.isSymmetricInDirectionOne = False + inp.isSymmetricInDirectionTwo = False + if "count2" in args: + require(inp.setDirectionTwo(axis(design, value, comp, args.get("axis2", "y")), q("count2", ""), q("spacing2")), "pattern_failed", "Second pattern direction was refused.") + elif kind == "circular_pattern": + group = factory(features, "circularPatternFeatures") + inp = group.createInput(collection(select("bodies", ("BRepBody",))), axis(design, value, comp, args["axis"])) + inp.quantity = q("count", "") + inp.totalAngle = quantity(design, args.get("angle", 360), "deg", True) + inp.isSymmetric = False + elif kind == "draft": + group = factory(features, "draftFeatures") + inp = group.createInput(select("faces", ("BRepFace",)), plane(design, value, comp, args["plane"]), args.get("tangentChain", False)) + require(inp.setSingleAngle(False, q("angle", "deg", False)), "draft_failed", "Draft angle was refused.") + elif kind == "thread": + group = factory(features, "threadFeatures") + create_info = getattr(getattr(adsk.fusion, "ThreadInfo", None), "create", None) + require(callable(create_info), "unsupported_capability", "ThreadInfo.create is unavailable.") + info = create_info(False, args["internal"], args["threadType"], args["designation"], args["threadClass"], True) + require(info is not None, "invalid_thread", "Requested thread standard/designation/class is unavailable.") + inp = group.createInput(collection(select("faces", ("BRepFace",))), info) + inp.isModeled = args.get("modeled", False) + inp.isFullLength = True + else: + require(False, "unsupported_feature", "This feature type is not supported.") + feature = named(group.add(inp), args) + return lambda fresh: {"feature": feature_data(feature, fresh), + "bodies": [topology_data(body, fresh) for body in items(feature.bodies)]} + + +def feature_edit(design, value, args): + require(any(key in args for key in ("name", "suppressed", "parameters")), "invalid_input", "Specify a feature property to edit.") + feature = resolve(design, value, args["feature"]) + timeline = getattr(feature, "timelineObject", None) + require(timeline is not None, "wrong_entity_type", "Feature must have a timeline object.") + if "name" in args: + feature.name = args["name"] + if "suppressed" in args: + timeline.isSuppressed = args["suppressed"] + for name, expression in args.get("parameters", {}).items(): + parameter = design.allParameters.itemByName(name) + require(parameter is not None and same(getattr(parameter, "createdBy", None), feature), "wrong_parameter", "Parameter is not owned by this feature: " + name) + require(design.unitsManager.isValidExpression(expression, parameter.unit), "invalid_expression", "Invalid parameter expression.") + parameter.expression = expression + return lambda fresh: {"feature": feature_data(feature, fresh)} + + +def entity_delete(design, value, args): + entity = resolve(design, value, args["entity"]) + kind = entity.objectType.split("::")[-1] + require(kind in ("Sketch", "SketchLine", "SketchCircle", "SketchArc", "SketchPoint", "SketchFittedSpline", "ConstructionPlane") or kind.endswith("Feature"), + "unsupported_delete", "Delete supports sketches, sketch geometry, construction planes and timeline features.") + description = {"name": getattr(entity, "name", None), "type": entity.objectType} + timeline = getattr(entity, "timelineObject", None) + if timeline: + require(timeline.isDeletable, "delete_refused", "Fusion reports this timeline entity cannot be deleted independently.") + require(timeline.deleteObject(), "delete_failed", "Feature deletion failed.") + else: + require(entity.deleteMe(), "delete_failed", "Entity deletion failed.") + return {"deleted": description} + + +def parameter_batch(design, value, args): + changed = [] + for change in args["changes"]: + name, action = change["name"], change["action"] + parameter = design.allParameters.itemByName(name) + if action == "create": + require(parameter is None, "parameter_exists", "Parameter exists: " + name) + parameter = design.userParameters.add(name, quantity(design, change["expression"], change["unit"]), change["unit"], change.get("comment", "")) + require(parameter is not None, "parameter_create_failed", "Parameter creation failed.") + else: + require(parameter is not None, "parameter_missing", "Parameter not found: " + name) + if action == "delete": + user = design.userParameters.itemByName(name) + require(user is not None, "delete_refused", "Only user parameters can be deleted.") + require(user.deleteMe(), "delete_refused", "Parameter is in use or cannot be deleted.") + changed.append({"name": name, "deleted": True}) + continue + expression = change["expression"] + require(design.unitsManager.isValidExpression(expression, parameter.unit), "invalid_expression", "Invalid expression for " + name) + parameter.expression = expression + changed.append(parameter_data(design, parameter)) + return {"parameters": changed} + + +OPERATIONS = {"feature_create": feature_create, "feature_edit": feature_edit, + "entity_delete": entity_delete, "parameter_batch": parameter_batch} diff --git a/addin/AdomBridge/commands/mechanical/geometry.py b/addin/AdomBridge/commands/mechanical/geometry.py new file mode 100644 index 0000000..7123ad2 --- /dev/null +++ b/addin/AdomBridge/commands/mechanical/geometry.py @@ -0,0 +1,279 @@ +"""Bounded geometry selection, engineering measurements and construction axes.""" +import math + +import adsk.core +import adsk.fusion + +from .core import (MAX_ENTITIES, UNITS, items, number, point_mm, ref, require, + resolve, same, target) +from .readback import concrete_geometry, geometry_data +from .sketches import component, named, point + + +def native(entity): + return getattr(entity, "nativeObject", None) or entity + + +def owner(entity): + entity = native(entity) + if hasattr(entity, "body"): + return entity.body.parentComponent + if hasattr(entity, "parentSketch"): + return entity.parentSketch.parentComponent + return getattr(entity, "parentComponent", None) or getattr(entity, "component", None) + + +def root_entity(design, entity): + """Never guess which instance of a non-root definition the caller means.""" + if entity.objectType.endswith("::Occurrence"): + require(any(same(entity, item) for item in items(design.rootComponent.allOccurrences)), + "root_context_required", "Occurrence must be resolved in the root assembly context.") + else: + context = getattr(entity, "assemblyContext", None) + require(context is not None or same(owner(entity), design.rootComponent), + "occurrence_required", "Root-space geometry in a child component requires an occurrence-context reference.") + if context is not None: + require(any(same(context, item) for item in items(design.rootComponent.allOccurrences)), + "root_context_required", "Geometry context must be a root assembly occurrence.") + return entity + + +def finite_point(value): + require(value is not None, "measurement_failed", "Native measurement has no witness point.") + result = point_mm(value) + require(all(math.isfinite(v) for v in result), "measurement_failed", "Native witness point is not finite.") + return result + + +def minimum(manager, first, second): + result = manager.measureMinimumDistance(first, second) + require(result is not None and result.isValid, "measurement_failed", "Native minimum-distance measurement failed.") + distance = number(result.value, "native distance") * 10 + require(distance >= 0, "measurement_failed", "Native minimum distance is negative.") + points = [finite_point(result.positionOne), finite_point(result.positionTwo)] + separation = math.dist(*points) + require(math.isclose(distance, separation, abs_tol=1e-5, rel_tol=1e-7), + "measurement_failed", "Native distance does not agree with its witness positions.") + return distance, points + + +MEASURABLE = ("Occurrence", "BRepBody", "BRepFace", "BRepEdge", "BRepVertex", + "ConstructionPlane", "ConstructionAxis", "ConstructionPoint", "SketchPoint", + "SketchLine", "SketchArc", "SketchCircle", "SketchEllipse", "SketchEllipticalArc", + "SketchFittedSpline", "SketchControlPointSpline", "SketchFixedSpline") +POINT_TYPES = ("BRepVertex", "ConstructionPoint", "SketchPoint") + + +def angle_direction(entity): + # Sketch geometry is in sketch coordinates; worldGeometry includes the + # sketch transform and the resolved occurrence context. + geometry = concrete_geometry(entity.worldGeometry if entity.objectType.endswith("::SketchLine") + else entity.geometry) + kind = geometry.objectType.split("::")[-1] + if kind == "Plane": + vector, meaning = geometry.normal, "normal" + elif kind == "Line3D": + vector, meaning = geometry.startPoint.vectorTo(geometry.endPoint), "line" + else: + require(kind == "InfiniteLine3D", "measurement_failed", "Cannot establish the native angle direction.") + vector, meaning = geometry.direction, "line" + return {"kind": meaning, "vector": unit_vector([vector.x, vector.y, vector.z])} + + +def angle_witness(entities, value, points): + a = [points[0][i] - points[1][i] for i in range(3)] + b = [points[2][i] - points[1][i] for i in range(3)] + if math.hypot(*a) > 1e-12 and math.hypot(*b) > 1e-12: + expected = math.degrees(math.acos(max(-1, min(1, sum(x*y for x, y in zip(unit_vector(a), unit_vector(b))))))) + require(math.isclose(value, expected, abs_tol=1e-5), "measurement_failed", "Angle disagrees with native witness positions.") + return {"method": "native_points", "nativePointWitnessDegenerate": False} + # Fusion returns three zero points for valid parallel-plane angles. Keep + # those original points, and establish parallelism from native geometry. + # Never accept a degenerate three-point angle or synthesize witness points. + require(len(entities) == 2 and all(e.objectType.split("::")[-1] not in POINT_TYPES for e in entities), + "invalid_direction", "Point angle witness is degenerate.") + require(min(abs(value), abs(value - 180)) <= 1e-5, + "measurement_failed", "Degenerate native points do not establish this angle.") + directions = [angle_direction(entity) for entity in entities] + dot = sum(x*y for x, y in zip(directions[0]["vector"], directions[1]["vector"])) + cosine = min(1, abs(dot)) + deviation = math.degrees(math.acos(cosine) if directions[0]["kind"] == directions[1]["kind"] + else math.asin(cosine)) + require(deviation <= 1e-5, "measurement_failed", "Degenerate angle points disagree with native geometry directions.") + return {"method": "native_geometry_directions", "nativePointWitnessDegenerate": True, + "directions": directions, "parallelDeviationDegrees": deviation} + + +def measure(app, args): + _, design, value = target(app, args) + entities = [root_entity(design, resolve(design, value, item, MEASURABLE)) for item in args["entities"]] + mode = args["type"] + if mode == "distance": + require(len(entities) == 2, "invalid_input", "Distance requires exactly two entities.") + result, witnesses = minimum(app.measureManager, *entities) + validation = {"method": "native_points"} + unit = "mm" + else: + kinds = [e.objectType.split("::")[-1] for e in entities] + if any(kind in POINT_TYPES for kind in kinds): + require(len(entities) == 3 and all(kind in POINT_TYPES for kind in kinds), + "invalid_input", "Point angles require exactly three points, with the second as apex.") + else: + require(len(entities) == 2, "invalid_input", "Line/plane angles require two entities.") + for entity, kind in zip(entities, kinds): + geometry = getattr(entity, "geometry", None) + require(kind in ("ConstructionPlane", "ConstructionAxis", "SketchLine") or + kind == "BRepEdge" and geometry.objectType.endswith("::Line3D") or + kind == "BRepFace" and geometry.objectType.endswith("::Plane"), + "unsupported_geometry", "Angle inputs must be linear or planar, or three points.") + measured = app.measureManager.measureAngle(*entities) + require(measured is not None and measured.isValid, "measurement_failed", "Native angle measurement failed.") + result = math.degrees(number(measured.value, "native angle")) + witnesses = [finite_point(getattr(measured, name)) for name in ("positionOne", "positionTwo", "positionThree")] + require(0 <= result <= 180 + 1e-7, "measurement_failed", "Native angle is outside 0..180 degrees.") + validation = angle_witness(entities, result, witnesses) + unit = "deg" + return {"target": value, "type": mode, "value": result, "unit": unit, + "frame": {"space": "root", "lengthUnit": "mm"}, "witnessPoints": witnesses, + "witnessValidation": validation, + "entities": [ref(entity, value) for entity in entities]} + + +def unit_vector(vector): + length = math.sqrt(sum(number(v, "direction") ** 2 for v in vector)) + require(length > 1e-12, "invalid_direction", "Direction or angle witness is degenerate.") + return [v / length for v in vector] + + +def transformed_direction(vector, context): + xyz = [vector.x, vector.y, vector.z] + if context is not None: + matrix = context.transform2.asArray() + xyz = [sum(matrix[row*4 + col] * xyz[col] for col in range(3)) for row in range(3)] + return unit_vector(xyz) + + +def query_row(entity, kind, context, value): + base = native(entity) + geometry = getattr(base, "geometry", None) + if geometry is not None: geometry = concrete_geometry(geometry) + row = {"ref": ref(base, value, context), "type": geometry.objectType.split("::")[-1] if geometry else "degenerate"} + direction = None + if kind == "faces": + row["area"] = base.area * 100 + if geometry.objectType.endswith("::Plane"): + ok, direction = base.evaluator.getNormalAtPoint(base.pointOnFace) + require(ok, "geometry_unavailable", "Could not evaluate planar face normal.") + elif kind == "edges": + row["length"] = base.length * 10 + if geometry and geometry.objectType.endswith("::Line3D"): + direction = geometry.startPoint.vectorTo(geometry.endPoint) + if geometry and hasattr(geometry, "radius"): + row["radius"] = geometry.radius * 10 + if direction: + row["direction"] = transformed_direction(direction, context) + return row + + +def near_geometry(manager, entity, kind, pick, frame_context, design): + candidate = entity.createForAssemblyContext(frame_context) if frame_context else entity + if frame_context is None and not same(owner(entity), design.rootComponent): + # MeasureManager rejects native B-Rep in a child definition. A temporary + # copy preserves its trimmed geometry in component space without moving + # or adding anything to the design. Vertices are transient points. + candidate = (entity.geometry if kind == "vertices" else + adsk.fusion.TemporaryBRepManager.get().copy(entity)) + require(candidate is not None, "measurement_failed", "Could not copy component geometry for measurement.") + distance, witnesses = minimum(manager, point(pick), candidate) + # Native temporary-body measurements can reverse the two witness positions. + # Bind the pick by coordinates instead of assuming positionTwo is on the CAD. + matching = [i for i, witness in enumerate(witnesses) if math.dist(pick, witness) <= 1e-5] + require(matching, "measurement_failed", "Native distance has no witness at the requested pick.") + return distance, witnesses[1 - matching[0]] + + +def geometry_query(app, args): + _, design, value = target(app, args) + body = resolve(design, value, args["body"], ("BRepBody",)) + space, kind, filters = args["space"], args["kind"], args.get("filters", {}) + if space == "root": + root_entity(design, body) + context = getattr(body, "assemblyContext", None) + frame_context = context if space == "root" else None + source = native(body) + selected, collection = [], getattr(source, kind) + require(collection.count <= MAX_ENTITIES, "inspection_budget", "Body topology exceeds query budget.") + allowed_filters = {"type", "near"} | ({"area", "radius", "direction"} if kind == "faces" else + {"length", "radius", "direction"} if kind == "edges" else set()) + require(not (set(filters) - allowed_filters), "invalid_input", "Filter is not supported for the selected topology kind.") + for key in ("area", "radius", "length"): + if key in filters: + limits = filters[key] + require(limits.get("min", 0) <= limits.get("max", math.inf), "invalid_input", "Filter minimum exceeds maximum.") + direction_filter = filters.get("direction") + desired = unit_vector(direction_filter["vector"]) if direction_filter else None + for entity in items(collection): + # Reference context and filter frame are separate: component-space + # queries on an instance still return references to that exact instance. + row = query_row(entity, kind, frame_context, value) + row["ref"] = ref(entity, value, context) + if "type" in filters and row["type"] != filters["type"]: continue + if any(key in filters and (key not in row or not filters[key].get("min", 0) <= row[key] <= filters[key].get("max", math.inf)) + for key in ("area", "radius", "length")): continue + if desired: + if "direction" not in row: continue + dot = sum(a*b for a, b in zip(desired, row["direction"])) + if direction_filter.get("orientation", "same") == "parallel": dot = abs(dot) + if dot < math.cos(math.radians(direction_filter.get("tolerance", 0.01))) - 1e-12: continue + if "near" in filters: + distance, nearest = near_geometry(app.measureManager, entity, kind, filters["near"]["point"], frame_context, design) + if distance > filters["near"]["tolerance"] + 1e-7: continue + row.update(distanceToPick=distance, nearestPoint=nearest) + selected.append(row) + require(len(selected) <= args.get("maxResults", 256), "query_budget", "Query result exceeds maxResults; narrow the filters.") + if args.get("selection", "one") == "one": + require(len(selected) == 1, "selection_empty" if not selected else "ambiguous_selection", + "Geometry query must select exactly one entity.", matchCount=len(selected)) + return {"target": value, "matches": selected, "count": len(selected), "complete": True, + "frame": {"space": space, "component": ref(source.parentComponent, value), + "occurrencePath": context.fullPathName if context else None}, "units": UNITS} + + +def axis_data(axis, value=None): + result = {"name": axis.name, "geometry": geometry_data(axis.geometry), "space": "component", + "healthState": int(axis.healthState), "message": axis.errorOrWarningMessage, + "parametric": axis.isParametric, "lengthUnit": "mm"} + if value: result["ref"] = ref(axis, value) + return result + + +def construction_axis(design, value, args): + comp = component(design, value, args) + methods = {"edge": ("setByEdge", ("BRepEdge", "SketchLine", "SketchArc", "SketchCircle"), 1), + "circular_face": ("setByCircularFace", ("BRepFace",), 1), + "two_planes": ("setByTwoPlanes", ("BRepFace", "ConstructionPlane"), 2), + "two_points": ("setByTwoPoints", POINT_TYPES, 2)} + method, allowed, count = methods[args["type"]] + require(len(args["entities"]) == count, "invalid_input", "Incorrect number of axis references.") + entities = [resolve(design, value, item, allowed) for item in args["entities"]] + require(count != 2 or not same(entities[0], entities[1]), "duplicate_entity", "Axis references must identify distinct entities.") + cross_component = any(not same(owner(entity), comp) or getattr(entity, "assemblyContext", None) for entity in entities) + inp = comp.constructionAxes.createInput() + if cross_component: + for entity in entities: root_entity(design, entity) + if not same(comp, design.rootComponent): + require(args.get("creationOccurrence"), "creation_occurrence_required", "Cross-component axes need an explicit occurrence of the target component.") + context = root_entity(design, resolve(design, value, args["creationOccurrence"], ("Occurrence",))) + require(same(context.component, comp), "wrong_component", "Creation occurrence does not instantiate the target component.") + inp.creationOccurrence = context + else: + require(not args.get("creationOccurrence"), "invalid_input", "Root axes do not use a creation occurrence.") + else: + require(not args.get("creationOccurrence"), "invalid_input", "Native same-component axis references do not use a creation occurrence.") + require(getattr(inp, method)(*entities), "axis_failed", "Fusion rejected the construction axis definition.") + axis = named(comp.constructionAxes.add(inp), args) + return lambda fresh: {"axis": axis_data(axis, fresh)} + + +READS = {"geometry_query": geometry_query, "measure": measure} +OPERATIONS = {"construction_axis": construction_axis} diff --git a/addin/AdomBridge/commands/mechanical/joint_state.py b/addin/AdomBridge/commands/mechanical/joint_state.py new file mode 100644 index 0000000..beefc5b --- /dev/null +++ b/addin/AdomBridge/commands/mechanical/joint_state.py @@ -0,0 +1,196 @@ +"""Semantic attachment graph and frames for regular joints, origins and links. + +Entity relationships use native API equality through session identities. Transient +JointGeometry wrappers and token strings never serve as graph identity. +""" +import math +import adsk.fusion + +from .core import MAX_ENTITIES, concrete, identity, items, number, point_mm, ref, require, same + +_source_entities = [] + + +def source_identity(entity): + """Re-resolve token locators before comparing API identity after recompute. + + Fusion can invalidate an old BRep wrapper while preserving its model entity. + A token is only a lookup locator; fresh native equality is the identity proof. + """ + from .geometry import owner + require(entity is not None and entity.isValid, 'lost_joint_geometry', 'Attachment native source is invalid.') + component = owner(entity) + if component is None: + return identity(entity) + design = component.parentDesign + token = entity.entityToken + for saved in _source_entities: + if saved['type'] != entity.objectType or not saved['owner'].isValid or not same(saved['owner'], component): + continue + candidates = [concrete(candidate) for candidate in design.findEntityByToken(saved['token'])] + require(len(candidates) <= MAX_ENTITIES, 'inspection_budget', 'Attachment token lookup exceeds inspection budget.') + matches = [candidate for candidate in candidates if candidate.isValid and same(candidate, entity)] + if matches: + require(len(candidates) == 1 and candidates[0].objectType == entity.objectType, + 'ambiguous_reference', 'Attachment identity token no longer resolves uniquely.', matchCount=len(candidates)) + return saved['id'] + require(len(_source_entities) < MAX_ENTITIES, 'identity_budget', 'Session attachment identity budget exhausted.') + result = identity(entity) + _source_entities.append({'owner': component, 'type': entity.objectType, 'token': token, 'id': result}) + return result + + +def entity_link(entity, value=None): + entity = concrete(entity) + if entity is None: + return None + require(entity.isValid, "lost_joint_geometry", "Joint attachment is no longer valid.") + native = getattr(entity, "nativeObject", None) or entity + context = getattr(entity, "assemblyContext", None) + result = {"entityId": source_identity(native), "objectType": native.objectType, + "occurrenceId": identity(context) if context is not None else None} + if value is not None: + result["ref"] = ref(entity, value) + return result + + +def vector_data(vector): + require(vector is not None, "joint_frame_unavailable", "Joint direction is unavailable.") + return [number(getattr(vector, axis), "joint direction") for axis in "xyz"] + + +def matrix_data(matrix): + require(matrix is not None, "joint_frame_unavailable", "Joint frame is unavailable.") + result = [number(v, "joint transform") for v in matrix.asArray()] + require(len(result) == 16, "joint_frame_unavailable", "Joint frame must be a 4 by 4 matrix.") + for i in (3, 7, 11): + result[i] *= 10 + return result + + +def parameter_state(parameter, unit): + require(parameter is not None, "unsupported_inferred_joint", "Joint parameter is unavailable (inferred joints are not editable).") + scale = 180 / math.pi if unit == "deg" else 10 + return {"expression": parameter.expression, "value": number(parameter.value, "joint parameter") * scale, "unit": unit} + + +def geometry_state(geometry, value=None): + geometry = concrete(geometry) + require(geometry is not None and geometry.isValid, "lost_joint_geometry", "Joint geometry is unavailable.") + if geometry.objectType.endswith("::JointOrigin"): + return {"origin": entity_link(geometry, value)} + require(geometry.objectType.endswith("::JointGeometry"), "unsupported_joint_geometry", "Unsupported joint attachment wrapper.") + result = {"geometryType": int(geometry.geometryType), "keyPointType": int(geometry.keyPointType), + "frameSpace": "native_joint_geometry_api", "originUnit": "mm", + "entityOne": entity_link(geometry.entityOne, value), "entityTwo": entity_link(geometry.entityTwo, value), + "origin": point_mm(geometry.origin), + "primaryAxis": vector_data(geometry.primaryAxisVector), + "secondaryAxis": vector_data(geometry.secondaryAxisVector), + "thirdAxis": vector_data(geometry.thirdAxisVector)} + # These parameters distinguish analytical face and tangent attachment sites. + kinds = adsk.fusion.JointGeometryTypes + if geometry.geometryType in (kinds.JointTangentBRepFaceGeometry, kinds.JointTangentBRepFaceEdgeGeometry): + for name in ("tangentFaceType", "tangentFaceParamOne", "tangentFaceParamTwo"): + result[name] = number(getattr(geometry, name), name) + for name in ("planeOne", "planeTwo"): + plane = getattr(geometry, name) + result[name] = entity_link(plane, value) + return result + + +def regular_state(joint, value=None): + return {"geometryOne": geometry_state(joint.geometryOrOriginOne, value), + "geometryTwo": geometry_state(joint.geometryOrOriginTwo, value), + "geometryOneTransform": matrix_data(joint.geometryOneTransform), + "geometryTwoTransform": matrix_data(joint.geometryTwoTransform), + "angle": parameter_state(joint.angle, "deg"), + "offset": parameter_state(joint.offset, "mm"), + "offsetX": parameter_state(joint.offsetX, "mm"), + "offsetY": parameter_state(joint.offsetY, "mm"), + "flipped": bool(joint.isFlipped), "locked": bool(joint.isLocked), + "ownerId": identity(joint.parentComponent), + "frameSpace": "native_joint_api", "transformTranslationUnit": "mm"} + + +def origin_data(origin, value=None): + origin = concrete(origin) + result = {"name": origin.name, "geometry": geometry_state(origin.geometry, value), + "flipped": bool(origin.isFlipped), "transform": matrix_data(origin.transform), + "xAxisEntity": entity_link(origin.xAxisEntity, value), + "zAxisEntity": entity_link(origin.zAxisEntity, value), + "primaryAxis": vector_data(origin.primaryAxisVector), + "secondaryAxis": vector_data(origin.secondaryAxisVector), + "thirdAxis": vector_data(origin.thirdAxisVector), + "ownerId": identity(origin.parentComponent), + "space": "root" if getattr(origin, "assemblyContext", None) else "component", + "units": {"length": "mm", "angle": "deg", "transformTranslation": "mm"}} + for name in ("angle", "offsetX", "offsetY", "offsetZ"): + result[name] = parameter_state(getattr(origin, name), "deg" if name == "angle" else "mm") + if value is not None: + result["ref"] = ref(origin, value) + return result + + +# Public motion names remain independent of Autodesk enum integer values. +LINK_MOTIONS = { + "RevoluteJointMotion": {"rotation": "RevoluteJointRotateMotionType"}, + "SliderJointMotion": {"slide": "SliderJointSlideMotionType"}, + "CylindricalJointMotion": {"rotation": "CylindricalJointRotateMotionType", "slide": "CylindricalJointSlideMotionType"}, + "PinSlotJointMotion": {"rotation": "PinSlotJointRotateMotionType", "slide": "PinSlotJointSlideMotionType"}, + "PlanarJointMotion": {"rotation": "PlanarJointRotateMotionType", "primarySlide": "PlanarJointSlideOneMotionType", "secondarySlide": "PlanarJointSlideTwoMotionType"}, + "BallJointMotion": {"pitch": "BallJointPitchMotionType", "roll": "BallJointRollMotionType", "yaw": "BallJointYawMotionType"}} + + +def link_motion_name(joint, enum): + import adsk.fusion + from .assembly_state import joint_motion + names = LINK_MOTIONS.get(joint_motion(joint).objectType.split("::")[-1], {}) + matches = [name for name, member in names.items() if getattr(adsk.fusion.JointMotionTypes, member) == enum] + require(len(matches) == 1, "unsupported_link_motion", "Motion link has an unsupported joint degree of freedom.") + return matches[0] + + +def link_data(link, value=None): + link = concrete(link) + first, second = concrete(link.jointOne), concrete(link.jointTwo) + names = [link_motion_name(joint, enum) for joint, enum in + ((first, link.motionOne), (second or first, link.motionTwo))] + result = {"name": link.name, "suppressed": bool(link.isSuppressed), "reversed": bool(link.isReversed), + "jointOne": entity_link(first, value), "jointTwo": entity_link(second, value), + "sameJoint": second is None or same(first, second), + "motionOne": names[0], "motionTwo": names[1], "healthState": int(link.healthState), + "message": link.errorOrWarningMessage} + for suffix, name in zip(("One", "Two"), names): + result["value" + suffix] = parameter_state(getattr(link, "value" + suffix), "mm" if "lide" in name else "deg") + if value is not None: + result["ref"] = ref(link, value) + return result + + +def component_joint_state(comp): + return {"jointOrigins": [origin_data(o) for o in items(comp.jointOrigins)] if hasattr(comp, "jointOrigins") else [], + "motionLinks": [link_data(o) for o in items(comp.motionLinks)] if hasattr(comp, "motionLinks") else []} + + +MOTION_AXES = { + "RigidJointMotion": (), + "RevoluteJointMotion": (("rotationAxis", "customRotationAxisEntity"),), + "CylindricalJointMotion": (("rotationAxis", "customRotationAxisEntity"),), + "SliderJointMotion": (("slideDirection", "customSlideDirectionEntity"),), + "PinSlotJointMotion": (("rotationAxis", "customRotationAxisEntity"), ("slideDirection", "customSlideDirectionEntity")), + "PlanarJointMotion": (("normalDirection", "customNormalDirectionEntity"), ("primarySlideDirection", "customPrimarySlideDirectionEntity")), + "BallJointMotion": (("pitchDirection", "customPitchDirectionEntity"), ("yawDirection", "customYawDirectionEntity"))} + + +def motion_axes_state(motion, value=None): + kind = motion.objectType.split("::")[-1] + require(kind in MOTION_AXES, "unsupported_joint_motion", "Unknown joint direction contract.") + result = {} + for direction, source in MOTION_AXES[kind]: + enum = getattr(motion, direction) + custom = getattr(motion, source) if enum == adsk.fusion.JointDirections.CustomJointDirection else None + require(enum != adsk.fusion.JointDirections.CustomJointDirection or custom is not None, + "lost_joint_geometry", "Custom joint axis has no source entity.") + result[direction] = int(enum) + result[source] = entity_link(custom, value) + return result diff --git a/addin/AdomBridge/commands/mechanical/journal.py b/addin/AdomBridge/commands/mechanical/journal.py new file mode 100644 index 0000000..6778a72 --- /dev/null +++ b/addin/AdomBridge/commands/mechanical/journal.py @@ -0,0 +1,325 @@ +"""Plain-data durable operation history. No Fusion API, replay or path arguments. + +SQLite transactions use FULL synchronous commits and the rollback journal. The +database is bounded; the temporary rollback journal can require one additional +database-sized allocation. Unsupported/corrupt storage is never reset or migrated. +""" +import copy +import hashlib +import json +import math +import os +import re +from pathlib import Path +import stat +import sys +import threading +import time +import uuid + +STORAGE_VERSION = 1 +APPLICATION_ID = 0x41444D4D +MAX_RECORDS = 10000 +MAX_DATABASE_BYTES = 64 * 1024 * 1024 +MAX_RECORD_BYTES = 4 * 1024 * 1024 +MAX_REQUEST_BYTES = 1024 * 1024 +TERMINAL = frozenset(('succeeded', 'failed', 'not_executed')) +PHASES = frozenset(('admitted', 'dispatched', 'executing')) | TERMINAL + + +class JournalError(Exception): + def __init__(self, code, message): + super().__init__(message) + self.code = code + + +class AdmissionConflict(JournalError): + """Proof that this submitted digest was rejected before admission.""" + def __init__(self, session_id, operation_id, submitted_digest, existing_digest): + super().__init__('operation_conflict', 'operationId was already used with different arguments.') + self.session_id, self.operation_id = session_id, operation_id + self.submitted_digest, self.existing_digest = submitted_digest, existing_digest + + +def require(condition, code, message): + if not condition: + raise JournalError(code, message) + + +def canonical(value, maximum=MAX_RECORD_BYTES): + try: + data = json.dumps(value, sort_keys=True, separators=(',', ':'), allow_nan=False).encode('utf-8') + except (TypeError, ValueError, RecursionError) as error: + raise JournalError('operation_record_invalid', 'Operation record is not canonical JSON: ' + str(error)) from error + require(len(data) <= maximum, 'operation_record_too_large', 'Operation record exceeds its durable byte limit.') + return data + + +def fingerprint(request): + return hashlib.sha256(canonical(request, MAX_REQUEST_BYTES)).hexdigest() + + +def storage_root(): + if os.name == 'nt': + base = os.environ.get('LOCALAPPDATA') + require(bool(base), 'operation_storage_unavailable', 'LOCALAPPDATA is unavailable.') + return Path(base) / 'Adom' / 'FusionBridge' / 'mechanical' + if sys.platform == 'darwin': + return Path.home() / 'Library' / 'Application Support' / 'Adom' / 'FusionBridge' / 'mechanical' + return Path.home() / '.local' / 'state' / 'adom' / 'fusion-bridge' / 'mechanical' + + +class Journal: + def __init__(self, directory=None, *, max_records=MAX_RECORDS, max_bytes=MAX_DATABASE_BYTES, + max_record_bytes=MAX_RECORD_BYTES): + # Constructor injection is for offline tests; no public verb accepts it. + self.directory = Path(directory) if directory is not None else storage_root() + self.path = self.directory / 'operations-v1.sqlite3' + self.max_records, self.max_bytes, self.max_record_bytes = max_records, max_bytes, max_record_bytes + self.lock = threading.RLock() + + def _initialization_link(self, info): + """Account for both links during exclusive initialization publication. + + A normal external hardlink is still refused: exactly two links must be + accounted for by the public file and one regular, same-inode staging + file inside this validated app-owned directory. No open staging writer + remains when initialization publishes it. + """ + if info.st_nlink != 2: + return False + matches = [] + for staged in self.directory.glob('.operations-v1-*.tmp'): + if not re.fullmatch(r'\.operations-v1-[0-9a-f]{32}\.tmp', staged.name): + continue + try: + other = staged.lstat() + except FileNotFoundError: + continue + if (stat.S_ISREG(other.st_mode) and not (getattr(other, 'st_file_attributes', 0) & 0x400) + and (other.st_dev, other.st_ino) == (info.st_dev, info.st_ino)): + matches.append(staged) + # If the publisher has already unlinked, verify the final file's fresh + # single-link state instead of rejecting the completed transition. + if not matches: + fresh = self.path.lstat() + return (fresh.st_dev, fresh.st_ino) == (info.st_dev, info.st_ino) and fresh.st_nlink == 1 + return len(matches) == 1 + + def _paths(self, create): + require(self.directory.is_absolute() and '..' not in self.directory.parts, + 'operation_storage_invalid_path', 'Operation storage must be an absolute app-owned path.') + for item in reversed((self.directory, *self.directory.parents)): + if not item.exists() and not item.is_symlink(): + continue + info = item.lstat() + require(stat.S_ISDIR(info.st_mode) and not stat.S_ISLNK(info.st_mode) + and not (getattr(info, 'st_file_attributes', 0) & 0x400), + 'operation_storage_invalid_path', 'Operation storage cannot traverse links, junctions or non-directories.') + if create: + self.directory.mkdir(mode=0o700, parents=True, exist_ok=True) + if self.directory.exists() and hasattr(os, 'getuid'): + info = self.directory.stat() + require(info.st_uid == os.getuid() and not (info.st_mode & 0o022), + 'operation_storage_invalid_path', 'Operation storage must be owned by this user and not writable by others.') + for suffix in ('', '-journal', '-wal', '-shm'): + item = Path(str(self.path) + suffix) + if not item.exists() and not item.is_symlink(): + continue + info = item.lstat() + links_safe = info.st_nlink == 1 or (not suffix and self._initialization_link(info)) + require(stat.S_ISREG(info.st_mode) and not stat.S_ISLNK(info.st_mode) and links_safe + and not (getattr(info, 'st_file_attributes', 0) & 0x400), + 'operation_storage_invalid_path', 'Operation storage files cannot be links or special files.') + + def _connect(self, create): + try: + import sqlite3 + except ImportError as error: + raise JournalError('operation_storage_unavailable', 'This Python runtime lacks SQLite support.') from error + self._paths(create) + exists = self.path.exists() + if not exists and not create: + return None + if not exists: + self._initialize(sqlite3) + connection = sqlite3.connect(str(self.path) if create else self.path.as_uri() + '?mode=rw', + uri=not create, timeout=2, isolation_level=None) + try: + # Existing-history reads may recover SQLite's hot rollback journal + # after a process crash. mode=rw never creates missing storage. + connection.execute('PRAGMA synchronous=FULL') + app_id = connection.execute('PRAGMA application_id').fetchone()[0] + version = connection.execute('PRAGMA user_version').fetchone()[0] + require(app_id == APPLICATION_ID, 'operation_storage_corrupt', 'Storage is not an operation journal; it will not be reset.') + require(version == STORAGE_VERSION, 'operation_storage_version', 'Unsupported operation journal version; automatic migration is disabled.') + columns = connection.execute('PRAGMA table_info(operations)').fetchall() + require([row[1] for row in columns] == ['session_id', 'operation_id', 'record_json', 'record_digest'], + 'operation_storage_corrupt', 'Operation journal schema is invalid.') + require(self.path.stat().st_size <= self.max_bytes, 'operation_storage_full', 'Operation journal exceeds its database byte limit.') + if create: + page_size = connection.execute('PRAGMA page_size').fetchone()[0] + connection.execute('PRAGMA max_page_count=%d' % max(1, self.max_bytes // page_size)) + require(connection.execute('PRAGMA quick_check').fetchone()[0] == 'ok', + 'operation_storage_corrupt', 'Operation database integrity check failed.') + return connection + except BaseException: + connection.close() + raise + + def _initialize(self, sqlite3): + # Publish a fully initialized database exclusively. Concurrent first + # callers must never observe a zero-byte file as corrupt or reset one. + pending = list(self.directory.glob('.operations-v1-*.tmp')) + require(len(pending) < 32, 'operation_storage_unavailable', 'Too many unfinished journal initialization files; retained for inspection.') + staged = self.directory / ('.operations-v1-' + uuid.uuid4().hex + '.tmp') + descriptor = os.open(staged, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + os.close(descriptor) + connection = None + try: + connection = sqlite3.connect(str(staged), isolation_level=None) + connection.execute('PRAGMA synchronous=FULL') + connection.execute('BEGIN IMMEDIATE') + connection.execute('CREATE TABLE operations (session_id TEXT NOT NULL, operation_id TEXT NOT NULL, record_json BLOB NOT NULL, record_digest TEXT NOT NULL, PRIMARY KEY(session_id,operation_id))') + connection.execute('PRAGMA application_id=%d' % APPLICATION_ID) + connection.execute('PRAGMA user_version=%d' % STORAGE_VERSION) + connection.execute('COMMIT') + connection.close(); connection = None + # Windows os.fsync uses _commit, which requires a writable handle. + # Reopen without truncation after SQLite has committed and closed; + # a failed flush must still prevent publication and admission. + with staged.open('r+b') as stream: + os.fsync(stream.fileno()) + try: + os.link(staged, self.path) + except FileExistsError: + pass # A different initializer published its complete database. + staged.unlink() + if os.name != 'nt': + descriptor = os.open(self.directory, os.O_RDONLY) + try: os.fsync(descriptor) + finally: os.close(descriptor) + finally: + if connection is not None: + connection.close() + if staged.exists(): + staged.unlink() + + def _decode(self, row, session_id, operation_id): + if row is None: + return None + raw = bytes(row[0]) + require(len(raw) <= self.max_record_bytes and hashlib.sha256(raw).hexdigest() == row[1], + 'operation_storage_corrupt', 'Operation record digest or size is invalid.') + try: + record = json.loads(raw) + valid = (record['storageVersion'] == STORAGE_VERSION and record['sessionId'] == session_id + and record['operationId'] == operation_id and record['phase'] in PHASES + and isinstance(record['requestDigest'], str) and len(record['requestDigest']) == 64 + and isinstance(record['events'], list) and 1 <= len(record['events']) <= 6 + and math.isfinite(record['createdAt']) and math.isfinite(record['updatedAt'])) + if record['phase'] in TERMINAL: + valid = valid and isinstance(record['result'], dict) and type(record['result'].get('success')) is bool + valid = valid and (record['result']['success'] == (record['phase'] == 'succeeded')) + else: + valid = valid and record['result'] is None + except (ValueError, TypeError, KeyError) as error: + raise JournalError('operation_storage_corrupt', 'Operation record is malformed.') from error + require(valid and canonical(record, self.max_record_bytes) == raw, + 'operation_storage_corrupt', 'Operation record identity, phase or canonical encoding is invalid.') + return record + + def _read(self, connection, session_id, operation_id): + return self._decode(connection.execute('SELECT record_json,record_digest FROM operations WHERE session_id=? AND operation_id=?', + (session_id, operation_id)).fetchone(), session_id, operation_id) + + def _save(self, connection, record): + raw = canonical(record, self.max_record_bytes) + record_digest = hashlib.sha256(raw).hexdigest() + self._decode((raw, record_digest), record['sessionId'], record['operationId']) + connection.execute('INSERT OR REPLACE INTO operations VALUES (?,?,?,?)', + (record['sessionId'], record['operationId'], raw, record_digest)) + + def _run(self, create, action): + with self.lock: + connection = None + try: + connection = self._connect(create) + if connection is None: + return None + if create: + connection.execute('BEGIN IMMEDIATE') + result = action(connection) + if create: + connection.execute('COMMIT') + return copy.deepcopy(result) + except JournalError: + raise + except Exception as error: + # Do not reset corrupt databases, retry a possibly committed + # operation, or claim native effects were rolled back here. + raise JournalError('operation_storage_failed', type(error).__name__ + ': ' + str(error)) from error + finally: + if connection is not None: + connection.close() + + def lookup(self, session_id, operation_id): + return self._run(False, lambda connection: self._read(connection, session_id, operation_id)) + + def admit(self, request, source_version): + raw_request = canonical(request, MAX_REQUEST_BYTES) + request = json.loads(raw_request) + digest = hashlib.sha256(raw_request).hexdigest() + require(isinstance(request, dict), 'operation_record_invalid', 'Operation request must be an object.') + session_id, operation_id = request.get('sessionId'), request.get('operationId') + require(all(isinstance(value, str) and 0 < len(value) <= 128 for value in (session_id, operation_id)), + 'operation_record_invalid', 'Session and operation IDs must be bounded strings.') + require(isinstance(request.get('op'), str) and 0 < len(request['op']) <= 128 + and isinstance(source_version, str) and 0 < len(source_version) <= 128, + 'operation_record_invalid', 'Operation name and source version must be bounded strings.') + def action(connection): + for sid, oid, payload, checksum in connection.execute('SELECT session_id,operation_id,record_json,record_digest FROM operations'): + self._decode((payload, checksum), sid, oid) + existing = self._read(connection, session_id, operation_id) + if existing: + if existing['requestDigest'] != digest: + raise AdmissionConflict(session_id, operation_id, digest, existing['requestDigest']) + return {'created': False, 'record': existing} + count = connection.execute('SELECT COUNT(*) FROM operations').fetchone()[0] + require(count < self.max_records, 'operation_capacity', 'Durable operation history is full; no records are evicted.') + now = time.time() + record = {'storageVersion': STORAGE_VERSION, 'sessionId': session_id, 'operationId': operation_id, + 'operation': request['op'], 'sourceVersion': source_version, 'requestDigest': digest, + 'requestedTarget': copy.deepcopy(request.get('target')), 'observedDocument': None, + 'phase': 'admitted', 'createdAt': now, 'updatedAt': now, + 'events': [{'phase': 'admitted', 'at': now}], 'dispatchId': None, 'result': None} + self._save(connection, record) + return {'created': True, 'record': record} + return self._run(True, action) + + def transition(self, session_id, operation_id, digest, phase, *, dispatch_id=None, document=None, result=None): + def action(connection): + record = self._read(connection, session_id, operation_id) + require(record is not None and record['requestDigest'] == digest, 'operation_conflict', 'Operation claim does not match its durable admission.') + old = record['phase'] + allowed = {'dispatched': {'admitted'}, 'executing': {'dispatched'}, + 'not_executed': {'admitted', 'dispatched'}, 'succeeded': {'executing'}, 'failed': {'executing'}} + require(phase in allowed, 'operation_record_invalid', 'Unsupported operation phase transition.') + if old not in allowed[phase]: + return {'claimed': False, 'record': record} + if old == 'dispatched' or old == 'executing': + require(dispatch_id == record['dispatchId'] and bool(dispatch_id), 'operation_conflict', 'Dispatch generation does not match the durable record.') + if phase == 'dispatched': + require(isinstance(dispatch_id, str) and 0 < len(dispatch_id) <= 128, 'operation_record_invalid', 'Dispatch ID is required.') + record['dispatchId'] = dispatch_id + if phase == 'executing': + record['observedDocument'] = copy.deepcopy(document) + if phase in TERMINAL: + require(isinstance(result, dict) and type(result.get('success')) is bool and result['success'] == (phase == 'succeeded'), + 'operation_record_invalid', 'Terminal phase contradicts its result.') + record['result'] = copy.deepcopy(result) + now = time.time(); record['phase'] = phase; record['updatedAt'] = now + record['events'].append({'phase': phase, 'at': now}) + self._save(connection, record) + return {'claimed': True, 'record': record} + return self._run(True, action) diff --git a/addin/AdomBridge/commands/mechanical/motion_links.py b/addin/AdomBridge/commands/mechanical/motion_links.py new file mode 100644 index 0000000..f032372 --- /dev/null +++ b/addin/AdomBridge/commands/mechanical/motion_links.py @@ -0,0 +1,157 @@ +"""Typed native coupling with exact joint/degree-of-freedom readback.""" +import math + +import adsk.fusion + +from .assembly_state import joint_motion +from .core import concrete, items, number, require, resolve, same +from .joint_state import LINK_MOTIONS, entity_link, link_data, link_motion_name +from .sketches import quantity + + +def root_joint(design, value, raw): + joint = resolve(design, value, raw, ("Joint", "AsBuiltJoint")) + require(getattr(joint, "assemblyContext", None) is None and same(joint.parentComponent, design.rootComponent), + "root_joint_required", "Motion links currently require root-owned joints.") + require(not joint.isSuppressed, "suppressed_joint", "Unsuppress a joint before coupling its motion.") + return joint + + +def motion_enum(joint, name): + family = joint_motion(joint).objectType.split("::")[-1] + members = LINK_MOTIONS.get(family, {}) + require(name in members, "wrong_joint_type", "The selected joint does not have motion " + name + ".") + enums = getattr(adsk.fusion, "JointMotionTypes", None) + require(enums is not None and hasattr(enums, members[name]), "unsupported_capability", "This Fusion build does not expose native motion links.") + return getattr(enums, members[name]) + + +def inputs(design, first, second, args): + require(not same(first, second) or args["motionOne"] != args["motionTwo"], "invalid_motion_link", "Two different joint degrees of freedom are required.") + enums, values, expected = [], [], {} + for suffix, joint in (("One", first), ("Two", second)): + motion = args["motion" + suffix] + enums.append(motion_enum(joint, motion)) + unit = "mm" if "lide" in motion else "deg" + inp = quantity(design, args["value" + suffix], unit, positive=True) + magnitude = design.unitsManager.evaluateExpression(inp.stringValue, unit) + require(magnitude > 0, "invalid_motion_link", "Link magnitudes must be positive; use reversed for opposite direction.") + values.append(inp) + expected["value" + suffix] = magnitude * (10 if unit == "mm" else 180 / math.pi) + return enums, values, expected + + +def result(link, fresh, args, first, second, expected): + data = link_data(link, fresh) + # A same-joint link can expose either null or the first joint again as its + # native second member. Canonicalize only the effective member for equality; + # link_data preserves the actual native jointTwo representation. + actual_first, actual_second = concrete(link.jointOne), concrete(link.jointTwo) + require(entity_link(actual_first) == entity_link(first) + and entity_link(actual_second or actual_first) == entity_link(second or first), + "motion_link_refused", "Linked joint identities did not read back.") + for key in ("name", "suppressed", "reversed", "motionOne", "motionTwo"): + require(key not in args or data[key] == args[key], "motion_link_refused", "Motion link did not retain " + key) + for key, wanted in expected.items(): + require(math.isclose(data[key]["value"], wanted, rel_tol=1e-8, abs_tol=1e-6), "motion_link_refused", "Motion-link ratio did not read back.") + require(link.healthState == adsk.fusion.FeatureHealthStates.HealthyFeatureHealthState, "unhealthy_motion_link", "Motion link is not healthy.") + return {"motionLink": data} + + +def motion_value(joint, enum): + name = link_motion_name(joint, enum) + motion = joint_motion(joint) + require(hasattr(motion, name + "Value"), "motion_link_unverifiable", "Linked motion has no readable value.") + return number(getattr(motion, name + "Value"), "linked motion") + + +def coupling_snapshot(design): + collection = getattr(design.rootComponent, "motionLinks", None) + if collection is None: + return [] + result = [] + for link in items(collection): + if link.isSuppressed: + continue + first, second = concrete(link.jointOne), concrete(link.jointTwo or link.jointOne) + require(not first.isSuppressed and not second.isSuppressed, "motion_link_unverifiable", "An active link references a suppressed joint.") + result.append((link, motion_value(first, link.motionOne), motion_value(second, link.motionTwo), link_data(link))) + return result + + +def verify_coupling(before): + verified = [] + for link, one, two, state in before: + require(link.isValid and not link.isSuppressed, "motion_link_refused", "Driving unexpectedly removed or suppressed a motion link.") + first, second = concrete(link.jointOne), concrete(link.jointTwo or link.jointOne) + require(link_data(link) == state, "motion_link_refused", "Driving unexpectedly changed motion-link membership or settings.") + delta_one = motion_value(first, link.motionOne) - one + delta_two = motion_value(second, link.motionTwo) - two + value_one, value_two = number(link.valueOne.value, "link magnitude"), number(link.valueTwo.value, "link magnitude") + require(abs(value_one) > 1e-12 and abs(value_two) > 1e-12, "motion_link_unverifiable", "Link magnitude cannot be zero.") + require(math.isclose(delta_one/value_one, (-1 if link.isReversed else 1)*delta_two/value_two, abs_tol=1e-7, rel_tol=1e-7), + "motion_link_drive_refused", "Driven motion did not propagate according to the active link ratio.") + verified.append({"name": link.name, "jointOne": entity_link(first), "jointTwo": entity_link(second), "ratioVerified": True}) + return verified + + +def motion_link_create(design, value, args): + first = root_joint(design, value, args["jointOne"]) + second = root_joint(design, value, args["jointTwo"]) if "jointTwo" in args else None + require(second is None or not same(first, second), "invalid_motion_link", "Omit jointTwo to couple two motions of jointOne.") + enums, values, expected = inputs(design, first, second or first, args) + collection = getattr(design.rootComponent, "motionLinks", None) + require(collection is not None, "unsupported_capability", "This Fusion build has no motionLinks collection.") + inp = collection.createInput(first, second) + require(inp is not None, "motion_link_failed", "Fusion did not return MotionLinkInput.") + inp.motionOne, inp.motionTwo = enums + inp.valueOne, inp.valueTwo = values + inp.isReversed = args.get("reversed", False) + link = collection.add(inp) + require(link is not None and link.isValid, "motion_link_failed", "Fusion did not create a motion link.") + if "name" in args: + link.name = args["name"] + return lambda fresh: result(link, fresh, {"reversed": False, **args}, first, second, expected) + + +def motion_link_edit(design, value, args): + link = resolve(design, value, args["link"], ("MotionLink",)) + require(getattr(link, "assemblyContext", None) is None and same(link.parentComponent, design.rootComponent), + "root_joint_required", "Edit the root-owned motion link.") + require(set(args) & {"name", "suppressed", "reversed", "motionOne", "motionTwo", "valueOne", "valueTwo"}, "invalid_input", "Supply a motion-link change.") + first, second = concrete(link.jointOne), concrete(link.jointTwo) + coupling = set(args) & {"motionOne", "motionTwo", "valueOne", "valueTwo", "reversed"} + expected = {} + effective = dict(args) + if coupling: + old = link_data(link) + changes = {key: args.get(key, old[key]) for key in ("motionOne", "motionTwo", "reversed")} + changes.update({key: args.get(key, old[key]["expression"]) for key in ("valueOne", "valueTwo")}) + effective.update(changes) + enums, values, expected = inputs(design, first, second or first, changes) + # Unlike regular-joint redefinition, setMotionData requires the link to + # exist in the evaluated timeline. Native rollback before this item + # removes its component context and fails InternalValidationError. + require(design.timeline.markerPosition == design.timeline.count, "timeline_not_at_end", + "Motion-link coupling edits require the timeline at the end.") + require(link.setMotionData(enums[0], values[0], enums[1], values[1], changes["reversed"]), + "motion_link_failed", "Fusion refused updated coupling data.") + if "name" in args: + link.name = args["name"] + if "suppressed" in args: + link.isSuppressed = args["suppressed"] + if design.snapshots.hasPendingSnapshot: + require(design.snapshots.add() is not None, "motion_link_capture_failed", "Could not capture link suppression.") + return lambda fresh: result(link, fresh, effective, first, second, expected) + + +def motion_link_delete(design, value, args): + link = resolve(design, value, args["link"], ("MotionLink",)) + require(getattr(link, "assemblyContext", None) is None and same(link.parentComponent, design.rootComponent), + "root_joint_required", "Delete the root-owned motion link.") + name = link.name + require(link.deleteMe(), "delete_failed", "Fusion refused motion-link deletion.") + return {"deletedMotionLink": name} + + +OPERATIONS = {name: globals()[name] for name in ("motion_link_create", "motion_link_edit", "motion_link_delete")} diff --git a/addin/AdomBridge/commands/mechanical/operation_history.py b/addin/AdomBridge/commands/mechanical/operation_history.py new file mode 100644 index 0000000..c874d01 --- /dev/null +++ b/addin/AdomBridge/commands/mechanical/operation_history.py @@ -0,0 +1,234 @@ +"""Plain-data admission, status and persistence boundaries for synchronous writes.""" +import copy +import hashlib +import threading + +from .journal import AdmissionConflict, Journal, JournalError, STORAGE_VERSION, TERMINAL, canonical, fingerprint + + +class OperationHistory: + def __init__(self, session_id, source_version, journal=None): + self.session_id, self.source_version = session_id, source_version + self._journal = journal + self._lock = threading.RLock() + self._unconfirmed = {} + self._known_requests = {} + self._observation_bytes = 0 + + def store(self): + with self._lock: + if self._journal is None: + self._journal = Journal() + return self._journal + + def failure(self, error, descriptor=None, *, observed=None, not_started=False, remember=False): + result = {'success': False, 'sessionId': self.session_id, 'errorCode': error.code, + 'error': str(error)[:4096], 'outcome': 'not_executed' if not_started else 'unknown', + 'nativeExecutionStarted': False if not_started else None} + if descriptor: + result['operationId'] = descriptor['operationId'] + result['requestDigest'] = descriptor.get('requestDigest') + if observed is not None: + result.update(errorCode='operation_reconciliation_required', rollbackClaimed=False, nativeExecutionStarted=True, + persistenceError={'code': error.code, 'message': str(error)[:4096]}) + try: + raw = canonical(observed) + result['observation'] = {'sha256': hashlib.sha256(raw).hexdigest(), 'bytes': len(raw)} + if len(raw) <= 65536: + result['observedResult'] = copy.deepcopy(observed) + else: + result['observation']['resultOmitted'] = True + except JournalError: + result['observation'] = {'resultOmitted': True, 'reason': 'Observed result exceeds record limits or is not canonical JSON.'} + remember = True + key = (descriptor['sessionId'], descriptor['operationId']) if descriptor else None + with self._lock: + known = key is not None and self._known_requests.get(key) == descriptor.get('requestDigest') and key in self._known_requests + old = self._unconfirmed.get(key, {}) + if known and old.get('observation') and observed is None: + result = copy.deepcopy(old) + result.pop('_payloadBytes', None) + result['laterPersistenceFailure'] = {'code': error.code, 'message': str(error)[:4096]} + remember = True + if observed is not None: + result.update(reconciliationRequired=True, durability={'terminalRecorded': False}) + if remember and known: + result.update(reconciliationRequired=True, durability={'terminalRecorded': False}) + cached = copy.deepcopy(result) + # Keep plain failure evidence for every admitted operation, but cap + # retained native-result payloads across the process at four MiB. + with self._lock: + key = (descriptor['sessionId'], descriptor['operationId']) + old = self._unconfirmed.get(key, {}) + if 'observation' in old and observed is None: + # A duplicate event or failed delivery acknowledgement may + # not erase stronger evidence from an already-run handler. + cached = copy.deepcopy(old) + cached.pop('_payloadBytes', None) + cached['laterPersistenceFailure'] = {'code': error.code, 'message': str(error)[:4096]} + result = copy.deepcopy(cached) + payload_bytes = len(canonical(cached.get('observedResult'))) + self._observation_bytes -= old.get('_payloadBytes', 0) + if self._observation_bytes + payload_bytes > 4 * 1024 * 1024: + cached.pop('observedResult', None) + if 'observation' in cached: + cached['observation']['resultOmitted'] = True + payload_bytes = 0 + cached['_payloadBytes'] = payload_bytes + self._observation_bytes += payload_bytes + self._unconfirmed[key] = cached + return result + + def unconfirmed(self, key): + with self._lock: + result = copy.deepcopy(self._unconfirmed.get(key)) + if result: + result.pop('_payloadBytes', None) + return result + + def remember_record(self, record): + if record['sessionId'] == self.session_id: + with self._lock: + key = (record['sessionId'], record['operationId']) + existing = self._known_requests.get(key) + if existing is None or existing == record['requestDigest']: + self._known_requests[key] = record['requestDigest'] + + @staticmethod + def metadata(record): + return {key: copy.deepcopy(record[key]) for key in + ('storageVersion', 'sessionId', 'operationId', 'operation', 'sourceVersion', 'requestDigest', + 'requestedTarget', 'observedDocument', 'phase', 'createdAt', 'updatedAt', 'events')} + + def terminal_reply(self, record, replayed=False): + result = copy.deepcopy(record['result']) + result['durability'] = {'terminalRecorded': True, 'storageVersion': STORAGE_VERSION, + 'recordedSessionId': record['sessionId'], 'phase': record['phase']} + if replayed: + result['replayed'] = True + return result + + def pending_reply(self, record): + if record['phase'] in TERMINAL: + return self.terminal_reply(record, replayed=True) + unconfirmed = self.unconfirmed((record['sessionId'], record['operationId'])) + if unconfirmed: + return unconfirmed + if record['phase'] == 'admitted': + return {'success': False, 'sessionId': self.session_id, 'operationId': record['operationId'], + 'errorCode': 'operation_reconciliation_required', 'outcome': 'unknown', + 'reconciliationRequired': True, 'durability': {'terminalRecorded': False}, + 'error': 'Admission has no durable dispatch claim. Query status; automatic redispatch is disabled.'} + return {'success': False, 'sessionId': self.session_id, 'operationId': record['operationId'], + 'errorCode': 'operation_in_progress', 'error': 'Operation is admitted; query its recorded status. No duplicate dispatch occurred.', + 'phase': record['phase'], 'outcome': 'pending'} + + def admit(self, request, dispatch_id): + descriptor = {'sessionId': request['sessionId'], 'operationId': request['operationId'], + 'requestDigest': fingerprint(request), 'dispatchId': dispatch_id} + if request['sessionId'] != self.session_id: + return None, {'success': False, 'sessionId': self.session_id, 'errorCode': 'session_mismatch', + 'error': 'Old-session operation history is readable, but native execution is never replayed across sessions.'} + accepted = None + try: + accepted = self.store().admit(request, self.source_version) + self.remember_record(accepted['record']) + if not accepted['created']: + return None, self.pending_reply(accepted['record']) + transition = self.store().transition(descriptor['sessionId'], descriptor['operationId'], descriptor['requestDigest'], + 'dispatched', dispatch_id=dispatch_id) + if not transition['claimed']: + return None, self.pending_reply(transition['record']) + return descriptor, None + except AdmissionConflict as error: + # This proof applies to the submitted digest, not to the existing + # operation sharing its ID. Never replace/cache its execution state. + if (accepted is None and error.session_id == descriptor['sessionId'] + and error.operation_id == descriptor['operationId'] + and error.submitted_digest == descriptor['requestDigest'] + and error.existing_digest != error.submitted_digest): + reply = self.failure(error, descriptor, not_started=True) + reply['conflict'] = {'scope': 'submitted_request', 'existingRequestDigest': error.existing_digest} + return None, reply + return None, self.failure(error, descriptor) + except JournalError as error: + if accepted is None: + # The admission COMMIT itself may have succeeded before its + # acknowledgement was lost. Reconcile without creating rows. + try: + record = self.store().lookup(descriptor['sessionId'], descriptor['operationId']) + if record is not None and record['requestDigest'] == descriptor['requestDigest']: + self.remember_record(record) + except JournalError: + pass + return None, self.failure(error, descriptor, not_started=bool(accepted and accepted['created']), remember=True) + + def start(self, descriptor, request, document): + if (descriptor.get('sessionId') != self.session_id or descriptor.get('sessionId') != request.get('sessionId') + or descriptor.get('operationId') != request.get('operationId') or descriptor.get('requestDigest') != fingerprint(request)): + return {'success': False, 'sessionId': self.session_id, 'errorCode': 'operation_conflict', + 'error': 'Dispatch identity does not match this session and canonical operation.'} + try: + transition = self.store().transition(descriptor['sessionId'], descriptor['operationId'], descriptor['requestDigest'], + 'executing', dispatch_id=descriptor['dispatchId'], document=document) + return None if transition['claimed'] else self.pending_reply(transition['record']) + except JournalError as error: + return self.failure(error, descriptor, remember=True) + + def finish(self, descriptor, result): + try: + transition = self.store().transition(descriptor['sessionId'], descriptor['operationId'], descriptor['requestDigest'], + 'succeeded' if result['success'] else 'failed', dispatch_id=descriptor['dispatchId'], result=result) + if not transition['claimed']: + raise JournalError('operation_conflict', 'Terminal transition lost its executing claim; outcome requires reconciliation.') + return self.terminal_reply(transition['record']) + except JournalError as error: + return self.failure(error, descriptor, observed=result) + + def not_executed(self, descriptor, result): + if descriptor is None: + return result + if descriptor.get('sessionId') != self.session_id: + return {'success': False, 'sessionId': self.session_id, 'errorCode': 'session_mismatch', + 'error': 'A stale dispatch cannot update history from a previous session.', 'outcome': 'unknown'} + result = {**result, 'sessionId': descriptor['sessionId'], 'operationId': descriptor['operationId'], + 'nativeExecutionStarted': False} + try: + transition = self.store().transition(descriptor['sessionId'], descriptor['operationId'], descriptor['requestDigest'], + 'not_executed', dispatch_id=descriptor['dispatchId'], result=result) + if transition['claimed']: + return self.terminal_reply(transition['record']) + # A fast native handler may already have claimed/completed the event. + # Delivery failure must never overwrite that execution evidence. + return self.pending_reply(transition['record']) + except JournalError as error: + return self.failure(error, descriptor, remember=True) + + def status(self, args): + session_id, operation_id = args.get('sessionId'), args.get('operationId') + if not all(isinstance(value, str) and 0 < len(value) <= 128 for value in (session_id, operation_id)): + return {'success': False, 'errorCode': 'invalid_input', 'error': 'Bounded sessionId and operationId strings are required.'} + try: + record = self.store().lookup(session_id, operation_id) + except JournalError as error: + retained = self.unconfirmed((session_id, operation_id)) + return {'success': False, 'sessionId': session_id, 'currentSessionId': self.session_id, 'operationId': operation_id, + 'errorCode': error.code, 'error': str(error), 'outcome': 'unknown', 'reconciliationRequired': True, + 'persistenceFailure': retained, 'durability': {'terminalRecorded': False}, + 'observedResult': retained.get('observedResult') if retained else None} + if record is None: + return {'success': False, 'sessionId': session_id, 'currentSessionId': self.session_id, 'operationId': operation_id, + 'errorCode': 'operation_unknown', 'error': 'No durable record identifies this operation; no execution inference is made.', 'outcome': 'unknown'} + historical = session_id != self.session_id + terminal = record['phase'] in TERMINAL + unconfirmed = self.unconfirmed((session_id, operation_id)) + state = ('succeeded' if record['phase'] == 'succeeded' else 'failed') if terminal else ( + 'interrupted' if historical else 'reconciliation_required' if unconfirmed or record['phase'] == 'admitted' else 'running') + return {'success': True, 'sessionId': session_id, 'currentSessionId': self.session_id, 'operationId': operation_id, + 'state': state, 'phase': record['phase'], 'historical': historical, 'history': self.metadata(record), + 'outcome': 'recorded' if terminal else 'unknown' if historical else unconfirmed['outcome'] if unconfirmed else 'unknown' if record['phase'] == 'admitted' else 'pending', + 'nativeExecutionStarted': unconfirmed.get('nativeExecutionStarted') if unconfirmed and not historical and not terminal else None, + 'reconciliationRequired': not terminal and (historical or unconfirmed is not None or record['phase'] == 'admitted'), + 'result': self.terminal_reply(record) if terminal else None, + 'observedResult': unconfirmed.get('observedResult') if unconfirmed and not terminal else None, + 'persistenceFailure': unconfirmed if unconfirmed and not terminal else None} diff --git a/addin/AdomBridge/commands/mechanical/operations.py b/addin/AdomBridge/commands/mechanical/operations.py new file mode 100644 index 0000000..645e20f --- /dev/null +++ b/addin/AdomBridge/commands/mechanical/operations.py @@ -0,0 +1,237 @@ +"""Typed mechanical operations. Add new operations here behind shared guards.""" +import re + +import adsk.core +import adsk.fusion + +from .core import (MechanicalError, active, body_data, current_target, health, + identity, items, number, parameter_data, recovery_snapshot, + ref, require, require_healthy, resolve, target, UNITS) +from .exporting import export_design + + +def inspect(app, args): + from .assembly_state import metadata, joint_data, rigid_group_data + doc, design, key = active(app) + value = target(app, args)[2] if args.get("target") else current_target(app) + bodies, components, occurrences, sketches, features, axes = [], [], [], [], [], [] + for comp in items(design.allComponents): + components.append({"id": identity(comp), **metadata(comp), "ref": ref(comp, value), + "bodyCount": comp.bRepBodies.count, "sketchCount": comp.sketches.count}) + for body in items(comp.bRepBodies): + row = body_data(body) + row.update({"ref": ref(body, value), "componentId": identity(comp)}) + bodies.append(row) + for sketch in items(comp.sketches): + sketches.append({"ref": ref(sketch, value), "name": sketch.name, "componentId": identity(comp), + "profileCount": sketch.profiles.count, "fullyConstrained": sketch.isFullyConstrained}) + for index, feature in enumerate(items(comp.features)): + from .readback import feature_data + features.append(feature_data(feature, value, order=index)) + from .geometry import axis_data + axes.extend(axis_data(axis, value) for axis in items(comp.constructionAxes)) + for occ in items(design.rootComponent.allOccurrences): + matrix = list(occ.transform2.asArray()) + # Matrix4x4.asArray is row-major; translation lies at 3,7,11. + for i in (3, 7, 11): + matrix[i] *= 10 + occurrences.append({"ref": ref(occ, value), "componentId": identity(occ.component), + "path": occ.fullPathName, "transform": matrix, + "transformLayout": "row-major", "translationUnit": "mm", + "space": "root", "grounded": occ.isGrounded}) + from .joint_state import origin_data, link_data + from .configurations import snapshot as configuration_snapshot + configuration = configuration_snapshot(design) + return {"target": value, "document": {"name": doc.name, "creationId": doc.creationId, + "saved": doc.isSaved}, "designIntent": str(getattr(design, "designIntent", "unavailable")), + "units": UNITS, "components": components, "bodies": bodies, "occurrences": occurrences, + "sketches": sketches, "features": features, "constructionAxes": axes, + "joints": [joint_data(j, value) for comp in items(design.allComponents) + for name in ("joints", "asBuiltJoints") for j in items(getattr(comp, name))], + "rigidGroups": [rigid_group_data(g, value) for comp in items(design.allComponents) for g in items(comp.rigidGroups)], + "jointOrigins": [origin_data(o, value) for comp in items(design.allComponents) for o in items(comp.jointOrigins)], + "motionLinks": [link_data(o, value) for comp in items(design.allComponents) for o in items(comp.motionLinks)] if hasattr(design.rootComponent, "motionLinks") else [], + "parameters": [parameter_data(design, p) for p in items(design.allParameters)], + "health": health(design), "complete": configuration["complete"], "configuration": configuration} + + +def inspect_entity(app, args): + _, design, value = target(app, args) + entity = resolve(design, value, args.get("entity")) + result = {"ref": ref(entity, value), "objectType": entity.objectType, "name": getattr(entity, "name", None)} + if entity.objectType.endswith("::BRepBody"): + from .readback import topology_data + result.update(topology_data(entity, value)) + result["space"] = "assembly_context" if args["entity"].get("occurrenceToken") else "component" + result["faceRefs"] = [ref(f, value) for f in items(entity.faces)] + result["edgeRefs"] = [ref(e, value) for e in items(entity.edges)] + elif entity.objectType.endswith("::Sketch"): + from .readback import sketch_data + result.update(sketch_data(entity, value)) + elif entity.objectType.endswith("::Occurrence"): + result["bodyRefs"] = [ref(b, value, entity) for b in items(entity.component.bRepBodies)] + result["childRefs"] = [ref(o, value) for o in items(entity.childOccurrences)] + result["jointOriginRefs"] = [ref(o, value, entity) for o in items(entity.component.jointOrigins)] + elif entity.objectType.endswith(("::AsBuiltJoint", "::Joint")): + from .assembly_state import joint_data + result.update(joint_data(entity, value)) + elif entity.objectType.endswith("::Component"): + from .assembly_state import metadata + result.update(metadata(entity)) + elif entity.objectType.endswith("::RigidGroup"): + from .assembly_state import rigid_group_data + result.update(rigid_group_data(entity, value)) + elif entity.objectType.endswith("::ConstructionAxis"): + from .geometry import axis_data + result.update(axis_data(entity, value)) + result["space"] = "root" if getattr(entity, "assemblyContext", None) else "component" + elif entity.objectType.endswith("::JointOrigin"): + from .joint_state import origin_data + result.update(origin_data(entity, value)) + elif entity.objectType.endswith("::MotionLink"): + from .joint_state import link_data + result.update(link_data(entity, value)) + return {"target": value, "entity": result} + + +def new_design(app, args): + name = args.get("name", "Adom mechanical") + require(isinstance(name, str) and 0 < len(name) <= 120, "invalid_input", "name must be 1..120 characters.") + box = args.get("box") + if box is not None: + require(isinstance(box, dict), "invalid_input", "box must contain width, height and depth in mm.") + dims = [number(box.get(n), n, positive=True) for n in ("width", "height", "depth")] + previous = app.activeDocument + doc = None + try: + doc = app.documents.add(adsk.core.DocumentTypes.FusionDesignDocumentType) + require(doc is not None, "create_failed", "Fusion did not create a document.") + design = adsk.fusion.Design.cast(doc.products.itemByProductType("DesignProductType")) + require(design is not None, "create_failed", "Created document has no Design product.") + design.designType = adsk.fusion.DesignTypes.ParametricDesignType + from . import transactions + if not transactions.capability()["verified"]: + transactions.probe(app, {"target": current_target(app)}) + if box is not None: + create_box(design, dims) + require_healthy(design) + # Naming a never-saved Fusion document makes close(False) capable of + # raising a save dialog. Keep the scratch document unnamed and store the + # requested label as model metadata until an explicit save operation. + design.rootComponent.attributes.add("AdomMechanical", "requestedName", name) + result = inspect(app, {}) + result["document"]["requestedName"] = name + return result + except Exception as exc: + recovery = {"strategy": "discard_new_unsaved_document", "verified": False} + if doc is not None: + try: + require(not doc.isSaved, "recovery_refused", "New document unexpectedly saved.") + require(doc.name.lower().startswith("untitled"), "recovery_refused", "Document was renamed; refusing a close that could prompt.") + doc.close(False) + recovery["verified"] = not doc.isValid + if previous and previous.isValid: + previous.activate() + except Exception as close_error: + recovery["error"] = str(close_error) + raise MechanicalError("create_failed", str(exc), recovery=recovery) + + +def create_box(design, dimensions): + root = design.rootComponent + for name, value in zip(("width", "height", "depth"), dimensions): + design.userParameters.add(name, adsk.core.ValueInput.createByString(str(value) + " mm"), "mm", "Adom driving dimension") + width, height, _ = [n / 10 for n in dimensions] + sketch = root.sketches.add(root.xYConstructionPlane) + sketch.name = "Base rectangle" + origin = adsk.core.Point3D.create(0, 0, 0) + lines = sketch.sketchCurves.sketchLines.addTwoPointRectangle(origin, adsk.core.Point3D.create(width, height, 0)) + first = lines.item(0) + second = lines.item(1) + orient = adsk.fusion.DimensionOrientations + sx = sketch.sketchDimensions.addDistanceDimension(first.startSketchPoint, first.endSketchPoint, + orient.HorizontalDimensionOrientation, adsk.core.Point3D.create(width / 2, -height / 3, 0)) + sy = sketch.sketchDimensions.addDistanceDimension(second.startSketchPoint, second.endSketchPoint, + orient.VerticalDimensionOrientation, adsk.core.Point3D.create(width * 1.3, height / 2, 0)) + sx.parameter.expression, sy.parameter.expression = "width", "height" + # addTwoPointRectangle at the origin supplies the origin coincidence itself. + feature_input = root.features.extrudeFeatures.createInput(sketch.profiles.item(0), adsk.fusion.FeatureOperations.NewBodyFeatureOperation) + extent = adsk.fusion.DistanceExtentDefinition.create(adsk.core.ValueInput.createByString("depth")) + feature_input.setOneSideExtent(extent, adsk.fusion.ExtentDirections.PositiveExtentDirection) + feature = root.features.extrudeFeatures.add(feature_input) + feature.name = "Base extrusion" + feature.bodies.item(0).name = "Block" + + +def parameter_set(app, args): + _, design, before_target = target(app, args, True) + require_healthy(design, False) + name, expression = args.get("name"), args.get("expression") + require(isinstance(name, str) and isinstance(expression, str) and expression.strip(), "invalid_input", "name and expression are required strings.") + parameter = design.allParameters.itemByName(name) + require(parameter is not None, "parameter_missing", "No parameter named " + name) + require(design.unitsManager.isValidExpression(expression, parameter.unit), "invalid_expression", "Expression is invalid for the parameter's unit.") + before, old = recovery_snapshot(design), parameter.expression + try: + parameter.expression = expression + state = require_healthy(design) + except Exception as exc: + recovery = {"strategy": "restore_parameter_expression", "verified": False, + "scope": "parameter expressions, body metrics, occurrence transforms, counts and health"} + try: + parameter.expression = old + require_healthy(design) + recovery["verified"] = recovery_snapshot(design) == before + except Exception as restore_error: + recovery["error"] = str(restore_error) + raise MechanicalError("parameter_edit_failed", str(exc), recovery=recovery, target=current_target(app)) + return {"target": current_target(app), "parameter": parameter_data(design, parameter), + "health": state, "recoveryStrategy": "restore_parameter_expression", "transactional": False} + + +def parameter_create(app, args): + _, design, _ = target(app, args, True) + require_healthy(design, False) + name, expression, unit = args.get("name"), args.get("expression"), args.get("unit") + require(isinstance(name, str) and re.fullmatch(r"[A-Za-z][A-Za-z0-9_]{0,63}", name), "invalid_input", "Use a short parameter identifier.") + require(unit in ("mm", "deg", "kg", ""), "invalid_input", "unit must be mm, deg, kg or empty for unitless.") + require(isinstance(expression, str) and design.unitsManager.isValidExpression(expression, unit), "invalid_expression", "Expression must match the declared unit.") + require(design.allParameters.itemByName(name) is None, "parameter_exists", "Parameter already exists.") + before = recovery_snapshot(design) + parameter = None + try: + parameter = design.userParameters.add(name, adsk.core.ValueInput.createByString(expression), unit, args.get("comment", "")) + require(parameter is not None, "parameter_create_failed", "Fusion returned no parameter.") + state = require_healthy(design) + except Exception as exc: + recovery = {"strategy": "delete_created_parameter", "verified": False} + try: + created = parameter or design.userParameters.itemByName(name) + if created: + created.deleteMe() + require_healthy(design) + recovery["verified"] = recovery_snapshot(design) == before + except Exception as restore_error: + recovery["error"] = str(restore_error) + raise MechanicalError("parameter_create_failed", str(exc), recovery=recovery) + return {"target": current_target(app), "parameter": parameter_data(design, parameter), "health": state, "transactional": False} + + +def check_health(app, args): + _, design, _ = target(app, args, args.get("recompute", False)) + state = health(design, args.get("recompute", False)) + return {"target": current_target(app), "health": state} + + +def export(app, args): + _, design, value = target(app, args, True, model_write=False) + require_healthy(design, False) + result = export_design(design, args) + result["target"] = value + return result + + +OPERATIONS = {"inspect": inspect, "inspect_entity": inspect_entity, "new_design": new_design, + "parameter_set": parameter_set, "parameter_create": parameter_create, + "health": check_health, "export": export} +WRITES = {"new_design", "parameter_set", "parameter_create", "export"} diff --git a/addin/AdomBridge/commands/mechanical/purchased.py b/addin/AdomBridge/commands/mechanical/purchased.py new file mode 100644 index 0000000..7f3aa99 --- /dev/null +++ b/addin/AdomBridge/commands/mechanical/purchased.py @@ -0,0 +1,137 @@ +"""Local supplier CAD ingestion and Fusion's native McMaster command.""" +import hashlib +from pathlib import Path +from urllib.parse import urlparse + +import adsk.core + +from . import transactions +from .assemblies import apply_metadata, created_occurrence_result, hybrid, parent_component, place_created_occurrence, transform +from .assembly_state import metadata +from .core import MechanicalError, active, current_target, items, ref, require, resolve, target + +MCM_COMMAND = "InsertMcMasterCarrComponentCommand" + + +def file_hash(path): + result = hashlib.sha256() + with path.open("rb") as stream: + for block in iter(lambda: stream.read(1024 * 1024), b""): + result.update(block) + return result.hexdigest() + + +def preflight_file(args): + path = Path(args["filename"]) + require(path.is_absolute() and path.is_file() and not path.is_symlink(), "invalid_source", "Supplier CAD must be an absolute regular local file.") + require(path.suffix.lower() in (".step", ".stp", ".sat"), "unsupported_format", "Local supplier ingestion supports STEP and SAT.") + require(0 < path.stat().st_size <= 512 * 1024 * 1024, "invalid_source", "Supplier CAD must be nonempty and no larger than 512 MiB.") + provenance = args["provenance"] + url = urlparse(provenance["sourceUrl"]) + require(url.scheme == "https" and url.hostname and not url.username and not url.password, "invalid_provenance", "sourceUrl must be an HTTPS supplier URL without credentials.") + if provenance["supplier"].lower() in ("mcmaster", "mcmaster-carr"): + require(url.hostname.lower() == "mcmaster.com" or url.hostname.lower().endswith(".mcmaster.com"), "invalid_provenance", "McMaster provenance must use the supplier's domain.") + sha = file_hash(path) + if "sha256" in args: + require(args["sha256"].lower() == sha, "source_hash_mismatch", "Downloaded CAD does not match the expected SHA-256.") + return path, sha + + +def cad_import(app, args): + # Source preflight runs before any transaction/model mutation. The importer + # only reads this file; no cloud upload, browser download or file rewrite. + target(app, args, True) + path, sha = preflight_file(args) + document_count = app.documents.count + manager = app.importManager + factory = manager.createSATImportOptions if path.suffix.lower() == ".sat" else manager.createSTEPImportOptions + options = factory(str(path)) + require(options is not None, "import_unavailable", "Fusion refused import options.") + options.isViewFit = False + + def mutate(design, value, request): + hybrid(design) + parent = parent_component(design, value, request) + expected = transform(request.get("transform")) + occurrence = parent.occurrences.addNewComponent(expected) + require(occurrence is not None, "import_failed", "Could not create import wrapper component.") + comp = occurrence.component + comp.name = request.get("name", path.stem) + imported = manager.importToTarget2(options, comp) + require(imported is not None and imported.count > 0, "import_failed", "Fusion returned no imported objects.") + require(app.documents.count == document_count, "import_document_side_effect", "Importer changed the document set; inspect documents before continuing.") + components = [comp] + [o.component for o in items(comp.allOccurrences)] + require(any(c.bRepBodies.count for c in components), "import_empty", "Imported CAD contains no BRep bodies.") + require(file_hash(path) == sha, "source_changed", "Supplier CAD changed while import was running.") + provenance = {**request["provenance"], "sourceFile": str(path), "sha256": sha, + "asPurchased": request.get("asPurchased", True), "partNumber": request["provenance"]["supplierPartNumber"]} + apply_metadata(comp, provenance) + captured = place_created_occurrence(design, parent, occurrence, expected) + return lambda fresh: {**created_occurrence_result(design, parent, occurrence, fresh, expected), "positionCaptured": captured, + "source": {"filename": str(path), "sha256": sha}, + "importedObjectCount": imported.count, "externalEffects": "read-only source file; no cloud or document creation requested"} + try: + return transactions.execute(app, args, mutate) + except MechanicalError as exc: + exc.details["sourceFileModified"] = False + exc.details["documentSetUnchanged"] = app.documents.count == document_count + raise + + +def mcmaster_discover(app, args): + _, design, _ = active(app) + command = app.userInterface.commandDefinitions.itemById(MCM_COMMAND) + return {"target": current_target(app), "commandId": MCM_COMMAND, "available": command is not None, + "name": command.name if command else None, "designIntent": str(getattr(design, "designIntent", "unavailable")), + "requirements": ["Hybrid design", "online supplier access", "interactive selection and download"], + "downloadedCadAlternative": "cad_import with local STEP/SAT and supplier provenance"} + + +def mcmaster_open(app, args): + _, design, value = target(app, args, True) + hybrid(design) + command = app.userInterface.commandDefinitions.itemById(MCM_COMMAND) + require(command is not None, "mcmaster_unavailable", "Fusion's native McMaster command is not installed.") + require(command.execute(), "mcmaster_launch_failed", "Fusion refused the native McMaster command.") + return {"commandId": MCM_COMMAND, "launchRequested": True, "targetAtLaunch": value, + "completion": "interactive; inspect after inserting or cancelling", "refreshRequired": True, + "transactional": False, "recovery": "native command controls its own user interaction"} + + +def material_list(app, args): + _, design, value = target(app, args) + libraries = [{"id": library.id, "name": library.name, "count": library.materials.count} for library in items(app.materialLibraries)] + source = design.materials + if args.get("libraryId"): + matches = [lib for lib in items(app.materialLibraries) if lib.id == args["libraryId"]] + require(len(matches) == 1, "material_library_missing", "Material library ID must identify one loaded library.") + source = matches[0].materials + query = args.get("query", "").lower() + rows = [{"id": material.id, "name": material.name} for material in items(source) if query in material.name.lower()] + limit = args.get("limit", 100) + return {"target": value, "libraries": libraries, "materials": rows[:limit], "complete": len(rows) <= limit, + "matchingCount": len(rows), "libraryId": args.get("libraryId")} + + +def material_assign(app, args): + def mutate(design, value, request): + entity = resolve(design, value, request["entity"], ("Component", "BRepBody")) + require(getattr(entity, "assemblyContext", None) is None, "definition_reference_required", "Material assignment affects a native component or body definition.") + source = design.materials + if request.get("libraryId"): + matches = [lib for lib in items(app.materialLibraries) if lib.id == request["libraryId"]] + require(len(matches) == 1, "material_library_missing", "Material library ID must identify one loaded library.") + source = matches[0].materials + material = source.itemById(request["materialId"]) + require(material is not None, "material_missing", "Material ID was not found in the selected source.") + entity.material = material + def result(fresh): + require(entity.material is not None and entity.material.id == material.id, "material_failed", "Assigned material ID did not read back; same display names are not material identity.") + return {"entity": ref(entity, fresh), "material": {"id": entity.material.id, "name": entity.material.name}} + return result + return transactions.execute(app, args, mutate) + + +OPERATIONS = {"cad_import": cad_import, "mcmaster_open": mcmaster_open, "material_assign": material_assign, + "mcmaster_discover": mcmaster_discover, "material_list": material_list} +WRITES = {"cad_import", "mcmaster_open", "material_assign"} diff --git a/addin/AdomBridge/commands/mechanical/readback.py b/addin/AdomBridge/commands/mechanical/readback.py new file mode 100644 index 0000000..d5c1ea8 --- /dev/null +++ b/addin/AdomBridge/commands/mechanical/readback.py @@ -0,0 +1,252 @@ +"""Detailed topology and sketch readback using resolvable guarded references.""" +import json +import adsk.core +import adsk.fusion +from .core import body_data, digest, items, point_mm, ref, require, same + + +def concrete_geometry(geometry): + """Read subclass properties even when a getter returns a base API wrapper.""" + kind = getattr(adsk.core, geometry.objectType.split("::")[-1], None) + if kind is not None and hasattr(kind, "cast"): + geometry = kind.cast(geometry) + require(geometry is not None, "unavailable_geometry", "Cannot cast native geometry to its advertised type.") + return geometry + + +def geometry_data(geometry): + geometry = concrete_geometry(geometry) + result = {"type": geometry.objectType} + for name in ("startPoint", "endPoint", "center", "origin"): + if hasattr(geometry, name): + result[name] = point_mm(getattr(geometry, name)) + for name in ("normal", "direction", "majorAxis", "minorAxis"): + if hasattr(geometry, name): + p = getattr(geometry, name) + result[name] = [p.x, p.y, p.z] + for name in ("radius", "majorRadius", "minorRadius"): + if hasattr(geometry, name): + result[name] = getattr(geometry, name) * 10 + if geometry.objectType.endswith("::NurbsCurve3D"): + ok, controls, degree, knots, rational, weights, periodic = geometry.getData() + require(ok, "unavailable_geometry", "Cannot read complete spline geometry for the semantic witness.") + result.update(controlPoints=[point_mm(p) for p in controls], knots=list(knots), weights=list(weights), + degree=degree, rational=rational, periodic=periodic) + return result + + +def edge_geometry_data(edge): + geometry = edge.geometry + if geometry is not None: + return geometry_data(geometry) + require(edge.isDegenerate, "unavailable_geometry", "Nondegenerate BRep edge has no readable curve geometry.") + vertices = [point_mm(vertex.geometry) for vertex in (edge.startVertex, edge.endVertex) if vertex is not None] + require(vertices, "unavailable_geometry", "Degenerate BRep edge has no readable vertex position.") + return {"type": "degenerate", "isDegenerate": True, "vertices": vertices} + + +def profile_signature(profile): + props = profile.areaProperties() + return {"area": props.area * 100, "centroid": point_mm(props.centroid), "loops": profile.profileLoops.count} + + +def profile_ref(sketch, profile, index, value): + return {**value, "objectType": "adsk::fusion::Profile", "sketchToken": sketch.entityToken, + "profileIndex": index, "fingerprint": digest(profile_signature(profile))} + + +def curve_data(curve, value=None): + result = {"type": curve.objectType, "construction": curve.isConstruction, + "fixed": curve.isFixed, "geometry": geometry_data(curve.geometry)} + if value: + result["ref"] = ref(curve, value) + for name in ("startSketchPoint", "endSketchPoint", "centerSketchPoint"): + if hasattr(curve, name): + point = getattr(curve, name) + result[name] = {"position": point_mm(point.geometry)} + if value: + result[name]["ref"] = ref(point, value) + if hasattr(curve, "fitPoints"): + result["fitPoints"] = [point_mm(p.geometry) for p in items(curve.fitPoints)] + return result + + +def sketch_data(sketch, value=None): + curves, points = items(sketch.sketchCurves), items(sketch.sketchPoints) + constraints, dimensions = items(sketch.geometricConstraints), items(sketch.sketchDimensions) + graph = SketchGraph(curves, points, constraints) + result = {"name": sketch.name, "space": "sketch", "lengthUnit": "mm", + "fullyConstrained": sketch.isFullyConstrained, + "curves": [curve_data(curve, value) for curve in curves], + "points": [{"position": point_mm(p.geometry), "fixed": p.isFixed, **({"ref": ref(p, value)} if value else {})} + for p in points], + "curvePointLinks": [graph.curve_points(curve) for curve in curves], + "constraints": [constraint_data(constraint, graph) for constraint in constraints], + "dimensions": [dimension_data(dimension, graph) for dimension in dimensions]} + if value: + result["ref"] = ref(sketch, value) + result["originRef"] = ref(sketch.originPoint, value) + result["profiles"] = [{**profile_signature(profile), "ref": profile_ref(sketch, profile, index, value)} + for index, profile in enumerate(items(sketch.profiles))] + return result + + +CONSTRAINT_LINKS = { + "CoincidentConstraint": ("point", "entity"), + "HorizontalConstraint": ("line",), "VerticalConstraint": ("line",), + "HorizontalPointsConstraint": ("pointOne", "pointTwo"), + "VerticalPointsConstraint": ("pointOne", "pointTwo"), + "ParallelConstraint": ("lineOne", "lineTwo"), "CollinearConstraint": ("lineOne", "lineTwo"), + "PerpendicularConstraint": ("lineOne", "lineTwo"), + "EqualConstraint": ("curveOne", "curveTwo"), "TangentConstraint": ("curveOne", "curveTwo"), + "SmoothConstraint": ("curveOne", "curveTwo"), "ConcentricConstraint": ("entityOne", "entityTwo"), + "MidPointConstraint": ("point", "midPointCurve"), + "SymmetryConstraint": ("entityOne", "entityTwo", "symmetryLine"), + "OffsetConstraint": (), +} +DIMENSION_LINKS = { + "SketchLinearDimension": ("entityOne", "entityTwo"), + "SketchAngularDimension": ("lineOne", "lineTwo"), + "SketchRadialDimension": ("entity",), "SketchDiameterDimension": ("entity",), + "SketchOffsetDimension": ("line", "entityTwo"), + "SketchOffsetCurvesDimension": ("offsetConstraint",), + "SketchConcentricCircleDimension": ("circleOne", "circleTwo"), + "SketchEllipseMajorRadiusDimension": ("ellipse",), "SketchEllipseMinorRadiusDimension": ("ellipse",), + "SketchLinearDiameterDimension": ("line", "entityTwo"), + "SketchTangentDistanceDimension": ("entityOne", "circleOrArc"), +} + + +class SketchGraph: + """Links use local inventory nodes resolved by API equality, never tokens. + + The associated geometry inventories are part of the same witness. Native + reordering may conservatively refuse recovery; retargeting distinct nodes + cannot disappear merely because their current positions coincide. + """ + def __init__(self, curves, points, constraints): + self.nodes = {"curves": curves, "points": points, "constraints": constraints} + self.comparisons = 0 + self.direct = {} + for name, entities in self.nodes.items(): + for index, entity in enumerate(entities): + self.direct.setdefault(id(entity), []).append({"collection": name, "index": index}) + + def node(self, entity): + matches = self.direct.get(id(entity)) + if matches is None: + self.comparisons += sum(len(entities) for entities in self.nodes.values()) + require(self.comparisons <= 1000000, "inspection_budget", "Native sketch graph equality lookup exceeds inspection budget.") + matches = [{"collection": name, "index": index} + for name, entities in self.nodes.items() for index, item in enumerate(entities) + if entity is not None and same(entity, item)] + require(len(matches) == 1, "unsupported_constraint_graph", + "Sketch graph link is null, ambiguous or outside the readable native sketch inventory.") + return matches[0] + + def links(self, item, fields): + try: + return {name: self.node(getattr(item, name)) for name in fields} + except AttributeError as exc: + require(False, "unsupported_constraint_graph", "Native constraint relationship is unavailable.", + objectType=item.objectType, detail=str(exc)) + + def curve_points(self, curve): + result = self.links(curve, [name for name in ("startSketchPoint", "endSketchPoint", "centerSketchPoint") if hasattr(curve, name)]) + if hasattr(curve, "fitPoints"): + result["fitPoints"] = [self.node(p) for p in items(curve.fitPoints)] + return result + + +def graph_item(item, supported): + kind = item.objectType.split("::")[-1] + require(kind in supported, "unsupported_constraint_graph", + "No complete recovery graph reader for this native constraint/dimension type.", objectType=item.objectType) + subtype = getattr(adsk.fusion, kind, None) + if subtype is not None: + item = subtype.cast(item) + require(item is not None, "unsupported_constraint_graph", "Cannot cast native sketch relationship.") + require(item.isValid, "unsupported_constraint_graph", "Native sketch relationship is no longer valid.") + return item, kind + + +def constraint_data(constraint, graph): + constraint, kind = graph_item(constraint, CONSTRAINT_LINKS) + result = {"type": constraint.objectType} + fields = CONSTRAINT_LINKS[kind] + if kind == "PerpendicularConstraint" and hasattr(constraint, "curve"): + # September 2026 added line/curve perpendicularity. Earlier runtimes + # expose only the two-line relation; do not invent the missing link. + fields = ("line", "curve") + result["links"] = graph.links(constraint, fields) + if kind == "OffsetConstraint": + # Offset links and sign belong to the recovery witness, beyond the + # number of constraints and generated curve geometry. + result.update(distance=constraint.distance * 10, topologyMatched=constraint.isTopologyMatched, + parents=sorted([graph.node(curve) for curve in constraint.parentCurves], key=lambda row: row["index"]), + children=sorted([graph.node(curve) for curve in constraint.childCurves], key=lambda row: row["index"])) + return result + + +def dimension_data(dimension, graph): + dimension, kind = graph_item(dimension, DIMENSION_LINKS) + parameter = dimension.parameter + result = {"type": dimension.objectType, "name": parameter.name if parameter else None, + "expression": parameter.expression if parameter else None, "driving": dimension.isDriving, + "value": dimension.value * (180 / 3.141592653589793 if kind == "SketchAngularDimension" else 10), + "unit": "deg" if kind == "SketchAngularDimension" else "mm", + "links": graph.links(dimension, DIMENSION_LINKS[kind])} + if kind == "SketchLinearDimension": result["orientation"] = int(dimension.orientation) + return result + + +def construction_state(component): + from .geometry import axis_data + return [axis_data(axis) for axis in items(component.constructionAxes)] if hasattr(component, "constructionAxes") else [] + + +def topology_data(body, value): + result = body_data(body) + result["ref"] = ref(body, value) + result["faces"] = [{"ref": ref(face, value), "area": face.area * 100, + "point": point_mm(face.pointOnFace), "surface": geometry_data(face.geometry), + "edges": [ref(edge, value) for edge in items(face.edges)]} + for face in items(body.faces)] + result["edges"] = [{"ref": ref(edge, value), "length": edge.length * 10, + "curve": edge_geometry_data(edge)} for edge in items(body.edges)] + return result + + +def topology_witness(body): + result = body_data(body) + # Compare geometry rather than revision or transient topology IDs after undo. + result["faceGeometry"] = sorted([ + {"area": f.area * 100, "point": point_mm(f.pointOnFace), "surface": geometry_data(f.geometry)} + for f in items(body.faces)], key=lambda row: json.dumps(row, sort_keys=True)) + result["edgeGeometry"] = sorted([ + {"length": e.length * 10, "curve": edge_geometry_data(e)} for e in items(body.edges)], + key=lambda row: json.dumps(row, sort_keys=True)) + return result + + +def feature_data(feature, value=None, order=None): + result = {"name": feature.name, "type": feature.objectType} + if order is not None: + result["componentFeatureOrder"] = order + if value: + result["ref"] = ref(feature, value) + timeline = getattr(feature, "timelineObject", None) + if timeline: + result["suppressed"] = timeline.isSuppressed + try: + result["index"] = timeline.index + except RuntimeError as exc: + # Imported/native grouped features can have a valid timeline object + # whose index getter rejects its documented non-indexed position. + # Only this known getter failure is optional; retain name, type, + # suppression and component collection order in semantic witnesses. + if "InternalValidationError : res >= 0" not in str(exc): + raise + require(timeline.isValid, "invalid_timeline", "Feature timeline object is no longer valid.") + result.update(index=None, indexUnavailable="native_timeline_position_unavailable") + return result diff --git a/addin/AdomBridge/commands/mechanical/regular_joints.py b/addin/AdomBridge/commands/mechanical/regular_joints.py new file mode 100644 index 0000000..3216f1d --- /dev/null +++ b/addin/AdomBridge/commands/mechanical/regular_joints.py @@ -0,0 +1,488 @@ +"""Geometry-solving joints and reusable origins; all writes use PTransaction.""" +from contextlib import contextmanager +import math + +import adsk.core +import adsk.fusion + +from .core import concrete, identity, items, ref, require, require_healthy, resolve, same +from .geometry import native, owner, root_entity +from .joint_state import (geometry_state, matrix_data, origin_data, + regular_state) +from .sketches import component, named, quantity +from .assemblies import configure_joint, joint_result + +GEOMETRY_TYPES = ("BRepFace", "BRepEdge", "BRepVertex", "SketchPoint", "ConstructionPoint", + "SketchLine", "SketchArc", "SketchCircle", "SketchEllipse", "SketchEllipticalArc") +MOTION_METHODS = {"rigid": "Rigid", "revolute": "Revolute", "slider": "Slider", "cylindrical": "Cylindrical", + "pin_slot": "PinSlot", "planar": "Planar", "ball": "Ball"} + + +@contextmanager +def before_timeline(design, entity): + """Restore the exact caller marker, including on native setter exceptions.""" + position = design.timeline.markerPosition + timeline = entity.timelineObject + require(timeline is not None and timeline.isValid, "timeline_unavailable", "This edit requires a valid parametric timeline item.") + try: + require(timeline.rollTo(True), "timeline_roll_failed", "Could not roll immediately before the joint item.") + yield + finally: + design.timeline.markerPosition = position + require(design.timeline.markerPosition == position, "timeline_restore_failed", "Could not restore the original timeline marker.") + + +def geometry_from_entity(entity, key_name=None, edge=None): + entity, edge = concrete(entity), concrete(edge) + kind = entity.objectType.split("::")[-1] + geometry = getattr(entity, "geometry", None) + shape = geometry.objectType.split("::")[-1] if geometry is not None else "" + default = "middle" if kind == "BRepFace" and shape in ("Cylinder", "Cone") else "center" + key_name = key_name or default + key = getattr(adsk.fusion.JointKeyPointTypes, {"center": "CenterKeyPoint", "start": "StartKeyPoint", + "middle": "MiddleKeyPoint", "end": "EndKeyPoint"}[key_name]) + factory = adsk.fusion.JointGeometry + if kind in ("BRepVertex", "SketchPoint", "ConstructionPoint"): + require(key_name == "center" and edge is None, "invalid_joint_geometry", "Point attachments have no edge/key-point choice.") + result = factory.createByPoint(entity) + elif kind == "BRepEdge" or kind.startswith("Sketch"): + require(edge is None, "invalid_joint_geometry", "An edge qualifier belongs only to a planar face.") + require(shape in ("Line3D", "Arc3D", "Circle3D", "Ellipse3D", "EllipticalArc3D"), + "unsupported_joint_geometry", "Use a line or analytical arc/circle/ellipse attachment.") + require(shape not in ("Circle3D", "Ellipse3D") or key_name == "center", "invalid_joint_geometry", "Closed circles/ellipses require center.") + result = factory.createByCurve(entity, key) + elif kind == "BRepFace" and shape == "Plane": + require(edge is not None or key_name == "center", "invalid_joint_geometry", "A planar face requires center or one of its explicit edges.") + if edge is not None: + require(any(same(native(item), native(edge)) for item in items(entity.edges)), "invalid_joint_geometry", "Selected edge does not belong to this face.") + result = factory.createByPlanarFace(entity, edge, key) + else: + require(kind == "BRepFace" and shape in ("Cylinder", "Cone", "Sphere", "Torus") and edge is None, + "unsupported_joint_geometry", "Joint face attachment requires a plane, cylinder, cone, sphere or torus.") + result = factory.createByNonPlanarFace(entity, key) + require(result is not None and result.isValid, "invalid_joint_geometry", "Fusion did not create joint geometry.") + return result + + +def selector(design, value, raw, definition=None): + if "origin" in raw: + require(definition is None, "invalid_joint_geometry", "A joint origin must be attached to geometry, not another origin.") + origin = resolve(design, value, raw["origin"], ("JointOrigin",)) + root_entity(design, origin) + return origin + entity = resolve(design, value, raw["entity"], GEOMETRY_TYPES) + edge = resolve(design, value, raw["edge"], ("BRepEdge",)) if "edge" in raw else None + for item in (entity, edge): + if item is None: + continue + if definition is None: + root_entity(design, item) + else: + require(getattr(item, "assemblyContext", None) is None and same(owner(item), definition), + "wrong_component", "Joint-origin inputs require native geometry in the selected component definition.") + if edge is not None: + require(same(getattr(entity, "assemblyContext", None), getattr(edge, "assemblyContext", None)), + "invalid_context", "Face and edge must refer to the same occurrence instance.") + return geometry_from_entity(entity, raw.get("keyPoint"), edge) + + +def attachment_context(geometry): + geometry = concrete(geometry) + entity = concrete(geometry if geometry.objectType.endswith("::JointOrigin") else geometry.entityOne) + require(entity is not None and entity.isValid, "lost_joint_geometry", "Attachment has no live source entity.") + return getattr(entity, "assemblyContext", None) + + +def check_pair(design, first, second, contexts=None): + contexts = list(contexts) if contexts is not None else [attachment_context(g) for g in (first, second)] + require(not same(*contexts), "invalid_joint", "Joint attachments must belong to distinct root/component instances.") + for context in contexts: + if context is not None: + root_entity(design, context) + if all(context is not None for context in contexts): + a, b = [context.fullPathName for context in contexts] + require(not a.startswith(b + "+") and not b.startswith(a + "+"), "invalid_joint", "Cannot joint an occurrence to its own descendant.") + require(not all(context is None or context.isGrounded for context in contexts), + "grounded_conflict", "Both joint sides are grounded; unground the intended movable instance explicitly.") + return contexts + + +def set_motion(subject, args): + kind = args["type"] + require(not (kind in ("rigid", "ball") and ("axis" in args or "slideAxis" in args)), "invalid_joint_axes", "This joint motion has no configurable axis choice.") + require(kind == "pin_slot" or "slideAxis" not in args, "invalid_joint_axes", "slideAxis is specific to pin-slot joints.") + method = getattr(subject, "setAs" + MOTION_METHODS[kind] + "JointMotion") + directions = adsk.fusion.JointDirections + axis = getattr(directions, args.get("axis", "z").upper() + "AxisJointDirection") + if kind == "rigid": + ok = method() + elif kind == "ball": + ok = method(directions.ZAxisJointDirection, directions.XAxisJointDirection) + elif kind == "pin_slot": + require(args.get("axis", "z") != args.get("slideAxis", "x"), "invalid_joint_axes", "Pin-slot rotation and slide axes must differ.") + ok = method(axis, getattr(directions, args.get("slideAxis", "x").upper() + "AxisJointDirection")) + else: + ok = method(axis) + require(ok, "joint_failed", "Fusion refused the requested motion definition.") + + +def lock_arguments(args, joint=None): + """Rigid motion has no lock setter; its implicit native lock flag is false.""" + kind = args.get("type") + if kind is None: + motion = joint.jointMotion + require(motion is not None, "unsupported_inferred_joint", "Cannot determine the joint's native motion definition.") + rigid = motion.objectType == "adsk::fusion::RigidJointMotion" + else: + rigid = kind == "rigid" + if rigid: + require(not args.get("locked", False), "unsupported_joint_lock", "Rigid joints cannot be locked; omit locked or use false.") + # Verify the implicit flag even when a rigid redefinition omitted it. + return {**args, "locked": False} + return args + + +def apply_lock(joint, args): + if "locked" not in args: + return + motion = joint.jointMotion + require(motion is not None, "unsupported_inferred_joint", "Cannot apply a lock without a native motion definition.") + if motion.objectType == "adsk::fusion::RigidJointMotion": + require(not args["locked"], "joint_edit_refused", "The native joint remained rigid and cannot accept a lock.") + # Native rigid setters throw even for false. setAsRigidJointMotion + # clears an existing moving-joint lock; regular_result checks readback. + return + joint.isLocked = args["locked"] + + +def expected_parameters(design, args, names): + result = {} + for name in names: + if name in args: + unit = "deg" if name == "angle" else "mm" + inp = quantity(design, args[name], unit) + number = design.unitsManager.evaluateExpression(inp.stringValue, unit) + result[name] = (inp, number * (180 / math.pi if unit == "deg" else 10)) + return result + + +def verify_parameters(actual, expected): + for name, (_, wanted) in expected.items(): + require(math.isclose(actual[name]["value"], wanted, abs_tol=1e-6, rel_tol=1e-8), + "joint_parameter_refused", "Joint did not retain the requested " + name + ".") + + +def capture_pending(design): + if design.snapshots.hasPendingSnapshot: + require(design.snapshots.add() is not None, "joint_capture_failed", "Could not capture pending joint state.") + return True + return False + + +def source_reference(entity, value): + entity = concrete(entity) + if entity is None: + return None + component = owner(entity) + require(component is not None, 'unsupported_joint_geometry', 'Attachment source has no readable component owner.') + return {'reference': ref(entity, value), 'ownerId': identity(component)} + + +def capture_attachment(geometry, value, context=None): + """Freeze graph locators/scalars before solve; retain no transient geometry.""" + geometry = concrete(geometry) + state = geometry_state(geometry) + result = {'type': geometry.objectType, 'context': ref(context, value) if context is not None else None} + if geometry.objectType.endswith('::JointOrigin'): + result['origin'] = source_reference(geometry, value) + else: + result.update({key: state[key] for key in ('geometryType', 'keyPointType')}) + result['sources'] = {key: source_reference(getattr(geometry, key), value) + for key in ('entityOne', 'entityTwo', 'planeOne', 'planeTwo')} + result['tangent'] = {key: state[key] for key in ('tangentFaceType', 'tangentFaceParamOne', 'tangentFaceParamTwo') if key in state} + return result + + +def resolve_source(design, value, saved): + if saved is None: + return None + entity = resolve(design, value, saved['reference']) + require(owner(entity) is not None and identity(owner(entity)) == saved['ownerId'], + 'joint_attachment_refused', 'Attachment source changed component owner.') + return entity + + +def verify_attachment(design, value, actual, saved, occurrence=None, definition=False): + """Compare fresh native API entities, never stale wrappers or token text.""" + actual = concrete(actual) + require(actual is not None and actual.isValid and actual.objectType == saved['type'], + 'joint_attachment_refused', 'Joint attachment type changed.') + expected_context = resolve(design, value, saved['context'], ('Occurrence',)) if saved['context'] else None + require(same(occurrence, expected_context), 'joint_attachment_refused', 'Joint resolved a different occurrence instance.') + require(not definition or (occurrence is None and expected_context is None and 'origin' not in saved), + 'joint_attachment_refused', 'Definition verification is only valid for native origin geometry.') + context = attachment_context(actual) + require(definition or context is None or same(context, occurrence), + 'joint_attachment_refused', 'Attachment proxy disagrees with the joint occurrence.') + if 'origin' in saved: + require(same(native(actual), native(resolve_source(design, value, saved['origin']))), + 'joint_attachment_refused', 'Joint origin identity changed.') + return + state = geometry_state(actual) + require(all(state[key] == saved[key] for key in ('geometryType', 'keyPointType')) + and all(state.get(key) == wanted for key, wanted in saved['tangent'].items()), + 'joint_attachment_refused', 'Joint attachment definition changed.') + for key, locator in saved['sources'].items(): + found, wanted = concrete(getattr(actual, key)), resolve_source(design, value, locator) + require((found is None and wanted is None) or (found is not None and wanted is not None + and found.objectType == wanted.objectType and same(native(found), native(wanted))), + 'joint_attachment_refused', 'Joint attachment source did not read back: ' + key) + found_context = getattr(found, 'assemblyContext', None) if found is not None else None + require(found_context is None or (definition and same(found_context.component, owner(wanted))) + or (not definition and same(found_context, occurrence)), + 'joint_attachment_refused', 'Joint attachment source has a different occurrence context.') + + +def validate_frame(matrix): + require(len(matrix) == 16 and all(math.isfinite(v) for v in matrix), "joint_frame_unavailable", "Attachment frame is incomplete.") + axes = [[matrix[row * 4 + col] for row in range(3)] for col in range(3)] + require(all(math.isclose(sum(v*v for v in axis), 1, abs_tol=1e-6) for axis in axes) + and all(abs(sum(a*b for a,b in zip(axes[i],axes[k]))) < 1e-6 for i,k in ((0,1),(0,2),(1,2))), + "joint_frame_unavailable", "Attachment frame is not orthonormal.") + cross = [axes[0][1]*axes[1][2]-axes[0][2]*axes[1][1], axes[0][2]*axes[1][0]-axes[0][0]*axes[1][2], axes[0][0]*axes[1][1]-axes[0][1]*axes[1][0]] + require(sum(a*b for a,b in zip(cross,axes[2])) > 1-1e-6, "joint_frame_unavailable", "Attachment frame is not right-handed.") + + +def attachment_frames(joint): + # Native joint transforms already locate root-owned joint anchors in root + # space. JointGeometry frames use another axis convention and can become + # unreadable after compute even while isValid remains true. Do not rebuild + # them from native faces or apply occurrence transforms a second time. + require(getattr(joint, 'assemblyContext', None) is None + and same(joint.parentComponent, joint.parentComponent.parentDesign.rootComponent), + 'root_joint_required', 'Frame readback requires a root-owned joint.') + matrices = [matrix_data(joint.geometryOneTransform), matrix_data(joint.geometryTwoTransform)] + for matrix in matrices: + validate_frame(matrix) + return {"space": "native_joint_api", "originSpace": "root", "translationUnit": "mm", "transformLayout": "row-major", + "source": "Joint.geometryOneTransform / Joint.geometryTwoTransform", + "axisConvention": "native joint transform; distinct from transient JointGeometry axes", + "one": matrices[0], "two": matrices[1]} + + +def initial_alignment(joint, offset, angle, kind): + frames = attachment_frames(joint) + one, two = frames["one"], frames["two"] + delta = [one[i]-two[i] for i in (3,7,11)] + distance = math.sqrt(sum(v*v for v in delta)) + require(math.isclose(distance, abs(offset), abs_tol=1e-5, rel_tol=1e-7), + "joint_alignment_failed", "Solved attachment origins do not match the requested offset magnitude.", actualDistance=distance, expectedDistance=abs(offset)) + if abs(offset) > 1e-7: + axis = [two[i] for i in (2,6,10)] + axial = sum(a*b for a,b in zip(delta,axis)) + require(math.sqrt(max(0, distance*distance-axial*axial)) < 1e-5, + "joint_alignment_failed", "Joint offset is not along the attachment Z axis.") + axes_checked = kind != "ball" and abs(angle) < 1e-9 + if axes_checked: + dot = sum(one[i]*two[i] for i in (2,6,10)) + require(abs(abs(dot)-1) < 1e-6, "joint_alignment_failed", "Solved attachment Z axes are not collinear.") + return {**frames, "originDistance": distance, "offsetMagnitudeVerified": True, + "zeroAngleAxisCollinearityVerified": axes_checked, + "orientationSenseVerification": "native angle/flip parameter readback; physical orientation requires independent occurrence geometry acceptance"} + + +def poses(design): + return [(o, list(o.transform2.asArray()), bool(o.isGrounded), bool(getattr(o, "isGroundToParent", False))) + for o in items(design.rootComponent.allOccurrences)] + + +def pose_result(design, before): + live = items(design.rootComponent.allOccurrences) + require(len(live) == len(before), "joint_structure_changed", "Joint solving unexpectedly changed the occurrence count.") + changed = [] + for occurrence, old, grounded, ground_to_parent in before: + require(occurrence.isValid and any(same(occurrence, o) for o in live), "joint_structure_changed", "Joint solving replaced an occurrence.") + current = list(occurrence.transform2.asArray()) + moved = any(not math.isclose(a, b, abs_tol=1e-8, rel_tol=1e-8) for a, b in zip(current, old)) + require(bool(occurrence.isGrounded) == grounded and bool(getattr(occurrence, "isGroundToParent", False)) == ground_to_parent, + "grounding_changed", "Joint solving changed grounding state.") + require(not grounded or not moved, "grounded_conflict", "Joint solving moved a grounded occurrence.") + if moved: + changed.append({"occurrenceId": identity(occurrence), "path": occurrence.fullPathName, + "before": [v * 10 if i in (3, 7, 11) else v for i, v in enumerate(old)], + "after": matrix_data(occurrence.transform2)}) + return {"space": "root", "translationUnit": "mm", "transformLayout": "row-major", "changedOccurrences": changed, + "groundedOccurrencesPreserved": True} + + +def regular_result(design, joint, fresh, args, expected, attachments, before, original_value): + result = joint_result(joint, fresh, args) + state = result["joint"] + verify_parameters(state, expected) + for key, prop in (("flipped", "isFlipped"), ("locked", "isLocked")): + require(key not in args or getattr(joint, prop) == args[key], "joint_edit_refused", "Joint did not retain " + key) + if "type" in args: + require(state["motionType"].endswith("::" + MOTION_METHODS[args["type"]] + "JointMotion"), "joint_edit_refused", "Joint motion type did not read back.") + verify_motion_axes(state["axes"], args) + contexts = (joint.occurrenceOne, joint.occurrenceTwo) + for (prop, expected_attachment), context in zip(attachments.items(), contexts): + verify_attachment(design, original_value, getattr(joint, prop), expected_attachment, context) + result["placement"] = pose_result(design, before) + result["attachmentFrames"] = attachment_frames(joint) + return result + + +def verify_motion_axes(actual, args): + kind = args["type"] + field = {"revolute": "rotationAxis", "slider": "slideDirection", "cylindrical": "rotationAxis", + "pin_slot": "rotationAxis", "planar": "normalDirection"}.get(kind) + expected = {field: args.get("axis", "z")} if field else {} + if kind == "pin_slot": + expected["slideDirection"] = args.get("slideAxis", "x") + if kind == "ball": + expected.update(pitchDirection="z", yawDirection="x") + for key, axis in expected.items(): + require(actual[key] == getattr(adsk.fusion.JointDirections, axis.upper() + "AxisJointDirection"), + "joint_axis_refused", "Joint did not retain the requested " + key + ".") + + +def regular_joint_create(design, value, args): + args = lock_arguments({"flipped": False, **args}) + require(not design.snapshots.hasPendingSnapshot, "pending_position_changes", "Capture or revert pending positions before regular-joint solving.") + first, second = [selector(design, value, args[key]) for key in ("geometryOne", "geometryTwo")] + expected_contexts = check_pair(design, first, second) + attachments = {prop: capture_attachment(geometry, value, context) for prop, geometry, context in + zip(('geometryOrOriginOne', 'geometryOrOriginTwo'), (first, second), expected_contexts)} + before = poses(design) + expected = expected_parameters(design, {"angle": 0, "offset": 0, **args}, ("angle", "offset")) + inp = design.rootComponent.joints.createInput(first, second) + require(inp is not None, "joint_failed", "Fusion did not return JointInput.") + for name, (quantity_input, _) in expected.items(): + setattr(inp, name, quantity_input) + inp.isFlipped = args.get("flipped", False) + set_motion(inp, args) + joint = named(design.rootComponent.joints.add(inp), args) + require_healthy(design) + alignment = initial_alignment(joint, expected["offset"][1], expected["angle"][1], args["type"]) + apply_lock(joint, args) + configure_joint(design, joint, args) + capture_pending(design) + def result(fresh): + row = regular_result(design, joint, fresh, args, expected, attachments, before, value) + row["initialAlignment"] = alignment + return row + return result + + +def regular_joint_edit(design, value, args): + require(not design.snapshots.hasPendingSnapshot, "pending_position_changes", "Capture or revert pending positions before regular-joint redefinition.") + joint = resolve(design, value, args["joint"], ("Joint",)) + changed = set(args) & {"geometryOne", "geometryTwo", "angle", "offset", "offsetX", "offsetY", "flipped", "locked", "type"} + require(changed, "invalid_input", "Supply a geometry, offset, angle, flip, lock or motion-definition change.") + args = lock_arguments(args, joint) + require(getattr(joint, "assemblyContext", None) is None and same(joint.parentComponent, design.rootComponent), + "root_joint_required", "Regular-joint redefinition currently requires a root-owned joint.") + regular_state(joint) # Fail before rollback if attachment readback is incomplete. + require("type" in args or not ({"axis", "slideAxis"} & set(args)), "invalid_input", "Axis changes require the motion type.") + attachments = {prop: selector(design, value, args[key]) if key in args else getattr(joint, prop) + for key, prop in (("geometryOne", "geometryOrOriginOne"), ("geometryTwo", "geometryOrOriginTwo"))} + expected_contexts = [attachment_context(attachments[prop]) if key in args else getattr(joint, occurrence) + for key,prop,occurrence in (("geometryOne","geometryOrOriginOne","occurrenceOne"),("geometryTwo","geometryOrOriginTwo","occurrenceTwo"))] + check_pair(design, *attachments.values(), contexts=expected_contexts) + saved_attachments = {prop: capture_attachment(geometry, value, context) + for (prop, geometry), context in zip(attachments.items(), expected_contexts)} + expected = expected_parameters(design, args, ("angle", "offset", "offsetX", "offsetY")) + before = poses(design) + with before_timeline(design, joint): + for key, prop in (("geometryOne", "geometryOrOriginOne"), ("geometryTwo", "geometryOrOriginTwo")): + if key in args: + setattr(joint, prop, attachments[prop]) + if "type" in args: + set_motion(joint, args) + if "flipped" in args: + joint.isFlipped = args["flipped"] + for name, (quantity_input, _) in expected.items(): + getattr(joint, name).expression = quantity_input.stringValue + apply_lock(joint, args) + capture_pending(design) + return lambda fresh: regular_result(design, joint, fresh, args, expected, saved_attachments, before, value) + + +def origin_axes(design, value, args, comp): + result = {} + for key in ("xAxisEntity", "zAxisEntity"): + if key in args: + entity = resolve(design, value, args[key], ("ConstructionAxis", "SketchLine", "BRepEdge")) + require(getattr(entity, "assemblyContext", None) is None and same(owner(entity), comp), + "wrong_component", "Orientation axes must be native geometry in the origin's component.") + require(entity.geometry.objectType.endswith(("::Line3D", "::InfiniteLine3D")), "invalid_axis", "Orientation requires straight linear geometry.") + result[key] = entity + return result + + +def origin_result(design, origin, fresh, args, expected, geometry, axes, original_value): + result = origin_data(origin, fresh) + verify_parameters(result, expected) + for key in ("name", "flipped"): + require(key not in args or result[key] == args[key], "joint_origin_refused", "Origin did not retain " + key) + # A native origin belongs to a definition, but Fusion can return its geometry + # as a root-context proxy. Match native source and owner, not that incidental + # instance. Regular joints keep the strict occurrence-context check above. + verify_attachment(design, original_value, origin.geometry, geometry, definition=True) + for key, locator in axes.items(): + require(same(native(concrete(getattr(origin, key))), native(resolve_source(design, original_value, locator))), + "joint_origin_refused", "Origin axis did not read back.") + return {"jointOrigin": result, "editScope": "component_definition"} + + +def joint_origin_create(design, value, args): + args = {"flipped": False, **args} + comp = component(design, value, args) + geometry = selector(design, value, args["geometry"], comp) + axes = origin_axes(design, value, args, comp) + saved_geometry = capture_attachment(geometry, value) + saved_axes = {key: source_reference(entity, value) for key, entity in axes.items()} + expected = expected_parameters(design, {"angle": 0, "offsetX": 0, "offsetY": 0, "offsetZ": 0, **args}, ("angle", "offsetX", "offsetY", "offsetZ")) + inp = comp.jointOrigins.createInput(geometry) + require(inp is not None, "joint_origin_failed", "Fusion did not return JointOriginInput.") + inp.isFlipped = args.get("flipped", False) + for name, (quantity_input, _) in expected.items(): + setattr(inp, name, quantity_input) + for key, entity in axes.items(): + setattr(inp, key, entity) + origin = named(comp.jointOrigins.add(inp), args) + return lambda fresh: origin_result(design, origin, fresh, args, expected, saved_geometry, saved_axes, value) + + +def joint_origin_edit(design, value, args): + origin = resolve(design, value, args["origin"], ("JointOrigin",)) + require(getattr(origin, "assemblyContext", None) is None, "definition_reference_required", "Edit the native origin definition.") + require(set(args) & {"name", "geometry", "flipped", "angle", "offsetX", "offsetY", "offsetZ", "xAxisEntity", "zAxisEntity"}, "invalid_input", "Supply an origin change.") + geometry = selector(design, value, args["geometry"], origin.parentComponent) if "geometry" in args else origin.geometry + axes = origin_axes(design, value, args, origin.parentComponent) + saved_geometry = capture_attachment(geometry, value) + saved_axes = {key: source_reference(entity, value) for key, entity in axes.items()} + expected = expected_parameters(design, args, ("angle", "offsetX", "offsetY", "offsetZ")) + with before_timeline(design, origin): + if "geometry" in args: + origin.geometry = geometry + if "flipped" in args: + origin.isFlipped = args["flipped"] + for key, entity in axes.items(): + setattr(origin, key, entity) + for name, (quantity_input, _) in expected.items(): + getattr(origin, name).expression = quantity_input.stringValue + if "name" in args: + origin.name = args["name"] + return lambda fresh: origin_result(design, origin, fresh, args, expected, saved_geometry, saved_axes, value) + + +def joint_origin_delete(design, value, args): + origin = resolve(design, value, args["origin"], ("JointOrigin",)) + require(getattr(origin, "assemblyContext", None) is None, "definition_reference_required", "Delete the native origin definition.") + name = origin.name + require(origin.deleteMe(), "delete_failed", "Fusion refused joint-origin deletion.") + return {"deletedJointOrigin": name} + + +OPERATIONS = {name: globals()[name] for name in ("regular_joint_create", "regular_joint_edit", "joint_origin_create", "joint_origin_edit", "joint_origin_delete")} diff --git a/addin/AdomBridge/commands/mechanical/schema.json b/addin/AdomBridge/commands/mechanical/schema.json new file mode 100644 index 0000000..0916f6e --- /dev/null +++ b/addin/AdomBridge/commands/mechanical/schema.json @@ -0,0 +1,6377 @@ +{ + "inspect": { + "type": "object", + "required": [ + "op" + ], + "properties": {}, + "description": "Inspect the active Design without mutation. Returns target identity/revision, native component-space bodies and occurrence transforms, parameters and feature health." + }, + "inspect_entity": { + "type": "object", + "required": [ + "op", + "target", + "entity" + ], + "properties": { + "entity": { + "type": "object", + "description": "Exact reference returned by inspect; resolves uniquely with document/revision/type/occurrence guards." + } + } + }, + "new_design": { + "type": "object", + "required": [ + "op", + "sessionId", + "operationId" + ], + "properties": { + "name": { + "type": "string" + }, + "box": { + "type": "object", + "required": [ + "width", + "height", + "depth" + ], + "additionalProperties": false, + "properties": { + "width": { + "type": "number", + "exclusiveMinimum": 0 + }, + "height": { + "type": "number", + "exclusiveMinimum": 0 + }, + "depth": { + "type": "number", + "exclusiveMinimum": 0 + } + } + } + }, + "description": "Create an unnamed unsaved parametric hybrid Design; name is retained as requestedName metadata to keep failure recovery free of save dialogs. Optional box dimensions in mm create a driving-parameter fixture. Never edits an existing document." + }, + "parameter_set": { + "type": "object", + "required": [ + "op", + "sessionId", + "operationId", + "target", + "name", + "expression" + ], + "properties": { + "name": { + "type": "string" + }, + "expression": { + "type": "string" + } + }, + "description": "Set an expression, recompute and inspect health. Failure restores old expression and reports a semantic recovery witness; not an atomic transaction." + }, + "parameter_create": { + "type": "object", + "required": [ + "op", + "sessionId", + "operationId", + "target", + "name", + "expression", + "unit" + ], + "properties": { + "name": { + "type": "string" + }, + "expression": { + "type": "string" + }, + "unit": { + "enum": [ + "mm", + "deg", + "kg", + "" + ] + }, + "comment": { + "type": "string" + } + } + }, + "health": { + "type": "object", + "required": [ + "op", + "target" + ], + "properties": { + "recompute": { + "type": "boolean", + "default": false + } + }, + "description": "Read timeline warnings/errors. recompute:true also requires current target revision, sessionId and operationId." + }, + "export": { + "type": "object", + "required": [ + "op", + "sessionId", + "operationId", + "target", + "outputPath" + ], + "properties": { + "format": { + "enum": [ + "step", + "stl", + "3mf", + "f3d", + "iges", + "sat", + "smt", + "usdz", + "fbx", + "skp" + ], + "default": "step" + }, + "outputPath": { + "type": "string" + }, + "overwrite": { + "type": "boolean", + "default": false + }, + "refinement": { + "enum": [ + "low", + "medium", + "high" + ], + "default": "medium" + } + }, + "description": "Export entire design using runtime-checked API. Preflight and unique same-filesystem staging preserve the destination on failure; publish only nonempty verified output. Availability differs by Fusion build. STL numeric coordinates use mm." + }, + "transaction_probe": { + "type": "object", + "properties": {}, + "required": [ + "op", + "sessionId", + "operationId", + "target" + ], + "description": "Verify runtime PTransaction abort+commit using temporary user parameters, remove probe data and compare semantic restoration. Required once per running add-in session before feature/sketch/atomic-batch writes. Returns fresh target; inspect again for current references." + }, + "sketch_create": { + "type": "object", + "properties": { + "plane": { + "oneOf": [ + { + "enum": [ + "xy", + "xz", + "yz" + ] + }, + { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring." + } + ] + }, + "component": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring." + }, + "name": { + "type": "string" + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target" + ], + "description": "Create a native component-definition sketch on an origin/construction plane or planar face. Coordinates in subsequent geometry are sketch-space mm." + }, + "sketch_geometry": { + "type": "object", + "properties": { + "sketch": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring." + }, + "items": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "enum": [ + "point" + ] + }, + "position": { + "type": "array", + "items": { + "type": "number" + }, + "minItems": 2, + "maxItems": 3 + } + }, + "required": [ + "type", + "position" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "enum": [ + "line" + ] + }, + "start": { + "type": "array", + "items": { + "type": "number" + }, + "minItems": 2, + "maxItems": 3 + }, + "end": { + "type": "array", + "items": { + "type": "number" + }, + "minItems": 2, + "maxItems": 3 + }, + "construction": { + "type": "boolean" + } + }, + "required": [ + "type", + "start", + "end" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "enum": [ + "rectangle" + ] + }, + "start": { + "type": "array", + "items": { + "type": "number" + }, + "minItems": 2, + "maxItems": 3 + }, + "end": { + "type": "array", + "items": { + "type": "number" + }, + "minItems": 2, + "maxItems": 3 + }, + "construction": { + "type": "boolean" + } + }, + "required": [ + "type", + "start", + "end" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "enum": [ + "circle" + ] + }, + "center": { + "type": "array", + "items": { + "type": "number" + }, + "minItems": 2, + "maxItems": 3 + }, + "radius": { + "type": "number", + "exclusiveMinimum": 0 + }, + "construction": { + "type": "boolean" + } + }, + "required": [ + "type", + "center", + "radius" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "enum": [ + "arc" + ] + }, + "center": { + "type": "array", + "items": { + "type": "number" + }, + "minItems": 2, + "maxItems": 3 + }, + "start": { + "type": "array", + "items": { + "type": "number" + }, + "minItems": 2, + "maxItems": 3 + }, + "sweep": { + "type": "number" + }, + "construction": { + "type": "boolean" + } + }, + "required": [ + "type", + "center", + "start", + "sweep" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "enum": [ + "polygon" + ] + }, + "center": { + "type": "array", + "items": { + "type": "number" + }, + "minItems": 2, + "maxItems": 3 + }, + "radius": { + "type": "number", + "exclusiveMinimum": 0 + }, + "sides": { + "type": "integer", + "minimum": 3, + "maximum": 128 + }, + "angle": { + "type": "number" + }, + "construction": { + "type": "boolean" + } + }, + "required": [ + "type", + "center", + "radius", + "sides" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "enum": [ + "slot" + ] + }, + "start": { + "type": "array", + "items": { + "type": "number" + }, + "minItems": 2, + "maxItems": 3 + }, + "end": { + "type": "array", + "items": { + "type": "number" + }, + "minItems": 2, + "maxItems": 3 + }, + "width": { + "type": "number", + "exclusiveMinimum": 0 + }, + "construction": { + "type": "boolean" + } + }, + "required": [ + "type", + "start", + "end", + "width" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "enum": [ + "spline" + ] + }, + "points": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "number" + }, + "minItems": 2, + "maxItems": 3 + }, + "minItems": 2, + "maxItems": 128 + }, + "closed": { + "type": "boolean" + }, + "construction": { + "type": "boolean" + } + }, + "required": [ + "type", + "points" + ], + "additionalProperties": false + } + ] + }, + "minItems": 1, + "maxItems": 128 + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "sketch", + "items" + ], + "description": "Create a transactionally grouped set of sketch primitives. Numeric geometry uses mm and arc sweeps/polygon angles use deg." + }, + "sketch_constraint": { + "type": "object", + "properties": { + "sketch": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring." + }, + "type": { + "enum": [ + "horizontal", + "vertical", + "coincident", + "parallel", + "perpendicular", + "tangent", + "equal", + "concentric", + "collinear", + "midpoint", + "symmetry", + "fix" + ] + }, + "entities": { + "type": "array", + "items": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring." + }, + "minItems": 1, + "maxItems": 256 + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "sketch", + "type", + "entities" + ], + "description": "Constrain entities in one sketch. midpoint order is point,line; symmetry order entity1,entity2,symmetryLine. fix sets one entity fixed." + }, + "sketch_dimension": { + "type": "object", + "properties": { + "sketch": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring." + }, + "type": { + "enum": [ + "distance", + "horizontal", + "vertical", + "radius", + "diameter", + "angle" + ] + }, + "entities": { + "type": "array", + "items": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring." + }, + "minItems": 1, + "maxItems": 256 + }, + "expression": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "position": { + "type": "array", + "items": { + "type": "number" + }, + "minItems": 2, + "maxItems": 3 + }, + "parameterName": { + "type": "string" + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "sketch", + "type", + "entities", + "expression", + "position" + ], + "description": "Add driving dimensions. Distance uses two point references; radius/diameter one curve; angle two lines. Numeric expressions use mm or deg." + }, + "sketch_project": { + "type": "object", + "properties": { + "sketch": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring." + }, + "entities": { + "type": "array", + "items": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring." + }, + "minItems": 1, + "maxItems": 256 + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "sketch", + "entities" + ], + "description": "Project selected topology into a sketch with Fusion associative projection." + }, + "sketch_edit": { + "type": "object", + "properties": { + "sketch": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring." + }, + "entity": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring." + }, + "changes": { + "type": "object", + "properties": { + "position": { + "type": "array", + "items": { + "type": "number" + }, + "minItems": 2, + "maxItems": 3 + }, + "start": { + "type": "array", + "items": { + "type": "number" + }, + "minItems": 2, + "maxItems": 3 + }, + "end": { + "type": "array", + "items": { + "type": "number" + }, + "minItems": 2, + "maxItems": 3 + }, + "center": { + "type": "array", + "items": { + "type": "number" + }, + "minItems": 2, + "maxItems": 3 + }, + "radius": { + "type": "number" + }, + "construction": { + "type": "boolean" + }, + "fixed": { + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false, + "minProperties": 1 + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "sketch", + "entity", + "changes" + ], + "description": "Move sketch points/endpoints/centers in sketch-space mm, change radius or construction/fixed flags; solver failure aborts the operation." + }, + "plane_create": { + "type": "object", + "properties": { + "type": { + "enum": [ + "offset", + "angle" + ] + }, + "plane": { + "oneOf": [ + { + "enum": [ + "xy", + "xz", + "yz" + ] + }, + { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring." + } + ] + }, + "component": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring." + }, + "offset": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "axis": { + "oneOf": [ + { + "enum": [ + "x", + "y", + "z" + ] + }, + { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring." + } + ] + }, + "angle": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "name": { + "type": "string" + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "type" + ], + "description": "Create offset plane (offset in mm) or angled plane around axis (angle in deg).", + "oneOf": [ + { + "type": "object", + "required": [ + "type", + "offset" + ], + "properties": { + "op": { + "type": "string" + }, + "sessionId": { + "type": "string" + }, + "operationId": { + "type": "string" + }, + "target": { + "type": "object", + "required": [ + "documentId", + "revision" + ] + }, + "plane": { + "oneOf": [ + { + "enum": [ + "xy", + "xz", + "yz" + ] + }, + { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring." + } + ] + }, + "component": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring." + }, + "name": { + "type": "string" + }, + "type": { + "enum": [ + "offset" + ] + }, + "offset": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "type", + "axis", + "angle" + ], + "properties": { + "op": { + "type": "string" + }, + "sessionId": { + "type": "string" + }, + "operationId": { + "type": "string" + }, + "target": { + "type": "object", + "required": [ + "documentId", + "revision" + ] + }, + "plane": { + "oneOf": [ + { + "enum": [ + "xy", + "xz", + "yz" + ] + }, + { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring." + } + ] + }, + "component": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring." + }, + "name": { + "type": "string" + }, + "type": { + "enum": [ + "angle" + ] + }, + "axis": { + "oneOf": [ + { + "enum": [ + "x", + "y", + "z" + ] + }, + { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring." + } + ] + }, + "angle": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + "additionalProperties": false + } + ] + }, + "feature_create": { + "type": "object", + "properties": { + "type": { + "enum": [ + "extrude", + "revolve", + "fillet", + "chamfer", + "shell", + "hole", + "loft", + "sweep", + "combine", + "split", + "move", + "mirror", + "rectangular_pattern", + "circular_pattern", + "draft", + "thread" + ] + }, + "component": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring." + }, + "name": { + "type": "string" + }, + "profiles": {}, + "distance": {}, + "direction": {}, + "taper": {}, + "bodies": {}, + "operation": {}, + "axis": {}, + "angle": {}, + "edges": {}, + "radius": {}, + "tangentChain": {}, + "faces": {}, + "thickness": {}, + "points": {}, + "diameter": {}, + "depth": {}, + "path": {}, + "body": {}, + "tools": {}, + "keepTools": {}, + "plane": {}, + "extend": {}, + "translation": {}, + "origin": {}, + "count": {}, + "spacing": {}, + "axis2": {}, + "count2": {}, + "spacing2": {}, + "internal": {}, + "threadType": {}, + "designation": {}, + "threadClass": {}, + "modeled": {} + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "type" + ], + "description": "Create a typed native solid feature. All length quantities are mm (or expressions), angles deg. Cut/intersect require explicit participant bodies. Join needs a single existing component body; combine selects multiple bodies explicitly. Feature availability is runtime-checked; missing APIs fail before commit.", + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "enum": [ + "extrude" + ] + }, + "op": { + "type": "string" + }, + "sessionId": { + "type": "string" + }, + "operationId": { + "type": "string" + }, + "target": { + "type": "object", + "required": [ + "documentId", + "revision" + ], + "properties": { + "documentId": { + "type": "string" + }, + "revision": { + "type": "string" + } + }, + "additionalProperties": false + }, + "component": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring." + }, + "name": { + "type": "string" + }, + "profiles": { + "type": "array", + "items": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring." + }, + "minItems": 1, + "maxItems": 256 + }, + "distance": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "direction": { + "enum": [ + "positive", + "negative", + "symmetric" + ] + }, + "taper": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "bodies": { + "type": "array", + "items": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring." + }, + "minItems": 1, + "maxItems": 256 + }, + "operation": { + "enum": [ + "new_body", + "join", + "cut", + "intersect" + ] + } + }, + "required": [ + "type", + "profiles", + "distance" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "enum": [ + "revolve" + ] + }, + "op": { + "type": "string" + }, + "sessionId": { + "type": "string" + }, + "operationId": { + "type": "string" + }, + "target": { + "type": "object", + "required": [ + "documentId", + "revision" + ], + "properties": { + "documentId": { + "type": "string" + }, + "revision": { + "type": "string" + } + }, + "additionalProperties": false + }, + "component": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring." + }, + "name": { + "type": "string" + }, + "profiles": { + "type": "array", + "items": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring." + }, + "minItems": 1, + "maxItems": 256 + }, + "axis": { + "oneOf": [ + { + "enum": [ + "x", + "y", + "z" + ] + }, + { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring." + } + ] + }, + "angle": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "bodies": { + "type": "array", + "items": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring." + }, + "minItems": 1, + "maxItems": 256 + }, + "operation": { + "enum": [ + "new_body", + "join", + "cut", + "intersect" + ] + } + }, + "required": [ + "type", + "profiles", + "axis", + "angle" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "enum": [ + "fillet" + ] + }, + "op": { + "type": "string" + }, + "sessionId": { + "type": "string" + }, + "operationId": { + "type": "string" + }, + "target": { + "type": "object", + "required": [ + "documentId", + "revision" + ], + "properties": { + "documentId": { + "type": "string" + }, + "revision": { + "type": "string" + } + }, + "additionalProperties": false + }, + "component": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring." + }, + "name": { + "type": "string" + }, + "edges": { + "type": "array", + "items": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring." + }, + "minItems": 1, + "maxItems": 256 + }, + "radius": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "tangentChain": { + "type": "boolean" + } + }, + "required": [ + "type", + "edges", + "radius" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "enum": [ + "chamfer" + ] + }, + "op": { + "type": "string" + }, + "sessionId": { + "type": "string" + }, + "operationId": { + "type": "string" + }, + "target": { + "type": "object", + "required": [ + "documentId", + "revision" + ], + "properties": { + "documentId": { + "type": "string" + }, + "revision": { + "type": "string" + } + }, + "additionalProperties": false + }, + "component": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring." + }, + "name": { + "type": "string" + }, + "edges": { + "type": "array", + "items": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring." + }, + "minItems": 1, + "maxItems": 256 + }, + "distance": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "tangentChain": { + "type": "boolean" + } + }, + "required": [ + "type", + "edges", + "distance" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "enum": [ + "shell" + ] + }, + "op": { + "type": "string" + }, + "sessionId": { + "type": "string" + }, + "operationId": { + "type": "string" + }, + "target": { + "type": "object", + "required": [ + "documentId", + "revision" + ], + "properties": { + "documentId": { + "type": "string" + }, + "revision": { + "type": "string" + } + }, + "additionalProperties": false + }, + "component": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring." + }, + "name": { + "type": "string" + }, + "faces": { + "type": "array", + "items": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring." + }, + "minItems": 1, + "maxItems": 256 + }, + "thickness": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "tangentChain": { + "type": "boolean" + } + }, + "required": [ + "type", + "faces", + "thickness" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "enum": [ + "hole" + ] + }, + "op": { + "type": "string" + }, + "sessionId": { + "type": "string" + }, + "operationId": { + "type": "string" + }, + "target": { + "type": "object", + "required": [ + "documentId", + "revision" + ], + "properties": { + "documentId": { + "type": "string" + }, + "revision": { + "type": "string" + } + }, + "additionalProperties": false + }, + "component": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring." + }, + "name": { + "type": "string" + }, + "points": { + "type": "array", + "items": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring." + }, + "minItems": 1, + "maxItems": 256 + }, + "diameter": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "depth": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "bodies": { + "type": "array", + "items": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring." + }, + "minItems": 1, + "maxItems": 256 + } + }, + "required": [ + "type", + "points", + "diameter", + "depth", + "bodies" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "enum": [ + "loft" + ] + }, + "op": { + "type": "string" + }, + "sessionId": { + "type": "string" + }, + "operationId": { + "type": "string" + }, + "target": { + "type": "object", + "required": [ + "documentId", + "revision" + ], + "properties": { + "documentId": { + "type": "string" + }, + "revision": { + "type": "string" + } + }, + "additionalProperties": false + }, + "component": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring." + }, + "name": { + "type": "string" + }, + "profiles": { + "type": "array", + "items": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring." + }, + "minItems": 1, + "maxItems": 256 + }, + "bodies": { + "type": "array", + "items": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring." + }, + "minItems": 1, + "maxItems": 256 + }, + "operation": { + "enum": [ + "new_body", + "join", + "cut", + "intersect" + ] + } + }, + "required": [ + "type", + "profiles" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "enum": [ + "sweep" + ] + }, + "op": { + "type": "string" + }, + "sessionId": { + "type": "string" + }, + "operationId": { + "type": "string" + }, + "target": { + "type": "object", + "required": [ + "documentId", + "revision" + ], + "properties": { + "documentId": { + "type": "string" + }, + "revision": { + "type": "string" + } + }, + "additionalProperties": false + }, + "component": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring." + }, + "name": { + "type": "string" + }, + "profiles": { + "type": "array", + "items": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring." + }, + "minItems": 1, + "maxItems": 256 + }, + "path": { + "type": "array", + "items": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring." + }, + "minItems": 1, + "maxItems": 256 + }, + "bodies": { + "type": "array", + "items": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring." + }, + "minItems": 1, + "maxItems": 256 + }, + "operation": { + "enum": [ + "new_body", + "join", + "cut", + "intersect" + ] + } + }, + "required": [ + "type", + "profiles", + "path" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "enum": [ + "combine" + ] + }, + "op": { + "type": "string" + }, + "sessionId": { + "type": "string" + }, + "operationId": { + "type": "string" + }, + "target": { + "type": "object", + "required": [ + "documentId", + "revision" + ], + "properties": { + "documentId": { + "type": "string" + }, + "revision": { + "type": "string" + } + }, + "additionalProperties": false + }, + "component": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring." + }, + "name": { + "type": "string" + }, + "body": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring." + }, + "tools": { + "type": "array", + "items": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring." + }, + "minItems": 1, + "maxItems": 256 + }, + "operation": { + "enum": [ + "join", + "cut", + "intersect" + ] + }, + "keepTools": { + "type": "boolean" + } + }, + "required": [ + "type", + "body", + "tools", + "operation" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "enum": [ + "split" + ] + }, + "op": { + "type": "string" + }, + "sessionId": { + "type": "string" + }, + "operationId": { + "type": "string" + }, + "target": { + "type": "object", + "required": [ + "documentId", + "revision" + ], + "properties": { + "documentId": { + "type": "string" + }, + "revision": { + "type": "string" + } + }, + "additionalProperties": false + }, + "component": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring." + }, + "name": { + "type": "string" + }, + "bodies": { + "type": "array", + "items": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring." + }, + "minItems": 1, + "maxItems": 256 + }, + "plane": { + "oneOf": [ + { + "enum": [ + "xy", + "xz", + "yz" + ] + }, + { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring." + } + ] + }, + "extend": { + "type": "boolean" + } + }, + "required": [ + "type", + "bodies", + "plane" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "enum": [ + "move" + ] + }, + "op": { + "type": "string" + }, + "sessionId": { + "type": "string" + }, + "operationId": { + "type": "string" + }, + "target": { + "type": "object", + "required": [ + "documentId", + "revision" + ], + "properties": { + "documentId": { + "type": "string" + }, + "revision": { + "type": "string" + } + }, + "additionalProperties": false + }, + "component": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring." + }, + "name": { + "type": "string" + }, + "bodies": { + "type": "array", + "items": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring." + }, + "minItems": 1, + "maxItems": 256 + }, + "translation": { + "type": "array", + "items": { + "type": "number" + }, + "minItems": 2, + "maxItems": 3 + }, + "axis": { + "type": "array", + "items": { + "type": "number" + }, + "minItems": 3, + "maxItems": 3 + }, + "angle": { + "type": "number" + }, + "origin": { + "type": "array", + "items": { + "type": "number" + }, + "minItems": 2, + "maxItems": 3 + } + }, + "required": [ + "type", + "bodies" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "enum": [ + "mirror" + ] + }, + "op": { + "type": "string" + }, + "sessionId": { + "type": "string" + }, + "operationId": { + "type": "string" + }, + "target": { + "type": "object", + "required": [ + "documentId", + "revision" + ], + "properties": { + "documentId": { + "type": "string" + }, + "revision": { + "type": "string" + } + }, + "additionalProperties": false + }, + "component": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring." + }, + "name": { + "type": "string" + }, + "bodies": { + "type": "array", + "items": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring." + }, + "minItems": 1, + "maxItems": 256 + }, + "plane": { + "oneOf": [ + { + "enum": [ + "xy", + "xz", + "yz" + ] + }, + { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring." + } + ] + } + }, + "required": [ + "type", + "bodies", + "plane" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "enum": [ + "rectangular_pattern" + ] + }, + "op": { + "type": "string" + }, + "sessionId": { + "type": "string" + }, + "operationId": { + "type": "string" + }, + "target": { + "type": "object", + "required": [ + "documentId", + "revision" + ], + "properties": { + "documentId": { + "type": "string" + }, + "revision": { + "type": "string" + } + }, + "additionalProperties": false + }, + "component": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring." + }, + "name": { + "type": "string" + }, + "bodies": { + "type": "array", + "items": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring." + }, + "minItems": 1, + "maxItems": 256 + }, + "axis": { + "oneOf": [ + { + "enum": [ + "x", + "y", + "z" + ] + }, + { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring." + } + ] + }, + "count": { + "type": "integer", + "minimum": 2, + "maximum": 1000 + }, + "spacing": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "axis2": { + "oneOf": [ + { + "enum": [ + "x", + "y", + "z" + ] + }, + { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring." + } + ] + }, + "count2": { + "type": "integer", + "minimum": 1, + "maximum": 1000 + }, + "spacing2": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + "required": [ + "type", + "bodies", + "axis", + "count", + "spacing" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "enum": [ + "circular_pattern" + ] + }, + "op": { + "type": "string" + }, + "sessionId": { + "type": "string" + }, + "operationId": { + "type": "string" + }, + "target": { + "type": "object", + "required": [ + "documentId", + "revision" + ], + "properties": { + "documentId": { + "type": "string" + }, + "revision": { + "type": "string" + } + }, + "additionalProperties": false + }, + "component": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring." + }, + "name": { + "type": "string" + }, + "bodies": { + "type": "array", + "items": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring." + }, + "minItems": 1, + "maxItems": 256 + }, + "axis": { + "oneOf": [ + { + "enum": [ + "x", + "y", + "z" + ] + }, + { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring." + } + ] + }, + "count": { + "type": "integer", + "minimum": 2, + "maximum": 1000 + }, + "angle": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + "required": [ + "type", + "bodies", + "axis", + "count" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "enum": [ + "draft" + ] + }, + "op": { + "type": "string" + }, + "sessionId": { + "type": "string" + }, + "operationId": { + "type": "string" + }, + "target": { + "type": "object", + "required": [ + "documentId", + "revision" + ], + "properties": { + "documentId": { + "type": "string" + }, + "revision": { + "type": "string" + } + }, + "additionalProperties": false + }, + "component": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring." + }, + "name": { + "type": "string" + }, + "faces": { + "type": "array", + "items": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring." + }, + "minItems": 1, + "maxItems": 256 + }, + "plane": { + "oneOf": [ + { + "enum": [ + "xy", + "xz", + "yz" + ] + }, + { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring." + } + ] + }, + "angle": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "tangentChain": { + "type": "boolean" + } + }, + "required": [ + "type", + "faces", + "plane", + "angle" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "enum": [ + "thread" + ] + }, + "op": { + "type": "string" + }, + "sessionId": { + "type": "string" + }, + "operationId": { + "type": "string" + }, + "target": { + "type": "object", + "required": [ + "documentId", + "revision" + ], + "properties": { + "documentId": { + "type": "string" + }, + "revision": { + "type": "string" + } + }, + "additionalProperties": false + }, + "component": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring." + }, + "name": { + "type": "string" + }, + "faces": { + "type": "array", + "items": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring." + }, + "minItems": 1, + "maxItems": 256 + }, + "internal": { + "type": "boolean" + }, + "threadType": { + "type": "string" + }, + "designation": { + "type": "string" + }, + "threadClass": { + "type": "string" + }, + "modeled": { + "type": "boolean" + } + }, + "required": [ + "type", + "faces", + "internal", + "threadType", + "designation", + "threadClass" + ], + "additionalProperties": false + } + ] + }, + "feature_edit": { + "type": "object", + "properties": { + "feature": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring." + }, + "name": { + "type": "string" + }, + "suppressed": { + "type": "boolean" + }, + "parameters": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "feature" + ], + "description": "Rename/suppress a feature or change expressions of model parameters whose createdBy is that exact feature; read health and abort on downstream errors." + }, + "entity_delete": { + "type": "object", + "properties": { + "entity": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring." + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "entity" + ], + "description": "Delete sketch geometry or independently deletable timeline features. Model transaction and semantic recovery cover downstream failures; document and external file deletion are excluded." + }, + "parameter_batch": { + "type": "object", + "properties": { + "changes": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "action": { + "enum": [ + "create" + ] + }, + "name": { + "type": "string" + }, + "expression": { + "type": "string" + }, + "unit": { + "enum": [ + "mm", + "deg", + "kg", + "" + ] + }, + "comment": { + "type": "string" + } + }, + "required": [ + "action", + "name", + "expression", + "unit" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "action": { + "enum": [ + "set" + ] + }, + "name": { + "type": "string" + }, + "expression": { + "type": "string" + } + }, + "required": [ + "action", + "name", + "expression" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "action": { + "enum": [ + "delete" + ] + }, + "name": { + "type": "string" + } + }, + "required": [ + "action", + "name" + ], + "additionalProperties": false + } + ] + }, + "minItems": 1, + "maxItems": 128 + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "changes" + ], + "description": "Atomic sequential create/set/delete parameter edits with one recompute+health gate; delete supports user parameters only. Any failure aborts all edits and reports semantic recovery." + }, + "component_create": { + "type": "object", + "properties": { + "parent": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context." + }, + "name": { + "type": "string" + }, + "transform": { + "type": "object", + "properties": { + "position": { + "type": "array", + "items": { + "type": "number" + }, + "minItems": 3, + "maxItems": 3 + }, + "axis": { + "type": "array", + "items": { + "type": "number" + }, + "minItems": 3, + "maxItems": 3 + }, + "angle": { + "type": "number" + } + }, + "required": [], + "additionalProperties": false, + "description": "Absolute rotation about the given nonzero axis through origin (deg), then translation (mm). Create/insert use parent component coordinates; updates use the selected occurrence assembly context." + }, + "metadata": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "partNumber": { + "type": "string" + }, + "description": { + "type": "string" + }, + "supplier": { + "type": "string" + }, + "supplierPartNumber": { + "type": "string" + }, + "sourceUrl": { + "type": "string" + }, + "asPurchased": { + "type": "boolean" + }, + "excludeFromBom": { + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false, + "minProperties": 1 + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "name" + ], + "description": "Create a native internal component and its first occurrence in a Hybrid design. parent is a component definition; edits to it affect every instance. Returns a representative root-context occurrence and all affectedOccurrences when the parent definition is reused." + }, + "occurrence_insert": { + "type": "object", + "properties": { + "component": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context." + }, + "parent": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context." + }, + "transform": { + "type": "object", + "properties": { + "position": { + "type": "array", + "items": { + "type": "number" + }, + "minItems": 3, + "maxItems": 3 + }, + "axis": { + "type": "array", + "items": { + "type": "number" + }, + "minItems": 3, + "maxItems": 3 + }, + "angle": { + "type": "number" + } + }, + "required": [], + "additionalProperties": false, + "description": "Absolute rotation about the given nonzero axis through origin (deg), then translation (mm). Create/insert use parent component coordinates; updates use the selected occurrence assembly context." + }, + "independentCopy": { + "type": "boolean" + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "component" + ], + "description": "Insert another occurrence of an existing definition; independentCopy:true uses Paste New to create a separate definition. Nested placement is in parent component coordinates. Returns a representative root-context occurrence and all affectedOccurrences when the parent definition is reused." + }, + "occurrence_update": { + "type": "object", + "properties": { + "occurrence": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context." + }, + "transform": { + "type": "object", + "properties": { + "position": { + "type": "array", + "items": { + "type": "number" + }, + "minItems": 3, + "maxItems": 3 + }, + "axis": { + "type": "array", + "items": { + "type": "number" + }, + "minItems": 3, + "maxItems": 3 + }, + "angle": { + "type": "number" + } + }, + "required": [], + "additionalProperties": false, + "description": "Absolute rotation about the given nonzero axis through origin (deg), then translation (mm). Create/insert use parent component coordinates; updates use the selected occurrence assembly context." + }, + "grounded": { + "type": "boolean" + }, + "groundedToParent": { + "type": "boolean" + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "occurrence" + ], + "description": "Set occurrence pose in its assembly context and capture position; explicit grounding/ungrounding. Grounded or constrained transforms fail with recovery." + }, + "occurrence_delete": { + "type": "object", + "properties": { + "occurrence": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context." + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "occurrence" + ], + "description": "Delete one selected occurrence through the shared transaction executor." + }, + "component_metadata": { + "type": "object", + "properties": { + "component": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context." + }, + "changes": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "partNumber": { + "type": "string" + }, + "description": { + "type": "string" + }, + "supplier": { + "type": "string" + }, + "supplierPartNumber": { + "type": "string" + }, + "sourceUrl": { + "type": "string" + }, + "asPurchased": { + "type": "boolean" + }, + "excludeFromBom": { + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false, + "minProperties": 1 + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "changes" + ], + "description": "Update component-definition name/part number/description and persistent supplier or BOM attributes. Shared occurrences all see this metadata." + }, + "joint_create": { + "type": "object", + "properties": { + "type": { + "enum": [ + "rigid", + "revolute", + "slider", + "cylindrical", + "pin_slot", + "planar", + "ball" + ] + }, + "occurrenceOne": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context." + }, + "occurrenceTwo": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context." + }, + "geometry": { + "type": "object", + "properties": { + "entity": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context." + }, + "keyPoint": { + "enum": [ + "center", + "start", + "middle", + "end" + ] + } + }, + "required": [ + "entity" + ], + "additionalProperties": false + }, + "axis": { + "enum": [ + "x", + "y", + "z" + ] + }, + "name": { + "type": "string" + }, + "suppressed": { + "type": "boolean" + }, + "rotationLimits": { + "type": "object", + "properties": { + "minimum": { + "type": "number" + }, + "maximum": { + "type": "number" + }, + "rest": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "maximumEnabled": { + "type": "boolean" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false, + "minProperties": 1 + }, + "slideLimits": { + "type": "object", + "properties": { + "minimum": { + "type": "number" + }, + "maximum": { + "type": "number" + }, + "rest": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "maximumEnabled": { + "type": "boolean" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false, + "minProperties": 1 + }, + "rotationValue": { + "type": "number" + }, + "slideValue": { + "type": "number" + }, + "primarySlideLimits": { + "type": "object", + "properties": { + "minimum": { + "type": "number" + }, + "maximum": { + "type": "number" + }, + "rest": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "maximumEnabled": { + "type": "boolean" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false, + "minProperties": 1 + }, + "primarySlideValue": { + "type": "number" + }, + "secondarySlideLimits": { + "type": "object", + "properties": { + "minimum": { + "type": "number" + }, + "maximum": { + "type": "number" + }, + "rest": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "maximumEnabled": { + "type": "boolean" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false, + "minProperties": 1 + }, + "secondarySlideValue": { + "type": "number" + }, + "pitchLimits": { + "type": "object", + "properties": { + "minimum": { + "type": "number" + }, + "maximum": { + "type": "number" + }, + "rest": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "maximumEnabled": { + "type": "boolean" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false, + "minProperties": 1 + }, + "pitchValue": { + "type": "number" + }, + "rollLimits": { + "type": "object", + "properties": { + "minimum": { + "type": "number" + }, + "maximum": { + "type": "number" + }, + "rest": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "maximumEnabled": { + "type": "boolean" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false, + "minProperties": 1 + }, + "rollValue": { + "type": "number" + }, + "yawLimits": { + "type": "object", + "properties": { + "minimum": { + "type": "number" + }, + "maximum": { + "type": "number" + }, + "rest": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "maximumEnabled": { + "type": "boolean" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false, + "minProperties": 1 + }, + "yawValue": { + "type": "number" + }, + "slideAxis": { + "enum": [ + "x", + "y", + "z" + ] + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "type", + "occurrenceOne", + "occurrenceTwo" + ], + "description": "Create an as-built joint without moving occurrences. Non-rigid motion requires explicit joint geometry; x/y/z are joint-coordinate directions. Limits and drive values use mm/deg. Seven native motion types; ball uses joint Z pitch/X yaw; planar uses native primary slide direction reported on readback. Use regular_joint_create for geometry-solving placement.", + "oneOf": [ + { + "type": "object", + "properties": { + "op": { + "type": "string" + }, + "sessionId": { + "type": "string" + }, + "operationId": { + "type": "string" + }, + "target": { + "type": "object" + }, + "type": { + "enum": [ + "rigid" + ] + }, + "occurrenceOne": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context." + }, + "occurrenceTwo": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context." + }, + "name": { + "type": "string" + }, + "suppressed": { + "type": "boolean" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "op": { + "type": "string" + }, + "sessionId": { + "type": "string" + }, + "operationId": { + "type": "string" + }, + "target": { + "type": "object" + }, + "type": { + "enum": [ + "revolute" + ] + }, + "occurrenceOne": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context." + }, + "occurrenceTwo": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context." + }, + "name": { + "type": "string" + }, + "suppressed": { + "type": "boolean" + }, + "geometry": { + "type": "object", + "properties": { + "entity": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context." + }, + "keyPoint": { + "enum": [ + "center", + "start", + "middle", + "end" + ] + } + }, + "required": [ + "entity" + ], + "additionalProperties": false + }, + "axis": { + "enum": [ + "x", + "y", + "z" + ] + }, + "rotationLimits": { + "type": "object", + "properties": { + "minimum": { + "type": "number" + }, + "maximum": { + "type": "number" + }, + "rest": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "maximumEnabled": { + "type": "boolean" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false, + "minProperties": 1 + }, + "rotationValue": { + "type": "number" + } + }, + "required": [ + "type", + "geometry" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "op": { + "type": "string" + }, + "sessionId": { + "type": "string" + }, + "operationId": { + "type": "string" + }, + "target": { + "type": "object" + }, + "type": { + "enum": [ + "slider" + ] + }, + "occurrenceOne": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context." + }, + "occurrenceTwo": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context." + }, + "name": { + "type": "string" + }, + "suppressed": { + "type": "boolean" + }, + "geometry": { + "type": "object", + "properties": { + "entity": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context." + }, + "keyPoint": { + "enum": [ + "center", + "start", + "middle", + "end" + ] + } + }, + "required": [ + "entity" + ], + "additionalProperties": false + }, + "axis": { + "enum": [ + "x", + "y", + "z" + ] + }, + "slideLimits": { + "type": "object", + "properties": { + "minimum": { + "type": "number" + }, + "maximum": { + "type": "number" + }, + "rest": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "maximumEnabled": { + "type": "boolean" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false, + "minProperties": 1 + }, + "slideValue": { + "type": "number" + } + }, + "required": [ + "type", + "geometry" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "op": { + "type": "string" + }, + "sessionId": { + "type": "string" + }, + "operationId": { + "type": "string" + }, + "target": { + "type": "object" + }, + "type": { + "enum": [ + "cylindrical" + ] + }, + "occurrenceOne": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context." + }, + "occurrenceTwo": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context." + }, + "name": { + "type": "string" + }, + "suppressed": { + "type": "boolean" + }, + "geometry": { + "type": "object", + "properties": { + "entity": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context." + }, + "keyPoint": { + "enum": [ + "center", + "start", + "middle", + "end" + ] + } + }, + "required": [ + "entity" + ], + "additionalProperties": false + }, + "axis": { + "enum": [ + "x", + "y", + "z" + ] + }, + "rotationLimits": { + "type": "object", + "properties": { + "minimum": { + "type": "number" + }, + "maximum": { + "type": "number" + }, + "rest": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "maximumEnabled": { + "type": "boolean" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false, + "minProperties": 1 + }, + "rotationValue": { + "type": "number" + }, + "slideLimits": { + "type": "object", + "properties": { + "minimum": { + "type": "number" + }, + "maximum": { + "type": "number" + }, + "rest": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "maximumEnabled": { + "type": "boolean" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false, + "minProperties": 1 + }, + "slideValue": { + "type": "number" + } + }, + "required": [ + "type", + "geometry" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "op": { + "type": "string" + }, + "sessionId": { + "type": "string" + }, + "operationId": { + "type": "string" + }, + "target": { + "type": "object" + }, + "type": { + "enum": [ + "pin_slot" + ] + }, + "occurrenceOne": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context." + }, + "occurrenceTwo": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context." + }, + "name": { + "type": "string" + }, + "suppressed": { + "type": "boolean" + }, + "geometry": { + "type": "object", + "properties": { + "entity": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context." + }, + "keyPoint": { + "enum": [ + "center", + "start", + "middle", + "end" + ] + } + }, + "required": [ + "entity" + ], + "additionalProperties": false + }, + "axis": { + "enum": [ + "x", + "y", + "z" + ] + }, + "slideAxis": { + "enum": [ + "x", + "y", + "z" + ] + }, + "rotationLimits": { + "type": "object", + "properties": { + "minimum": { + "type": "number" + }, + "maximum": { + "type": "number" + }, + "rest": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "maximumEnabled": { + "type": "boolean" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false, + "minProperties": 1 + }, + "rotationValue": { + "type": "number" + }, + "slideLimits": { + "type": "object", + "properties": { + "minimum": { + "type": "number" + }, + "maximum": { + "type": "number" + }, + "rest": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "maximumEnabled": { + "type": "boolean" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false, + "minProperties": 1 + }, + "slideValue": { + "type": "number" + } + }, + "required": [ + "type", + "geometry" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "op": { + "type": "string" + }, + "sessionId": { + "type": "string" + }, + "operationId": { + "type": "string" + }, + "target": { + "type": "object" + }, + "type": { + "enum": [ + "planar" + ] + }, + "occurrenceOne": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context." + }, + "occurrenceTwo": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context." + }, + "name": { + "type": "string" + }, + "suppressed": { + "type": "boolean" + }, + "geometry": { + "type": "object", + "properties": { + "entity": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context." + }, + "keyPoint": { + "enum": [ + "center", + "start", + "middle", + "end" + ] + } + }, + "required": [ + "entity" + ], + "additionalProperties": false + }, + "axis": { + "enum": [ + "x", + "y", + "z" + ] + }, + "rotationLimits": { + "type": "object", + "properties": { + "minimum": { + "type": "number" + }, + "maximum": { + "type": "number" + }, + "rest": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "maximumEnabled": { + "type": "boolean" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false, + "minProperties": 1 + }, + "rotationValue": { + "type": "number" + }, + "primarySlideLimits": { + "type": "object", + "properties": { + "minimum": { + "type": "number" + }, + "maximum": { + "type": "number" + }, + "rest": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "maximumEnabled": { + "type": "boolean" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false, + "minProperties": 1 + }, + "primarySlideValue": { + "type": "number" + }, + "secondarySlideLimits": { + "type": "object", + "properties": { + "minimum": { + "type": "number" + }, + "maximum": { + "type": "number" + }, + "rest": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "maximumEnabled": { + "type": "boolean" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false, + "minProperties": 1 + }, + "secondarySlideValue": { + "type": "number" + } + }, + "required": [ + "type", + "geometry" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "op": { + "type": "string" + }, + "sessionId": { + "type": "string" + }, + "operationId": { + "type": "string" + }, + "target": { + "type": "object" + }, + "type": { + "enum": [ + "ball" + ] + }, + "occurrenceOne": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context." + }, + "occurrenceTwo": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context." + }, + "name": { + "type": "string" + }, + "suppressed": { + "type": "boolean" + }, + "geometry": { + "type": "object", + "properties": { + "entity": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context." + }, + "keyPoint": { + "enum": [ + "center", + "start", + "middle", + "end" + ] + } + }, + "required": [ + "entity" + ], + "additionalProperties": false + }, + "pitchLimits": { + "type": "object", + "properties": { + "minimum": { + "type": "number" + }, + "maximum": { + "type": "number" + }, + "rest": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "maximumEnabled": { + "type": "boolean" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false, + "minProperties": 1 + }, + "pitchValue": { + "type": "number" + }, + "rollLimits": { + "type": "object", + "properties": { + "minimum": { + "type": "number" + }, + "maximum": { + "type": "number" + }, + "rest": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "maximumEnabled": { + "type": "boolean" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false, + "minProperties": 1 + }, + "rollValue": { + "type": "number" + }, + "yawLimits": { + "type": "object", + "properties": { + "minimum": { + "type": "number" + }, + "maximum": { + "type": "number" + }, + "rest": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "maximumEnabled": { + "type": "boolean" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false, + "minProperties": 1 + }, + "yawValue": { + "type": "number" + } + }, + "required": [ + "type", + "geometry" + ], + "additionalProperties": false + } + ] + }, + "joint_edit": { + "type": "object", + "properties": { + "joint": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context." + }, + "name": { + "type": "string" + }, + "suppressed": { + "type": "boolean" + }, + "rotationLimits": { + "type": "object", + "properties": { + "minimum": { + "type": "number" + }, + "maximum": { + "type": "number" + }, + "rest": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "maximumEnabled": { + "type": "boolean" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false, + "minProperties": 1 + }, + "slideLimits": { + "type": "object", + "properties": { + "minimum": { + "type": "number" + }, + "maximum": { + "type": "number" + }, + "rest": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "maximumEnabled": { + "type": "boolean" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false, + "minProperties": 1 + }, + "rotationValue": { + "type": "number" + }, + "slideValue": { + "type": "number" + }, + "primarySlideLimits": { + "type": "object", + "properties": { + "minimum": { + "type": "number" + }, + "maximum": { + "type": "number" + }, + "rest": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "maximumEnabled": { + "type": "boolean" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false, + "minProperties": 1 + }, + "primarySlideValue": { + "type": "number" + }, + "secondarySlideLimits": { + "type": "object", + "properties": { + "minimum": { + "type": "number" + }, + "maximum": { + "type": "number" + }, + "rest": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "maximumEnabled": { + "type": "boolean" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false, + "minProperties": 1 + }, + "secondarySlideValue": { + "type": "number" + }, + "pitchLimits": { + "type": "object", + "properties": { + "minimum": { + "type": "number" + }, + "maximum": { + "type": "number" + }, + "rest": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "maximumEnabled": { + "type": "boolean" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false, + "minProperties": 1 + }, + "pitchValue": { + "type": "number" + }, + "rollLimits": { + "type": "object", + "properties": { + "minimum": { + "type": "number" + }, + "maximum": { + "type": "number" + }, + "rest": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "maximumEnabled": { + "type": "boolean" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false, + "minProperties": 1 + }, + "rollValue": { + "type": "number" + }, + "yawLimits": { + "type": "object", + "properties": { + "minimum": { + "type": "number" + }, + "maximum": { + "type": "number" + }, + "rest": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "maximumEnabled": { + "type": "boolean" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false, + "minProperties": 1 + }, + "yawValue": { + "type": "number" + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "joint" + ], + "description": "Edit joint name/suppression, enabled limits, rest and drive values; wrong motion type or out-of-limit values fail with recovery. For driven root joints, active motion links must preserve their settings and their partner motion must follow the native ratio; a coupling mismatch aborts." + }, + "joint_delete": { + "type": "object", + "properties": { + "joint": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context." + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "joint" + ], + "description": "Delete one selected Joint or AsBuiltJoint with recovery verification." + }, + "interference": { + "type": "object", + "properties": { + "entities": { + "type": "array", + "items": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context." + }, + "minItems": 2, + "maxItems": 256 + }, + "includeTouching": { + "type": "boolean" + }, + "volumeTolerance": { + "type": "number", + "minimum": 0 + } + }, + "required": [ + "op", + "target", + "entities" + ], + "description": "Analyze root-context occurrences/body proxies; returns overlap volume mm3 or touching. Does not create interference bodies." + }, + "bom": { + "type": "object", + "properties": { + "explodePurchased": { + "type": "boolean" + } + }, + "required": [ + "op", + "target" + ], + "description": "Structured component-definition and instance BOM; quantities by actual identity, root bodies plus nested children; purchased metadata collapses subtrees unless exploded. Physical mass includes all unsuppressed bodies exactly once per instance. Includes exact current active configuration provenance; incomplete configuration scope refuses. Visibility does not remove quantity." + }, + "bom_export": { + "type": "object", + "properties": { + "filename": { + "type": "string" + }, + "overwrite": { + "type": "boolean" + }, + "explodePurchased": { + "type": "boolean" + }, + "mode": { + "enum": [ + "flat", + "structured" + ] + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "filename" + ], + "description": "Export flat grouped purchasing rows or structured parent/path/depth instance rows and supplier provenance to CSV with exclusive/atomic publication. This external effect is separate from model transactions. Both CSV modes include document/revision and current active configuration table/row provenance." + }, + "cad_import": { + "type": "object", + "properties": { + "filename": { + "type": "string" + }, + "sha256": { + "type": "string" + }, + "name": { + "type": "string" + }, + "parent": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context." + }, + "transform": { + "type": "object", + "properties": { + "position": { + "type": "array", + "items": { + "type": "number" + }, + "minItems": 3, + "maxItems": 3 + }, + "axis": { + "type": "array", + "items": { + "type": "number" + }, + "minItems": 3, + "maxItems": 3 + }, + "angle": { + "type": "number" + } + }, + "required": [], + "additionalProperties": false, + "description": "Absolute rotation about the given nonzero axis through origin (deg), then translation (mm). Create/insert use parent component coordinates; updates use the selected occurrence assembly context." + }, + "provenance": { + "type": "object", + "properties": { + "supplier": { + "type": "string" + }, + "supplierPartNumber": { + "type": "string" + }, + "sourceUrl": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "required": [ + "supplier", + "supplierPartNumber", + "sourceUrl" + ], + "additionalProperties": false + }, + "asPurchased": { + "type": "boolean" + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "filename", + "provenance" + ], + "description": "Import a pre-downloaded local STEP/SAT into a new wrapper component in Hybrid design. Validate optional expected SHA-256, record actual source/hash/supplier metadata, preserve camera, measure model recovery. Does not fetch URLs or create cloud documents. Returns a representative root-context occurrence and all affectedOccurrences when the parent definition is reused." + }, + "mcmaster_discover": { + "type": "object", + "properties": {}, + "required": [ + "op" + ], + "description": "Discover Fusion native Insert McMaster-Carr command and requirements in the active design." + }, + "mcmaster_open": { + "type": "object", + "properties": {}, + "required": [ + "op", + "sessionId", + "operationId", + "target" + ], + "description": "Invoke Fusion native McMaster UI in the targeted Hybrid design. Interactive external operation: completion/insert/cancel stays with Fusion; inspect afterward. Idempotency records launch only." + }, + "material_list": { + "type": "object", + "properties": { + "libraryId": { + "type": "string" + }, + "query": { + "type": "string" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 1000 + } + }, + "required": [ + "op", + "target" + ], + "description": "List loaded material libraries and matching materials by stable library/material IDs. Omit libraryId for document materials." + }, + "material_assign": { + "type": "object", + "properties": { + "entity": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context." + }, + "libraryId": { + "type": "string" + }, + "materialId": { + "type": "string" + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "entity", + "materialId" + ], + "description": "Assign a loaded library or document material to an exact native Component/BRepBody; verify material ID readback with model transaction recovery. Query assembly_properties for resulting mass." + }, + "rigid_group_create": { + "type": "object", + "properties": { + "occurrences": { + "type": "array", + "items": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context." + }, + "minItems": 2, + "maxItems": 256 + }, + "includeChildren": { + "type": "boolean" + }, + "name": { + "type": "string" + } + }, + "required": [ + "op", + "target", + "sessionId", + "operationId", + "occurrences" + ], + "description": "Create a native rigid group from exact occurrence instances; includeChildren defaults true." + }, + "rigid_group_edit": { + "type": "object", + "properties": { + "group": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context." + }, + "name": { + "type": "string" + }, + "suppressed": { + "type": "boolean" + } + }, + "required": [ + "op", + "target", + "sessionId", + "operationId", + "group" + ], + "description": "Rename or suppress an exact rigid group with transaction recovery." + }, + "rigid_group_delete": { + "type": "object", + "properties": { + "group": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context." + } + }, + "required": [ + "op", + "target", + "sessionId", + "operationId", + "group" + ], + "description": "Delete an exact rigid group with transaction recovery." + }, + "assembly_properties": { + "type": "object", + "properties": { + "entity": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context." + } + }, + "required": [ + "op", + "target" + ], + "description": "High-accuracy physical properties of whole root assembly (default) or an exact Component/Occurrence/BRepBody. Returns mass kg, volume mm3, area mm2, center of mass mm, mean density kg/mm3 and definition/instance coordinate scope." + }, + "geometry_query": { + "type": "object", + "properties": { + "body": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Current guarded entity reference; root-space child geometry requires its exact occurrence context." + }, + "kind": { + "enum": [ + "faces", + "edges", + "vertices" + ] + }, + "space": { + "enum": [ + "root", + "component" + ] + }, + "selection": { + "enum": [ + "one", + "many" + ] + }, + "filters": { + "type": "object", + "properties": { + "type": { + "enum": [ + "Plane", + "Cylinder", + "Cone", + "Sphere", + "Torus", + "NurbsSurface", + "Line3D", + "Arc3D", + "Circle3D", + "Ellipse3D", + "EllipticalArc3D", + "NurbsCurve3D", + "Point3D", + "degenerate" + ] + }, + "area": { + "type": "object", + "properties": { + "min": { + "type": "number", + "minimum": 0 + }, + "max": { + "type": "number", + "minimum": 0 + } + }, + "required": [], + "additionalProperties": false, + "minProperties": 1 + }, + "length": { + "type": "object", + "properties": { + "min": { + "type": "number", + "minimum": 0 + }, + "max": { + "type": "number", + "minimum": 0 + } + }, + "required": [], + "additionalProperties": false, + "minProperties": 1 + }, + "radius": { + "type": "object", + "properties": { + "min": { + "type": "number", + "minimum": 0 + }, + "max": { + "type": "number", + "minimum": 0 + } + }, + "required": [], + "additionalProperties": false, + "minProperties": 1 + }, + "near": { + "type": "object", + "properties": { + "point": { + "type": "array", + "items": { + "type": "number" + }, + "minItems": 3, + "maxItems": 3 + }, + "tolerance": { + "type": "number", + "minimum": 0 + } + }, + "required": [ + "point", + "tolerance" + ], + "additionalProperties": false + }, + "direction": { + "type": "object", + "properties": { + "vector": { + "type": "array", + "items": { + "type": "number" + }, + "minItems": 3, + "maxItems": 3 + }, + "tolerance": { + "type": "number", + "minimum": 0, + "maximum": 90 + }, + "orientation": { + "enum": [ + "same", + "parallel" + ] + } + }, + "required": [ + "vector" + ], + "additionalProperties": false + } + }, + "required": [], + "additionalProperties": false + }, + "maxResults": { + "type": "integer", + "minimum": 1, + "maximum": 1024 + } + }, + "required": [ + "op", + "target", + "body", + "kind", + "space" + ], + "description": "Complete body-scoped topology query, no arbitrary first-match selection. selection one (default) refuses zero/multiple matches; many may return empty. Geometry types are exact native types; size ranges inclusive in mm/mm2, near is minimum distance in the explicit frame; direction is outward planar face normal or linear edge tangent. Returned refs retain instance context even for component-space filters. Exceeding maxResults fails rather than truncating." + }, + "measure": { + "type": "object", + "properties": { + "type": { + "enum": [ + "distance", + "angle" + ] + }, + "space": { + "enum": [ + "root" + ] + }, + "entities": { + "type": "array", + "items": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Current guarded entity reference; root-space child geometry requires its exact occurrence context." + }, + "minItems": 2, + "maxItems": 3 + } + }, + "required": [ + "op", + "target", + "type", + "space", + "entities" + ], + "description": "Native engineering minimum distance or angle in root assembly space. Non-root definition refs require an occurrence context. Distance needs2 entities; angle needs2 lines/planes or3 points (second apex). Returns mm/deg and native witness positions in root mm. witnessValidation identifies native points or native geometry directions used for valid parallel angles with degenerate native points; degenerate point angles and unavailable/inconsistent witnesses fail." + }, + "sketch_curve_edit": { + "type": "object", + "properties": { + "sketch": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Current guarded entity reference; root-space child geometry requires its exact occurrence context." + }, + "curve": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Current guarded entity reference; root-space child geometry requires its exact occurrence context." + }, + "action": { + "enum": [ + "trim", + "extend", + "split", + "break" + ] + }, + "pick": { + "type": "array", + "items": { + "type": "number" + }, + "minItems": 2, + "maxItems": 3 + }, + "createConstraints": { + "type": "boolean" + }, + "allowDelete": { + "type": "boolean" + } + }, + "required": [ + "op", + "target", + "sessionId", + "operationId", + "sketch", + "curve", + "action", + "pick" + ], + "description": "Edit one native sketch curve with sketch-space mm pick. trim removes the nearest segment; extend chooses nearest endpoint; split requires an interior on-curve point; break uses neighboring intersections. Fresh replacement refs are returned; original may be deleted. In-place extend returns the original with a fresh ref only after its geometry change survives recompute. No change or empty break/split fails. Full trim deletion requires allowDelete true. Linked/derived/deferred-compute geometry refused." + }, + "sketch_offset": { + "type": "object", + "properties": { + "sketch": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Current guarded entity reference; root-space child geometry requires its exact occurrence context." + }, + "curves": { + "type": "array", + "items": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Current guarded entity reference; root-space child geometry requires its exact occurrence context." + }, + "minItems": 1, + "maxItems": 256 + }, + "distance": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "topologyMatched": { + "type": "boolean" + } + }, + "required": [ + "op", + "target", + "sessionId", + "operationId", + "sketch", + "curves", + "distance" + ], + "description": "Modern createOffsetInput/addOffset2. Curves must be native 2D, unique, in connected flow order (never reordered). Nonzero signed mm number/expression: positive right, negative left; a single circle/ellipse positive outside. Topology matched defaults true. Read back native signed distance, parent/child refs and controlling parameter after recompute. No retired Sketch.offset fallback." + }, + "construction_axis": { + "type": "object", + "properties": { + "component": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Current guarded entity reference; root-space child geometry requires its exact occurrence context." + }, + "type": { + "enum": [ + "edge", + "circular_face", + "two_planes", + "two_points" + ] + }, + "entities": { + "type": "array", + "items": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Current guarded entity reference; root-space child geometry requires its exact occurrence context." + }, + "minItems": 1, + "maxItems": 2 + }, + "creationOccurrence": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Current guarded entity reference; root-space child geometry requires its exact occurrence context." + }, + "name": { + "type": "string" + } + }, + "required": [ + "op", + "target", + "sessionId", + "operationId", + "type", + "entities" + ], + "description": "Create a parametric axis from native references (edge/circular face/two intersecting planes/two distinct points). Root-space external geometry requires exact occurrence context. For cross-component geometry into a non-root target component, creationOccurrence must identify a root-context instance of that target. Returns component-space native axis geometry/health. No direct-only infinite-line or preview snap APIs." + }, + "sketch_diagnostics": { + "type": "object", + "properties": { + "sketch": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Current guarded entity reference; root-space child geometry requires its exact occurrence context." + } + }, + "required": [ + "op", + "target", + "sketch" + ], + "description": "Read native sketch health/message, fully-constrained/fixed/linked/reference flags, geometric constraints and driving dimensions. Reports no solver DOF count or conflict-set explanation; no recompute/mutation." + }, + "joint_origin_create": { + "type": "object", + "properties": { + "component": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 120 + }, + "geometry": { + "type": "object", + "properties": { + "entity": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context." + }, + "keyPoint": { + "enum": [ + "center", + "start", + "middle", + "end" + ] + }, + "edge": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context." + } + }, + "required": [ + "entity" + ], + "additionalProperties": false + }, + "flipped": { + "type": "boolean" + }, + "xAxisEntity": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context." + }, + "zAxisEntity": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context." + }, + "angle": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string", + "minLength": 1, + "maxLength": 512 + } + ], + "description": "Number in mm (angle in deg), or a dimensionally valid Fusion parameter expression." + }, + "offsetX": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string", + "minLength": 1, + "maxLength": 512 + } + ], + "description": "Number in mm (angle in deg), or a dimensionally valid Fusion parameter expression." + }, + "offsetY": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string", + "minLength": 1, + "maxLength": 512 + } + ], + "description": "Number in mm (angle in deg), or a dimensionally valid Fusion parameter expression." + }, + "offsetZ": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string", + "minLength": 1, + "maxLength": 512 + } + ], + "description": "Number in mm (angle in deg), or a dimensionally valid Fusion parameter expression." + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "geometry" + ], + "description": "Create a native component-definition joint origin from guarded native geometry, with mm offsets/deg angle and optional straight axis references. Child occurrence origin references are exposed by inspect_entity(occurrence)." + }, + "joint_origin_edit": { + "type": "object", + "properties": { + "origin": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 120 + }, + "geometry": { + "type": "object", + "properties": { + "entity": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context." + }, + "keyPoint": { + "enum": [ + "center", + "start", + "middle", + "end" + ] + }, + "edge": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context." + } + }, + "required": [ + "entity" + ], + "additionalProperties": false + }, + "flipped": { + "type": "boolean" + }, + "xAxisEntity": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context." + }, + "zAxisEntity": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context." + }, + "angle": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string", + "minLength": 1, + "maxLength": 512 + } + ], + "description": "Number in mm (angle in deg), or a dimensionally valid Fusion parameter expression." + }, + "offsetX": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string", + "minLength": 1, + "maxLength": 512 + } + ], + "description": "Number in mm (angle in deg), or a dimensionally valid Fusion parameter expression." + }, + "offsetY": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string", + "minLength": 1, + "maxLength": 512 + } + ], + "description": "Number in mm (angle in deg), or a dimensionally valid Fusion parameter expression." + }, + "offsetZ": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string", + "minLength": 1, + "maxLength": 512 + } + ], + "description": "Number in mm (angle in deg), or a dimensionally valid Fusion parameter expression." + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "origin" + ], + "description": "Edit a native joint origin definition; rolls before its timeline item and restores the original marker in finally. Recomputes and verifies attachment, axes, offsets, angle, name and flip." + }, + "joint_origin_delete": { + "type": "object", + "properties": { + "origin": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context." + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "origin" + ], + "description": "Delete a native joint origin; unhealthy downstream joints cause transaction rollback." + }, + "regular_joint_create": { + "type": "object", + "properties": { + "type": { + "enum": [ + "rigid", + "revolute", + "slider", + "cylindrical", + "pin_slot", + "planar", + "ball" + ] + }, + "axis": { + "enum": [ + "x", + "y", + "z" + ] + }, + "name": { + "type": "string" + }, + "rotationLimits": { + "type": "object", + "properties": { + "minimum": { + "type": "number" + }, + "maximum": { + "type": "number" + }, + "rest": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "maximumEnabled": { + "type": "boolean" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false, + "minProperties": 1 + }, + "slideLimits": { + "type": "object", + "properties": { + "minimum": { + "type": "number" + }, + "maximum": { + "type": "number" + }, + "rest": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "maximumEnabled": { + "type": "boolean" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false, + "minProperties": 1 + }, + "rotationValue": { + "type": "number" + }, + "slideValue": { + "type": "number" + }, + "primarySlideLimits": { + "type": "object", + "properties": { + "minimum": { + "type": "number" + }, + "maximum": { + "type": "number" + }, + "rest": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "maximumEnabled": { + "type": "boolean" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false, + "minProperties": 1 + }, + "primarySlideValue": { + "type": "number" + }, + "secondarySlideLimits": { + "type": "object", + "properties": { + "minimum": { + "type": "number" + }, + "maximum": { + "type": "number" + }, + "rest": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "maximumEnabled": { + "type": "boolean" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false, + "minProperties": 1 + }, + "secondarySlideValue": { + "type": "number" + }, + "pitchLimits": { + "type": "object", + "properties": { + "minimum": { + "type": "number" + }, + "maximum": { + "type": "number" + }, + "rest": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "maximumEnabled": { + "type": "boolean" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false, + "minProperties": 1 + }, + "pitchValue": { + "type": "number" + }, + "rollLimits": { + "type": "object", + "properties": { + "minimum": { + "type": "number" + }, + "maximum": { + "type": "number" + }, + "rest": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "maximumEnabled": { + "type": "boolean" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false, + "minProperties": 1 + }, + "rollValue": { + "type": "number" + }, + "yawLimits": { + "type": "object", + "properties": { + "minimum": { + "type": "number" + }, + "maximum": { + "type": "number" + }, + "rest": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "maximumEnabled": { + "type": "boolean" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false, + "minProperties": 1 + }, + "yawValue": { + "type": "number" + }, + "slideAxis": { + "enum": [ + "x", + "y", + "z" + ] + }, + "geometryOne": { + "oneOf": [ + { + "type": "object", + "properties": { + "entity": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context." + }, + "keyPoint": { + "enum": [ + "center", + "start", + "middle", + "end" + ] + }, + "edge": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context." + } + }, + "required": [ + "entity" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "origin": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context." + } + }, + "required": [ + "origin" + ], + "additionalProperties": false + } + ] + }, + "geometryTwo": { + "oneOf": [ + { + "type": "object", + "properties": { + "entity": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context." + }, + "keyPoint": { + "enum": [ + "center", + "start", + "middle", + "end" + ] + }, + "edge": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context." + } + }, + "required": [ + "entity" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "origin": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context." + } + }, + "required": [ + "origin" + ], + "additionalProperties": false + } + ] + }, + "angle": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string", + "minLength": 1, + "maxLength": 512 + } + ], + "description": "Number in mm (angle in deg), or a dimensionally valid Fusion parameter expression." + }, + "offset": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string", + "minLength": 1, + "maxLength": 512 + } + ], + "description": "Number in mm (angle in deg), or a dimensionally valid Fusion parameter expression." + }, + "flipped": { + "type": "boolean" + }, + "locked": { + "type": "boolean" + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "type", + "geometryOne", + "geometryTwo" + ], + "description": "Create a geometry-solving regular joint in the root component using two distinct instance-context attachments or joint origins. All seven native motion families; offsets mm, angle and angular limits deg. Read back freshly resolved attachment graph, native parameters and root-space occurrence placement. Root/non-root sides must be unambiguous; both grounded and ancestor/descendant pairs are refused. Inferred joints are unsupported. Existing pending positions must be captured or reverted first. Before applying drive values, checks native joint-transform anchor offset magnitude/axiality and zero-angle axis collinearity. Native joint matrices have explicit provenance; physical angle/flip sense requires independent occurrence-geometry acceptance." + }, + "regular_joint_edit": { + "type": "object", + "properties": { + "joint": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context." + }, + "geometryOne": { + "oneOf": [ + { + "type": "object", + "properties": { + "entity": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context." + }, + "keyPoint": { + "enum": [ + "center", + "start", + "middle", + "end" + ] + }, + "edge": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context." + } + }, + "required": [ + "entity" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "origin": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context." + } + }, + "required": [ + "origin" + ], + "additionalProperties": false + } + ] + }, + "geometryTwo": { + "oneOf": [ + { + "type": "object", + "properties": { + "entity": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context." + }, + "keyPoint": { + "enum": [ + "center", + "start", + "middle", + "end" + ] + }, + "edge": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context." + } + }, + "required": [ + "entity" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "origin": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context." + } + }, + "required": [ + "origin" + ], + "additionalProperties": false + } + ] + }, + "type": { + "enum": [ + "rigid", + "revolute", + "slider", + "cylindrical", + "pin_slot", + "planar", + "ball" + ] + }, + "axis": { + "enum": [ + "x", + "y", + "z" + ] + }, + "slideAxis": { + "enum": [ + "x", + "y", + "z" + ] + }, + "angle": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string", + "minLength": 1, + "maxLength": 512 + } + ], + "description": "Number in mm (angle in deg), or a dimensionally valid Fusion parameter expression." + }, + "offset": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string", + "minLength": 1, + "maxLength": 512 + } + ], + "description": "Number in mm (angle in deg), or a dimensionally valid Fusion parameter expression." + }, + "offsetX": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string", + "minLength": 1, + "maxLength": 512 + } + ], + "description": "Number in mm (angle in deg), or a dimensionally valid Fusion parameter expression." + }, + "offsetY": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string", + "minLength": 1, + "maxLength": 512 + } + ], + "description": "Number in mm (angle in deg), or a dimensionally valid Fusion parameter expression." + }, + "flipped": { + "type": "boolean" + }, + "locked": { + "type": "boolean" + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "joint" + ], + "description": "Redefine root-owned regular joint geometry, motion family, offsets, angle, flip or lock. Restore timeline in finally and verify after recompute. Use joint_edit for name, suppression, limits and drive values; joint_delete for deletion." + }, + "motion_link_create": { + "type": "object", + "properties": { + "jointOne": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context." + }, + "jointTwo": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 120 + }, + "motionOne": { + "enum": [ + "rotation", + "slide", + "primarySlide", + "secondarySlide", + "pitch", + "roll", + "yaw" + ] + }, + "motionTwo": { + "enum": [ + "rotation", + "slide", + "primarySlide", + "secondarySlide", + "pitch", + "roll", + "yaw" + ] + }, + "valueOne": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string", + "minLength": 1, + "maxLength": 512 + } + ], + "description": "Number in mm (angle in deg), or a dimensionally valid Fusion parameter expression." + }, + "valueTwo": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string", + "minLength": 1, + "maxLength": 512 + } + ], + "description": "Number in mm (angle in deg), or a dimensionally valid Fusion parameter expression." + }, + "reversed": { + "type": "boolean" + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "jointOne", + "motionOne", + "motionTwo", + "valueOne", + "valueTwo" + ], + "description": "Create a native motion link between two root-owned joints, or two different motions of jointOne when jointTwo is omitted. Positive ratio magnitudes use mm for translation and deg for rotation; reversed selects opposite direction. Requires the November 2025+ API; no script fallback." + }, + "motion_link_edit": { + "type": "object", + "properties": { + "link": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context." + }, + "suppressed": { + "type": "boolean" + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 120 + }, + "motionOne": { + "enum": [ + "rotation", + "slide", + "primarySlide", + "secondarySlide", + "pitch", + "roll", + "yaw" + ] + }, + "motionTwo": { + "enum": [ + "rotation", + "slide", + "primarySlide", + "secondarySlide", + "pitch", + "roll", + "yaw" + ] + }, + "valueOne": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string", + "minLength": 1, + "maxLength": 512 + } + ], + "description": "Number in mm (angle in deg), or a dimensionally valid Fusion parameter expression." + }, + "valueTwo": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string", + "minLength": 1, + "maxLength": 512 + } + ], + "description": "Number in mm (angle in deg), or a dimensionally valid Fusion parameter expression." + }, + "reversed": { + "type": "boolean" + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "link" + ], + "description": "Edit a root-owned motion link ratio, degree-of-freedom pair, direction, name or suppression. Joint membership is immutable; recreate the link to change members. Verify exact joint identities, ratio, flags and health." + }, + "motion_link_delete": { + "type": "object", + "properties": { + "link": { + "type": "object", + "required": [ + "documentId", + "revision", + "objectType" + ], + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context." + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "link" + ], + "description": "Delete a root-owned motion link under the same transaction/recovery contract." + }, + "configuration_inspect": { + "type": "object", + "description": "Read the loaded Design configuration graph without activation or recompute: ordinary rows, the separate native reserved test row, all supported typed cells including inactive/test cells, exact controlled-source links and current ordinary row. Unreadable fields and unknown/theme/hierarchical/inserted scopes remain explicit incomplete diagnostics. Configuration lifecycle and configured-design model mutations remain disabled pending native recovery/archive gates.", + "properties": {}, + "required": [ + "op", + "target" + ] + } +} diff --git a/addin/AdomBridge/commands/mechanical/sketch_edits.py b/addin/AdomBridge/commands/mechanical/sketch_edits.py new file mode 100644 index 0000000..f306cae --- /dev/null +++ b/addin/AdomBridge/commands/mechanical/sketch_edits.py @@ -0,0 +1,148 @@ +"""Sketch-space curve edits and signed, parameter-driven native offsets.""" +import json +import math + +from .core import items, parameter_data, ref, require, resolve, same, target +from .readback import curve_data, sketch_data +from .sketches import get_sketch, point, quantity +from .transactions import equivalent + + +CURVES = ("SketchLine", "SketchArc", "SketchCircle", "SketchEllipse", "SketchEllipticalArc", + "SketchFittedSpline", "SketchControlPointSpline", "SketchFixedSpline") + + +def curve_shapes(sketch_result): + # Constraint/flag changes or collection reordering alone do not prove that + # trim/extend/split/break changed curve geometry. + return sorted(({key: data for key, data in curve.items() if key not in ("fixed", "construction", "ref")} + for curve in sketch_result["curves"]), key=lambda row: json.dumps(row, sort_keys=True)) + + +def editable_curve(design, value, reference, sketch): + curve = resolve(design, value, reference, CURVES) + require(getattr(curve, "assemblyContext", None) is None and same(curve.parentSketch, sketch), + "wrong_sketch", "Curve edits require native references in this sketch definition.") + require(not curve.isLinked and not curve.isReference, "linked_geometry", "Linked/reference geometry must be explicitly unlinked before curve editing.") + return curve + + +def sketch_curve_edit(design, value, args): + require("allowDelete" not in args or args["action"] == "trim", "invalid_input", "allowDelete applies only to trim.") + sketch = get_sketch(design, value, args) + require(not sketch.isDerived and not sketch.isComputeDeferred, "sketch_not_editable", "Derived or deferred-compute sketches cannot be edited.") + curve = editable_curve(design, value, args["curve"], sketch) + before = sketch_data(sketch) + selected_before = curve_shapes({"curves": [curve_data(curve)]}) + pick = point(args["pick"]) + if args["action"] == "split": + ok, parameter = curve.geometry.evaluator.getParameterAtPoint(pick) + require(ok, "pick_off_curve", "Split point must lie on the selected curve in sketch coordinates.") + ok, actual = curve.geometry.evaluator.getPointAtParameter(parameter) + require(ok and actual.distanceTo(pick) <= 1e-7, "pick_off_curve", "Split point must lie on the curve.") + if hasattr(curve, "startSketchPoint") and hasattr(curve, "endSketchPoint"): + require(min(pick.distanceTo(curve.startSketchPoint.geometry), pick.distanceTo(curve.endSketchPoint.geometry)) > 1e-7, + "invalid_pick", "Split point must be inside the curve, not an endpoint.") + if args["action"] == "extend" and hasattr(curve, "startSketchPoint") and hasattr(curve, "endSketchPoint"): + require(abs(pick.distanceTo(curve.startSketchPoint.geometry) - pick.distanceTo(curve.endSketchPoint.geometry)) > 1e-7, + "ambiguous_pick", "Extension pick is equally near both endpoints.") + method = "breakCurve" if args["action"] == "break" else args["action"] + returned = getattr(curve, method)(pick, args.get("createConstraints", True)) + require(returned is not None, "curve_edit_failed", "Fusion returned no curve edit result.") + replacements = items(returned) + deleted = not curve.isValid + # Native extend can mutate the original in place and return an empty + # collection. The post-compute selected-geometry checks below must still + # prove the edit; this merely supplies its fresh reference on success. + if args["action"] == "extend" and not replacements and not deleted: + replacements = [curve] + require(replacements or args["action"] == "trim" and deleted and args.get("allowDelete", False), + "curve_edit_empty", "Edit returned no replacement curves; full trim deletion requires allowDelete:true.") + def result(fresh): + require(not equivalent(curve_shapes(before), curve_shapes(sketch_data(sketch))), "curve_edit_unchanged", "Curve edit made no persistent curve geometry change.") + require((not curve.isValid) == deleted, "curve_edit_failed", "Original curve deletion state changed during recompute.") + require(deleted or not equivalent(selected_before, curve_shapes({"curves": [curve_data(curve)]})), + "curve_edit_unchanged", "Selected curve did not change; unrelated sketch changes cannot prove this edit.") + require(all(item.isValid and same(item.parentSketch, sketch) for item in replacements), + "curve_edit_failed", "Replacement curve disappeared after recompute.") + return {"action": args["action"], "space": "sketch", "originalDeleted": deleted, + "curves": [curve_data(item, fresh) for item in replacements], "sketch": sketch_data(sketch, fresh)} + return result + + +def ordered_chain(curves): + """Validate supplied order; never reorder and silently change offset sign.""" + require(all(curve.is2D for curve in curves), "unsupported_geometry", "Offset requires curves in the sketch XY plane.") + for index, curve in enumerate(curves): + require(not any(same(curve, prior) for prior in curves[:index]), "duplicate_entity", "Offset input repeats a curve.") + if len(curves) == 1: return + ends = [] + for curve in curves: + require(hasattr(curve, "startSketchPoint") and hasattr(curve, "endSketchPoint"), + "disconnected_chain", "Closed standalone curves cannot be mixed into an offset chain.") + ends.append([curve.startSketchPoint.geometry, curve.endSketchPoint.geometry]) + close = lambda a, b: a.distanceTo(b) <= 1e-7 + shared = [(i, j) for i in (0, 1) for j in (0, 1) if close(ends[0][i], ends[1][j])] + require(len(shared) == 1, "ambiguous_chain", "First two curves must share exactly one endpoint.") + previous = ends[1][1-shared[0][1]] + for pair in ends[2:]: + match = [index for index, endpoint in enumerate(pair) if close(previous, endpoint)] + require(len(match) == 1, "disconnected_chain", "Curves must be supplied in connected flow order.") + previous = pair[1-match[0]] + + +def sketch_offset(design, value, args): + sketch = get_sketch(design, value, args) + require(not sketch.isDerived and not sketch.isComputeDeferred, "sketch_not_editable", "Derived or deferred-compute sketches cannot be edited.") + curves = [editable_curve(design, value, item, sketch) for item in args["curves"]] + ordered_chain(curves) + offset = quantity(design, args["distance"]) + expression = offset.stringValue + expected = design.unitsManager.evaluateExpression(expression, "mm") + require(abs(expected) > 1e-8, "invalid_quantity", "Offset distance must be nonzero.") + constraints = sketch.geometricConstraints + require(hasattr(constraints, "createOffsetInput") and hasattr(constraints, "addOffset2"), + "unsupported_capability", "This Fusion runtime lacks the modern signed offset API.") + inp = constraints.createOffsetInput(curves, offset) + require(inp is not None, "offset_failed", "Fusion rejected the offset inputs.") + matched = args.get("topologyMatched", True) + inp.isTopologyMatched = matched + constraint = constraints.addOffset2(inp) + require(constraint is not None and constraint.isValid, "offset_failed", "Fusion did not create an offset constraint.") + def result(fresh): + require(constraint.isValid and constraint.dimension is not None, "offset_failed", "Offset or its controlling dimension disappeared.") + children = list(constraint.childCurves) + require(children and all(child.isValid for child in children), "offset_failed", "Offset has no valid child curves.") + require(constraint.isTopologyMatched == matched, "offset_failed", "Native offset topology setting differs from the request.") + require(math.isclose(constraint.distance, expected, abs_tol=1e-8, rel_tol=1e-8), + "offset_failed", "Native signed offset distance differs from the requested value.") + parents = list(constraint.parentCurves) + require(len(parents) == len(curves) and all(any(same(parent, curve) for parent in parents) for curve in curves), + "offset_failed", "Native offset parents differ from the selected chain.") + return {"space": "sketch", "offset": {"ref": ref(constraint, fresh), "distance": constraint.distance * 10, + "unit": "mm", "requestedExpression": expression, "topologyMatched": constraint.isTopologyMatched, + "parameter": parameter_data(design, constraint.dimension.parameter), + "parentRefs": [ref(curve, fresh) for curve in curves], + "curves": [curve_data(child, fresh) for child in children]}, "sketch": sketch_data(sketch, fresh)} + return result + + +def sketch_diagnostics(app, args): + _, design, value = target(app, args) + sketch = resolve(design, value, args["sketch"], ("Sketch",)) + entities = items(sketch.sketchCurves) + items(sketch.sketchPoints) + return {"target": value, "sketch": ref(sketch, value), "fullyConstrained": sketch.isFullyConstrained, + "healthState": int(sketch.healthState), "message": sketch.errorOrWarningMessage, + "computeDeferred": sketch.isComputeDeferred, + "entities": [{"ref": ref(entity, value), "fullyConstrained": entity.isFullyConstrained, + "fixed": entity.isFixed, "linked": entity.isLinked, "reference": entity.isReference} + for entity in entities], + "constraints": [{"ref": ref(item, value), "type": item.objectType, "valid": item.isValid} + for item in items(sketch.geometricConstraints)], + "dimensions": [{"name": item.parameter.name, "expression": item.parameter.expression, + "driving": item.isDriving} for item in items(sketch.sketchDimensions)], + "diagnosticScope": "Native health and constraint flags only; no solver degrees-of-freedom or conflict-set explanation."} + + +OPERATIONS = {"sketch_curve_edit": sketch_curve_edit, "sketch_offset": sketch_offset} +READS = {"sketch_diagnostics": sketch_diagnostics} diff --git a/addin/AdomBridge/commands/mechanical/sketches.py b/addin/AdomBridge/commands/mechanical/sketches.py new file mode 100644 index 0000000..58bbecb --- /dev/null +++ b/addin/AdomBridge/commands/mechanical/sketches.py @@ -0,0 +1,234 @@ +"""Typed sketches, dimensions, constraints and construction planes (mm/deg).""" +import math + +import adsk.core +import adsk.fusion + +from .core import items, number, ref, require, resolve, same +from .readback import sketch_data + + +def collection(entities): + result = adsk.core.ObjectCollection.create() + for entity in entities: + result.add(entity) + return result + + +def point(value): + require(isinstance(value, list) and len(value) in (2, 3), "invalid_input", "Point must be [x,y] or [x,y,z] in mm.") + coords = [number(v, "coordinate") / 10 for v in value] + return adsk.core.Point3D.create(*(coords + [0] if len(coords) == 2 else coords)) + + +def quantity(design, value, unit="mm", positive=False): + if isinstance(value, (int, float)) and not isinstance(value, bool): + expression = str(number(value, "quantity", positive)) + (" " + unit if unit else "") + else: + require(isinstance(value, str) and value.strip(), "invalid_input", "Quantity must be a number or unit-bearing expression.") + expression = value + require(design.unitsManager.isValidExpression(expression, unit), "invalid_expression", "Quantity does not match " + (unit or "unitless") + ".") + evaluated = design.unitsManager.evaluateExpression(expression, unit) + require(math.isfinite(evaluated) and (not positive or evaluated > 0), "invalid_quantity", "Quantity must be finite" + (" and positive." if positive else ".")) + return adsk.core.ValueInput.createByString(expression) + + +def component(design, value, args): + if args.get("component"): + return resolve(design, value, args["component"], ("Component",)) + return design.rootComponent + + +def plane(design, value, comp, selector): + if isinstance(selector, str): + require(selector in ("xy", "xz", "yz"), "invalid_plane", "Plane must be xy, xz, yz or a guarded reference.") + return getattr(comp, {"xy": "xYConstructionPlane", "xz": "xZConstructionPlane", "yz": "yZConstructionPlane"}[selector]) + entity = resolve(design, value, selector, ("ConstructionPlane", "BRepFace")) + owner = entity.body.parentComponent if hasattr(entity, "body") else entity.component + require(getattr(entity, "assemblyContext", None) is None and same(owner, comp), "wrong_component", "Plane reference must belong to this native component definition.") + return entity + + +def axis(design, value, comp, selector): + if isinstance(selector, str): + require(selector in ("x", "y", "z"), "invalid_axis", "Axis must be x, y, z or a guarded reference.") + return getattr(comp, selector + "ConstructionAxis") + entity = resolve(design, value, selector, ("ConstructionAxis", "SketchLine", "BRepEdge")) + owner = entity.body.parentComponent if hasattr(entity, "body") else (entity.parentSketch.parentComponent if hasattr(entity, "parentSketch") else entity.component) + require(getattr(entity, "assemblyContext", None) is None and same(owner, comp), "wrong_component", "Axis reference must belong to this native component definition.") + return entity + + +def named(entity, args): + require(entity is not None and entity.isValid, "creation_failed", "Fusion did not return a valid entity.") + if args.get("name"): + entity.name = args["name"] + return entity + + +def get_sketch(design, value, args): + sketch = resolve(design, value, args["sketch"], ("Sketch",)) + require(getattr(sketch, "assemblyContext", None) is None, "definition_reference_required", "Sketch edits require a native definition reference.") + return sketch + + +def sketch_create(design, value, args): + comp = component(design, value, args) + support = plane(design, value, comp, args.get("plane", "xy")) + sketch = named(comp.sketches.add(support), args) + return lambda fresh: {"sketch": sketch_data(sketch, fresh)} + + +def sketch_geometry(design, value, args): + sketch = get_sketch(design, value, args) + curves = sketch.sketchCurves + for item in args["items"]: + kind = item["type"] + created = [] + if kind == "point": + created = [sketch.sketchPoints.add(point(item["position"]))] + elif kind == "line": + created = [curves.sketchLines.addByTwoPoints(point(item["start"]), point(item["end"]))] + elif kind == "rectangle": + created = items(curves.sketchLines.addTwoPointRectangle(point(item["start"]), point(item["end"]))) + elif kind == "circle": + radius = number(item["radius"], "radius", True) / 10 + created = [curves.sketchCircles.addByCenterRadius(point(item["center"]), radius)] + elif kind == "arc": + sweep = math.radians(number(item["sweep"], "sweep")) + require(0 < abs(sweep) < 2 * math.pi, "invalid_input", "Arc sweep magnitude must be between 0 and 360 deg.") + created = [curves.sketchArcs.addByCenterStartSweep(point(item["center"]), point(item["start"]), sweep)] + elif kind == "polygon": + center = point(item["center"]) + count = item["sides"] + require(isinstance(count, int) and not isinstance(count, bool) and 3 <= count <= 128, "invalid_input", "Polygon sides must be 3..128.") + radius = number(item["radius"], "radius", True) / 10 + angle = math.radians(number(item.get("angle", 0), "angle")) + vertices = [adsk.core.Point3D.create(center.x + radius * math.cos(angle + 2 * math.pi * i / count), + center.y + radius * math.sin(angle + 2 * math.pi * i / count), center.z) for i in range(count)] + created = [curves.sketchLines.addByTwoPoints(vertices[i], vertices[(i + 1) % count]) for i in range(count)] + elif kind == "slot": + start, end = point(item["start"]), point(item["end"]) + require(abs(start.z - end.z) < 1e-9, "invalid_input", "Slot center points must share their sketch-space Z coordinate.") + radius = number(item["width"], "width", True) / 20 + dx, dy = end.x - start.x, end.y - start.y + length = math.hypot(dx, dy) + require(length > 1e-8, "invalid_input", "Slot center points must differ.") + ox, oy = -dy * radius / length, dx * radius / length + p = lambda center, sign: adsk.core.Point3D.create(center.x + sign * ox, center.y + sign * oy, center.z) + a, b, cc, d = p(start, 1), p(end, 1), p(end, -1), p(start, -1) + created = [curves.sketchLines.addByTwoPoints(a, b), + curves.sketchArcs.addByCenterStartSweep(end, b, -math.pi), + curves.sketchLines.addByTwoPoints(cc, d), + curves.sketchArcs.addByCenterStartSweep(start, d, -math.pi)] + elif kind == "spline": + spline = curves.sketchFittedSplines.add(collection([point(p) for p in item["points"]])) + if item.get("closed"): + spline.isClosed = True + created = [spline] + else: + require(False, "unsupported_geometry", "Unsupported sketch primitive.") + for entity in created: + require(entity and entity.isValid, "creation_failed", "Sketch primitive creation failed.") + if "construction" in item: + require(hasattr(entity, "isConstruction"), "invalid_input", "Construction applies to curves.") + entity.isConstruction = item["construction"] + return lambda fresh: {"sketch": sketch_data(sketch, fresh)} + + +def sketch_entities(design, value, args, sketch): + entities = [resolve(design, value, entity) for entity in args["entities"]] + for entity in entities: + require(hasattr(entity, "parentSketch") and same(entity.parentSketch, sketch), "wrong_sketch", "Every selected entity must belong to the specified sketch.") + return entities + + +def sketch_constraint(design, value, args): + sketch = get_sketch(design, value, args) + entities = sketch_entities(design, value, args, sketch) + methods = {"horizontal": ("addHorizontal", 1), "vertical": ("addVertical", 1), + "coincident": ("addCoincident", 2), "parallel": ("addParallel", 2), + "perpendicular": ("addPerpendicular", 2), "tangent": ("addTangent", 2), + "equal": ("addEqual", 2), "concentric": ("addConcentric", 2), + "collinear": ("addCollinear", 2), "midpoint": ("addMidPoint", 2), + "symmetry": ("addSymmetry", 3)} + if args["type"] == "fix": + require(len(entities) == 1, "invalid_input", "fix requires one entity.") + entities[0].isFixed = True + else: + method, count = methods[args["type"]] + require(len(entities) == count, "invalid_input", "Constraint requires " + str(count) + " entities.") + result = getattr(sketch.geometricConstraints, method)(*entities) + require(result is not None, "constraint_failed", "Fusion did not create the constraint.") + return lambda fresh: {"sketch": sketch_data(sketch, fresh)} + + +def sketch_dimension(design, value, args): + sketch = get_sketch(design, value, args) + entities = sketch_entities(design, value, args, sketch) + position, kind = point(args["position"]), args["type"] + dims = sketch.sketchDimensions + if kind in ("distance", "horizontal", "vertical"): + require(len(entities) == 2, "invalid_input", "Distance dimension requires two sketch points.") + orientation = getattr(adsk.fusion.DimensionOrientations, {"distance": "AlignedDimensionOrientation", "horizontal": "HorizontalDimensionOrientation", "vertical": "VerticalDimensionOrientation"}[kind]) + dimension = dims.addDistanceDimension(entities[0], entities[1], orientation, position) + elif kind == "angle": + require(len(entities) == 2, "invalid_input", "Angle dimension requires two lines.") + dimension = dims.addAngularDimension(entities[0], entities[1], position) + else: + require(len(entities) == 1, "invalid_input", "Radius/diameter dimension requires one circle or arc.") + dimension = getattr(dims, "addRadialDimension" if kind == "radius" else "addDiameterDimension")(entities[0], position) + require(dimension is not None, "dimension_failed", "Fusion did not create the dimension.") + expression = args["expression"] + quantity(design, expression, "deg" if kind == "angle" else "mm") + dimension.parameter.expression = str(expression) + (" deg" if kind == "angle" else " mm") if isinstance(expression, (int, float)) else expression + if args.get("parameterName"): + dimension.parameter.name = args["parameterName"] + return lambda fresh: {"sketch": sketch_data(sketch, fresh), "parameterName": dimension.parameter.name} + + +def sketch_project(design, value, args): + sketch = get_sketch(design, value, args) + projected = [] + for reference in args["entities"]: + entity = resolve(design, value, reference, ("BRepEdge", "BRepFace", "BRepVertex", "SketchPoint", "SketchLine", "SketchCircle", "SketchArc")) + projected.extend(items(sketch.project(entity))) + require(projected, "projection_failed", "No sketch entities were projected.") + return lambda fresh: {"sketch": sketch_data(sketch, fresh), "projected": [ref(e, fresh) for e in projected]} + + +def sketch_edit(design, value, args): + sketch = get_sketch(design, value, args) + entity = resolve(design, value, args["entity"]) + require(same(entity.parentSketch, sketch), "wrong_sketch", "Entity is not in this sketch.") + changes = args["changes"] + for key, val in changes.items(): + if key in ("construction", "fixed"): + setattr(entity, "isConstruction" if key == "construction" else "isFixed", val) + elif key == "radius": + entity.radius = number(val, "radius", True) / 10 + elif key in ("position", "start", "end", "center"): + handle = entity if key == "position" else getattr(entity, {"start": "startSketchPoint", "end": "endSketchPoint", "center": "centerSketchPoint"}[key]) + wanted, old = point(val), handle.geometry + require(handle.move(adsk.core.Vector3D.create(wanted.x - old.x, wanted.y - old.y, wanted.z - old.z)), "edit_failed", "Sketch point move was refused.") + else: + require(False, "invalid_input", "Unknown sketch edit property.") + return lambda fresh: {"sketch": sketch_data(sketch, fresh)} + + +def plane_create(design, value, args): + comp = component(design, value, args) + base = plane(design, value, comp, args.get("plane", "xy")) + inp = comp.constructionPlanes.createInput() + if args.get("type", "offset") == "offset": + require(inp.setByOffset(base, quantity(design, args["offset"])), "plane_failed", "Offset plane input failed.") + else: + rotation = axis(design, value, comp, args["axis"]) + require(inp.setByAngle(rotation, quantity(design, args["angle"], "deg"), base), "plane_failed", "Angled plane input failed.") + created = named(comp.constructionPlanes.add(inp), args) + return lambda fresh: {"plane": {"ref": ref(created, fresh), "name": created.name}} + + +OPERATIONS = {"sketch_create": sketch_create, "sketch_geometry": sketch_geometry, + "sketch_constraint": sketch_constraint, "sketch_dimension": sketch_dimension, + "sketch_project": sketch_project, "sketch_edit": sketch_edit, "plane_create": plane_create} diff --git a/addin/AdomBridge/commands/mechanical/transactions.py b/addin/AdomBridge/commands/mechanical/transactions.py new file mode 100644 index 0000000..adc5c3f --- /dev/null +++ b/addin/AdomBridge/commands/mechanical/transactions.py @@ -0,0 +1,144 @@ +"""Verified Fusion PTransaction adapter; excludes documents and external files. + +Autodesk's FusionMCPSample uses these text commands. Availability alone is not +proof: this adapter is enabled only after a session probe demonstrates both abort +and commit effects, followed by semantic restoration. Commands fail closed. +""" +import json +import math +import uuid + +import adsk.core + +from .core import (MechanicalError, active, current_target, recovery_snapshot, + require, require_healthy, same, target) + +_verified = False +_probe_evidence = None + + +def capability(): + return {"verified": _verified, "backend": "PTransaction", "evidence": _probe_evidence, + "scope": "one active Fusion Design; no document lifecycle, files or cloud effects"} + + +def command(app, text): + response = str(app.executeTextCommand(text)).strip() + require(response == "1", "transaction_unavailable", "Fusion did not acknowledge transaction command.", + command=text.split()[0], response=response) + + +def equivalent(left, right): + if isinstance(left, float) and isinstance(right, (float, int)): + return math.isclose(left, right, rel_tol=1e-8, abs_tol=1e-8) + if isinstance(left, dict) and isinstance(right, dict): + return left.keys() == right.keys() and all(equivalent(left[k], right[k]) for k in left) + if isinstance(left, list) and isinstance(right, list): + return len(left) == len(right) and all(equivalent(a, b) for a, b in zip(left, right)) + return left == right + + +def probe(app, args): + global _verified, _probe_evidence + doc, design, _ = target(app, args, True) + require_healthy(design, False) + before = recovery_snapshot(design) + name = "AdomProbe" + uuid.uuid4().hex + started = False + try: + command(app, 'PTransaction.Start "Adom recovery capability probe"') + started = True + parameter = design.userParameters.add(name, adsk.core.ValueInput.createByString("1 mm"), "mm", "temporary recovery probe") + require(parameter is not None, "transaction_probe_failed", "Probe parameter creation failed.") + command(app, "PTransaction.Abort") + started = False + require(design.userParameters.itemByName(name) is None, "transaction_probe_failed", "Abort did not remove the probe parameter.") + command(app, 'PTransaction.Start "Adom commit capability probe"') + started = True + design.userParameters.add(name, adsk.core.ValueInput.createByString("2 mm"), "mm", "temporary recovery probe") + command(app, "PTransaction.Commit") + started = False + parameter = design.userParameters.itemByName(name) + require(parameter is not None and abs(parameter.value - 0.2) < 1e-9, "transaction_probe_failed", "Commit did not preserve the probe parameter.") + require(bool(parameter.deleteMe()), "transaction_probe_failed", "Could not remove committed probe parameter.") + require_healthy(design) + require(equivalent(before, recovery_snapshot(design)), "transaction_probe_failed", "Probe did not restore the semantic witness.") + _verified = True + _probe_evidence = {"abortRemovedParameter": True, "commitPreservedParameter": True, + "semanticRestoration": True, "applicationVersion": str(getattr(app, "version", "unknown"))} + return {"target": current_target(app), "transaction": capability()} + except Exception as exc: + _verified = False + recovery = {"verified": False, "strategy": "abort_and_remove_probe_parameter"} + try: + require(same(app.activeDocument, doc), "wrong_document", "Active document changed during probe.") + if started: + command(app, "PTransaction.Abort") + parameter = design.userParameters.itemByName(name) + if parameter: + require(parameter.deleteMe(), "recovery_failed", "Probe parameter deletion failed.") + require_healthy(design) + recovery["verified"] = equivalent(before, recovery_snapshot(design)) + except Exception as restore_error: + recovery["error"] = str(restore_error) + raise MechanicalError("transaction_probe_failed", str(exc), recovery=recovery) + + +def _retarget(value, fresh): + if isinstance(value, dict): + if "documentId" in value and "revision" in value: + value.update(fresh) + for nested in value.values(): + _retarget(nested, fresh) + elif isinstance(value, list): + for nested in value: + _retarget(nested, fresh) + + +def execute(app, args, operation): + require(_verified, "transaction_not_verified", "Run transaction_probe in this add-in session before typed feature mutations.") + doc, design, value = target(app, args, True) + require(design.timeline.markerPosition == design.timeline.count, "timeline_not_at_end", "Finish editing history before a typed model mutation.") + require_healthy(design, False) + before = recovery_snapshot(design) + started, committed = False, False + try: + command(app, 'PTransaction.Start "Adom mechanical operation"') + started = True + serialize = operation(design, value, args) + require(same(app.activeDocument, doc), "wrong_document", "Active document changed during the operation.") + state = require_healthy(design) + provisional = current_target(app) + result = serialize(provisional) if callable(serialize) else serialize + result.update(target=provisional, health=state, transactional=True) + json.dumps(result, allow_nan=False) # Serialization is validated before commit. + command(app, "PTransaction.Commit") + committed = True + fresh = current_target(app) + _retarget(result, fresh) + result["transaction"] = {"committed": True, "backend": "PTransaction"} + return result + except Exception as exc: + if committed: + # Readback after commit can fail, but the model has already changed. + return {"transaction": {"committed": True, "backend": "PTransaction"}, + "inspectionError": str(exc), "refreshRequired": True} + recovery = {"strategy": "PTransaction.Abort", "verified": False, + "witness": "complete supported configuration graph including inactive cells, parameters, feature state, sketch geometry/constraint graph and offset links, construction axes, body geometry metrics, component metadata/materials, occurrence transforms/grounding, joint attachments/origins/motion/limits, motion links and timeline marker"} + try: + require(same(app.activeDocument, doc), "wrong_document", "Refusing to abort another document's transaction.") + if started: + command(app, "PTransaction.Abort") + require_healthy(design) + recovery["verified"] = equivalent(before, recovery_snapshot(design)) + except Exception as restore_error: + recovery["error"] = str(restore_error) + details = {"recovery": recovery, "causeCode": exc.code if isinstance(exc, MechanicalError) else + ("unsupported_capability" if isinstance(exc, AttributeError) else "kernel_error")} + if isinstance(exc, MechanicalError) and exc.details: + details["causeDetails"] = exc.details + try: + details["target"] = current_target(app) + except Exception: + details["refreshRequired"] = True + raise MechanicalError("mutation_failed", str(exc), **details) diff --git a/addin/AdomBridge/commands/physical_properties.py b/addin/AdomBridge/commands/physical_properties.py index a83ef57..35678b4 100644 --- a/addin/AdomBridge/commands/physical_properties.py +++ b/addin/AdomBridge/commands/physical_properties.py @@ -1,64 +1,52 @@ -"""Per-component physical properties (volume, mass, density, area, center of mass). - -Fusion exposes physical properties only through the modeling API, so before this verb a -caller had to run getPhysicalProperties inside fusion_run_modeling_script. This verb reads -them directly, per component, so BOM/costing tools can price by volume or mass without a -custom script. -""" - +"""Definition-scoped mass properties, including root-owned bodies.""" import adsk.core import adsk.fusion - -_ACC = { - "low": adsk.fusion.CalculationAccuracy.LowCalculationAccuracy, - "medium": adsk.fusion.CalculationAccuracy.MediumCalculationAccuracy, - "high": adsk.fusion.CalculationAccuracy.HighCalculationAccuracy, - "veryhigh": adsk.fusion.CalculationAccuracy.VeryHighCalculationAccuracy, -} +from .mechanical.core import identity, items + +_ACC = {name: getattr(adsk.fusion.CalculationAccuracy, api) for name, api in ( + ("low", "LowCalculationAccuracy"), ("medium", "MediumCalculationAccuracy"), + ("high", "HighCalculationAccuracy"), ("veryhigh", "VeryHighCalculationAccuracy"))} + + +def own_properties(comp, accuracy): + area = volume = mass = 0.0 + weighted = [0.0, 0.0, 0.0] + for body in items(comp.bRepBodies): + pp = body.getPhysicalProperties(accuracy) + area += pp.area + volume += pp.volume + mass += pp.mass + for i, value in enumerate((pp.centerOfMass.x, pp.centerOfMass.y, pp.centerOfMass.z)): + weighted[i] += value * pp.mass + return {"volume_cm3": volume, "mass_kg": mass, "area_cm2": area, + "density": mass / volume if volume else None, "densityUnit": "kg/cm3", + "center_of_mass": [v / mass for v in weighted] if mass else None, + "centerOfMassUnit": "cm", "coordinateSpace": "component", + "scope": "direct_component_bodies", "bodies": comp.bRepBodies.count} def handle_physical_properties(app: adsk.core.Application, args: dict) -> dict: - """Return physical properties per unique component in the active design. - - args: - names optional list of component names to measure (default: all). - accuracy optional "low" | "medium" | "high" | "veryhigh" (default: low). - """ design = adsk.fusion.Design.cast(app.activeProduct) if not design: - return {"success": False, "error": "No active Fusion Design.", - "_hint": "Open a design in the Design workspace, then retry."} - + return {"success": False, "error": "No active Fusion Design."} args = args or {} + accuracy = args.get("accuracy", "low").lower() + if accuracy not in _ACC: + return {"success": False, "errorCode": "invalid_accuracy", "error": "accuracy must be low, medium, high or veryhigh."} names = set(args.get("names") or []) - acc = _ACC.get((args.get("accuracy") or "low").lower(), - adsk.fusion.CalculationAccuracy.LowCalculationAccuracy) - - out = {} - seen = set() - for occ in design.rootComponent.allOccurrences: - comp = occ.component - if comp.name in seen: - continue + rows, errors = [], [] + for comp in items(design.allComponents): if names and comp.name not in names: continue - seen.add(comp.name) try: - pp = comp.getPhysicalProperties(acc) - entry = { - "volume_cm3": round(pp.volume, 4), - "mass_kg": round(pp.mass, 6), - "density": round(pp.density, 6), - "area_cm2": round(pp.area, 4), - } - try: - com = pp.centerOfMass - entry["center_of_mass"] = [round(com.x, 4), round(com.y, 4), round(com.z, 4)] - except Exception: - pass - out[comp.name] = entry - except Exception as e: - out[comp.name] = {"error": str(e)[:80]} - - return {"success": True, "count": len(out), "accuracy": (args.get("accuracy") or "low"), - "properties": out} + rows.append({"componentId": identity(comp), "componentName": comp.name, + **own_properties(comp, _ACC[accuracy])}) + except Exception as exc: + errors.append({"componentName": comp.name, "error": str(exc)}) + counts = {} + for row in rows: + counts[row["componentName"]] = counts.get(row["componentName"], 0) + 1 + properties = {(r["componentName"] if counts[r["componentName"]] == 1 else + r["componentName"] + "#" + r["componentId"]): r for r in rows} + return {"success": not errors, "complete": not errors, "count": len(rows), + "accuracy": accuracy, "properties": properties, "components": rows, "errors": errors} diff --git a/addin/AdomBridge/http_server.py b/addin/AdomBridge/http_server.py index ed3367b..26debe9 100644 --- a/addin/AdomBridge/http_server.py +++ b/addin/AdomBridge/http_server.py @@ -20,7 +20,10 @@ Threading model: """ import json +import io +import math import os +import socket import threading import time import uuid @@ -31,6 +34,10 @@ import adsk.core from .commands.cloud_documents import get_walk_progress PORT = 8774 +MAX_REQUEST_BYTES = 16 * 1024 * 1024 +MAX_COMMAND_TIMEOUT = 3600 +REQUEST_READ_TIMEOUT = 30 +RESPONSE_WRITE_TIMEOUT = 30 # Add-in version — read from the sibling manifest so /health + /status can # REPORT it. The bridge compares this against the version it bundles to catch a @@ -51,6 +58,27 @@ _server = None _server_thread = None _custom_event_id = None _app = None +_lifecycle_lock = threading.RLock() + + +class ListenerContext: + """Per-listener dispatch identity; accepted sockets never borrow a successor.""" + def __init__(self, app, event_id): + self.app = app + self.event_id = event_id + self.stopped = threading.Event() + + +class ExclusiveHTTPServer(ThreadingHTTPServer): + """One listening copy, including on Windows where REUSEADDR permits hijack.""" + allow_reuse_address = False + allow_reuse_port = False + daemon_threads = True + + def server_bind(self): + if os.name == "nt": + self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1) + super().server_bind() # Thread-safe result passing: request_id -> (Event, result_dict) _result_events = {} @@ -73,16 +101,19 @@ _busy_state_lock = threading.Lock() _busy_state = None # None or {"command": str, "started_at": float, "request_id": str} -def _set_busy(command: str, request_id: str): +def _set_busy(command: str, request_id: str, timeout=None): with _busy_state_lock: global _busy_state - _busy_state = {"command": command, "started_at": time.time(), "request_id": request_id} + _busy_state = {"command": command, "started_at": time.time(), + "started_monotonic": time.monotonic(), "request_id": request_id, + "timeout": timeout if timeout is not None else PER_COMMAND_TIMEOUT.get(command, COMMAND_TIMEOUT)} -def _clear_busy(): +def _clear_busy(request_id=None): with _busy_state_lock: global _busy_state - _busy_state = None + if request_id is None or (_busy_state and _busy_state["request_id"] == request_id): + _busy_state = None def _get_busy() -> dict | None: @@ -90,28 +121,12 @@ def _get_busy() -> dict | None: return dict(_busy_state) if _busy_state else None -# ── Stale-lock watchdog (v1.0.2) ────────────────────────────────────── -# -# Failure mode being defended against: -# Fusion's native cloud SDK can wedge on a slow / failed cloud API call -# while holding the Python GIL. While wedged, Python code in the addin -# does NOT run — including the per-command timeout in _wait_for_result. -# The HTTP thread eventually times out (after `timeout` seconds) and -# returns to the client, but the `finally` block can't run because the -# thread is blocked at the C++/Fusion boundary. Net result: -# - _main_thread_lock stays held forever -# - _busy_state stays populated forever -# - Every new POST /command request blocks on _main_thread_lock.acquire() -# for its `timeout` seconds, then returns main_thread_busy -# - From the user's perspective, the addin is dead until Fusion restarts -# -# Defense: a daemon thread checks _busy_state every 5s. If a busy state -# has been active longer than the per-command timeout + WATCHDOG_GRACE, -# we forcibly release _main_thread_lock so new requests can proceed. -# We can't unwedge the previously-stuck command (it's blocked in C++), -# but at least subsequent calls work without restarting Fusion. - -WATCHDOG_GRACE = 30 # seconds beyond per-command timeout before force-release +# The watchdog reports a stalled owner; it must never release another +# request's lock or erase its pending state. HTTP waits already have bounded +# deadlines. A native call monopolizing the GIL also prevents this Python +# watchdog from running, so forcibly unlocking cannot repair that condition. + +WATCHDOG_GRACE = 30 # seconds beyond the owner's deadline before diagnostics _watchdog_stop = threading.Event() _watchdog_thread = None @@ -128,52 +143,28 @@ def _watchdog_note(text): pass +def _watchdog_check(): + """Observe one stalled owner without modifying execution or waiter state.""" + busy = _get_busy() + if not busy: + return + elapsed = time.monotonic() - busy["started_monotonic"] + if elapsed > busy["timeout"] + WATCHDOG_GRACE: + _watchdog_note( + f"AdomBridge: request {busy['request_id']} ({busy['command']}) " + f"has held the main-thread lock for {elapsed:.1f}s, beyond " + f"its {busy['timeout']}s deadline. Ownership is retained; " + "inspect operation status before any mutation retry." + ) + + def _watchdog_loop(): - """Force-release _main_thread_lock if a command has been busy too long.""" while not _watchdog_stop.is_set(): try: - busy = _get_busy() - if busy: - cmd = busy.get("command", "") - started = busy.get("started_at", 0.0) - elapsed = time.time() - started - # Get the timeout that applied to this command - cmd_timeout = PER_COMMAND_TIMEOUT.get(cmd, COMMAND_TIMEOUT) - if elapsed > cmd_timeout + WATCHDOG_GRACE: - try: - # Not a Fusion API call: this runs on the watchdog thread, and the add-in - # keeps every API call on the main thread (issue #76 audit, 2026-09-06). - _watchdog_note( - f"AdomBridge: WATCHDOG force-releasing _main_thread_lock — " - f"'{cmd}' held for {elapsed:.1f}s " - f"(> {cmd_timeout}+{WATCHDOG_GRACE}s grace). " - f"Fusion main thread likely wedged on native cloud call. " - f"Subsequent requests can now proceed; the wedged " - f"command will still leak its C++/Python state until " - f"Fusion restarts." - ) - except Exception: - pass - # Clear the busy marker FIRST so /status no longer - # reports the stale command. - _clear_busy() - # Force-release. If the lock owner thread eventually - # unwedges and tries to release(), it'll get a - # RuntimeError which we accept as the cost of keeping - # subsequent requests flowing. - try: - _main_thread_lock.release() - except RuntimeError: - # Already released (race with the owner thread - # somehow making progress). Fine. - pass - # Also drop any pending request_ids so set_result() - # from a late-arriving wedged command can't leak. - with _lock: - _pending_request_ids.clear() + _watchdog_check() except Exception: - pass # watchdog must never die - _watchdog_stop.wait(5) # check every 5s + pass # diagnostics must never alter request execution + _watchdog_stop.wait(5) # Default per-command timeout. Heavy 3D exports (STEP/IGES/SAT/STL on @@ -183,6 +174,7 @@ COMMAND_TIMEOUT = 300 # seconds # Per-command timeout overrides. Fast commands keep short timeouts so a # hung main thread is surfaced quickly. PER_COMMAND_TIMEOUT = { + "mechanical": 300, "get_app_state": 10, "document_info": 15, "take_screenshot": 30, @@ -234,8 +226,10 @@ def set_result(request_id: str, result: dict): _pending_request_ids.discard(request_id) event = _result_events.get(request_id) - if event: + if event and not event.is_set(): # Normal path: HTTP thread is still waiting + # The first callback owns the terminal result. A duplicate callback + # must not turn an already recorded failure into success. _result_data[request_id] = result event.set() # else: HTTP thread already timed out — discard result (don't leak) @@ -253,11 +247,10 @@ def _thread_name_histogram(limit: int = 8) -> dict: return {} -def _wait_for_result(request_id: str, timeout: float) -> dict: +def _wait_for_result(request_id: str, timeout: float, on_timeout=None) -> dict: """Wait for the main thread to deliver a result for this request.""" - event = threading.Event() with _lock: - _result_events[request_id] = event + event = _result_events.setdefault(request_id, threading.Event()) got_result = event.wait(timeout=timeout) @@ -269,15 +262,63 @@ def _wait_for_result(request_id: str, timeout: float) -> dict: _pending_request_ids.discard(request_id) if not got_result or result is None: - return {"success": False, "error": f"Command timed out after {timeout}s"} + result = {"success": False, "errorCode": "request_wait_timeout", + "error": f"Command timed out after {timeout}s"} + # Waiter removal precedes the durable compare-and-set. A queued event + # loses permission to start; an already claimed/completed handler wins + # the journal race and retains its execution evidence. No native cancel. + return on_timeout(result) if on_timeout is not None else result return result +class _DeadlineSocketReader(io.RawIOBase): + """Bound the entire header/body read, including clients trickling bytes.""" + + def __init__(self, connection, deadline): + super().__init__() + self.connection = connection + self.deadline = deadline + + def readable(self): + return True + + def readinto(self, buffer): + remaining = self.deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError("request read deadline exceeded") + self.connection.settimeout(remaining) + return self.connection.recv_into(buffer) + + class AdomBridgeHandler(BaseHTTPRequestHandler): """HTTP handler for the add-in server.""" + def setup(self): + self._listener_context = getattr(self.server, "_bridge_context", None) + super().setup() + self.rfile.close() + self.rfile = io.BufferedReader(_DeadlineSocketReader( + self.connection, time.monotonic() + REQUEST_READ_TIMEOUT)) + + def _listener_active(self): + context = getattr(self, "_listener_context", None) + return context is not None and not context.stopped.is_set() + + def _listener_refusal(self): + return {"success": False, "errorCode": "listener_stopped", + "error": "The listener which accepted this request has stopped; this request was not dispatched."} + + def _respond_observation(self, status, data): + if not self._listener_active(): + self._respond(503, self._listener_refusal()) + else: + self._respond(status, data) + def do_GET(self): + if not self._listener_active(): + self._respond(503, self._listener_refusal()) + return if self.path == "/health": with _lock: pending = len(_pending_request_ids) @@ -295,7 +336,7 @@ class AdomBridgeHandler(BaseHTTPRequestHandler): else: main_status = "responsive" - self._respond(200, { + self._respond_observation(200, { "status": "ok", "addin": "AdomBridge", "version": ADDIN_VERSION, @@ -343,38 +384,100 @@ class AdomBridgeHandler(BaseHTTPRequestHandler): "Dismiss dialogs via fusion_dismiss_blocking_dialogs, " "then the command will auto-resume." ) - self._respond(200, resp) + self._respond_observation(200, resp) else: - self._respond(200, {"busy": False, "version": ADDIN_VERSION, + self._respond_observation(200, {"busy": False, "version": ADDIN_VERSION, "pyThreads": threading.active_count()}) else: self._respond(404, {"error": "Not found"}) def do_POST(self): + if not self._listener_active(): + self._respond(503, self._listener_refusal()) + return if self.path != "/command": self._respond(404, {"error": "Not found"}) return - content_length = int(self.headers.get("Content-Length", 0)) - body = self.rfile.read(content_length) + lengths = self.headers.get_all("Content-Length", []) + try: + if self.headers.get("Transfer-Encoding") or len(lengths) != 1: + raise ValueError("one Content-Length header is required; chunked bodies are unsupported") + if not lengths[0].isascii() or not lengths[0].isdigit(): + raise ValueError("Content-Length must be a nonnegative integer") + content_length = int(lengths[0]) + except (ValueError, TypeError): + self._respond(400, {"success": False, "errorCode": "invalid_input", "error": "Invalid request framing."}) + return + if content_length > MAX_REQUEST_BYTES: + self._respond(413, {"success": False, "errorCode": "request_too_large", "error": "Request body exceeds 16 MiB."}) + return + try: + body = self.rfile.read(content_length) + except (OSError, TimeoutError): + self._respond(408, {"success": False, "errorCode": "request_read_timeout", "error": "Request body did not arrive before its deadline."}) + return + if len(body) != content_length: + self._respond(400, {"success": False, "errorCode": "invalid_input", "error": "Incomplete request body."}) + return + # An accepted slow-body socket can outlive shutdown/server_close. Its + # listener may have stopped while read() waited; never admit it into a + # successor generation's history or event queue. + if not self._listener_active(): + self._respond(503, self._listener_refusal()) + return try: request = json.loads(body) - except json.JSONDecodeError as e: - self._respond(400, {"success": False, "error": f"Invalid JSON: {e}"}) + except (ValueError, UnicodeError, RecursionError) as e: + self._respond(400, {"success": False, "errorCode": "invalid_input", "error": f"Invalid JSON: {e}"}) return + if not isinstance(request, dict): + self._respond(400, {"success": False, "errorCode": "invalid_input", "error": "Request must be an object."}) + return command = request.get("command", "") args = request.get("args", {}) + if not isinstance(command, str) or not command or len(command) > 256 or not isinstance(args, dict): + self._respond(400, {"success": False, "errorCode": "invalid_input", "error": "A command string and an arguments object are required."}) + return request_id = str(uuid.uuid4()) - # Per-command timeout timeout = PER_COMMAND_TIMEOUT.get(command, COMMAND_TIMEOUT) - # Allow caller override (useful for very large models) - caller_timeout = request.get("timeout") - if isinstance(caller_timeout, (int, float)) and caller_timeout > 0: + if "timeout" in request: + caller_timeout = request["timeout"] + if (isinstance(caller_timeout, bool) or not isinstance(caller_timeout, (int, float)) + or not 0 < caller_timeout <= MAX_COMMAND_TIMEOUT or not math.isfinite(caller_timeout)): + self._respond(400, {"success": False, "errorCode": "invalid_input", "error": "Timeout must be finite, positive and at most 3600 seconds."}) + return timeout = float(caller_timeout) + # These handlers read Python-owned metadata only. Keeping them outside + # CustomEvent/the singleton lock lets a timed-out mutation be observed + # while Fusion is busy. Never add a handler that touches the Fusion API. + if command in ("mech_operation_status", "mech_capabilities"): + from .commands.mechanical import operation_status, capabilities + if not isinstance(args, dict): + self._respond(400, {"success": False, "errorCode": "invalid_input", "error": "Arguments must be an object."}) + return + handler = operation_status if command == "mech_operation_status" else capabilities + self._respond_observation(200, handler(None, args)) + return + + admission = None + if command == "mechanical": + from .commands.mechanical import admit_for_dispatch + args, admission, early_reply = admit_for_dispatch(args, request_id) + if early_reply is not None: + self._respond(200, early_reply) + return + + def delivery_failure(result): + if admission is None: + return result + from .commands.mechanical import not_executed + return not_executed(admission, result) + # Serialize CustomEvent fires through the main-thread lock so # requests don't stack. If another request is already in flight # to the main thread, we block here. We bound the wait so a @@ -405,36 +508,55 @@ class AdomBridgeHandler(BaseHTTPRequestHandler): } if walk: resp["walkProgress"] = walk - self._respond(200, resp) + self._respond(200, delivery_failure(resp)) return try: - _set_busy(command, request_id) + if not self._listener_active(): + self._respond(200, delivery_failure(self._listener_refusal())) + return + _set_busy(command, request_id, timeout) # Track as pending before firing with _lock: _pending_request_ids.add(request_id) + _result_events[request_id] = threading.Event() # Fire custom event to marshal to main thread try: - _app.fireCustomEvent( - _custom_event_id, - json.dumps({"request_id": request_id, "command": command, "args": args}), - ) + event = {"request_id": request_id, "command": command, "args": args} + if admission is not None: + event["mechanicalAdmission"] = admission + context = self._listener_context + # Fusion 2705.1.30 has delivered a native callback after this + # method returned False. The return value is therefore not + # authoritative nonexecution evidence. Keep the waiter until + # callback or bounded expiry; expiry's durable CAS arbitrates + # unclaimed delivery versus executing/completed native work. + context.app.fireCustomEvent(context.event_id, json.dumps(event)) except Exception as e: with _lock: _pending_request_ids.discard(request_id) - self._respond(500, {"success": False, "error": f"Failed to fire event: {e}"}) + _result_events.pop(request_id, None) + _result_data.pop(request_id, None) + reply = delivery_failure({"success": False, "errorCode": "event_delivery_failed", + "error": f"Failed to fire event: {e}"}) + # A fast callback may already have durably completed. Its + # semantic reply must not be converted to an HTTP failure. + self._respond(200 if admission is not None else 500, reply) return # Wait for the main thread handler to deliver the result - result = _wait_for_result(request_id, timeout=timeout) + result = _wait_for_result(request_id, timeout=timeout, on_timeout=delivery_failure) self._respond(200, result) finally: - _clear_busy() + _clear_busy(request_id) _main_thread_lock.release() def _respond(self, status: int, data: dict): + # Reading a slow request may leave only milliseconds on the socket. + # A completed command's response gets its own bounded write budget. + self.connection.settimeout(RESPONSE_WRITE_TIMEOUT) self.send_response(status) self.send_header("Content-Type", "application/json") self.end_headers() @@ -449,30 +571,52 @@ def start(custom_event_id: str, app: adsk.core.Application) -> threading.Thread: """Start the HTTP server on a background daemon thread.""" global _server, _server_thread, _custom_event_id, _app, _watchdog_thread - _custom_event_id = custom_event_id - _app = app - - # ThreadingHTTPServer so /health and concurrent requests don't block - # behind a slow request. Main-thread work is still serialized via - # _main_thread_lock + CustomEvent marshaling. - _server = ThreadingHTTPServer(("127.0.0.1", PORT), AdomBridgeHandler) - _server.daemon_threads = True - - _server_thread = threading.Thread(target=_server.serve_forever, daemon=True) - _server_thread.start() - - # Start stale-lock watchdog (see _watchdog_loop docstring for rationale) - _watchdog_stop.clear() - _watchdog_thread = threading.Thread(target=_watchdog_loop, daemon=True, name="AdomBridge-Watchdog") - _watchdog_thread.start() - - return _server_thread + with _lifecycle_lock: + if _server is not None: + if (_server_thread is not None and _server_thread.is_alive() + and _custom_event_id == custom_event_id and _app is app): + return _server_thread + raise RuntimeError("This add-in copy already owns an HTTP listener.") + if _main_thread_lock.locked() or _pending_request_ids: + raise RuntimeError("Previous add-in requests have not finished; restart refused.") + + # Bind before publishing module globals. A failed constructor closes its + # own socket; shutdown() on a server which never served would deadlock. + server = ExclusiveHTTPServer(("127.0.0.1", PORT), AdomBridgeHandler) + server._bridge_context = ListenerContext(app, custom_event_id) + _server, _custom_event_id, _app = server, custom_event_id, app + _watchdog_stop.clear() + try: + _server_thread = threading.Thread(target=server.serve_forever, daemon=True) + _server_thread.start() + _watchdog_thread = threading.Thread(target=_watchdog_loop, daemon=True, name="AdomBridge-Watchdog") + _watchdog_thread.start() + except Exception: + stop() + raise + return _server_thread def stop(): """Stop the HTTP server.""" - global _server - _watchdog_stop.set() - if _server: - _server.shutdown() - _server = None + global _server, _server_thread, _watchdog_thread, _app, _custom_event_id + with _lifecycle_lock: + _watchdog_stop.set() + server, thread, watchdog = _server, _server_thread, _watchdog_thread + if server is not None: + server._bridge_context.stopped.set() + try: + if server is not None: + try: + if thread is not None and thread.is_alive(): + server.shutdown() + finally: + server.server_close() + if thread is not None and thread.is_alive(): + thread.join(2) + if watchdog is not None and watchdog.is_alive(): + watchdog.join(2) + finally: + _server = _server_thread = _watchdog_thread = None + if not _main_thread_lock.locked() and not _pending_request_ids: + _app = _custom_event_id = None diff --git a/bridge.json b/bridge.json index 4582eb2..e888092 100644 --- a/bridge.json +++ b/bridge.json @@ -131,6 +131,9 @@ "fusion_mcp_resources", "fusion_mcp_status", "fusion_mcp_tools", + "fusion_mech_capabilities", + "fusion_mech_operation_status", + "fusion_mechanical", "fusion_move_parts", "fusion_narrate", "fusion_new_electronics_from_eagle", diff --git a/contracts/describe.json b/contracts/describe.json index 225a908..271cbd6 100644 --- a/contracts/describe.json +++ b/contracts/describe.json @@ -2045,7 +2045,7 @@ { "example": { "arguments": { - "queryType": "summary" + "queryType": "activeCommand" }, "tool": "fusion_mcp_read" }, @@ -2123,6 +2123,12824 @@ "summary": "Full tool list from Autodesk's Fusion MCP server INCLUDING input schemas, so you can construct a valid fusion_mcp_call. These are Autodesk's tools, not this bridge's.", "timeoutSeconds": 45 }, + { + "example": {}, + "input": {}, + "longRunning": false, + "name": "fusion_mech_capabilities", + "output": { + "idempotency": "object", + "operations": { + "assembly_properties": { + "description": "High-accuracy physical properties of whole root assembly (default) or an exact Component/Occurrence/BRepBody. Returns mass kg, volume mm3, area mm2, center of mass mm, mean density kg/mm3 and definition/instance coordinate scope.", + "properties": { + "entity": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + } + }, + "required": [ + "op", + "target" + ], + "type": "object" + }, + "bom": { + "description": "Structured component-definition and instance BOM; quantities by actual identity, root bodies plus nested children; purchased metadata collapses subtrees unless exploded. Physical mass includes all unsuppressed bodies exactly once per instance. Includes exact current active configuration provenance; incomplete configuration scope refuses. Visibility does not remove quantity.", + "properties": { + "explodePurchased": { + "type": "boolean" + } + }, + "required": [ + "op", + "target" + ], + "type": "object" + }, + "bom_export": { + "description": "Export flat grouped purchasing rows or structured parent/path/depth instance rows and supplier provenance to CSV with exclusive/atomic publication. This external effect is separate from model transactions. Both CSV modes include document/revision and current active configuration table/row provenance.", + "properties": { + "explodePurchased": { + "type": "boolean" + }, + "filename": { + "type": "string" + }, + "mode": { + "enum": [ + "flat", + "structured" + ] + }, + "overwrite": { + "type": "boolean" + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "filename" + ], + "type": "object" + }, + "cad_import": { + "description": "Import a pre-downloaded local STEP/SAT into a new wrapper component in Hybrid design. Validate optional expected SHA-256, record actual source/hash/supplier metadata, preserve camera, measure model recovery. Does not fetch URLs or create cloud documents. Returns a representative root-context occurrence and all affectedOccurrences when the parent definition is reused.", + "properties": { + "asPurchased": { + "type": "boolean" + }, + "filename": { + "type": "string" + }, + "name": { + "type": "string" + }, + "parent": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "provenance": { + "additionalProperties": false, + "properties": { + "description": { + "type": "string" + }, + "sourceUrl": { + "type": "string" + }, + "supplier": { + "type": "string" + }, + "supplierPartNumber": { + "type": "string" + } + }, + "required": [ + "supplier", + "supplierPartNumber", + "sourceUrl" + ], + "type": "object" + }, + "sha256": { + "type": "string" + }, + "transform": { + "additionalProperties": false, + "description": "Absolute rotation about the given nonzero axis through origin (deg), then translation (mm). Create/insert use parent component coordinates; updates use the selected occurrence assembly context.", + "properties": { + "angle": { + "type": "number" + }, + "axis": { + "items": { + "type": "number" + }, + "maxItems": 3, + "minItems": 3, + "type": "array" + }, + "position": { + "items": { + "type": "number" + }, + "maxItems": 3, + "minItems": 3, + "type": "array" + } + }, + "required": [], + "type": "object" + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "filename", + "provenance" + ], + "type": "object" + }, + "component_create": { + "description": "Create a native internal component and its first occurrence in a Hybrid design. parent is a component definition; edits to it affect every instance. Returns a representative root-context occurrence and all affectedOccurrences when the parent definition is reused.", + "properties": { + "metadata": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "asPurchased": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "excludeFromBom": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "partNumber": { + "type": "string" + }, + "sourceUrl": { + "type": "string" + }, + "supplier": { + "type": "string" + }, + "supplierPartNumber": { + "type": "string" + } + }, + "required": [], + "type": "object" + }, + "name": { + "type": "string" + }, + "parent": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "transform": { + "additionalProperties": false, + "description": "Absolute rotation about the given nonzero axis through origin (deg), then translation (mm). Create/insert use parent component coordinates; updates use the selected occurrence assembly context.", + "properties": { + "angle": { + "type": "number" + }, + "axis": { + "items": { + "type": "number" + }, + "maxItems": 3, + "minItems": 3, + "type": "array" + }, + "position": { + "items": { + "type": "number" + }, + "maxItems": 3, + "minItems": 3, + "type": "array" + } + }, + "required": [], + "type": "object" + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "name" + ], + "type": "object" + }, + "component_metadata": { + "description": "Update component-definition name/part number/description and persistent supplier or BOM attributes. Shared occurrences all see this metadata.", + "properties": { + "changes": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "asPurchased": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "excludeFromBom": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "partNumber": { + "type": "string" + }, + "sourceUrl": { + "type": "string" + }, + "supplier": { + "type": "string" + }, + "supplierPartNumber": { + "type": "string" + } + }, + "required": [], + "type": "object" + }, + "component": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "changes" + ], + "type": "object" + }, + "configuration_inspect": { + "description": "Read the loaded Design configuration graph without activation or recompute: ordinary rows, the separate native reserved test row, all supported typed cells including inactive/test cells, exact controlled-source links and current ordinary row. Unreadable fields and unknown/theme/hierarchical/inserted scopes remain explicit incomplete diagnostics. Configuration lifecycle and configured-design model mutations remain disabled pending native recovery/archive gates.", + "properties": {}, + "required": [ + "op", + "target" + ], + "type": "object" + }, + "construction_axis": { + "description": "Create a parametric axis from native references (edge/circular face/two intersecting planes/two distinct points). Root-space external geometry requires exact occurrence context. For cross-component geometry into a non-root target component, creationOccurrence must identify a root-context instance of that target. Returns component-space native axis geometry/health. No direct-only infinite-line or preview snap APIs.", + "properties": { + "component": { + "description": "Current guarded entity reference; root-space child geometry requires its exact occurrence context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "creationOccurrence": { + "description": "Current guarded entity reference; root-space child geometry requires its exact occurrence context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "entities": { + "items": { + "description": "Current guarded entity reference; root-space child geometry requires its exact occurrence context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "maxItems": 2, + "minItems": 1, + "type": "array" + }, + "name": { + "type": "string" + }, + "type": { + "enum": [ + "edge", + "circular_face", + "two_planes", + "two_points" + ] + } + }, + "required": [ + "op", + "target", + "sessionId", + "operationId", + "type", + "entities" + ], + "type": "object" + }, + "entity_delete": { + "description": "Delete sketch geometry or independently deletable timeline features. Model transaction and semantic recovery cover downstream failures; document and external file deletion are excluded.", + "properties": { + "entity": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "entity" + ], + "type": "object" + }, + "export": { + "description": "Export entire design using runtime-checked API. Preflight and unique same-filesystem staging preserve the destination on failure; publish only nonempty verified output. Availability differs by Fusion build. STL numeric coordinates use mm.", + "properties": { + "format": { + "default": "step", + "enum": [ + "step", + "stl", + "3mf", + "f3d", + "iges", + "sat", + "smt", + "usdz", + "fbx", + "skp" + ] + }, + "outputPath": { + "type": "string" + }, + "overwrite": { + "default": false, + "type": "boolean" + }, + "refinement": { + "default": "medium", + "enum": [ + "low", + "medium", + "high" + ] + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "outputPath" + ], + "type": "object" + }, + "feature_create": { + "description": "Create a typed native solid feature. All length quantities are mm (or expressions), angles deg. Cut/intersect require explicit participant bodies. Join needs a single existing component body; combine selects multiple bodies explicitly. Feature availability is runtime-checked; missing APIs fail before commit.", + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "bodies": { + "items": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "maxItems": 256, + "minItems": 1, + "type": "array" + }, + "component": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "direction": { + "enum": [ + "positive", + "negative", + "symmetric" + ] + }, + "distance": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "name": { + "type": "string" + }, + "op": { + "type": "string" + }, + "operation": { + "enum": [ + "new_body", + "join", + "cut", + "intersect" + ] + }, + "operationId": { + "type": "string" + }, + "profiles": { + "items": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "maxItems": 256, + "minItems": 1, + "type": "array" + }, + "sessionId": { + "type": "string" + }, + "taper": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "target": { + "additionalProperties": false, + "properties": { + "documentId": { + "type": "string" + }, + "revision": { + "type": "string" + } + }, + "required": [ + "documentId", + "revision" + ], + "type": "object" + }, + "type": { + "enum": [ + "extrude" + ] + } + }, + "required": [ + "type", + "profiles", + "distance" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "angle": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "axis": { + "oneOf": [ + { + "enum": [ + "x", + "y", + "z" + ] + }, + { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + } + ] + }, + "bodies": { + "items": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "maxItems": 256, + "minItems": 1, + "type": "array" + }, + "component": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "name": { + "type": "string" + }, + "op": { + "type": "string" + }, + "operation": { + "enum": [ + "new_body", + "join", + "cut", + "intersect" + ] + }, + "operationId": { + "type": "string" + }, + "profiles": { + "items": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "maxItems": 256, + "minItems": 1, + "type": "array" + }, + "sessionId": { + "type": "string" + }, + "target": { + "additionalProperties": false, + "properties": { + "documentId": { + "type": "string" + }, + "revision": { + "type": "string" + } + }, + "required": [ + "documentId", + "revision" + ], + "type": "object" + }, + "type": { + "enum": [ + "revolve" + ] + } + }, + "required": [ + "type", + "profiles", + "axis", + "angle" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "component": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "edges": { + "items": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "maxItems": 256, + "minItems": 1, + "type": "array" + }, + "name": { + "type": "string" + }, + "op": { + "type": "string" + }, + "operationId": { + "type": "string" + }, + "radius": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "sessionId": { + "type": "string" + }, + "tangentChain": { + "type": "boolean" + }, + "target": { + "additionalProperties": false, + "properties": { + "documentId": { + "type": "string" + }, + "revision": { + "type": "string" + } + }, + "required": [ + "documentId", + "revision" + ], + "type": "object" + }, + "type": { + "enum": [ + "fillet" + ] + } + }, + "required": [ + "type", + "edges", + "radius" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "component": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "distance": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "edges": { + "items": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "maxItems": 256, + "minItems": 1, + "type": "array" + }, + "name": { + "type": "string" + }, + "op": { + "type": "string" + }, + "operationId": { + "type": "string" + }, + "sessionId": { + "type": "string" + }, + "tangentChain": { + "type": "boolean" + }, + "target": { + "additionalProperties": false, + "properties": { + "documentId": { + "type": "string" + }, + "revision": { + "type": "string" + } + }, + "required": [ + "documentId", + "revision" + ], + "type": "object" + }, + "type": { + "enum": [ + "chamfer" + ] + } + }, + "required": [ + "type", + "edges", + "distance" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "component": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "faces": { + "items": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "maxItems": 256, + "minItems": 1, + "type": "array" + }, + "name": { + "type": "string" + }, + "op": { + "type": "string" + }, + "operationId": { + "type": "string" + }, + "sessionId": { + "type": "string" + }, + "tangentChain": { + "type": "boolean" + }, + "target": { + "additionalProperties": false, + "properties": { + "documentId": { + "type": "string" + }, + "revision": { + "type": "string" + } + }, + "required": [ + "documentId", + "revision" + ], + "type": "object" + }, + "thickness": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "type": { + "enum": [ + "shell" + ] + } + }, + "required": [ + "type", + "faces", + "thickness" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "bodies": { + "items": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "maxItems": 256, + "minItems": 1, + "type": "array" + }, + "component": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "depth": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "diameter": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "name": { + "type": "string" + }, + "op": { + "type": "string" + }, + "operationId": { + "type": "string" + }, + "points": { + "items": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "maxItems": 256, + "minItems": 1, + "type": "array" + }, + "sessionId": { + "type": "string" + }, + "target": { + "additionalProperties": false, + "properties": { + "documentId": { + "type": "string" + }, + "revision": { + "type": "string" + } + }, + "required": [ + "documentId", + "revision" + ], + "type": "object" + }, + "type": { + "enum": [ + "hole" + ] + } + }, + "required": [ + "type", + "points", + "diameter", + "depth", + "bodies" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "bodies": { + "items": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "maxItems": 256, + "minItems": 1, + "type": "array" + }, + "component": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "name": { + "type": "string" + }, + "op": { + "type": "string" + }, + "operation": { + "enum": [ + "new_body", + "join", + "cut", + "intersect" + ] + }, + "operationId": { + "type": "string" + }, + "profiles": { + "items": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "maxItems": 256, + "minItems": 1, + "type": "array" + }, + "sessionId": { + "type": "string" + }, + "target": { + "additionalProperties": false, + "properties": { + "documentId": { + "type": "string" + }, + "revision": { + "type": "string" + } + }, + "required": [ + "documentId", + "revision" + ], + "type": "object" + }, + "type": { + "enum": [ + "loft" + ] + } + }, + "required": [ + "type", + "profiles" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "bodies": { + "items": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "maxItems": 256, + "minItems": 1, + "type": "array" + }, + "component": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "name": { + "type": "string" + }, + "op": { + "type": "string" + }, + "operation": { + "enum": [ + "new_body", + "join", + "cut", + "intersect" + ] + }, + "operationId": { + "type": "string" + }, + "path": { + "items": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "maxItems": 256, + "minItems": 1, + "type": "array" + }, + "profiles": { + "items": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "maxItems": 256, + "minItems": 1, + "type": "array" + }, + "sessionId": { + "type": "string" + }, + "target": { + "additionalProperties": false, + "properties": { + "documentId": { + "type": "string" + }, + "revision": { + "type": "string" + } + }, + "required": [ + "documentId", + "revision" + ], + "type": "object" + }, + "type": { + "enum": [ + "sweep" + ] + } + }, + "required": [ + "type", + "profiles", + "path" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "body": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "component": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "keepTools": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "op": { + "type": "string" + }, + "operation": { + "enum": [ + "join", + "cut", + "intersect" + ] + }, + "operationId": { + "type": "string" + }, + "sessionId": { + "type": "string" + }, + "target": { + "additionalProperties": false, + "properties": { + "documentId": { + "type": "string" + }, + "revision": { + "type": "string" + } + }, + "required": [ + "documentId", + "revision" + ], + "type": "object" + }, + "tools": { + "items": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "maxItems": 256, + "minItems": 1, + "type": "array" + }, + "type": { + "enum": [ + "combine" + ] + } + }, + "required": [ + "type", + "body", + "tools", + "operation" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "bodies": { + "items": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "maxItems": 256, + "minItems": 1, + "type": "array" + }, + "component": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "extend": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "op": { + "type": "string" + }, + "operationId": { + "type": "string" + }, + "plane": { + "oneOf": [ + { + "enum": [ + "xy", + "xz", + "yz" + ] + }, + { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + } + ] + }, + "sessionId": { + "type": "string" + }, + "target": { + "additionalProperties": false, + "properties": { + "documentId": { + "type": "string" + }, + "revision": { + "type": "string" + } + }, + "required": [ + "documentId", + "revision" + ], + "type": "object" + }, + "type": { + "enum": [ + "split" + ] + } + }, + "required": [ + "type", + "bodies", + "plane" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "angle": { + "type": "number" + }, + "axis": { + "items": { + "type": "number" + }, + "maxItems": 3, + "minItems": 3, + "type": "array" + }, + "bodies": { + "items": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "maxItems": 256, + "minItems": 1, + "type": "array" + }, + "component": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "name": { + "type": "string" + }, + "op": { + "type": "string" + }, + "operationId": { + "type": "string" + }, + "origin": { + "items": { + "type": "number" + }, + "maxItems": 3, + "minItems": 2, + "type": "array" + }, + "sessionId": { + "type": "string" + }, + "target": { + "additionalProperties": false, + "properties": { + "documentId": { + "type": "string" + }, + "revision": { + "type": "string" + } + }, + "required": [ + "documentId", + "revision" + ], + "type": "object" + }, + "translation": { + "items": { + "type": "number" + }, + "maxItems": 3, + "minItems": 2, + "type": "array" + }, + "type": { + "enum": [ + "move" + ] + } + }, + "required": [ + "type", + "bodies" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "bodies": { + "items": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "maxItems": 256, + "minItems": 1, + "type": "array" + }, + "component": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "name": { + "type": "string" + }, + "op": { + "type": "string" + }, + "operationId": { + "type": "string" + }, + "plane": { + "oneOf": [ + { + "enum": [ + "xy", + "xz", + "yz" + ] + }, + { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + } + ] + }, + "sessionId": { + "type": "string" + }, + "target": { + "additionalProperties": false, + "properties": { + "documentId": { + "type": "string" + }, + "revision": { + "type": "string" + } + }, + "required": [ + "documentId", + "revision" + ], + "type": "object" + }, + "type": { + "enum": [ + "mirror" + ] + } + }, + "required": [ + "type", + "bodies", + "plane" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "axis": { + "oneOf": [ + { + "enum": [ + "x", + "y", + "z" + ] + }, + { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + } + ] + }, + "axis2": { + "oneOf": [ + { + "enum": [ + "x", + "y", + "z" + ] + }, + { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + } + ] + }, + "bodies": { + "items": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "maxItems": 256, + "minItems": 1, + "type": "array" + }, + "component": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "count": { + "maximum": 1000, + "minimum": 2, + "type": "integer" + }, + "count2": { + "maximum": 1000, + "minimum": 1, + "type": "integer" + }, + "name": { + "type": "string" + }, + "op": { + "type": "string" + }, + "operationId": { + "type": "string" + }, + "sessionId": { + "type": "string" + }, + "spacing": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "spacing2": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "target": { + "additionalProperties": false, + "properties": { + "documentId": { + "type": "string" + }, + "revision": { + "type": "string" + } + }, + "required": [ + "documentId", + "revision" + ], + "type": "object" + }, + "type": { + "enum": [ + "rectangular_pattern" + ] + } + }, + "required": [ + "type", + "bodies", + "axis", + "count", + "spacing" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "angle": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "axis": { + "oneOf": [ + { + "enum": [ + "x", + "y", + "z" + ] + }, + { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + } + ] + }, + "bodies": { + "items": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "maxItems": 256, + "minItems": 1, + "type": "array" + }, + "component": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "count": { + "maximum": 1000, + "minimum": 2, + "type": "integer" + }, + "name": { + "type": "string" + }, + "op": { + "type": "string" + }, + "operationId": { + "type": "string" + }, + "sessionId": { + "type": "string" + }, + "target": { + "additionalProperties": false, + "properties": { + "documentId": { + "type": "string" + }, + "revision": { + "type": "string" + } + }, + "required": [ + "documentId", + "revision" + ], + "type": "object" + }, + "type": { + "enum": [ + "circular_pattern" + ] + } + }, + "required": [ + "type", + "bodies", + "axis", + "count" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "angle": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "component": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "faces": { + "items": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "maxItems": 256, + "minItems": 1, + "type": "array" + }, + "name": { + "type": "string" + }, + "op": { + "type": "string" + }, + "operationId": { + "type": "string" + }, + "plane": { + "oneOf": [ + { + "enum": [ + "xy", + "xz", + "yz" + ] + }, + { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + } + ] + }, + "sessionId": { + "type": "string" + }, + "tangentChain": { + "type": "boolean" + }, + "target": { + "additionalProperties": false, + "properties": { + "documentId": { + "type": "string" + }, + "revision": { + "type": "string" + } + }, + "required": [ + "documentId", + "revision" + ], + "type": "object" + }, + "type": { + "enum": [ + "draft" + ] + } + }, + "required": [ + "type", + "faces", + "plane", + "angle" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "component": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "designation": { + "type": "string" + }, + "faces": { + "items": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "maxItems": 256, + "minItems": 1, + "type": "array" + }, + "internal": { + "type": "boolean" + }, + "modeled": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "op": { + "type": "string" + }, + "operationId": { + "type": "string" + }, + "sessionId": { + "type": "string" + }, + "target": { + "additionalProperties": false, + "properties": { + "documentId": { + "type": "string" + }, + "revision": { + "type": "string" + } + }, + "required": [ + "documentId", + "revision" + ], + "type": "object" + }, + "threadClass": { + "type": "string" + }, + "threadType": { + "type": "string" + }, + "type": { + "enum": [ + "thread" + ] + } + }, + "required": [ + "type", + "faces", + "internal", + "threadType", + "designation", + "threadClass" + ], + "type": "object" + } + ], + "properties": { + "angle": {}, + "axis": {}, + "axis2": {}, + "bodies": {}, + "body": {}, + "component": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "count": {}, + "count2": {}, + "depth": {}, + "designation": {}, + "diameter": {}, + "direction": {}, + "distance": {}, + "edges": {}, + "extend": {}, + "faces": {}, + "internal": {}, + "keepTools": {}, + "modeled": {}, + "name": { + "type": "string" + }, + "operation": {}, + "origin": {}, + "path": {}, + "plane": {}, + "points": {}, + "profiles": {}, + "radius": {}, + "spacing": {}, + "spacing2": {}, + "tangentChain": {}, + "taper": {}, + "thickness": {}, + "threadClass": {}, + "threadType": {}, + "tools": {}, + "translation": {}, + "type": { + "enum": [ + "extrude", + "revolve", + "fillet", + "chamfer", + "shell", + "hole", + "loft", + "sweep", + "combine", + "split", + "move", + "mirror", + "rectangular_pattern", + "circular_pattern", + "draft", + "thread" + ] + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "type" + ], + "type": "object" + }, + "feature_edit": { + "description": "Rename/suppress a feature or change expressions of model parameters whose createdBy is that exact feature; read health and abort on downstream errors.", + "properties": { + "feature": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "name": { + "type": "string" + }, + "parameters": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "suppressed": { + "type": "boolean" + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "feature" + ], + "type": "object" + }, + "geometry_query": { + "description": "Complete body-scoped topology query, no arbitrary first-match selection. selection one (default) refuses zero/multiple matches; many may return empty. Geometry types are exact native types; size ranges inclusive in mm/mm2, near is minimum distance in the explicit frame; direction is outward planar face normal or linear edge tangent. Returned refs retain instance context even for component-space filters. Exceeding maxResults fails rather than truncating.", + "properties": { + "body": { + "description": "Current guarded entity reference; root-space child geometry requires its exact occurrence context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "filters": { + "additionalProperties": false, + "properties": { + "area": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "max": { + "minimum": 0, + "type": "number" + }, + "min": { + "minimum": 0, + "type": "number" + } + }, + "required": [], + "type": "object" + }, + "direction": { + "additionalProperties": false, + "properties": { + "orientation": { + "enum": [ + "same", + "parallel" + ] + }, + "tolerance": { + "maximum": 90, + "minimum": 0, + "type": "number" + }, + "vector": { + "items": { + "type": "number" + }, + "maxItems": 3, + "minItems": 3, + "type": "array" + } + }, + "required": [ + "vector" + ], + "type": "object" + }, + "length": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "max": { + "minimum": 0, + "type": "number" + }, + "min": { + "minimum": 0, + "type": "number" + } + }, + "required": [], + "type": "object" + }, + "near": { + "additionalProperties": false, + "properties": { + "point": { + "items": { + "type": "number" + }, + "maxItems": 3, + "minItems": 3, + "type": "array" + }, + "tolerance": { + "minimum": 0, + "type": "number" + } + }, + "required": [ + "point", + "tolerance" + ], + "type": "object" + }, + "radius": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "max": { + "minimum": 0, + "type": "number" + }, + "min": { + "minimum": 0, + "type": "number" + } + }, + "required": [], + "type": "object" + }, + "type": { + "enum": [ + "Plane", + "Cylinder", + "Cone", + "Sphere", + "Torus", + "NurbsSurface", + "Line3D", + "Arc3D", + "Circle3D", + "Ellipse3D", + "EllipticalArc3D", + "NurbsCurve3D", + "Point3D", + "degenerate" + ] + } + }, + "required": [], + "type": "object" + }, + "kind": { + "enum": [ + "faces", + "edges", + "vertices" + ] + }, + "maxResults": { + "maximum": 1024, + "minimum": 1, + "type": "integer" + }, + "selection": { + "enum": [ + "one", + "many" + ] + }, + "space": { + "enum": [ + "root", + "component" + ] + } + }, + "required": [ + "op", + "target", + "body", + "kind", + "space" + ], + "type": "object" + }, + "health": { + "description": "Read timeline warnings/errors. recompute:true also requires current target revision, sessionId and operationId.", + "properties": { + "recompute": { + "default": false, + "type": "boolean" + } + }, + "required": [ + "op", + "target" + ], + "type": "object" + }, + "inspect": { + "description": "Inspect the active Design without mutation. Returns target identity/revision, native component-space bodies and occurrence transforms, parameters and feature health.", + "properties": {}, + "required": [ + "op" + ], + "type": "object" + }, + "inspect_entity": { + "properties": { + "entity": { + "description": "Exact reference returned by inspect; resolves uniquely with document/revision/type/occurrence guards.", + "type": "object" + } + }, + "required": [ + "op", + "target", + "entity" + ], + "type": "object" + }, + "interference": { + "description": "Analyze root-context occurrences/body proxies; returns overlap volume mm3 or touching. Does not create interference bodies.", + "properties": { + "entities": { + "items": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "maxItems": 256, + "minItems": 2, + "type": "array" + }, + "includeTouching": { + "type": "boolean" + }, + "volumeTolerance": { + "minimum": 0, + "type": "number" + } + }, + "required": [ + "op", + "target", + "entities" + ], + "type": "object" + }, + "joint_create": { + "description": "Create an as-built joint without moving occurrences. Non-rigid motion requires explicit joint geometry; x/y/z are joint-coordinate directions. Limits and drive values use mm/deg. Seven native motion types; ball uses joint Z pitch/X yaw; planar uses native primary slide direction reported on readback. Use regular_joint_create for geometry-solving placement.", + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "occurrenceOne": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "occurrenceTwo": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "op": { + "type": "string" + }, + "operationId": { + "type": "string" + }, + "sessionId": { + "type": "string" + }, + "suppressed": { + "type": "boolean" + }, + "target": { + "type": "object" + }, + "type": { + "enum": [ + "rigid" + ] + } + }, + "required": [ + "type" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "axis": { + "enum": [ + "x", + "y", + "z" + ] + }, + "geometry": { + "additionalProperties": false, + "properties": { + "entity": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "keyPoint": { + "enum": [ + "center", + "start", + "middle", + "end" + ] + } + }, + "required": [ + "entity" + ], + "type": "object" + }, + "name": { + "type": "string" + }, + "occurrenceOne": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "occurrenceTwo": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "op": { + "type": "string" + }, + "operationId": { + "type": "string" + }, + "rotationLimits": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "maximum": { + "type": "number" + }, + "maximumEnabled": { + "type": "boolean" + }, + "minimum": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "rest": { + "type": "number" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "type": "object" + }, + "rotationValue": { + "type": "number" + }, + "sessionId": { + "type": "string" + }, + "suppressed": { + "type": "boolean" + }, + "target": { + "type": "object" + }, + "type": { + "enum": [ + "revolute" + ] + } + }, + "required": [ + "type", + "geometry" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "axis": { + "enum": [ + "x", + "y", + "z" + ] + }, + "geometry": { + "additionalProperties": false, + "properties": { + "entity": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "keyPoint": { + "enum": [ + "center", + "start", + "middle", + "end" + ] + } + }, + "required": [ + "entity" + ], + "type": "object" + }, + "name": { + "type": "string" + }, + "occurrenceOne": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "occurrenceTwo": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "op": { + "type": "string" + }, + "operationId": { + "type": "string" + }, + "sessionId": { + "type": "string" + }, + "slideLimits": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "maximum": { + "type": "number" + }, + "maximumEnabled": { + "type": "boolean" + }, + "minimum": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "rest": { + "type": "number" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "type": "object" + }, + "slideValue": { + "type": "number" + }, + "suppressed": { + "type": "boolean" + }, + "target": { + "type": "object" + }, + "type": { + "enum": [ + "slider" + ] + } + }, + "required": [ + "type", + "geometry" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "axis": { + "enum": [ + "x", + "y", + "z" + ] + }, + "geometry": { + "additionalProperties": false, + "properties": { + "entity": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "keyPoint": { + "enum": [ + "center", + "start", + "middle", + "end" + ] + } + }, + "required": [ + "entity" + ], + "type": "object" + }, + "name": { + "type": "string" + }, + "occurrenceOne": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "occurrenceTwo": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "op": { + "type": "string" + }, + "operationId": { + "type": "string" + }, + "rotationLimits": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "maximum": { + "type": "number" + }, + "maximumEnabled": { + "type": "boolean" + }, + "minimum": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "rest": { + "type": "number" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "type": "object" + }, + "rotationValue": { + "type": "number" + }, + "sessionId": { + "type": "string" + }, + "slideLimits": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "maximum": { + "type": "number" + }, + "maximumEnabled": { + "type": "boolean" + }, + "minimum": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "rest": { + "type": "number" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "type": "object" + }, + "slideValue": { + "type": "number" + }, + "suppressed": { + "type": "boolean" + }, + "target": { + "type": "object" + }, + "type": { + "enum": [ + "cylindrical" + ] + } + }, + "required": [ + "type", + "geometry" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "axis": { + "enum": [ + "x", + "y", + "z" + ] + }, + "geometry": { + "additionalProperties": false, + "properties": { + "entity": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "keyPoint": { + "enum": [ + "center", + "start", + "middle", + "end" + ] + } + }, + "required": [ + "entity" + ], + "type": "object" + }, + "name": { + "type": "string" + }, + "occurrenceOne": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "occurrenceTwo": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "op": { + "type": "string" + }, + "operationId": { + "type": "string" + }, + "rotationLimits": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "maximum": { + "type": "number" + }, + "maximumEnabled": { + "type": "boolean" + }, + "minimum": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "rest": { + "type": "number" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "type": "object" + }, + "rotationValue": { + "type": "number" + }, + "sessionId": { + "type": "string" + }, + "slideAxis": { + "enum": [ + "x", + "y", + "z" + ] + }, + "slideLimits": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "maximum": { + "type": "number" + }, + "maximumEnabled": { + "type": "boolean" + }, + "minimum": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "rest": { + "type": "number" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "type": "object" + }, + "slideValue": { + "type": "number" + }, + "suppressed": { + "type": "boolean" + }, + "target": { + "type": "object" + }, + "type": { + "enum": [ + "pin_slot" + ] + } + }, + "required": [ + "type", + "geometry" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "axis": { + "enum": [ + "x", + "y", + "z" + ] + }, + "geometry": { + "additionalProperties": false, + "properties": { + "entity": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "keyPoint": { + "enum": [ + "center", + "start", + "middle", + "end" + ] + } + }, + "required": [ + "entity" + ], + "type": "object" + }, + "name": { + "type": "string" + }, + "occurrenceOne": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "occurrenceTwo": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "op": { + "type": "string" + }, + "operationId": { + "type": "string" + }, + "primarySlideLimits": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "maximum": { + "type": "number" + }, + "maximumEnabled": { + "type": "boolean" + }, + "minimum": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "rest": { + "type": "number" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "type": "object" + }, + "primarySlideValue": { + "type": "number" + }, + "rotationLimits": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "maximum": { + "type": "number" + }, + "maximumEnabled": { + "type": "boolean" + }, + "minimum": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "rest": { + "type": "number" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "type": "object" + }, + "rotationValue": { + "type": "number" + }, + "secondarySlideLimits": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "maximum": { + "type": "number" + }, + "maximumEnabled": { + "type": "boolean" + }, + "minimum": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "rest": { + "type": "number" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "type": "object" + }, + "secondarySlideValue": { + "type": "number" + }, + "sessionId": { + "type": "string" + }, + "suppressed": { + "type": "boolean" + }, + "target": { + "type": "object" + }, + "type": { + "enum": [ + "planar" + ] + } + }, + "required": [ + "type", + "geometry" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "geometry": { + "additionalProperties": false, + "properties": { + "entity": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "keyPoint": { + "enum": [ + "center", + "start", + "middle", + "end" + ] + } + }, + "required": [ + "entity" + ], + "type": "object" + }, + "name": { + "type": "string" + }, + "occurrenceOne": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "occurrenceTwo": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "op": { + "type": "string" + }, + "operationId": { + "type": "string" + }, + "pitchLimits": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "maximum": { + "type": "number" + }, + "maximumEnabled": { + "type": "boolean" + }, + "minimum": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "rest": { + "type": "number" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "type": "object" + }, + "pitchValue": { + "type": "number" + }, + "rollLimits": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "maximum": { + "type": "number" + }, + "maximumEnabled": { + "type": "boolean" + }, + "minimum": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "rest": { + "type": "number" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "type": "object" + }, + "rollValue": { + "type": "number" + }, + "sessionId": { + "type": "string" + }, + "suppressed": { + "type": "boolean" + }, + "target": { + "type": "object" + }, + "type": { + "enum": [ + "ball" + ] + }, + "yawLimits": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "maximum": { + "type": "number" + }, + "maximumEnabled": { + "type": "boolean" + }, + "minimum": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "rest": { + "type": "number" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "type": "object" + }, + "yawValue": { + "type": "number" + } + }, + "required": [ + "type", + "geometry" + ], + "type": "object" + } + ], + "properties": { + "axis": { + "enum": [ + "x", + "y", + "z" + ] + }, + "geometry": { + "additionalProperties": false, + "properties": { + "entity": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "keyPoint": { + "enum": [ + "center", + "start", + "middle", + "end" + ] + } + }, + "required": [ + "entity" + ], + "type": "object" + }, + "name": { + "type": "string" + }, + "occurrenceOne": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "occurrenceTwo": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "pitchLimits": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "maximum": { + "type": "number" + }, + "maximumEnabled": { + "type": "boolean" + }, + "minimum": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "rest": { + "type": "number" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "type": "object" + }, + "pitchValue": { + "type": "number" + }, + "primarySlideLimits": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "maximum": { + "type": "number" + }, + "maximumEnabled": { + "type": "boolean" + }, + "minimum": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "rest": { + "type": "number" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "type": "object" + }, + "primarySlideValue": { + "type": "number" + }, + "rollLimits": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "maximum": { + "type": "number" + }, + "maximumEnabled": { + "type": "boolean" + }, + "minimum": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "rest": { + "type": "number" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "type": "object" + }, + "rollValue": { + "type": "number" + }, + "rotationLimits": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "maximum": { + "type": "number" + }, + "maximumEnabled": { + "type": "boolean" + }, + "minimum": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "rest": { + "type": "number" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "type": "object" + }, + "rotationValue": { + "type": "number" + }, + "secondarySlideLimits": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "maximum": { + "type": "number" + }, + "maximumEnabled": { + "type": "boolean" + }, + "minimum": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "rest": { + "type": "number" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "type": "object" + }, + "secondarySlideValue": { + "type": "number" + }, + "slideAxis": { + "enum": [ + "x", + "y", + "z" + ] + }, + "slideLimits": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "maximum": { + "type": "number" + }, + "maximumEnabled": { + "type": "boolean" + }, + "minimum": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "rest": { + "type": "number" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "type": "object" + }, + "slideValue": { + "type": "number" + }, + "suppressed": { + "type": "boolean" + }, + "type": { + "enum": [ + "rigid", + "revolute", + "slider", + "cylindrical", + "pin_slot", + "planar", + "ball" + ] + }, + "yawLimits": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "maximum": { + "type": "number" + }, + "maximumEnabled": { + "type": "boolean" + }, + "minimum": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "rest": { + "type": "number" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "type": "object" + }, + "yawValue": { + "type": "number" + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "type", + "occurrenceOne", + "occurrenceTwo" + ], + "type": "object" + }, + "joint_delete": { + "description": "Delete one selected Joint or AsBuiltJoint with recovery verification.", + "properties": { + "joint": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "joint" + ], + "type": "object" + }, + "joint_edit": { + "description": "Edit joint name/suppression, enabled limits, rest and drive values; wrong motion type or out-of-limit values fail with recovery. For driven root joints, active motion links must preserve their settings and their partner motion must follow the native ratio; a coupling mismatch aborts.", + "properties": { + "joint": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "name": { + "type": "string" + }, + "pitchLimits": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "maximum": { + "type": "number" + }, + "maximumEnabled": { + "type": "boolean" + }, + "minimum": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "rest": { + "type": "number" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "type": "object" + }, + "pitchValue": { + "type": "number" + }, + "primarySlideLimits": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "maximum": { + "type": "number" + }, + "maximumEnabled": { + "type": "boolean" + }, + "minimum": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "rest": { + "type": "number" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "type": "object" + }, + "primarySlideValue": { + "type": "number" + }, + "rollLimits": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "maximum": { + "type": "number" + }, + "maximumEnabled": { + "type": "boolean" + }, + "minimum": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "rest": { + "type": "number" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "type": "object" + }, + "rollValue": { + "type": "number" + }, + "rotationLimits": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "maximum": { + "type": "number" + }, + "maximumEnabled": { + "type": "boolean" + }, + "minimum": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "rest": { + "type": "number" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "type": "object" + }, + "rotationValue": { + "type": "number" + }, + "secondarySlideLimits": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "maximum": { + "type": "number" + }, + "maximumEnabled": { + "type": "boolean" + }, + "minimum": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "rest": { + "type": "number" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "type": "object" + }, + "secondarySlideValue": { + "type": "number" + }, + "slideLimits": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "maximum": { + "type": "number" + }, + "maximumEnabled": { + "type": "boolean" + }, + "minimum": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "rest": { + "type": "number" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "type": "object" + }, + "slideValue": { + "type": "number" + }, + "suppressed": { + "type": "boolean" + }, + "yawLimits": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "maximum": { + "type": "number" + }, + "maximumEnabled": { + "type": "boolean" + }, + "minimum": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "rest": { + "type": "number" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "type": "object" + }, + "yawValue": { + "type": "number" + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "joint" + ], + "type": "object" + }, + "joint_origin_create": { + "description": "Create a native component-definition joint origin from guarded native geometry, with mm offsets/deg angle and optional straight axis references. Child occurrence origin references are exposed by inspect_entity(occurrence).", + "properties": { + "angle": { + "description": "Number in mm (angle in deg), or a dimensionally valid Fusion parameter expression.", + "oneOf": [ + { + "type": "number" + }, + { + "maxLength": 512, + "minLength": 1, + "type": "string" + } + ] + }, + "component": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "flipped": { + "type": "boolean" + }, + "geometry": { + "additionalProperties": false, + "properties": { + "edge": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "entity": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "keyPoint": { + "enum": [ + "center", + "start", + "middle", + "end" + ] + } + }, + "required": [ + "entity" + ], + "type": "object" + }, + "name": { + "maxLength": 120, + "minLength": 1, + "type": "string" + }, + "offsetX": { + "description": "Number in mm (angle in deg), or a dimensionally valid Fusion parameter expression.", + "oneOf": [ + { + "type": "number" + }, + { + "maxLength": 512, + "minLength": 1, + "type": "string" + } + ] + }, + "offsetY": { + "description": "Number in mm (angle in deg), or a dimensionally valid Fusion parameter expression.", + "oneOf": [ + { + "type": "number" + }, + { + "maxLength": 512, + "minLength": 1, + "type": "string" + } + ] + }, + "offsetZ": { + "description": "Number in mm (angle in deg), or a dimensionally valid Fusion parameter expression.", + "oneOf": [ + { + "type": "number" + }, + { + "maxLength": 512, + "minLength": 1, + "type": "string" + } + ] + }, + "xAxisEntity": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "zAxisEntity": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "geometry" + ], + "type": "object" + }, + "joint_origin_delete": { + "description": "Delete a native joint origin; unhealthy downstream joints cause transaction rollback.", + "properties": { + "origin": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "origin" + ], + "type": "object" + }, + "joint_origin_edit": { + "description": "Edit a native joint origin definition; rolls before its timeline item and restores the original marker in finally. Recomputes and verifies attachment, axes, offsets, angle, name and flip.", + "properties": { + "angle": { + "description": "Number in mm (angle in deg), or a dimensionally valid Fusion parameter expression.", + "oneOf": [ + { + "type": "number" + }, + { + "maxLength": 512, + "minLength": 1, + "type": "string" + } + ] + }, + "flipped": { + "type": "boolean" + }, + "geometry": { + "additionalProperties": false, + "properties": { + "edge": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "entity": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "keyPoint": { + "enum": [ + "center", + "start", + "middle", + "end" + ] + } + }, + "required": [ + "entity" + ], + "type": "object" + }, + "name": { + "maxLength": 120, + "minLength": 1, + "type": "string" + }, + "offsetX": { + "description": "Number in mm (angle in deg), or a dimensionally valid Fusion parameter expression.", + "oneOf": [ + { + "type": "number" + }, + { + "maxLength": 512, + "minLength": 1, + "type": "string" + } + ] + }, + "offsetY": { + "description": "Number in mm (angle in deg), or a dimensionally valid Fusion parameter expression.", + "oneOf": [ + { + "type": "number" + }, + { + "maxLength": 512, + "minLength": 1, + "type": "string" + } + ] + }, + "offsetZ": { + "description": "Number in mm (angle in deg), or a dimensionally valid Fusion parameter expression.", + "oneOf": [ + { + "type": "number" + }, + { + "maxLength": 512, + "minLength": 1, + "type": "string" + } + ] + }, + "origin": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "xAxisEntity": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "zAxisEntity": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "origin" + ], + "type": "object" + }, + "material_assign": { + "description": "Assign a loaded library or document material to an exact native Component/BRepBody; verify material ID readback with model transaction recovery. Query assembly_properties for resulting mass.", + "properties": { + "entity": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "libraryId": { + "type": "string" + }, + "materialId": { + "type": "string" + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "entity", + "materialId" + ], + "type": "object" + }, + "material_list": { + "description": "List loaded material libraries and matching materials by stable library/material IDs. Omit libraryId for document materials.", + "properties": { + "libraryId": { + "type": "string" + }, + "limit": { + "maximum": 1000, + "minimum": 1, + "type": "integer" + }, + "query": { + "type": "string" + } + }, + "required": [ + "op", + "target" + ], + "type": "object" + }, + "mcmaster_discover": { + "description": "Discover Fusion native Insert McMaster-Carr command and requirements in the active design.", + "properties": {}, + "required": [ + "op" + ], + "type": "object" + }, + "mcmaster_open": { + "description": "Invoke Fusion native McMaster UI in the targeted Hybrid design. Interactive external operation: completion/insert/cancel stays with Fusion; inspect afterward. Idempotency records launch only.", + "properties": {}, + "required": [ + "op", + "sessionId", + "operationId", + "target" + ], + "type": "object" + }, + "measure": { + "description": "Native engineering minimum distance or angle in root assembly space. Non-root definition refs require an occurrence context. Distance needs2 entities; angle needs2 lines/planes or3 points (second apex). Returns mm/deg and native witness positions in root mm. witnessValidation identifies native points or native geometry directions used for valid parallel angles with degenerate native points; degenerate point angles and unavailable/inconsistent witnesses fail.", + "properties": { + "entities": { + "items": { + "description": "Current guarded entity reference; root-space child geometry requires its exact occurrence context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "maxItems": 3, + "minItems": 2, + "type": "array" + }, + "space": { + "enum": [ + "root" + ] + }, + "type": { + "enum": [ + "distance", + "angle" + ] + } + }, + "required": [ + "op", + "target", + "type", + "space", + "entities" + ], + "type": "object" + }, + "motion_link_create": { + "description": "Create a native motion link between two root-owned joints, or two different motions of jointOne when jointTwo is omitted. Positive ratio magnitudes use mm for translation and deg for rotation; reversed selects opposite direction. Requires the November 2025+ API; no script fallback.", + "properties": { + "jointOne": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "jointTwo": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "motionOne": { + "enum": [ + "rotation", + "slide", + "primarySlide", + "secondarySlide", + "pitch", + "roll", + "yaw" + ] + }, + "motionTwo": { + "enum": [ + "rotation", + "slide", + "primarySlide", + "secondarySlide", + "pitch", + "roll", + "yaw" + ] + }, + "name": { + "maxLength": 120, + "minLength": 1, + "type": "string" + }, + "reversed": { + "type": "boolean" + }, + "valueOne": { + "description": "Number in mm (angle in deg), or a dimensionally valid Fusion parameter expression.", + "oneOf": [ + { + "type": "number" + }, + { + "maxLength": 512, + "minLength": 1, + "type": "string" + } + ] + }, + "valueTwo": { + "description": "Number in mm (angle in deg), or a dimensionally valid Fusion parameter expression.", + "oneOf": [ + { + "type": "number" + }, + { + "maxLength": 512, + "minLength": 1, + "type": "string" + } + ] + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "jointOne", + "motionOne", + "motionTwo", + "valueOne", + "valueTwo" + ], + "type": "object" + }, + "motion_link_delete": { + "description": "Delete a root-owned motion link under the same transaction/recovery contract.", + "properties": { + "link": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "link" + ], + "type": "object" + }, + "motion_link_edit": { + "description": "Edit a root-owned motion link ratio, degree-of-freedom pair, direction, name or suppression. Joint membership is immutable; recreate the link to change members. Verify exact joint identities, ratio, flags and health.", + "properties": { + "link": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "motionOne": { + "enum": [ + "rotation", + "slide", + "primarySlide", + "secondarySlide", + "pitch", + "roll", + "yaw" + ] + }, + "motionTwo": { + "enum": [ + "rotation", + "slide", + "primarySlide", + "secondarySlide", + "pitch", + "roll", + "yaw" + ] + }, + "name": { + "maxLength": 120, + "minLength": 1, + "type": "string" + }, + "reversed": { + "type": "boolean" + }, + "suppressed": { + "type": "boolean" + }, + "valueOne": { + "description": "Number in mm (angle in deg), or a dimensionally valid Fusion parameter expression.", + "oneOf": [ + { + "type": "number" + }, + { + "maxLength": 512, + "minLength": 1, + "type": "string" + } + ] + }, + "valueTwo": { + "description": "Number in mm (angle in deg), or a dimensionally valid Fusion parameter expression.", + "oneOf": [ + { + "type": "number" + }, + { + "maxLength": 512, + "minLength": 1, + "type": "string" + } + ] + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "link" + ], + "type": "object" + }, + "new_design": { + "description": "Create an unnamed unsaved parametric hybrid Design; name is retained as requestedName metadata to keep failure recovery free of save dialogs. Optional box dimensions in mm create a driving-parameter fixture. Never edits an existing document.", + "properties": { + "box": { + "additionalProperties": false, + "properties": { + "depth": { + "exclusiveMinimum": 0, + "type": "number" + }, + "height": { + "exclusiveMinimum": 0, + "type": "number" + }, + "width": { + "exclusiveMinimum": 0, + "type": "number" + } + }, + "required": [ + "width", + "height", + "depth" + ], + "type": "object" + }, + "name": { + "type": "string" + } + }, + "required": [ + "op", + "sessionId", + "operationId" + ], + "type": "object" + }, + "occurrence_delete": { + "description": "Delete one selected occurrence through the shared transaction executor.", + "properties": { + "occurrence": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "occurrence" + ], + "type": "object" + }, + "occurrence_insert": { + "description": "Insert another occurrence of an existing definition; independentCopy:true uses Paste New to create a separate definition. Nested placement is in parent component coordinates. Returns a representative root-context occurrence and all affectedOccurrences when the parent definition is reused.", + "properties": { + "component": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "independentCopy": { + "type": "boolean" + }, + "parent": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "transform": { + "additionalProperties": false, + "description": "Absolute rotation about the given nonzero axis through origin (deg), then translation (mm). Create/insert use parent component coordinates; updates use the selected occurrence assembly context.", + "properties": { + "angle": { + "type": "number" + }, + "axis": { + "items": { + "type": "number" + }, + "maxItems": 3, + "minItems": 3, + "type": "array" + }, + "position": { + "items": { + "type": "number" + }, + "maxItems": 3, + "minItems": 3, + "type": "array" + } + }, + "required": [], + "type": "object" + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "component" + ], + "type": "object" + }, + "occurrence_update": { + "description": "Set occurrence pose in its assembly context and capture position; explicit grounding/ungrounding. Grounded or constrained transforms fail with recovery.", + "properties": { + "grounded": { + "type": "boolean" + }, + "groundedToParent": { + "type": "boolean" + }, + "occurrence": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "transform": { + "additionalProperties": false, + "description": "Absolute rotation about the given nonzero axis through origin (deg), then translation (mm). Create/insert use parent component coordinates; updates use the selected occurrence assembly context.", + "properties": { + "angle": { + "type": "number" + }, + "axis": { + "items": { + "type": "number" + }, + "maxItems": 3, + "minItems": 3, + "type": "array" + }, + "position": { + "items": { + "type": "number" + }, + "maxItems": 3, + "minItems": 3, + "type": "array" + } + }, + "required": [], + "type": "object" + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "occurrence" + ], + "type": "object" + }, + "parameter_batch": { + "description": "Atomic sequential create/set/delete parameter edits with one recompute+health gate; delete supports user parameters only. Any failure aborts all edits and reports semantic recovery.", + "properties": { + "changes": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "action": { + "enum": [ + "create" + ] + }, + "comment": { + "type": "string" + }, + "expression": { + "type": "string" + }, + "name": { + "type": "string" + }, + "unit": { + "enum": [ + "mm", + "deg", + "kg", + "" + ] + } + }, + "required": [ + "action", + "name", + "expression", + "unit" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "action": { + "enum": [ + "set" + ] + }, + "expression": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "action", + "name", + "expression" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "action": { + "enum": [ + "delete" + ] + }, + "name": { + "type": "string" + } + }, + "required": [ + "action", + "name" + ], + "type": "object" + } + ] + }, + "maxItems": 128, + "minItems": 1, + "type": "array" + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "changes" + ], + "type": "object" + }, + "parameter_create": { + "properties": { + "comment": { + "type": "string" + }, + "expression": { + "type": "string" + }, + "name": { + "type": "string" + }, + "unit": { + "enum": [ + "mm", + "deg", + "kg", + "" + ] + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "name", + "expression", + "unit" + ], + "type": "object" + }, + "parameter_set": { + "description": "Set an expression, recompute and inspect health. Failure restores old expression and reports a semantic recovery witness; not an atomic transaction.", + "properties": { + "expression": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "name", + "expression" + ], + "type": "object" + }, + "plane_create": { + "description": "Create offset plane (offset in mm) or angled plane around axis (angle in deg).", + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "component": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "name": { + "type": "string" + }, + "offset": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "op": { + "type": "string" + }, + "operationId": { + "type": "string" + }, + "plane": { + "oneOf": [ + { + "enum": [ + "xy", + "xz", + "yz" + ] + }, + { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + } + ] + }, + "sessionId": { + "type": "string" + }, + "target": { + "required": [ + "documentId", + "revision" + ], + "type": "object" + }, + "type": { + "enum": [ + "offset" + ] + } + }, + "required": [ + "type", + "offset" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "angle": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "axis": { + "oneOf": [ + { + "enum": [ + "x", + "y", + "z" + ] + }, + { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + } + ] + }, + "component": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "name": { + "type": "string" + }, + "op": { + "type": "string" + }, + "operationId": { + "type": "string" + }, + "plane": { + "oneOf": [ + { + "enum": [ + "xy", + "xz", + "yz" + ] + }, + { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + } + ] + }, + "sessionId": { + "type": "string" + }, + "target": { + "required": [ + "documentId", + "revision" + ], + "type": "object" + }, + "type": { + "enum": [ + "angle" + ] + } + }, + "required": [ + "type", + "axis", + "angle" + ], + "type": "object" + } + ], + "properties": { + "angle": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "axis": { + "oneOf": [ + { + "enum": [ + "x", + "y", + "z" + ] + }, + { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + } + ] + }, + "component": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "name": { + "type": "string" + }, + "offset": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "plane": { + "oneOf": [ + { + "enum": [ + "xy", + "xz", + "yz" + ] + }, + { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + } + ] + }, + "type": { + "enum": [ + "offset", + "angle" + ] + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "type" + ], + "type": "object" + }, + "regular_joint_create": { + "description": "Create a geometry-solving regular joint in the root component using two distinct instance-context attachments or joint origins. All seven native motion families; offsets mm, angle and angular limits deg. Read back freshly resolved attachment graph, native parameters and root-space occurrence placement. Root/non-root sides must be unambiguous; both grounded and ancestor/descendant pairs are refused. Inferred joints are unsupported. Existing pending positions must be captured or reverted first. Before applying drive values, checks native joint-transform anchor offset magnitude/axiality and zero-angle axis collinearity. Native joint matrices have explicit provenance; physical angle/flip sense requires independent occurrence-geometry acceptance.", + "properties": { + "angle": { + "description": "Number in mm (angle in deg), or a dimensionally valid Fusion parameter expression.", + "oneOf": [ + { + "type": "number" + }, + { + "maxLength": 512, + "minLength": 1, + "type": "string" + } + ] + }, + "axis": { + "enum": [ + "x", + "y", + "z" + ] + }, + "flipped": { + "type": "boolean" + }, + "geometryOne": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "edge": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "entity": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "keyPoint": { + "enum": [ + "center", + "start", + "middle", + "end" + ] + } + }, + "required": [ + "entity" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "origin": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + } + }, + "required": [ + "origin" + ], + "type": "object" + } + ] + }, + "geometryTwo": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "edge": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "entity": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "keyPoint": { + "enum": [ + "center", + "start", + "middle", + "end" + ] + } + }, + "required": [ + "entity" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "origin": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + } + }, + "required": [ + "origin" + ], + "type": "object" + } + ] + }, + "locked": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "offset": { + "description": "Number in mm (angle in deg), or a dimensionally valid Fusion parameter expression.", + "oneOf": [ + { + "type": "number" + }, + { + "maxLength": 512, + "minLength": 1, + "type": "string" + } + ] + }, + "pitchLimits": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "maximum": { + "type": "number" + }, + "maximumEnabled": { + "type": "boolean" + }, + "minimum": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "rest": { + "type": "number" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "type": "object" + }, + "pitchValue": { + "type": "number" + }, + "primarySlideLimits": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "maximum": { + "type": "number" + }, + "maximumEnabled": { + "type": "boolean" + }, + "minimum": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "rest": { + "type": "number" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "type": "object" + }, + "primarySlideValue": { + "type": "number" + }, + "rollLimits": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "maximum": { + "type": "number" + }, + "maximumEnabled": { + "type": "boolean" + }, + "minimum": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "rest": { + "type": "number" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "type": "object" + }, + "rollValue": { + "type": "number" + }, + "rotationLimits": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "maximum": { + "type": "number" + }, + "maximumEnabled": { + "type": "boolean" + }, + "minimum": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "rest": { + "type": "number" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "type": "object" + }, + "rotationValue": { + "type": "number" + }, + "secondarySlideLimits": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "maximum": { + "type": "number" + }, + "maximumEnabled": { + "type": "boolean" + }, + "minimum": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "rest": { + "type": "number" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "type": "object" + }, + "secondarySlideValue": { + "type": "number" + }, + "slideAxis": { + "enum": [ + "x", + "y", + "z" + ] + }, + "slideLimits": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "maximum": { + "type": "number" + }, + "maximumEnabled": { + "type": "boolean" + }, + "minimum": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "rest": { + "type": "number" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "type": "object" + }, + "slideValue": { + "type": "number" + }, + "type": { + "enum": [ + "rigid", + "revolute", + "slider", + "cylindrical", + "pin_slot", + "planar", + "ball" + ] + }, + "yawLimits": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "maximum": { + "type": "number" + }, + "maximumEnabled": { + "type": "boolean" + }, + "minimum": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "rest": { + "type": "number" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "type": "object" + }, + "yawValue": { + "type": "number" + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "type", + "geometryOne", + "geometryTwo" + ], + "type": "object" + }, + "regular_joint_edit": { + "description": "Redefine root-owned regular joint geometry, motion family, offsets, angle, flip or lock. Restore timeline in finally and verify after recompute. Use joint_edit for name, suppression, limits and drive values; joint_delete for deletion.", + "properties": { + "angle": { + "description": "Number in mm (angle in deg), or a dimensionally valid Fusion parameter expression.", + "oneOf": [ + { + "type": "number" + }, + { + "maxLength": 512, + "minLength": 1, + "type": "string" + } + ] + }, + "axis": { + "enum": [ + "x", + "y", + "z" + ] + }, + "flipped": { + "type": "boolean" + }, + "geometryOne": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "edge": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "entity": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "keyPoint": { + "enum": [ + "center", + "start", + "middle", + "end" + ] + } + }, + "required": [ + "entity" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "origin": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + } + }, + "required": [ + "origin" + ], + "type": "object" + } + ] + }, + "geometryTwo": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "edge": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "entity": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "keyPoint": { + "enum": [ + "center", + "start", + "middle", + "end" + ] + } + }, + "required": [ + "entity" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "origin": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + } + }, + "required": [ + "origin" + ], + "type": "object" + } + ] + }, + "joint": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "locked": { + "type": "boolean" + }, + "offset": { + "description": "Number in mm (angle in deg), or a dimensionally valid Fusion parameter expression.", + "oneOf": [ + { + "type": "number" + }, + { + "maxLength": 512, + "minLength": 1, + "type": "string" + } + ] + }, + "offsetX": { + "description": "Number in mm (angle in deg), or a dimensionally valid Fusion parameter expression.", + "oneOf": [ + { + "type": "number" + }, + { + "maxLength": 512, + "minLength": 1, + "type": "string" + } + ] + }, + "offsetY": { + "description": "Number in mm (angle in deg), or a dimensionally valid Fusion parameter expression.", + "oneOf": [ + { + "type": "number" + }, + { + "maxLength": 512, + "minLength": 1, + "type": "string" + } + ] + }, + "slideAxis": { + "enum": [ + "x", + "y", + "z" + ] + }, + "type": { + "enum": [ + "rigid", + "revolute", + "slider", + "cylindrical", + "pin_slot", + "planar", + "ball" + ] + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "joint" + ], + "type": "object" + }, + "rigid_group_create": { + "description": "Create a native rigid group from exact occurrence instances; includeChildren defaults true.", + "properties": { + "includeChildren": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "occurrences": { + "items": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "maxItems": 256, + "minItems": 2, + "type": "array" + } + }, + "required": [ + "op", + "target", + "sessionId", + "operationId", + "occurrences" + ], + "type": "object" + }, + "rigid_group_delete": { + "description": "Delete an exact rigid group with transaction recovery.", + "properties": { + "group": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + } + }, + "required": [ + "op", + "target", + "sessionId", + "operationId", + "group" + ], + "type": "object" + }, + "rigid_group_edit": { + "description": "Rename or suppress an exact rigid group with transaction recovery.", + "properties": { + "group": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "name": { + "type": "string" + }, + "suppressed": { + "type": "boolean" + } + }, + "required": [ + "op", + "target", + "sessionId", + "operationId", + "group" + ], + "type": "object" + }, + "sketch_constraint": { + "description": "Constrain entities in one sketch. midpoint order is point,line; symmetry order entity1,entity2,symmetryLine. fix sets one entity fixed.", + "properties": { + "entities": { + "items": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "maxItems": 256, + "minItems": 1, + "type": "array" + }, + "sketch": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "type": { + "enum": [ + "horizontal", + "vertical", + "coincident", + "parallel", + "perpendicular", + "tangent", + "equal", + "concentric", + "collinear", + "midpoint", + "symmetry", + "fix" + ] + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "sketch", + "type", + "entities" + ], + "type": "object" + }, + "sketch_create": { + "description": "Create a native component-definition sketch on an origin/construction plane or planar face. Coordinates in subsequent geometry are sketch-space mm.", + "properties": { + "component": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "name": { + "type": "string" + }, + "plane": { + "oneOf": [ + { + "enum": [ + "xy", + "xz", + "yz" + ] + }, + { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + } + ] + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target" + ], + "type": "object" + }, + "sketch_curve_edit": { + "description": "Edit one native sketch curve with sketch-space mm pick. trim removes the nearest segment; extend chooses nearest endpoint; split requires an interior on-curve point; break uses neighboring intersections. Fresh replacement refs are returned; original may be deleted. In-place extend returns the original with a fresh ref only after its geometry change survives recompute. No change or empty break/split fails. Full trim deletion requires allowDelete true. Linked/derived/deferred-compute geometry refused.", + "properties": { + "action": { + "enum": [ + "trim", + "extend", + "split", + "break" + ] + }, + "allowDelete": { + "type": "boolean" + }, + "createConstraints": { + "type": "boolean" + }, + "curve": { + "description": "Current guarded entity reference; root-space child geometry requires its exact occurrence context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "pick": { + "items": { + "type": "number" + }, + "maxItems": 3, + "minItems": 2, + "type": "array" + }, + "sketch": { + "description": "Current guarded entity reference; root-space child geometry requires its exact occurrence context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + } + }, + "required": [ + "op", + "target", + "sessionId", + "operationId", + "sketch", + "curve", + "action", + "pick" + ], + "type": "object" + }, + "sketch_diagnostics": { + "description": "Read native sketch health/message, fully-constrained/fixed/linked/reference flags, geometric constraints and driving dimensions. Reports no solver DOF count or conflict-set explanation; no recompute/mutation.", + "properties": { + "sketch": { + "description": "Current guarded entity reference; root-space child geometry requires its exact occurrence context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + } + }, + "required": [ + "op", + "target", + "sketch" + ], + "type": "object" + }, + "sketch_dimension": { + "description": "Add driving dimensions. Distance uses two point references; radius/diameter one curve; angle two lines. Numeric expressions use mm or deg.", + "properties": { + "entities": { + "items": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "maxItems": 256, + "minItems": 1, + "type": "array" + }, + "expression": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "parameterName": { + "type": "string" + }, + "position": { + "items": { + "type": "number" + }, + "maxItems": 3, + "minItems": 2, + "type": "array" + }, + "sketch": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "type": { + "enum": [ + "distance", + "horizontal", + "vertical", + "radius", + "diameter", + "angle" + ] + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "sketch", + "type", + "entities", + "expression", + "position" + ], + "type": "object" + }, + "sketch_edit": { + "description": "Move sketch points/endpoints/centers in sketch-space mm, change radius or construction/fixed flags; solver failure aborts the operation.", + "properties": { + "changes": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "center": { + "items": { + "type": "number" + }, + "maxItems": 3, + "minItems": 2, + "type": "array" + }, + "construction": { + "type": "boolean" + }, + "end": { + "items": { + "type": "number" + }, + "maxItems": 3, + "minItems": 2, + "type": "array" + }, + "fixed": { + "type": "boolean" + }, + "position": { + "items": { + "type": "number" + }, + "maxItems": 3, + "minItems": 2, + "type": "array" + }, + "radius": { + "type": "number" + }, + "start": { + "items": { + "type": "number" + }, + "maxItems": 3, + "minItems": 2, + "type": "array" + } + }, + "required": [], + "type": "object" + }, + "entity": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "sketch": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "sketch", + "entity", + "changes" + ], + "type": "object" + }, + "sketch_geometry": { + "description": "Create a transactionally grouped set of sketch primitives. Numeric geometry uses mm and arc sweeps/polygon angles use deg.", + "properties": { + "items": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "position": { + "items": { + "type": "number" + }, + "maxItems": 3, + "minItems": 2, + "type": "array" + }, + "type": { + "enum": [ + "point" + ] + } + }, + "required": [ + "type", + "position" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "construction": { + "type": "boolean" + }, + "end": { + "items": { + "type": "number" + }, + "maxItems": 3, + "minItems": 2, + "type": "array" + }, + "start": { + "items": { + "type": "number" + }, + "maxItems": 3, + "minItems": 2, + "type": "array" + }, + "type": { + "enum": [ + "line" + ] + } + }, + "required": [ + "type", + "start", + "end" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "construction": { + "type": "boolean" + }, + "end": { + "items": { + "type": "number" + }, + "maxItems": 3, + "minItems": 2, + "type": "array" + }, + "start": { + "items": { + "type": "number" + }, + "maxItems": 3, + "minItems": 2, + "type": "array" + }, + "type": { + "enum": [ + "rectangle" + ] + } + }, + "required": [ + "type", + "start", + "end" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "center": { + "items": { + "type": "number" + }, + "maxItems": 3, + "minItems": 2, + "type": "array" + }, + "construction": { + "type": "boolean" + }, + "radius": { + "exclusiveMinimum": 0, + "type": "number" + }, + "type": { + "enum": [ + "circle" + ] + } + }, + "required": [ + "type", + "center", + "radius" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "center": { + "items": { + "type": "number" + }, + "maxItems": 3, + "minItems": 2, + "type": "array" + }, + "construction": { + "type": "boolean" + }, + "start": { + "items": { + "type": "number" + }, + "maxItems": 3, + "minItems": 2, + "type": "array" + }, + "sweep": { + "type": "number" + }, + "type": { + "enum": [ + "arc" + ] + } + }, + "required": [ + "type", + "center", + "start", + "sweep" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "angle": { + "type": "number" + }, + "center": { + "items": { + "type": "number" + }, + "maxItems": 3, + "minItems": 2, + "type": "array" + }, + "construction": { + "type": "boolean" + }, + "radius": { + "exclusiveMinimum": 0, + "type": "number" + }, + "sides": { + "maximum": 128, + "minimum": 3, + "type": "integer" + }, + "type": { + "enum": [ + "polygon" + ] + } + }, + "required": [ + "type", + "center", + "radius", + "sides" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "construction": { + "type": "boolean" + }, + "end": { + "items": { + "type": "number" + }, + "maxItems": 3, + "minItems": 2, + "type": "array" + }, + "start": { + "items": { + "type": "number" + }, + "maxItems": 3, + "minItems": 2, + "type": "array" + }, + "type": { + "enum": [ + "slot" + ] + }, + "width": { + "exclusiveMinimum": 0, + "type": "number" + } + }, + "required": [ + "type", + "start", + "end", + "width" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "closed": { + "type": "boolean" + }, + "construction": { + "type": "boolean" + }, + "points": { + "items": { + "items": { + "type": "number" + }, + "maxItems": 3, + "minItems": 2, + "type": "array" + }, + "maxItems": 128, + "minItems": 2, + "type": "array" + }, + "type": { + "enum": [ + "spline" + ] + } + }, + "required": [ + "type", + "points" + ], + "type": "object" + } + ] + }, + "maxItems": 128, + "minItems": 1, + "type": "array" + }, + "sketch": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "sketch", + "items" + ], + "type": "object" + }, + "sketch_offset": { + "description": "Modern createOffsetInput/addOffset2. Curves must be native 2D, unique, in connected flow order (never reordered). Nonzero signed mm number/expression: positive right, negative left; a single circle/ellipse positive outside. Topology matched defaults true. Read back native signed distance, parent/child refs and controlling parameter after recompute. No retired Sketch.offset fallback.", + "properties": { + "curves": { + "items": { + "description": "Current guarded entity reference; root-space child geometry requires its exact occurrence context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "maxItems": 256, + "minItems": 1, + "type": "array" + }, + "distance": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "sketch": { + "description": "Current guarded entity reference; root-space child geometry requires its exact occurrence context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "topologyMatched": { + "type": "boolean" + } + }, + "required": [ + "op", + "target", + "sessionId", + "operationId", + "sketch", + "curves", + "distance" + ], + "type": "object" + }, + "sketch_project": { + "description": "Project selected topology into a sketch with Fusion associative projection.", + "properties": { + "entities": { + "items": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "maxItems": 256, + "minItems": 1, + "type": "array" + }, + "sketch": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "sketch", + "entities" + ], + "type": "object" + }, + "transaction_probe": { + "description": "Verify runtime PTransaction abort+commit using temporary user parameters, remove probe data and compare semantic restoration. Required once per running add-in session before feature/sketch/atomic-batch writes. Returns fresh target; inspect again for current references.", + "properties": {}, + "required": [ + "op", + "sessionId", + "operationId", + "target" + ], + "type": "object" + } + }, + "sessionId": "str", + "success": "bool", + "transactions": "object" + }, + "statusVerb": null, + "summary": "Discover typed mechanical operations, exact input schemas, units, recovery limitations and current add-in session ID.", + "timeoutSeconds": 30 + }, + { + "example": {}, + "input": { + "operationId": "required str", + "sessionId": "required str" + }, + "longRunning": false, + "name": "fusion_mech_operation_status", + "output": { + "currentSessionId": "str", + "historical": "bool", + "history": "durable identity/phase/timestamps", + "observedResult": "bounded unconfirmed current-session result or null", + "phase": "admitted|dispatched|executing|succeeded|failed|not_executed", + "reconciliationRequired": "bool", + "result": "recorded terminal result or null", + "state": "running|succeeded|failed|interrupted|reconciliation_required", + "success": "bool" + }, + "statusVerb": null, + "summary": "Read durable typed-operation history by recorded sessionId and operationId, including after HTTP timeout or add-in restart. Returns the recorded request digest, source version, target identity, phase/timestamps and terminal result. Historical nonterminal work is interrupted/unknown and never replayed. Admission without a durable dispatch claim or uncertain persistence requires reconciliation. Missing history never proves no execution. This observational route bypasses lifecycle repair, window etiquette and the CAD queue; it never starts/restarts Fusion or dismisses dialogs. An unavailable add-in returns operation_status_unavailable with unknown outcome. SQLite recovery may repair only the app-owned journal. A native call monopolizing Python's GIL can still delay responses.", + "timeoutSeconds": 30 + }, + { + "example": { + "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", + "operationId": "required unique write ID; reuse unchanged for retry", + "operationSchemas": { + "assembly_properties": { + "description": "High-accuracy physical properties of whole root assembly (default) or an exact Component/Occurrence/BRepBody. Returns mass kg, volume mm3, area mm2, center of mass mm, mean density kg/mm3 and definition/instance coordinate scope.", + "properties": { + "entity": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + } + }, + "required": [ + "op", + "target" + ], + "type": "object" + }, + "bom": { + "description": "Structured component-definition and instance BOM; quantities by actual identity, root bodies plus nested children; purchased metadata collapses subtrees unless exploded. Physical mass includes all unsuppressed bodies exactly once per instance. Includes exact current active configuration provenance; incomplete configuration scope refuses. Visibility does not remove quantity.", + "properties": { + "explodePurchased": { + "type": "boolean" + } + }, + "required": [ + "op", + "target" + ], + "type": "object" + }, + "bom_export": { + "description": "Export flat grouped purchasing rows or structured parent/path/depth instance rows and supplier provenance to CSV with exclusive/atomic publication. This external effect is separate from model transactions. Both CSV modes include document/revision and current active configuration table/row provenance.", + "properties": { + "explodePurchased": { + "type": "boolean" + }, + "filename": { + "type": "string" + }, + "mode": { + "enum": [ + "flat", + "structured" + ] + }, + "overwrite": { + "type": "boolean" + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "filename" + ], + "type": "object" + }, + "cad_import": { + "description": "Import a pre-downloaded local STEP/SAT into a new wrapper component in Hybrid design. Validate optional expected SHA-256, record actual source/hash/supplier metadata, preserve camera, measure model recovery. Does not fetch URLs or create cloud documents. Returns a representative root-context occurrence and all affectedOccurrences when the parent definition is reused.", + "properties": { + "asPurchased": { + "type": "boolean" + }, + "filename": { + "type": "string" + }, + "name": { + "type": "string" + }, + "parent": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "provenance": { + "additionalProperties": false, + "properties": { + "description": { + "type": "string" + }, + "sourceUrl": { + "type": "string" + }, + "supplier": { + "type": "string" + }, + "supplierPartNumber": { + "type": "string" + } + }, + "required": [ + "supplier", + "supplierPartNumber", + "sourceUrl" + ], + "type": "object" + }, + "sha256": { + "type": "string" + }, + "transform": { + "additionalProperties": false, + "description": "Absolute rotation about the given nonzero axis through origin (deg), then translation (mm). Create/insert use parent component coordinates; updates use the selected occurrence assembly context.", + "properties": { + "angle": { + "type": "number" + }, + "axis": { + "items": { + "type": "number" + }, + "maxItems": 3, + "minItems": 3, + "type": "array" + }, + "position": { + "items": { + "type": "number" + }, + "maxItems": 3, + "minItems": 3, + "type": "array" + } + }, + "required": [], + "type": "object" + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "filename", + "provenance" + ], + "type": "object" + }, + "component_create": { + "description": "Create a native internal component and its first occurrence in a Hybrid design. parent is a component definition; edits to it affect every instance. Returns a representative root-context occurrence and all affectedOccurrences when the parent definition is reused.", + "properties": { + "metadata": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "asPurchased": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "excludeFromBom": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "partNumber": { + "type": "string" + }, + "sourceUrl": { + "type": "string" + }, + "supplier": { + "type": "string" + }, + "supplierPartNumber": { + "type": "string" + } + }, + "required": [], + "type": "object" + }, + "name": { + "type": "string" + }, + "parent": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "transform": { + "additionalProperties": false, + "description": "Absolute rotation about the given nonzero axis through origin (deg), then translation (mm). Create/insert use parent component coordinates; updates use the selected occurrence assembly context.", + "properties": { + "angle": { + "type": "number" + }, + "axis": { + "items": { + "type": "number" + }, + "maxItems": 3, + "minItems": 3, + "type": "array" + }, + "position": { + "items": { + "type": "number" + }, + "maxItems": 3, + "minItems": 3, + "type": "array" + } + }, + "required": [], + "type": "object" + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "name" + ], + "type": "object" + }, + "component_metadata": { + "description": "Update component-definition name/part number/description and persistent supplier or BOM attributes. Shared occurrences all see this metadata.", + "properties": { + "changes": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "asPurchased": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "excludeFromBom": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "partNumber": { + "type": "string" + }, + "sourceUrl": { + "type": "string" + }, + "supplier": { + "type": "string" + }, + "supplierPartNumber": { + "type": "string" + } + }, + "required": [], + "type": "object" + }, + "component": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "changes" + ], + "type": "object" + }, + "configuration_inspect": { + "description": "Read the loaded Design configuration graph without activation or recompute: ordinary rows, the separate native reserved test row, all supported typed cells including inactive/test cells, exact controlled-source links and current ordinary row. Unreadable fields and unknown/theme/hierarchical/inserted scopes remain explicit incomplete diagnostics. Configuration lifecycle and configured-design model mutations remain disabled pending native recovery/archive gates.", + "properties": {}, + "required": [ + "op", + "target" + ], + "type": "object" + }, + "construction_axis": { + "description": "Create a parametric axis from native references (edge/circular face/two intersecting planes/two distinct points). Root-space external geometry requires exact occurrence context. For cross-component geometry into a non-root target component, creationOccurrence must identify a root-context instance of that target. Returns component-space native axis geometry/health. No direct-only infinite-line or preview snap APIs.", + "properties": { + "component": { + "description": "Current guarded entity reference; root-space child geometry requires its exact occurrence context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "creationOccurrence": { + "description": "Current guarded entity reference; root-space child geometry requires its exact occurrence context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "entities": { + "items": { + "description": "Current guarded entity reference; root-space child geometry requires its exact occurrence context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "maxItems": 2, + "minItems": 1, + "type": "array" + }, + "name": { + "type": "string" + }, + "type": { + "enum": [ + "edge", + "circular_face", + "two_planes", + "two_points" + ] + } + }, + "required": [ + "op", + "target", + "sessionId", + "operationId", + "type", + "entities" + ], + "type": "object" + }, + "entity_delete": { + "description": "Delete sketch geometry or independently deletable timeline features. Model transaction and semantic recovery cover downstream failures; document and external file deletion are excluded.", + "properties": { + "entity": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "entity" + ], + "type": "object" + }, + "export": { + "description": "Export entire design using runtime-checked API. Preflight and unique same-filesystem staging preserve the destination on failure; publish only nonempty verified output. Availability differs by Fusion build. STL numeric coordinates use mm.", + "properties": { + "format": { + "default": "step", + "enum": [ + "step", + "stl", + "3mf", + "f3d", + "iges", + "sat", + "smt", + "usdz", + "fbx", + "skp" + ] + }, + "outputPath": { + "type": "string" + }, + "overwrite": { + "default": false, + "type": "boolean" + }, + "refinement": { + "default": "medium", + "enum": [ + "low", + "medium", + "high" + ] + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "outputPath" + ], + "type": "object" + }, + "feature_create": { + "description": "Create a typed native solid feature. All length quantities are mm (or expressions), angles deg. Cut/intersect require explicit participant bodies. Join needs a single existing component body; combine selects multiple bodies explicitly. Feature availability is runtime-checked; missing APIs fail before commit.", + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "bodies": { + "items": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "maxItems": 256, + "minItems": 1, + "type": "array" + }, + "component": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "direction": { + "enum": [ + "positive", + "negative", + "symmetric" + ] + }, + "distance": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "name": { + "type": "string" + }, + "op": { + "type": "string" + }, + "operation": { + "enum": [ + "new_body", + "join", + "cut", + "intersect" + ] + }, + "operationId": { + "type": "string" + }, + "profiles": { + "items": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "maxItems": 256, + "minItems": 1, + "type": "array" + }, + "sessionId": { + "type": "string" + }, + "taper": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "target": { + "additionalProperties": false, + "properties": { + "documentId": { + "type": "string" + }, + "revision": { + "type": "string" + } + }, + "required": [ + "documentId", + "revision" + ], + "type": "object" + }, + "type": { + "enum": [ + "extrude" + ] + } + }, + "required": [ + "type", + "profiles", + "distance" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "angle": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "axis": { + "oneOf": [ + { + "enum": [ + "x", + "y", + "z" + ] + }, + { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + } + ] + }, + "bodies": { + "items": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "maxItems": 256, + "minItems": 1, + "type": "array" + }, + "component": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "name": { + "type": "string" + }, + "op": { + "type": "string" + }, + "operation": { + "enum": [ + "new_body", + "join", + "cut", + "intersect" + ] + }, + "operationId": { + "type": "string" + }, + "profiles": { + "items": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "maxItems": 256, + "minItems": 1, + "type": "array" + }, + "sessionId": { + "type": "string" + }, + "target": { + "additionalProperties": false, + "properties": { + "documentId": { + "type": "string" + }, + "revision": { + "type": "string" + } + }, + "required": [ + "documentId", + "revision" + ], + "type": "object" + }, + "type": { + "enum": [ + "revolve" + ] + } + }, + "required": [ + "type", + "profiles", + "axis", + "angle" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "component": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "edges": { + "items": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "maxItems": 256, + "minItems": 1, + "type": "array" + }, + "name": { + "type": "string" + }, + "op": { + "type": "string" + }, + "operationId": { + "type": "string" + }, + "radius": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "sessionId": { + "type": "string" + }, + "tangentChain": { + "type": "boolean" + }, + "target": { + "additionalProperties": false, + "properties": { + "documentId": { + "type": "string" + }, + "revision": { + "type": "string" + } + }, + "required": [ + "documentId", + "revision" + ], + "type": "object" + }, + "type": { + "enum": [ + "fillet" + ] + } + }, + "required": [ + "type", + "edges", + "radius" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "component": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "distance": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "edges": { + "items": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "maxItems": 256, + "minItems": 1, + "type": "array" + }, + "name": { + "type": "string" + }, + "op": { + "type": "string" + }, + "operationId": { + "type": "string" + }, + "sessionId": { + "type": "string" + }, + "tangentChain": { + "type": "boolean" + }, + "target": { + "additionalProperties": false, + "properties": { + "documentId": { + "type": "string" + }, + "revision": { + "type": "string" + } + }, + "required": [ + "documentId", + "revision" + ], + "type": "object" + }, + "type": { + "enum": [ + "chamfer" + ] + } + }, + "required": [ + "type", + "edges", + "distance" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "component": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "faces": { + "items": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "maxItems": 256, + "minItems": 1, + "type": "array" + }, + "name": { + "type": "string" + }, + "op": { + "type": "string" + }, + "operationId": { + "type": "string" + }, + "sessionId": { + "type": "string" + }, + "tangentChain": { + "type": "boolean" + }, + "target": { + "additionalProperties": false, + "properties": { + "documentId": { + "type": "string" + }, + "revision": { + "type": "string" + } + }, + "required": [ + "documentId", + "revision" + ], + "type": "object" + }, + "thickness": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "type": { + "enum": [ + "shell" + ] + } + }, + "required": [ + "type", + "faces", + "thickness" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "bodies": { + "items": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "maxItems": 256, + "minItems": 1, + "type": "array" + }, + "component": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "depth": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "diameter": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "name": { + "type": "string" + }, + "op": { + "type": "string" + }, + "operationId": { + "type": "string" + }, + "points": { + "items": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "maxItems": 256, + "minItems": 1, + "type": "array" + }, + "sessionId": { + "type": "string" + }, + "target": { + "additionalProperties": false, + "properties": { + "documentId": { + "type": "string" + }, + "revision": { + "type": "string" + } + }, + "required": [ + "documentId", + "revision" + ], + "type": "object" + }, + "type": { + "enum": [ + "hole" + ] + } + }, + "required": [ + "type", + "points", + "diameter", + "depth", + "bodies" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "bodies": { + "items": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "maxItems": 256, + "minItems": 1, + "type": "array" + }, + "component": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "name": { + "type": "string" + }, + "op": { + "type": "string" + }, + "operation": { + "enum": [ + "new_body", + "join", + "cut", + "intersect" + ] + }, + "operationId": { + "type": "string" + }, + "profiles": { + "items": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "maxItems": 256, + "minItems": 1, + "type": "array" + }, + "sessionId": { + "type": "string" + }, + "target": { + "additionalProperties": false, + "properties": { + "documentId": { + "type": "string" + }, + "revision": { + "type": "string" + } + }, + "required": [ + "documentId", + "revision" + ], + "type": "object" + }, + "type": { + "enum": [ + "loft" + ] + } + }, + "required": [ + "type", + "profiles" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "bodies": { + "items": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "maxItems": 256, + "minItems": 1, + "type": "array" + }, + "component": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "name": { + "type": "string" + }, + "op": { + "type": "string" + }, + "operation": { + "enum": [ + "new_body", + "join", + "cut", + "intersect" + ] + }, + "operationId": { + "type": "string" + }, + "path": { + "items": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "maxItems": 256, + "minItems": 1, + "type": "array" + }, + "profiles": { + "items": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "maxItems": 256, + "minItems": 1, + "type": "array" + }, + "sessionId": { + "type": "string" + }, + "target": { + "additionalProperties": false, + "properties": { + "documentId": { + "type": "string" + }, + "revision": { + "type": "string" + } + }, + "required": [ + "documentId", + "revision" + ], + "type": "object" + }, + "type": { + "enum": [ + "sweep" + ] + } + }, + "required": [ + "type", + "profiles", + "path" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "body": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "component": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "keepTools": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "op": { + "type": "string" + }, + "operation": { + "enum": [ + "join", + "cut", + "intersect" + ] + }, + "operationId": { + "type": "string" + }, + "sessionId": { + "type": "string" + }, + "target": { + "additionalProperties": false, + "properties": { + "documentId": { + "type": "string" + }, + "revision": { + "type": "string" + } + }, + "required": [ + "documentId", + "revision" + ], + "type": "object" + }, + "tools": { + "items": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "maxItems": 256, + "minItems": 1, + "type": "array" + }, + "type": { + "enum": [ + "combine" + ] + } + }, + "required": [ + "type", + "body", + "tools", + "operation" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "bodies": { + "items": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "maxItems": 256, + "minItems": 1, + "type": "array" + }, + "component": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "extend": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "op": { + "type": "string" + }, + "operationId": { + "type": "string" + }, + "plane": { + "oneOf": [ + { + "enum": [ + "xy", + "xz", + "yz" + ] + }, + { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + } + ] + }, + "sessionId": { + "type": "string" + }, + "target": { + "additionalProperties": false, + "properties": { + "documentId": { + "type": "string" + }, + "revision": { + "type": "string" + } + }, + "required": [ + "documentId", + "revision" + ], + "type": "object" + }, + "type": { + "enum": [ + "split" + ] + } + }, + "required": [ + "type", + "bodies", + "plane" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "angle": { + "type": "number" + }, + "axis": { + "items": { + "type": "number" + }, + "maxItems": 3, + "minItems": 3, + "type": "array" + }, + "bodies": { + "items": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "maxItems": 256, + "minItems": 1, + "type": "array" + }, + "component": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "name": { + "type": "string" + }, + "op": { + "type": "string" + }, + "operationId": { + "type": "string" + }, + "origin": { + "items": { + "type": "number" + }, + "maxItems": 3, + "minItems": 2, + "type": "array" + }, + "sessionId": { + "type": "string" + }, + "target": { + "additionalProperties": false, + "properties": { + "documentId": { + "type": "string" + }, + "revision": { + "type": "string" + } + }, + "required": [ + "documentId", + "revision" + ], + "type": "object" + }, + "translation": { + "items": { + "type": "number" + }, + "maxItems": 3, + "minItems": 2, + "type": "array" + }, + "type": { + "enum": [ + "move" + ] + } + }, + "required": [ + "type", + "bodies" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "bodies": { + "items": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "maxItems": 256, + "minItems": 1, + "type": "array" + }, + "component": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "name": { + "type": "string" + }, + "op": { + "type": "string" + }, + "operationId": { + "type": "string" + }, + "plane": { + "oneOf": [ + { + "enum": [ + "xy", + "xz", + "yz" + ] + }, + { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + } + ] + }, + "sessionId": { + "type": "string" + }, + "target": { + "additionalProperties": false, + "properties": { + "documentId": { + "type": "string" + }, + "revision": { + "type": "string" + } + }, + "required": [ + "documentId", + "revision" + ], + "type": "object" + }, + "type": { + "enum": [ + "mirror" + ] + } + }, + "required": [ + "type", + "bodies", + "plane" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "axis": { + "oneOf": [ + { + "enum": [ + "x", + "y", + "z" + ] + }, + { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + } + ] + }, + "axis2": { + "oneOf": [ + { + "enum": [ + "x", + "y", + "z" + ] + }, + { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + } + ] + }, + "bodies": { + "items": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "maxItems": 256, + "minItems": 1, + "type": "array" + }, + "component": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "count": { + "maximum": 1000, + "minimum": 2, + "type": "integer" + }, + "count2": { + "maximum": 1000, + "minimum": 1, + "type": "integer" + }, + "name": { + "type": "string" + }, + "op": { + "type": "string" + }, + "operationId": { + "type": "string" + }, + "sessionId": { + "type": "string" + }, + "spacing": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "spacing2": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "target": { + "additionalProperties": false, + "properties": { + "documentId": { + "type": "string" + }, + "revision": { + "type": "string" + } + }, + "required": [ + "documentId", + "revision" + ], + "type": "object" + }, + "type": { + "enum": [ + "rectangular_pattern" + ] + } + }, + "required": [ + "type", + "bodies", + "axis", + "count", + "spacing" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "angle": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "axis": { + "oneOf": [ + { + "enum": [ + "x", + "y", + "z" + ] + }, + { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + } + ] + }, + "bodies": { + "items": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "maxItems": 256, + "minItems": 1, + "type": "array" + }, + "component": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "count": { + "maximum": 1000, + "minimum": 2, + "type": "integer" + }, + "name": { + "type": "string" + }, + "op": { + "type": "string" + }, + "operationId": { + "type": "string" + }, + "sessionId": { + "type": "string" + }, + "target": { + "additionalProperties": false, + "properties": { + "documentId": { + "type": "string" + }, + "revision": { + "type": "string" + } + }, + "required": [ + "documentId", + "revision" + ], + "type": "object" + }, + "type": { + "enum": [ + "circular_pattern" + ] + } + }, + "required": [ + "type", + "bodies", + "axis", + "count" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "angle": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "component": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "faces": { + "items": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "maxItems": 256, + "minItems": 1, + "type": "array" + }, + "name": { + "type": "string" + }, + "op": { + "type": "string" + }, + "operationId": { + "type": "string" + }, + "plane": { + "oneOf": [ + { + "enum": [ + "xy", + "xz", + "yz" + ] + }, + { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + } + ] + }, + "sessionId": { + "type": "string" + }, + "tangentChain": { + "type": "boolean" + }, + "target": { + "additionalProperties": false, + "properties": { + "documentId": { + "type": "string" + }, + "revision": { + "type": "string" + } + }, + "required": [ + "documentId", + "revision" + ], + "type": "object" + }, + "type": { + "enum": [ + "draft" + ] + } + }, + "required": [ + "type", + "faces", + "plane", + "angle" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "component": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "designation": { + "type": "string" + }, + "faces": { + "items": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "maxItems": 256, + "minItems": 1, + "type": "array" + }, + "internal": { + "type": "boolean" + }, + "modeled": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "op": { + "type": "string" + }, + "operationId": { + "type": "string" + }, + "sessionId": { + "type": "string" + }, + "target": { + "additionalProperties": false, + "properties": { + "documentId": { + "type": "string" + }, + "revision": { + "type": "string" + } + }, + "required": [ + "documentId", + "revision" + ], + "type": "object" + }, + "threadClass": { + "type": "string" + }, + "threadType": { + "type": "string" + }, + "type": { + "enum": [ + "thread" + ] + } + }, + "required": [ + "type", + "faces", + "internal", + "threadType", + "designation", + "threadClass" + ], + "type": "object" + } + ], + "properties": { + "angle": {}, + "axis": {}, + "axis2": {}, + "bodies": {}, + "body": {}, + "component": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "count": {}, + "count2": {}, + "depth": {}, + "designation": {}, + "diameter": {}, + "direction": {}, + "distance": {}, + "edges": {}, + "extend": {}, + "faces": {}, + "internal": {}, + "keepTools": {}, + "modeled": {}, + "name": { + "type": "string" + }, + "operation": {}, + "origin": {}, + "path": {}, + "plane": {}, + "points": {}, + "profiles": {}, + "radius": {}, + "spacing": {}, + "spacing2": {}, + "tangentChain": {}, + "taper": {}, + "thickness": {}, + "threadClass": {}, + "threadType": {}, + "tools": {}, + "translation": {}, + "type": { + "enum": [ + "extrude", + "revolve", + "fillet", + "chamfer", + "shell", + "hole", + "loft", + "sweep", + "combine", + "split", + "move", + "mirror", + "rectangular_pattern", + "circular_pattern", + "draft", + "thread" + ] + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "type" + ], + "type": "object" + }, + "feature_edit": { + "description": "Rename/suppress a feature or change expressions of model parameters whose createdBy is that exact feature; read health and abort on downstream errors.", + "properties": { + "feature": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "name": { + "type": "string" + }, + "parameters": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "suppressed": { + "type": "boolean" + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "feature" + ], + "type": "object" + }, + "geometry_query": { + "description": "Complete body-scoped topology query, no arbitrary first-match selection. selection one (default) refuses zero/multiple matches; many may return empty. Geometry types are exact native types; size ranges inclusive in mm/mm2, near is minimum distance in the explicit frame; direction is outward planar face normal or linear edge tangent. Returned refs retain instance context even for component-space filters. Exceeding maxResults fails rather than truncating.", + "properties": { + "body": { + "description": "Current guarded entity reference; root-space child geometry requires its exact occurrence context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "filters": { + "additionalProperties": false, + "properties": { + "area": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "max": { + "minimum": 0, + "type": "number" + }, + "min": { + "minimum": 0, + "type": "number" + } + }, + "required": [], + "type": "object" + }, + "direction": { + "additionalProperties": false, + "properties": { + "orientation": { + "enum": [ + "same", + "parallel" + ] + }, + "tolerance": { + "maximum": 90, + "minimum": 0, + "type": "number" + }, + "vector": { + "items": { + "type": "number" + }, + "maxItems": 3, + "minItems": 3, + "type": "array" + } + }, + "required": [ + "vector" + ], + "type": "object" + }, + "length": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "max": { + "minimum": 0, + "type": "number" + }, + "min": { + "minimum": 0, + "type": "number" + } + }, + "required": [], + "type": "object" + }, + "near": { + "additionalProperties": false, + "properties": { + "point": { + "items": { + "type": "number" + }, + "maxItems": 3, + "minItems": 3, + "type": "array" + }, + "tolerance": { + "minimum": 0, + "type": "number" + } + }, + "required": [ + "point", + "tolerance" + ], + "type": "object" + }, + "radius": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "max": { + "minimum": 0, + "type": "number" + }, + "min": { + "minimum": 0, + "type": "number" + } + }, + "required": [], + "type": "object" + }, + "type": { + "enum": [ + "Plane", + "Cylinder", + "Cone", + "Sphere", + "Torus", + "NurbsSurface", + "Line3D", + "Arc3D", + "Circle3D", + "Ellipse3D", + "EllipticalArc3D", + "NurbsCurve3D", + "Point3D", + "degenerate" + ] + } + }, + "required": [], + "type": "object" + }, + "kind": { + "enum": [ + "faces", + "edges", + "vertices" + ] + }, + "maxResults": { + "maximum": 1024, + "minimum": 1, + "type": "integer" + }, + "selection": { + "enum": [ + "one", + "many" + ] + }, + "space": { + "enum": [ + "root", + "component" + ] + } + }, + "required": [ + "op", + "target", + "body", + "kind", + "space" + ], + "type": "object" + }, + "health": { + "description": "Read timeline warnings/errors. recompute:true also requires current target revision, sessionId and operationId.", + "properties": { + "recompute": { + "default": false, + "type": "boolean" + } + }, + "required": [ + "op", + "target" + ], + "type": "object" + }, + "inspect": { + "description": "Inspect the active Design without mutation. Returns target identity/revision, native component-space bodies and occurrence transforms, parameters and feature health.", + "properties": {}, + "required": [ + "op" + ], + "type": "object" + }, + "inspect_entity": { + "properties": { + "entity": { + "description": "Exact reference returned by inspect; resolves uniquely with document/revision/type/occurrence guards.", + "type": "object" + } + }, + "required": [ + "op", + "target", + "entity" + ], + "type": "object" + }, + "interference": { + "description": "Analyze root-context occurrences/body proxies; returns overlap volume mm3 or touching. Does not create interference bodies.", + "properties": { + "entities": { + "items": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "maxItems": 256, + "minItems": 2, + "type": "array" + }, + "includeTouching": { + "type": "boolean" + }, + "volumeTolerance": { + "minimum": 0, + "type": "number" + } + }, + "required": [ + "op", + "target", + "entities" + ], + "type": "object" + }, + "joint_create": { + "description": "Create an as-built joint without moving occurrences. Non-rigid motion requires explicit joint geometry; x/y/z are joint-coordinate directions. Limits and drive values use mm/deg. Seven native motion types; ball uses joint Z pitch/X yaw; planar uses native primary slide direction reported on readback. Use regular_joint_create for geometry-solving placement.", + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "occurrenceOne": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "occurrenceTwo": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "op": { + "type": "string" + }, + "operationId": { + "type": "string" + }, + "sessionId": { + "type": "string" + }, + "suppressed": { + "type": "boolean" + }, + "target": { + "type": "object" + }, + "type": { + "enum": [ + "rigid" + ] + } + }, + "required": [ + "type" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "axis": { + "enum": [ + "x", + "y", + "z" + ] + }, + "geometry": { + "additionalProperties": false, + "properties": { + "entity": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "keyPoint": { + "enum": [ + "center", + "start", + "middle", + "end" + ] + } + }, + "required": [ + "entity" + ], + "type": "object" + }, + "name": { + "type": "string" + }, + "occurrenceOne": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "occurrenceTwo": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "op": { + "type": "string" + }, + "operationId": { + "type": "string" + }, + "rotationLimits": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "maximum": { + "type": "number" + }, + "maximumEnabled": { + "type": "boolean" + }, + "minimum": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "rest": { + "type": "number" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "type": "object" + }, + "rotationValue": { + "type": "number" + }, + "sessionId": { + "type": "string" + }, + "suppressed": { + "type": "boolean" + }, + "target": { + "type": "object" + }, + "type": { + "enum": [ + "revolute" + ] + } + }, + "required": [ + "type", + "geometry" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "axis": { + "enum": [ + "x", + "y", + "z" + ] + }, + "geometry": { + "additionalProperties": false, + "properties": { + "entity": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "keyPoint": { + "enum": [ + "center", + "start", + "middle", + "end" + ] + } + }, + "required": [ + "entity" + ], + "type": "object" + }, + "name": { + "type": "string" + }, + "occurrenceOne": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "occurrenceTwo": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "op": { + "type": "string" + }, + "operationId": { + "type": "string" + }, + "sessionId": { + "type": "string" + }, + "slideLimits": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "maximum": { + "type": "number" + }, + "maximumEnabled": { + "type": "boolean" + }, + "minimum": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "rest": { + "type": "number" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "type": "object" + }, + "slideValue": { + "type": "number" + }, + "suppressed": { + "type": "boolean" + }, + "target": { + "type": "object" + }, + "type": { + "enum": [ + "slider" + ] + } + }, + "required": [ + "type", + "geometry" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "axis": { + "enum": [ + "x", + "y", + "z" + ] + }, + "geometry": { + "additionalProperties": false, + "properties": { + "entity": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "keyPoint": { + "enum": [ + "center", + "start", + "middle", + "end" + ] + } + }, + "required": [ + "entity" + ], + "type": "object" + }, + "name": { + "type": "string" + }, + "occurrenceOne": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "occurrenceTwo": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "op": { + "type": "string" + }, + "operationId": { + "type": "string" + }, + "rotationLimits": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "maximum": { + "type": "number" + }, + "maximumEnabled": { + "type": "boolean" + }, + "minimum": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "rest": { + "type": "number" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "type": "object" + }, + "rotationValue": { + "type": "number" + }, + "sessionId": { + "type": "string" + }, + "slideLimits": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "maximum": { + "type": "number" + }, + "maximumEnabled": { + "type": "boolean" + }, + "minimum": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "rest": { + "type": "number" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "type": "object" + }, + "slideValue": { + "type": "number" + }, + "suppressed": { + "type": "boolean" + }, + "target": { + "type": "object" + }, + "type": { + "enum": [ + "cylindrical" + ] + } + }, + "required": [ + "type", + "geometry" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "axis": { + "enum": [ + "x", + "y", + "z" + ] + }, + "geometry": { + "additionalProperties": false, + "properties": { + "entity": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "keyPoint": { + "enum": [ + "center", + "start", + "middle", + "end" + ] + } + }, + "required": [ + "entity" + ], + "type": "object" + }, + "name": { + "type": "string" + }, + "occurrenceOne": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "occurrenceTwo": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "op": { + "type": "string" + }, + "operationId": { + "type": "string" + }, + "rotationLimits": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "maximum": { + "type": "number" + }, + "maximumEnabled": { + "type": "boolean" + }, + "minimum": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "rest": { + "type": "number" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "type": "object" + }, + "rotationValue": { + "type": "number" + }, + "sessionId": { + "type": "string" + }, + "slideAxis": { + "enum": [ + "x", + "y", + "z" + ] + }, + "slideLimits": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "maximum": { + "type": "number" + }, + "maximumEnabled": { + "type": "boolean" + }, + "minimum": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "rest": { + "type": "number" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "type": "object" + }, + "slideValue": { + "type": "number" + }, + "suppressed": { + "type": "boolean" + }, + "target": { + "type": "object" + }, + "type": { + "enum": [ + "pin_slot" + ] + } + }, + "required": [ + "type", + "geometry" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "axis": { + "enum": [ + "x", + "y", + "z" + ] + }, + "geometry": { + "additionalProperties": false, + "properties": { + "entity": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "keyPoint": { + "enum": [ + "center", + "start", + "middle", + "end" + ] + } + }, + "required": [ + "entity" + ], + "type": "object" + }, + "name": { + "type": "string" + }, + "occurrenceOne": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "occurrenceTwo": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "op": { + "type": "string" + }, + "operationId": { + "type": "string" + }, + "primarySlideLimits": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "maximum": { + "type": "number" + }, + "maximumEnabled": { + "type": "boolean" + }, + "minimum": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "rest": { + "type": "number" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "type": "object" + }, + "primarySlideValue": { + "type": "number" + }, + "rotationLimits": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "maximum": { + "type": "number" + }, + "maximumEnabled": { + "type": "boolean" + }, + "minimum": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "rest": { + "type": "number" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "type": "object" + }, + "rotationValue": { + "type": "number" + }, + "secondarySlideLimits": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "maximum": { + "type": "number" + }, + "maximumEnabled": { + "type": "boolean" + }, + "minimum": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "rest": { + "type": "number" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "type": "object" + }, + "secondarySlideValue": { + "type": "number" + }, + "sessionId": { + "type": "string" + }, + "suppressed": { + "type": "boolean" + }, + "target": { + "type": "object" + }, + "type": { + "enum": [ + "planar" + ] + } + }, + "required": [ + "type", + "geometry" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "geometry": { + "additionalProperties": false, + "properties": { + "entity": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "keyPoint": { + "enum": [ + "center", + "start", + "middle", + "end" + ] + } + }, + "required": [ + "entity" + ], + "type": "object" + }, + "name": { + "type": "string" + }, + "occurrenceOne": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "occurrenceTwo": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "op": { + "type": "string" + }, + "operationId": { + "type": "string" + }, + "pitchLimits": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "maximum": { + "type": "number" + }, + "maximumEnabled": { + "type": "boolean" + }, + "minimum": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "rest": { + "type": "number" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "type": "object" + }, + "pitchValue": { + "type": "number" + }, + "rollLimits": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "maximum": { + "type": "number" + }, + "maximumEnabled": { + "type": "boolean" + }, + "minimum": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "rest": { + "type": "number" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "type": "object" + }, + "rollValue": { + "type": "number" + }, + "sessionId": { + "type": "string" + }, + "suppressed": { + "type": "boolean" + }, + "target": { + "type": "object" + }, + "type": { + "enum": [ + "ball" + ] + }, + "yawLimits": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "maximum": { + "type": "number" + }, + "maximumEnabled": { + "type": "boolean" + }, + "minimum": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "rest": { + "type": "number" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "type": "object" + }, + "yawValue": { + "type": "number" + } + }, + "required": [ + "type", + "geometry" + ], + "type": "object" + } + ], + "properties": { + "axis": { + "enum": [ + "x", + "y", + "z" + ] + }, + "geometry": { + "additionalProperties": false, + "properties": { + "entity": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "keyPoint": { + "enum": [ + "center", + "start", + "middle", + "end" + ] + } + }, + "required": [ + "entity" + ], + "type": "object" + }, + "name": { + "type": "string" + }, + "occurrenceOne": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "occurrenceTwo": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "pitchLimits": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "maximum": { + "type": "number" + }, + "maximumEnabled": { + "type": "boolean" + }, + "minimum": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "rest": { + "type": "number" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "type": "object" + }, + "pitchValue": { + "type": "number" + }, + "primarySlideLimits": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "maximum": { + "type": "number" + }, + "maximumEnabled": { + "type": "boolean" + }, + "minimum": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "rest": { + "type": "number" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "type": "object" + }, + "primarySlideValue": { + "type": "number" + }, + "rollLimits": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "maximum": { + "type": "number" + }, + "maximumEnabled": { + "type": "boolean" + }, + "minimum": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "rest": { + "type": "number" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "type": "object" + }, + "rollValue": { + "type": "number" + }, + "rotationLimits": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "maximum": { + "type": "number" + }, + "maximumEnabled": { + "type": "boolean" + }, + "minimum": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "rest": { + "type": "number" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "type": "object" + }, + "rotationValue": { + "type": "number" + }, + "secondarySlideLimits": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "maximum": { + "type": "number" + }, + "maximumEnabled": { + "type": "boolean" + }, + "minimum": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "rest": { + "type": "number" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "type": "object" + }, + "secondarySlideValue": { + "type": "number" + }, + "slideAxis": { + "enum": [ + "x", + "y", + "z" + ] + }, + "slideLimits": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "maximum": { + "type": "number" + }, + "maximumEnabled": { + "type": "boolean" + }, + "minimum": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "rest": { + "type": "number" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "type": "object" + }, + "slideValue": { + "type": "number" + }, + "suppressed": { + "type": "boolean" + }, + "type": { + "enum": [ + "rigid", + "revolute", + "slider", + "cylindrical", + "pin_slot", + "planar", + "ball" + ] + }, + "yawLimits": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "maximum": { + "type": "number" + }, + "maximumEnabled": { + "type": "boolean" + }, + "minimum": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "rest": { + "type": "number" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "type": "object" + }, + "yawValue": { + "type": "number" + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "type", + "occurrenceOne", + "occurrenceTwo" + ], + "type": "object" + }, + "joint_delete": { + "description": "Delete one selected Joint or AsBuiltJoint with recovery verification.", + "properties": { + "joint": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "joint" + ], + "type": "object" + }, + "joint_edit": { + "description": "Edit joint name/suppression, enabled limits, rest and drive values; wrong motion type or out-of-limit values fail with recovery. For driven root joints, active motion links must preserve their settings and their partner motion must follow the native ratio; a coupling mismatch aborts.", + "properties": { + "joint": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "name": { + "type": "string" + }, + "pitchLimits": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "maximum": { + "type": "number" + }, + "maximumEnabled": { + "type": "boolean" + }, + "minimum": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "rest": { + "type": "number" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "type": "object" + }, + "pitchValue": { + "type": "number" + }, + "primarySlideLimits": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "maximum": { + "type": "number" + }, + "maximumEnabled": { + "type": "boolean" + }, + "minimum": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "rest": { + "type": "number" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "type": "object" + }, + "primarySlideValue": { + "type": "number" + }, + "rollLimits": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "maximum": { + "type": "number" + }, + "maximumEnabled": { + "type": "boolean" + }, + "minimum": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "rest": { + "type": "number" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "type": "object" + }, + "rollValue": { + "type": "number" + }, + "rotationLimits": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "maximum": { + "type": "number" + }, + "maximumEnabled": { + "type": "boolean" + }, + "minimum": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "rest": { + "type": "number" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "type": "object" + }, + "rotationValue": { + "type": "number" + }, + "secondarySlideLimits": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "maximum": { + "type": "number" + }, + "maximumEnabled": { + "type": "boolean" + }, + "minimum": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "rest": { + "type": "number" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "type": "object" + }, + "secondarySlideValue": { + "type": "number" + }, + "slideLimits": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "maximum": { + "type": "number" + }, + "maximumEnabled": { + "type": "boolean" + }, + "minimum": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "rest": { + "type": "number" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "type": "object" + }, + "slideValue": { + "type": "number" + }, + "suppressed": { + "type": "boolean" + }, + "yawLimits": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "maximum": { + "type": "number" + }, + "maximumEnabled": { + "type": "boolean" + }, + "minimum": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "rest": { + "type": "number" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "type": "object" + }, + "yawValue": { + "type": "number" + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "joint" + ], + "type": "object" + }, + "joint_origin_create": { + "description": "Create a native component-definition joint origin from guarded native geometry, with mm offsets/deg angle and optional straight axis references. Child occurrence origin references are exposed by inspect_entity(occurrence).", + "properties": { + "angle": { + "description": "Number in mm (angle in deg), or a dimensionally valid Fusion parameter expression.", + "oneOf": [ + { + "type": "number" + }, + { + "maxLength": 512, + "minLength": 1, + "type": "string" + } + ] + }, + "component": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "flipped": { + "type": "boolean" + }, + "geometry": { + "additionalProperties": false, + "properties": { + "edge": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "entity": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "keyPoint": { + "enum": [ + "center", + "start", + "middle", + "end" + ] + } + }, + "required": [ + "entity" + ], + "type": "object" + }, + "name": { + "maxLength": 120, + "minLength": 1, + "type": "string" + }, + "offsetX": { + "description": "Number in mm (angle in deg), or a dimensionally valid Fusion parameter expression.", + "oneOf": [ + { + "type": "number" + }, + { + "maxLength": 512, + "minLength": 1, + "type": "string" + } + ] + }, + "offsetY": { + "description": "Number in mm (angle in deg), or a dimensionally valid Fusion parameter expression.", + "oneOf": [ + { + "type": "number" + }, + { + "maxLength": 512, + "minLength": 1, + "type": "string" + } + ] + }, + "offsetZ": { + "description": "Number in mm (angle in deg), or a dimensionally valid Fusion parameter expression.", + "oneOf": [ + { + "type": "number" + }, + { + "maxLength": 512, + "minLength": 1, + "type": "string" + } + ] + }, + "xAxisEntity": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "zAxisEntity": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "geometry" + ], + "type": "object" + }, + "joint_origin_delete": { + "description": "Delete a native joint origin; unhealthy downstream joints cause transaction rollback.", + "properties": { + "origin": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "origin" + ], + "type": "object" + }, + "joint_origin_edit": { + "description": "Edit a native joint origin definition; rolls before its timeline item and restores the original marker in finally. Recomputes and verifies attachment, axes, offsets, angle, name and flip.", + "properties": { + "angle": { + "description": "Number in mm (angle in deg), or a dimensionally valid Fusion parameter expression.", + "oneOf": [ + { + "type": "number" + }, + { + "maxLength": 512, + "minLength": 1, + "type": "string" + } + ] + }, + "flipped": { + "type": "boolean" + }, + "geometry": { + "additionalProperties": false, + "properties": { + "edge": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "entity": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "keyPoint": { + "enum": [ + "center", + "start", + "middle", + "end" + ] + } + }, + "required": [ + "entity" + ], + "type": "object" + }, + "name": { + "maxLength": 120, + "minLength": 1, + "type": "string" + }, + "offsetX": { + "description": "Number in mm (angle in deg), or a dimensionally valid Fusion parameter expression.", + "oneOf": [ + { + "type": "number" + }, + { + "maxLength": 512, + "minLength": 1, + "type": "string" + } + ] + }, + "offsetY": { + "description": "Number in mm (angle in deg), or a dimensionally valid Fusion parameter expression.", + "oneOf": [ + { + "type": "number" + }, + { + "maxLength": 512, + "minLength": 1, + "type": "string" + } + ] + }, + "offsetZ": { + "description": "Number in mm (angle in deg), or a dimensionally valid Fusion parameter expression.", + "oneOf": [ + { + "type": "number" + }, + { + "maxLength": 512, + "minLength": 1, + "type": "string" + } + ] + }, + "origin": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "xAxisEntity": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "zAxisEntity": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "origin" + ], + "type": "object" + }, + "material_assign": { + "description": "Assign a loaded library or document material to an exact native Component/BRepBody; verify material ID readback with model transaction recovery. Query assembly_properties for resulting mass.", + "properties": { + "entity": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "libraryId": { + "type": "string" + }, + "materialId": { + "type": "string" + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "entity", + "materialId" + ], + "type": "object" + }, + "material_list": { + "description": "List loaded material libraries and matching materials by stable library/material IDs. Omit libraryId for document materials.", + "properties": { + "libraryId": { + "type": "string" + }, + "limit": { + "maximum": 1000, + "minimum": 1, + "type": "integer" + }, + "query": { + "type": "string" + } + }, + "required": [ + "op", + "target" + ], + "type": "object" + }, + "mcmaster_discover": { + "description": "Discover Fusion native Insert McMaster-Carr command and requirements in the active design.", + "properties": {}, + "required": [ + "op" + ], + "type": "object" + }, + "mcmaster_open": { + "description": "Invoke Fusion native McMaster UI in the targeted Hybrid design. Interactive external operation: completion/insert/cancel stays with Fusion; inspect afterward. Idempotency records launch only.", + "properties": {}, + "required": [ + "op", + "sessionId", + "operationId", + "target" + ], + "type": "object" + }, + "measure": { + "description": "Native engineering minimum distance or angle in root assembly space. Non-root definition refs require an occurrence context. Distance needs2 entities; angle needs2 lines/planes or3 points (second apex). Returns mm/deg and native witness positions in root mm. witnessValidation identifies native points or native geometry directions used for valid parallel angles with degenerate native points; degenerate point angles and unavailable/inconsistent witnesses fail.", + "properties": { + "entities": { + "items": { + "description": "Current guarded entity reference; root-space child geometry requires its exact occurrence context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "maxItems": 3, + "minItems": 2, + "type": "array" + }, + "space": { + "enum": [ + "root" + ] + }, + "type": { + "enum": [ + "distance", + "angle" + ] + } + }, + "required": [ + "op", + "target", + "type", + "space", + "entities" + ], + "type": "object" + }, + "motion_link_create": { + "description": "Create a native motion link between two root-owned joints, or two different motions of jointOne when jointTwo is omitted. Positive ratio magnitudes use mm for translation and deg for rotation; reversed selects opposite direction. Requires the November 2025+ API; no script fallback.", + "properties": { + "jointOne": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "jointTwo": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "motionOne": { + "enum": [ + "rotation", + "slide", + "primarySlide", + "secondarySlide", + "pitch", + "roll", + "yaw" + ] + }, + "motionTwo": { + "enum": [ + "rotation", + "slide", + "primarySlide", + "secondarySlide", + "pitch", + "roll", + "yaw" + ] + }, + "name": { + "maxLength": 120, + "minLength": 1, + "type": "string" + }, + "reversed": { + "type": "boolean" + }, + "valueOne": { + "description": "Number in mm (angle in deg), or a dimensionally valid Fusion parameter expression.", + "oneOf": [ + { + "type": "number" + }, + { + "maxLength": 512, + "minLength": 1, + "type": "string" + } + ] + }, + "valueTwo": { + "description": "Number in mm (angle in deg), or a dimensionally valid Fusion parameter expression.", + "oneOf": [ + { + "type": "number" + }, + { + "maxLength": 512, + "minLength": 1, + "type": "string" + } + ] + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "jointOne", + "motionOne", + "motionTwo", + "valueOne", + "valueTwo" + ], + "type": "object" + }, + "motion_link_delete": { + "description": "Delete a root-owned motion link under the same transaction/recovery contract.", + "properties": { + "link": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "link" + ], + "type": "object" + }, + "motion_link_edit": { + "description": "Edit a root-owned motion link ratio, degree-of-freedom pair, direction, name or suppression. Joint membership is immutable; recreate the link to change members. Verify exact joint identities, ratio, flags and health.", + "properties": { + "link": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "motionOne": { + "enum": [ + "rotation", + "slide", + "primarySlide", + "secondarySlide", + "pitch", + "roll", + "yaw" + ] + }, + "motionTwo": { + "enum": [ + "rotation", + "slide", + "primarySlide", + "secondarySlide", + "pitch", + "roll", + "yaw" + ] + }, + "name": { + "maxLength": 120, + "minLength": 1, + "type": "string" + }, + "reversed": { + "type": "boolean" + }, + "suppressed": { + "type": "boolean" + }, + "valueOne": { + "description": "Number in mm (angle in deg), or a dimensionally valid Fusion parameter expression.", + "oneOf": [ + { + "type": "number" + }, + { + "maxLength": 512, + "minLength": 1, + "type": "string" + } + ] + }, + "valueTwo": { + "description": "Number in mm (angle in deg), or a dimensionally valid Fusion parameter expression.", + "oneOf": [ + { + "type": "number" + }, + { + "maxLength": 512, + "minLength": 1, + "type": "string" + } + ] + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "link" + ], + "type": "object" + }, + "new_design": { + "description": "Create an unnamed unsaved parametric hybrid Design; name is retained as requestedName metadata to keep failure recovery free of save dialogs. Optional box dimensions in mm create a driving-parameter fixture. Never edits an existing document.", + "properties": { + "box": { + "additionalProperties": false, + "properties": { + "depth": { + "exclusiveMinimum": 0, + "type": "number" + }, + "height": { + "exclusiveMinimum": 0, + "type": "number" + }, + "width": { + "exclusiveMinimum": 0, + "type": "number" + } + }, + "required": [ + "width", + "height", + "depth" + ], + "type": "object" + }, + "name": { + "type": "string" + } + }, + "required": [ + "op", + "sessionId", + "operationId" + ], + "type": "object" + }, + "occurrence_delete": { + "description": "Delete one selected occurrence through the shared transaction executor.", + "properties": { + "occurrence": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "occurrence" + ], + "type": "object" + }, + "occurrence_insert": { + "description": "Insert another occurrence of an existing definition; independentCopy:true uses Paste New to create a separate definition. Nested placement is in parent component coordinates. Returns a representative root-context occurrence and all affectedOccurrences when the parent definition is reused.", + "properties": { + "component": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "independentCopy": { + "type": "boolean" + }, + "parent": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "transform": { + "additionalProperties": false, + "description": "Absolute rotation about the given nonzero axis through origin (deg), then translation (mm). Create/insert use parent component coordinates; updates use the selected occurrence assembly context.", + "properties": { + "angle": { + "type": "number" + }, + "axis": { + "items": { + "type": "number" + }, + "maxItems": 3, + "minItems": 3, + "type": "array" + }, + "position": { + "items": { + "type": "number" + }, + "maxItems": 3, + "minItems": 3, + "type": "array" + } + }, + "required": [], + "type": "object" + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "component" + ], + "type": "object" + }, + "occurrence_update": { + "description": "Set occurrence pose in its assembly context and capture position; explicit grounding/ungrounding. Grounded or constrained transforms fail with recovery.", + "properties": { + "grounded": { + "type": "boolean" + }, + "groundedToParent": { + "type": "boolean" + }, + "occurrence": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "transform": { + "additionalProperties": false, + "description": "Absolute rotation about the given nonzero axis through origin (deg), then translation (mm). Create/insert use parent component coordinates; updates use the selected occurrence assembly context.", + "properties": { + "angle": { + "type": "number" + }, + "axis": { + "items": { + "type": "number" + }, + "maxItems": 3, + "minItems": 3, + "type": "array" + }, + "position": { + "items": { + "type": "number" + }, + "maxItems": 3, + "minItems": 3, + "type": "array" + } + }, + "required": [], + "type": "object" + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "occurrence" + ], + "type": "object" + }, + "parameter_batch": { + "description": "Atomic sequential create/set/delete parameter edits with one recompute+health gate; delete supports user parameters only. Any failure aborts all edits and reports semantic recovery.", + "properties": { + "changes": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "action": { + "enum": [ + "create" + ] + }, + "comment": { + "type": "string" + }, + "expression": { + "type": "string" + }, + "name": { + "type": "string" + }, + "unit": { + "enum": [ + "mm", + "deg", + "kg", + "" + ] + } + }, + "required": [ + "action", + "name", + "expression", + "unit" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "action": { + "enum": [ + "set" + ] + }, + "expression": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "action", + "name", + "expression" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "action": { + "enum": [ + "delete" + ] + }, + "name": { + "type": "string" + } + }, + "required": [ + "action", + "name" + ], + "type": "object" + } + ] + }, + "maxItems": 128, + "minItems": 1, + "type": "array" + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "changes" + ], + "type": "object" + }, + "parameter_create": { + "properties": { + "comment": { + "type": "string" + }, + "expression": { + "type": "string" + }, + "name": { + "type": "string" + }, + "unit": { + "enum": [ + "mm", + "deg", + "kg", + "" + ] + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "name", + "expression", + "unit" + ], + "type": "object" + }, + "parameter_set": { + "description": "Set an expression, recompute and inspect health. Failure restores old expression and reports a semantic recovery witness; not an atomic transaction.", + "properties": { + "expression": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "name", + "expression" + ], + "type": "object" + }, + "plane_create": { + "description": "Create offset plane (offset in mm) or angled plane around axis (angle in deg).", + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "component": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "name": { + "type": "string" + }, + "offset": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "op": { + "type": "string" + }, + "operationId": { + "type": "string" + }, + "plane": { + "oneOf": [ + { + "enum": [ + "xy", + "xz", + "yz" + ] + }, + { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + } + ] + }, + "sessionId": { + "type": "string" + }, + "target": { + "required": [ + "documentId", + "revision" + ], + "type": "object" + }, + "type": { + "enum": [ + "offset" + ] + } + }, + "required": [ + "type", + "offset" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "angle": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "axis": { + "oneOf": [ + { + "enum": [ + "x", + "y", + "z" + ] + }, + { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + } + ] + }, + "component": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "name": { + "type": "string" + }, + "op": { + "type": "string" + }, + "operationId": { + "type": "string" + }, + "plane": { + "oneOf": [ + { + "enum": [ + "xy", + "xz", + "yz" + ] + }, + { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + } + ] + }, + "sessionId": { + "type": "string" + }, + "target": { + "required": [ + "documentId", + "revision" + ], + "type": "object" + }, + "type": { + "enum": [ + "angle" + ] + } + }, + "required": [ + "type", + "axis", + "angle" + ], + "type": "object" + } + ], + "properties": { + "angle": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "axis": { + "oneOf": [ + { + "enum": [ + "x", + "y", + "z" + ] + }, + { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + } + ] + }, + "component": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "name": { + "type": "string" + }, + "offset": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "plane": { + "oneOf": [ + { + "enum": [ + "xy", + "xz", + "yz" + ] + }, + { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + } + ] + }, + "type": { + "enum": [ + "offset", + "angle" + ] + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "type" + ], + "type": "object" + }, + "regular_joint_create": { + "description": "Create a geometry-solving regular joint in the root component using two distinct instance-context attachments or joint origins. All seven native motion families; offsets mm, angle and angular limits deg. Read back freshly resolved attachment graph, native parameters and root-space occurrence placement. Root/non-root sides must be unambiguous; both grounded and ancestor/descendant pairs are refused. Inferred joints are unsupported. Existing pending positions must be captured or reverted first. Before applying drive values, checks native joint-transform anchor offset magnitude/axiality and zero-angle axis collinearity. Native joint matrices have explicit provenance; physical angle/flip sense requires independent occurrence-geometry acceptance.", + "properties": { + "angle": { + "description": "Number in mm (angle in deg), or a dimensionally valid Fusion parameter expression.", + "oneOf": [ + { + "type": "number" + }, + { + "maxLength": 512, + "minLength": 1, + "type": "string" + } + ] + }, + "axis": { + "enum": [ + "x", + "y", + "z" + ] + }, + "flipped": { + "type": "boolean" + }, + "geometryOne": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "edge": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "entity": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "keyPoint": { + "enum": [ + "center", + "start", + "middle", + "end" + ] + } + }, + "required": [ + "entity" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "origin": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + } + }, + "required": [ + "origin" + ], + "type": "object" + } + ] + }, + "geometryTwo": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "edge": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "entity": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "keyPoint": { + "enum": [ + "center", + "start", + "middle", + "end" + ] + } + }, + "required": [ + "entity" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "origin": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + } + }, + "required": [ + "origin" + ], + "type": "object" + } + ] + }, + "locked": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "offset": { + "description": "Number in mm (angle in deg), or a dimensionally valid Fusion parameter expression.", + "oneOf": [ + { + "type": "number" + }, + { + "maxLength": 512, + "minLength": 1, + "type": "string" + } + ] + }, + "pitchLimits": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "maximum": { + "type": "number" + }, + "maximumEnabled": { + "type": "boolean" + }, + "minimum": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "rest": { + "type": "number" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "type": "object" + }, + "pitchValue": { + "type": "number" + }, + "primarySlideLimits": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "maximum": { + "type": "number" + }, + "maximumEnabled": { + "type": "boolean" + }, + "minimum": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "rest": { + "type": "number" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "type": "object" + }, + "primarySlideValue": { + "type": "number" + }, + "rollLimits": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "maximum": { + "type": "number" + }, + "maximumEnabled": { + "type": "boolean" + }, + "minimum": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "rest": { + "type": "number" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "type": "object" + }, + "rollValue": { + "type": "number" + }, + "rotationLimits": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "maximum": { + "type": "number" + }, + "maximumEnabled": { + "type": "boolean" + }, + "minimum": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "rest": { + "type": "number" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "type": "object" + }, + "rotationValue": { + "type": "number" + }, + "secondarySlideLimits": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "maximum": { + "type": "number" + }, + "maximumEnabled": { + "type": "boolean" + }, + "minimum": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "rest": { + "type": "number" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "type": "object" + }, + "secondarySlideValue": { + "type": "number" + }, + "slideAxis": { + "enum": [ + "x", + "y", + "z" + ] + }, + "slideLimits": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "maximum": { + "type": "number" + }, + "maximumEnabled": { + "type": "boolean" + }, + "minimum": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "rest": { + "type": "number" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "type": "object" + }, + "slideValue": { + "type": "number" + }, + "type": { + "enum": [ + "rigid", + "revolute", + "slider", + "cylindrical", + "pin_slot", + "planar", + "ball" + ] + }, + "yawLimits": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "maximum": { + "type": "number" + }, + "maximumEnabled": { + "type": "boolean" + }, + "minimum": { + "type": "number" + }, + "minimumEnabled": { + "type": "boolean" + }, + "rest": { + "type": "number" + }, + "restEnabled": { + "type": "boolean" + } + }, + "required": [], + "type": "object" + }, + "yawValue": { + "type": "number" + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "type", + "geometryOne", + "geometryTwo" + ], + "type": "object" + }, + "regular_joint_edit": { + "description": "Redefine root-owned regular joint geometry, motion family, offsets, angle, flip or lock. Restore timeline in finally and verify after recompute. Use joint_edit for name, suppression, limits and drive values; joint_delete for deletion.", + "properties": { + "angle": { + "description": "Number in mm (angle in deg), or a dimensionally valid Fusion parameter expression.", + "oneOf": [ + { + "type": "number" + }, + { + "maxLength": 512, + "minLength": 1, + "type": "string" + } + ] + }, + "axis": { + "enum": [ + "x", + "y", + "z" + ] + }, + "flipped": { + "type": "boolean" + }, + "geometryOne": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "edge": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "entity": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "keyPoint": { + "enum": [ + "center", + "start", + "middle", + "end" + ] + } + }, + "required": [ + "entity" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "origin": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + } + }, + "required": [ + "origin" + ], + "type": "object" + } + ] + }, + "geometryTwo": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "edge": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "entity": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "keyPoint": { + "enum": [ + "center", + "start", + "middle", + "end" + ] + } + }, + "required": [ + "entity" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "origin": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + } + }, + "required": [ + "origin" + ], + "type": "object" + } + ] + }, + "joint": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "locked": { + "type": "boolean" + }, + "offset": { + "description": "Number in mm (angle in deg), or a dimensionally valid Fusion parameter expression.", + "oneOf": [ + { + "type": "number" + }, + { + "maxLength": 512, + "minLength": 1, + "type": "string" + } + ] + }, + "offsetX": { + "description": "Number in mm (angle in deg), or a dimensionally valid Fusion parameter expression.", + "oneOf": [ + { + "type": "number" + }, + { + "maxLength": 512, + "minLength": 1, + "type": "string" + } + ] + }, + "offsetY": { + "description": "Number in mm (angle in deg), or a dimensionally valid Fusion parameter expression.", + "oneOf": [ + { + "type": "number" + }, + { + "maxLength": 512, + "minLength": 1, + "type": "string" + } + ] + }, + "slideAxis": { + "enum": [ + "x", + "y", + "z" + ] + }, + "type": { + "enum": [ + "rigid", + "revolute", + "slider", + "cylindrical", + "pin_slot", + "planar", + "ball" + ] + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "joint" + ], + "type": "object" + }, + "rigid_group_create": { + "description": "Create a native rigid group from exact occurrence instances; includeChildren defaults true.", + "properties": { + "includeChildren": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "occurrences": { + "items": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "maxItems": 256, + "minItems": 2, + "type": "array" + } + }, + "required": [ + "op", + "target", + "sessionId", + "operationId", + "occurrences" + ], + "type": "object" + }, + "rigid_group_delete": { + "description": "Delete an exact rigid group with transaction recovery.", + "properties": { + "group": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + } + }, + "required": [ + "op", + "target", + "sessionId", + "operationId", + "group" + ], + "type": "object" + }, + "rigid_group_edit": { + "description": "Rename or suppress an exact rigid group with transaction recovery.", + "properties": { + "group": { + "description": "Guarded reference from inspect in this document/revision; occurrence references retain instance context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "name": { + "type": "string" + }, + "suppressed": { + "type": "boolean" + } + }, + "required": [ + "op", + "target", + "sessionId", + "operationId", + "group" + ], + "type": "object" + }, + "sketch_constraint": { + "description": "Constrain entities in one sketch. midpoint order is point,line; symmetry order entity1,entity2,symmetryLine. fix sets one entity fixed.", + "properties": { + "entities": { + "items": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "maxItems": 256, + "minItems": 1, + "type": "array" + }, + "sketch": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "type": { + "enum": [ + "horizontal", + "vertical", + "coincident", + "parallel", + "perpendicular", + "tangent", + "equal", + "concentric", + "collinear", + "midpoint", + "symmetry", + "fix" + ] + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "sketch", + "type", + "entities" + ], + "type": "object" + }, + "sketch_create": { + "description": "Create a native component-definition sketch on an origin/construction plane or planar face. Coordinates in subsequent geometry are sketch-space mm.", + "properties": { + "component": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "name": { + "type": "string" + }, + "plane": { + "oneOf": [ + { + "enum": [ + "xy", + "xz", + "yz" + ] + }, + { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + } + ] + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target" + ], + "type": "object" + }, + "sketch_curve_edit": { + "description": "Edit one native sketch curve with sketch-space mm pick. trim removes the nearest segment; extend chooses nearest endpoint; split requires an interior on-curve point; break uses neighboring intersections. Fresh replacement refs are returned; original may be deleted. In-place extend returns the original with a fresh ref only after its geometry change survives recompute. No change or empty break/split fails. Full trim deletion requires allowDelete true. Linked/derived/deferred-compute geometry refused.", + "properties": { + "action": { + "enum": [ + "trim", + "extend", + "split", + "break" + ] + }, + "allowDelete": { + "type": "boolean" + }, + "createConstraints": { + "type": "boolean" + }, + "curve": { + "description": "Current guarded entity reference; root-space child geometry requires its exact occurrence context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "pick": { + "items": { + "type": "number" + }, + "maxItems": 3, + "minItems": 2, + "type": "array" + }, + "sketch": { + "description": "Current guarded entity reference; root-space child geometry requires its exact occurrence context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + } + }, + "required": [ + "op", + "target", + "sessionId", + "operationId", + "sketch", + "curve", + "action", + "pick" + ], + "type": "object" + }, + "sketch_diagnostics": { + "description": "Read native sketch health/message, fully-constrained/fixed/linked/reference flags, geometric constraints and driving dimensions. Reports no solver DOF count or conflict-set explanation; no recompute/mutation.", + "properties": { + "sketch": { + "description": "Current guarded entity reference; root-space child geometry requires its exact occurrence context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + } + }, + "required": [ + "op", + "target", + "sketch" + ], + "type": "object" + }, + "sketch_dimension": { + "description": "Add driving dimensions. Distance uses two point references; radius/diameter one curve; angle two lines. Numeric expressions use mm or deg.", + "properties": { + "entities": { + "items": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "maxItems": 256, + "minItems": 1, + "type": "array" + }, + "expression": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "parameterName": { + "type": "string" + }, + "position": { + "items": { + "type": "number" + }, + "maxItems": 3, + "minItems": 2, + "type": "array" + }, + "sketch": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "type": { + "enum": [ + "distance", + "horizontal", + "vertical", + "radius", + "diameter", + "angle" + ] + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "sketch", + "type", + "entities", + "expression", + "position" + ], + "type": "object" + }, + "sketch_edit": { + "description": "Move sketch points/endpoints/centers in sketch-space mm, change radius or construction/fixed flags; solver failure aborts the operation.", + "properties": { + "changes": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "center": { + "items": { + "type": "number" + }, + "maxItems": 3, + "minItems": 2, + "type": "array" + }, + "construction": { + "type": "boolean" + }, + "end": { + "items": { + "type": "number" + }, + "maxItems": 3, + "minItems": 2, + "type": "array" + }, + "fixed": { + "type": "boolean" + }, + "position": { + "items": { + "type": "number" + }, + "maxItems": 3, + "minItems": 2, + "type": "array" + }, + "radius": { + "type": "number" + }, + "start": { + "items": { + "type": "number" + }, + "maxItems": 3, + "minItems": 2, + "type": "array" + } + }, + "required": [], + "type": "object" + }, + "entity": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "sketch": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "sketch", + "entity", + "changes" + ], + "type": "object" + }, + "sketch_geometry": { + "description": "Create a transactionally grouped set of sketch primitives. Numeric geometry uses mm and arc sweeps/polygon angles use deg.", + "properties": { + "items": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "position": { + "items": { + "type": "number" + }, + "maxItems": 3, + "minItems": 2, + "type": "array" + }, + "type": { + "enum": [ + "point" + ] + } + }, + "required": [ + "type", + "position" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "construction": { + "type": "boolean" + }, + "end": { + "items": { + "type": "number" + }, + "maxItems": 3, + "minItems": 2, + "type": "array" + }, + "start": { + "items": { + "type": "number" + }, + "maxItems": 3, + "minItems": 2, + "type": "array" + }, + "type": { + "enum": [ + "line" + ] + } + }, + "required": [ + "type", + "start", + "end" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "construction": { + "type": "boolean" + }, + "end": { + "items": { + "type": "number" + }, + "maxItems": 3, + "minItems": 2, + "type": "array" + }, + "start": { + "items": { + "type": "number" + }, + "maxItems": 3, + "minItems": 2, + "type": "array" + }, + "type": { + "enum": [ + "rectangle" + ] + } + }, + "required": [ + "type", + "start", + "end" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "center": { + "items": { + "type": "number" + }, + "maxItems": 3, + "minItems": 2, + "type": "array" + }, + "construction": { + "type": "boolean" + }, + "radius": { + "exclusiveMinimum": 0, + "type": "number" + }, + "type": { + "enum": [ + "circle" + ] + } + }, + "required": [ + "type", + "center", + "radius" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "center": { + "items": { + "type": "number" + }, + "maxItems": 3, + "minItems": 2, + "type": "array" + }, + "construction": { + "type": "boolean" + }, + "start": { + "items": { + "type": "number" + }, + "maxItems": 3, + "minItems": 2, + "type": "array" + }, + "sweep": { + "type": "number" + }, + "type": { + "enum": [ + "arc" + ] + } + }, + "required": [ + "type", + "center", + "start", + "sweep" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "angle": { + "type": "number" + }, + "center": { + "items": { + "type": "number" + }, + "maxItems": 3, + "minItems": 2, + "type": "array" + }, + "construction": { + "type": "boolean" + }, + "radius": { + "exclusiveMinimum": 0, + "type": "number" + }, + "sides": { + "maximum": 128, + "minimum": 3, + "type": "integer" + }, + "type": { + "enum": [ + "polygon" + ] + } + }, + "required": [ + "type", + "center", + "radius", + "sides" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "construction": { + "type": "boolean" + }, + "end": { + "items": { + "type": "number" + }, + "maxItems": 3, + "minItems": 2, + "type": "array" + }, + "start": { + "items": { + "type": "number" + }, + "maxItems": 3, + "minItems": 2, + "type": "array" + }, + "type": { + "enum": [ + "slot" + ] + }, + "width": { + "exclusiveMinimum": 0, + "type": "number" + } + }, + "required": [ + "type", + "start", + "end", + "width" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "closed": { + "type": "boolean" + }, + "construction": { + "type": "boolean" + }, + "points": { + "items": { + "items": { + "type": "number" + }, + "maxItems": 3, + "minItems": 2, + "type": "array" + }, + "maxItems": 128, + "minItems": 2, + "type": "array" + }, + "type": { + "enum": [ + "spline" + ] + } + }, + "required": [ + "type", + "points" + ], + "type": "object" + } + ] + }, + "maxItems": 128, + "minItems": 1, + "type": "array" + }, + "sketch": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "sketch", + "items" + ], + "type": "object" + }, + "sketch_offset": { + "description": "Modern createOffsetInput/addOffset2. Curves must be native 2D, unique, in connected flow order (never reordered). Nonzero signed mm number/expression: positive right, negative left; a single circle/ellipse positive outside. Topology matched defaults true. Read back native signed distance, parent/child refs and controlling parameter after recompute. No retired Sketch.offset fallback.", + "properties": { + "curves": { + "items": { + "description": "Current guarded entity reference; root-space child geometry requires its exact occurrence context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "maxItems": 256, + "minItems": 1, + "type": "array" + }, + "distance": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "sketch": { + "description": "Current guarded entity reference; root-space child geometry requires its exact occurrence context.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "topologyMatched": { + "type": "boolean" + } + }, + "required": [ + "op", + "target", + "sessionId", + "operationId", + "sketch", + "curves", + "distance" + ], + "type": "object" + }, + "sketch_project": { + "description": "Project selected topology into a sketch with Fusion associative projection.", + "properties": { + "entities": { + "items": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + }, + "maxItems": 256, + "minItems": 1, + "type": "array" + }, + "sketch": { + "description": "Reference returned by inspect/inspect_entity in this document revision; native component-definition references for authoring.", + "required": [ + "documentId", + "revision", + "objectType" + ], + "type": "object" + } + }, + "required": [ + "op", + "sessionId", + "operationId", + "target", + "sketch", + "entities" + ], + "type": "object" + }, + "transaction_probe": { + "description": "Verify runtime PTransaction abort+commit using temporary user parameters, remove probe data and compare semantic restoration. Required once per running add-in session before feature/sketch/atomic-batch writes. Returns fresh target; inspect again for current references.", + "properties": {}, + "required": [ + "op", + "sessionId", + "operationId", + "target" + ], + "type": "object" + } + }, + "sessionId": "required for writes from mech_capabilities", + "target": "existing-document writes: {documentId,revision} from inspect" + }, + "longRunning": true, + "name": "fusion_mechanical", + "output": { + "data": "operation result including fresh target", + "durability": "terminalRecorded and recorded phase for durable writes", + "errorCode": "stable str on failure", + "operationId": "str on writes", + "outcome": "unknown or not_executed on storage/delivery failure", + "recovery": "reported on failed mutation", + "sessionId": "str", + "success": "bool" + }, + "statusVerb": "fusion_mech_operation_status", + "summary": "Guarded main-thread parametric parts and assemblies: sketches, solid features, atomic parameter edits, component definitions/copies/placement, seven as-built joint motions and limits, rigid groups, interference, materials and assembly mass/center of mass, local supplier STEP/SAT import with SHA/provenance, native McMaster command, structured/flat BOM CSV, topology readback, health and safe exports. Geometry queries refuse ambiguous selections; root-space distance/angle measurements return witness points. Typed sketch trim/extend/split/break and signed modern offsets, reference-defined construction axes and native sketch constraint diagnostics are available through exact operation schemas. Regular geometry-solving joints, joint origins and native motion links have guarded typed operations, exact attachment/parameter readback and timeline restoration. Read-only configuration inspection and active-row BOM provenance are available; configuration lifecycle and configured-design model writes remain disabled pending native recovery/archive gates. Incomplete configuration scope also blocks complete BOM output. Typed model mutations use runtime-proven PTransaction recovery; transaction_probe verifies the backend (new_design does this automatically). Writes require sessionId,operationId and current target {documentId,revision}. Durable admission must commit before dispatch. Retry exact args with the same ID; query durable status after timeout. Old-session history is readable but never replayed. Storage failure after execution returns reconciliation-required, preserving available evidence without claiming rollback. References expire on model changes. Files/document lifecycle are outside model transactions.", + "timeoutSeconds": 300 + }, { "example": {}, "input": { diff --git a/describe.py b/describe.py index 3594302..d3cdb7a 100644 --- a/describe.py +++ b/describe.py @@ -9,10 +9,21 @@ Each entry: name, summary, input, output, timeoutSeconds, statusVerb, longRunnin example. Keep this in step with the actual handlers as verbs are added. """ +import json +from pathlib import Path + +_MECHANICAL_SCHEMA = json.loads((Path(__file__).parent / "addin/AdomBridge/commands/mechanical/schema.json").read_text(encoding="utf-8")) _STATUS = "fusion_get_app_state" # Compact source table: name -> (summary, input, output, timeout, statusVerb, longRunning, example) _T = [ + ("fusion_mech_capabilities", "Discover typed mechanical operations, exact input schemas, units, recovery limitations and current add-in session ID.", + {}, {"success":"bool", "sessionId":"str", "operations":_MECHANICAL_SCHEMA, "transactions":"object", "idempotency":"object"}, 30, None, False, {}), + ("fusion_mechanical", "Guarded main-thread parametric parts and assemblies: sketches, solid features, atomic parameter edits, component definitions/copies/placement, seven as-built joint motions and limits, rigid groups, interference, materials and assembly mass/center of mass, local supplier STEP/SAT import with SHA/provenance, native McMaster command, structured/flat BOM CSV, topology readback, health and safe exports. Geometry queries refuse ambiguous selections; root-space distance/angle measurements return witness points. Typed sketch trim/extend/split/break and signed modern offsets, reference-defined construction axes and native sketch constraint diagnostics are available through exact operation schemas. Regular geometry-solving joints, joint origins and native motion links have guarded typed operations, exact attachment/parameter readback and timeline restoration. Read-only configuration inspection and active-row BOM provenance are available; configuration lifecycle and configured-design model writes remain disabled pending native recovery/archive gates. Incomplete configuration scope also blocks complete BOM output. Typed model mutations use runtime-proven PTransaction recovery; transaction_probe verifies the backend (new_design does this automatically). Writes require sessionId,operationId and current target {documentId,revision}. Durable admission must commit before dispatch. Retry exact args with the same ID; query durable status after timeout. Old-session history is readable but never replayed. Storage failure after execution returns reconciliation-required, preserving available evidence without claiming rollback. References expire on model changes. Files/document lifecycle are outside model transactions.", + {"op":"required enum: " + "|".join(_MECHANICAL_SCHEMA), "sessionId":"required for writes from mech_capabilities", "operationId":"required unique write ID; reuse unchanged for retry", "target":"existing-document writes: {documentId,revision} from inspect", "operationSchemas":_MECHANICAL_SCHEMA}, + {"success":"bool", "sessionId":"str", "operationId":"str on writes", "data":"operation result including fresh target", "errorCode":"stable str on failure", "recovery":"reported on failed mutation", "durability":"terminalRecorded and recorded phase for durable writes", "outcome":"unknown or not_executed on storage/delivery failure"}, 300, "fusion_mech_operation_status", True, {"op":"inspect"}), + ("fusion_mech_operation_status", "Read durable typed-operation history by recorded sessionId and operationId, including after HTTP timeout or add-in restart. Returns the recorded request digest, source version, target identity, phase/timestamps and terminal result. Historical nonterminal work is interrupted/unknown and never replayed. Admission without a durable dispatch claim or uncertain persistence requires reconciliation. Missing history never proves no execution. This observational route bypasses lifecycle repair, window etiquette and the CAD queue; it never starts/restarts Fusion or dismisses dialogs. An unavailable add-in returns operation_status_unavailable with unknown outcome. SQLite recovery may repair only the app-owned journal. A native call monopolizing Python's GIL can still delay responses.", + {"sessionId":"required str", "operationId":"required str"}, {"success":"bool", "state":"running|succeeded|failed|interrupted|reconciliation_required", "phase":"admitted|dispatched|executing|succeeded|failed|not_executed", "historical":"bool", "currentSessionId":"str", "history":"durable identity/phase/timestamps", "reconciliationRequired":"bool", "result":"recorded terminal result or null", "observedResult":"bounded unconfirmed current-session result or null"},30,None,False,{}), ("fusion_routing_state", "Read the live board's identity, geometry revision, pads, copper and per-net airwires from a fresh Fusion export.", {"expectDocument":"required exact active board name","boardId":"optional document creationId guard"}, {"boardId":"str","revision":"str","nets":"list","pads":"list","segments":"list","vias":"list","remainingNets":"list"},60,"fusion_routing_state",False,{"expectDocument":"fusion-routing"}), @@ -111,7 +122,7 @@ _T = [ {}, {"count": "int", "tools": "list"}, 45, "fusion_mcp_status", False, {}), ("fusion_mcp_call", "Call any tool on Autodesk's Fusion MCP server (fusion_mcp_read, fusion_mcp_update, fusion_mcp_execute, fusion_mcp_electronics_read). The bridge handles the MCP streamable-HTTP handshake for you (initialize -> MCP-Session-Id -> notifications/initialized) and re-establishes a dropped session automatically. MCP acts on the ACTIVE Fusion document, so open one first. PREFER a native fusion_* verb when one exists - they are tested here and return richer hints - and reach for MCP for Autodesk's text-to-CAD surface.", {"tool": "required str (from fusion_mcp_tools)", "arguments": "optional obj (per that tool's inputSchema)", "timeout": "optional int seconds, default 120"}, - {"result": "obj", "tool": "str"}, 150, "fusion_mcp_status", True, {"tool": "fusion_mcp_read", "arguments": {"queryType": "summary"}}), + {"result": "obj", "tool": "str"}, 150, "fusion_mcp_status", True, {"tool": "fusion_mcp_read", "arguments": {"queryType": "activeCommand"}}), ("fusion_mcp_resources", "List Autodesk MCP resources, or read one by uri. These are mostly the Electronics entity schemas (resource://mcp.electronics_schema_*) that describe what fusion_mcp_electronics_read can return.", {"uri": "optional str (omit to list)"}, {"count": "int", "resources": "[str]", "result": "obj"}, 60, "fusion_mcp_status", False, {}), diff --git a/docs/CP8-ACCEPTANCE.md b/docs/CP8-ACCEPTANCE.md new file mode 100644 index 0000000..2fa79f7 --- /dev/null +++ b/docs/CP8-ACCEPTANCE.md @@ -0,0 +1,24 @@ +# Checkpoint 8 native acceptance + +Use the maintained ownership/cleanup harness on a dedicated scratch document. This checkpoint adds durable records to the existing synchronous typed operations; it adds no render job or cancellation API. Baseline and restore the exact user document set and preferences. Do not inject a crash into a session containing uncheckpointed user work. + +1. Confirm installed bridge/add-in version `1.12.0-dev.8`, exact package hashes, current `fusion_mech_capabilities` session and storage limits. The new journal location is app-owned local state, separate from bridge configuration. SQLite availability was independently observed in Fusion Python 3.14.0 / SQLite 3.50.4; on-disk native behavior remains this gate. +2. Call `fusion_mechanical` with a unique run-scoped ID: + + ```json + {"op":"new_design","sessionId":"","operationId":"-create","name":"Untitled","box":{"width":20,"height":30,"depth":10}} + ``` + + Confirm geometry/units independently, `durability.terminalRecorded:true`, then query: + + ```json + {"sessionId":"","operationId":"-create"} + ``` + + `fusion_mech_operation_status` must return `state:succeeded`, its original operation ID, source version, canonical request digest, source document identity, declared target, phase timestamps and recorded result. The created document is identified by the native result's fresh target, independently of the active document observed before creation. +3. Repeat exact creation arguments and verify replay without another document/body. Repeat the ID with changed dimensions and require `operation_conflict`. Repeat a parameter write with changed transport metadata only and verify the original result/no second edit. Use the actual public CLI envelope for all calls. +4. Run a normal parameter edit and an intentionally invalid typed edit; verify completed versus failed terminal states, current target/units, health and independent recovery evidence. A recorded failed native result stays a failure even though its status lookup itself succeeded. Status must remain observable while a separate owned native operation occupies the CAD queue. +5. Checkpoint every owned document to native F3D and verify archive bytes and reopen witnesses. Only after exact owned cleanup and an authorized safe add-in restart, query the prior session's terminal history: it must remain readable with `historical:true` and a new `currentSessionId`. Sending that old session as a new mutation must refuse before native work. Historical reference tokens must never be rebound to the current design. +6. Interrupted/nonterminal, commit-loss, disk-full/corruption, stale-generation, and false-event-return boundaries have controlled offline subprocess/socket tests. Any additional native interruption campaign needs an owned checkpoint and an explicit retained-document recovery plan; do not turn an infrastructure failure into successful CAD acceptance. A historical nonterminal row must report `interrupted`/unknown and never auto-execute. + +Record native behavior, journal durability, exact document cleanup and visual inspection separately. Keep original failures and their later adjudication. No acceptance claim follows solely from the offline SQLite suite. diff --git a/docs/EXPLODED-VIEWS.md b/docs/EXPLODED-VIEWS.md new file mode 100644 index 0000000..ed1d76f --- /dev/null +++ b/docs/EXPLODED-VIEWS.md @@ -0,0 +1,47 @@ +# Planned exploded views and assembly instructions + +Exploded assembly views are a core requirement of the next animation checkpoint. +They are planned, not implemented or native-qualified by the mechanical foundation +integration. The first delivery should cover ordered assembly/disassembly steps, +BOM-linked item labels, motion trails, and reversible presentation poses while +preserving the source assembly's joints, grounding, positions and configuration. + +Use a separate owned presentation scene with copied geometry for the initial +automated preview. Move its display nodes rather than source occurrences; do not +suppress joints or add source-design position snapshots to manufacture an exploded +view. Every repeated or nested physical occurrence needs its own display identity +and an explicit home transform. Parent and child offsets must be applied once in +the declared frame. Purchased-unit policy and displayed-piece counts remain +separate from engineering BOM quantities. + +The presentation plan should bind exact source session/document/revision, +configuration provenance, occurrence identities, home matrices, geometry hashes +and a stable BOM-item map. Each ordered step declares selected instances, +translation/rotation/pivot, frame and units, timing, visibility, labels and trails. +Explicit directions and distances come first; automatic radial layouts must be +labeled as heuristics rather than collision-free physical disassembly proofs. + +Fusion's documented Animation UI supports explode/restore, timed component actions +and trails. The inspected public API exposes animation managers and storyboard +lifecycle/playback, but does not expose a documented component-action/keyframe or +explode-authoring collection. Native storyboard authoring therefore remains a +separate backend requiring an independently verified command path. A custom +graphics preview must not be described as a native editable storyboard, drawing +or video. Relevant sources: [AnimationManager](https://help.autodesk.com/cloudhelp/ENU/Fusion-360-API/files/fusion_AnimationManager.htm), +[Storyboard](https://help.autodesk.com/cloudhelp/ENU/Fusion-360-API/files/fusion_Storyboard.htm), +[Animation transform tools](https://help.autodesk.com/cloudhelp/ENU/Fusion-Animate/files/ANI-TRANSFORM.htm), +[independent graphics copies](https://help.autodesk.com/cloudhelp/ENU/Fusion-360-API/files/fusion_CustomGraphicsGroup_addBRepBody.htm). + +The finite acceptance gate must exercise rotated/repeated/nested instances, +root bodies, purchased subassemblies and a grounded/jointed source. Independently +check each displayed matrix, labels/trails, unchanged BOM quantities and the full +source assembly graph. Reverse sequencing must restore presentation home poses. +Cleanup may remove only exact owned presentation objects; an uncertain call or a +user change retains evidence rather than invoking global Undo or overwriting the +user's newer state. Persisted presentation data and native archive reopening need +their own proof; graphics availability alone does not establish persistence. + +Still rendering, finite requested video export and durable asynchronous job +lifecycle remain separate gates. No render, video, capture loop, continuous feed +or Shotlog upload is enabled by this plan. Follow the +[capture policy](mechanical-verification/CAPTURE-POLICY.md). diff --git a/docs/MECHANICAL.md b/docs/MECHANICAL.md new file mode 100644 index 0000000..202d6d0 --- /dev/null +++ b/docs/MECHANICAL.md @@ -0,0 +1,219 @@ +# Mechanical design bridge + +Mechanical operations use three public verbs: `fusion_mech_capabilities`, `fusion_mechanical` and `fusion_mech_operation_status`. Discover the live schema before constructing a request. Each operation returns `success`; inspect that field after unwrapping the Bridge transport envelope. + +## Start a session + +1. Call `fusion_mech_capabilities {}`. Keep its `sessionId` and operation schemas. +2. Call `fusion_mechanical {"op":"inspect"}`. Keep the returned `data.target` and entity references. +3. Before transactional sketch, feature, assembly, supplier-import, material or atomic parameter mutations on an existing document, call `transaction_probe` with the session ID, a new operation ID and the current target. It proves commit/abort on this Fusion session using a temporary parameter, removes the probe and returns a fresh target. `new_design` performs this probe automatically when needed. +4. Use the exact fresh target for the next write. Fetch references again after edits; old revisions are deliberately rejected. Pass reference objects unchanged, including the opaque `rootComponentId` used for root-component references. + +To create a separate design, call `new_design` with the session ID and a unique operation ID. The optional `box` accepts width/height/depth in mm as a simple parametric fixture. A requested name is metadata; the scratch document remains unnamed until an explicit save to avoid Fusion's save dialogs. + +```json +{ + "op": "new_design", + "sessionId": "", + "operationId": "part-create-001", + "name": "Mounting bracket" +} +``` + +Existing-document mutations also require `target: {"documentId":"...","revision":"..."}` from inspection. Never fabricate or cache these values across an add-in restart. + +## Inspect native configurations + +Development checkpoint 7 adds `configuration_inspect` as a read-only operation: + +```json +{"op":"configuration_inspect","target":{"documentId":"","revision":""}} +``` + +The result contains the loaded design's ordered top table, ordinary rows, a separate `table.testRow` +when the native reserved test row exists, column source relationships, all supported cells and the +active ordinary row. Cells identify their `ordinary` or `test` scope; a native column's row count +includes both. The reader does not assume a default column count. Table/row/column IDs identify native objects; +localized names and titles are presentation fields. Numeric cells return their original expression, +an explicitly labeled engineering value, and a separate database-unit diagnostic. Text cells use +the native text getter. Numeric units are limited to length, angle, mass, area, volume and unitless; +the reader checks a positive conversion basis before calculating signed values, because native +conversion errors can return the otherwise valid numeric value `-1`. Other unit families are +explicitly incomplete. Model-parameter ownership uses its actual component, while user parameters +are identified as design-scoped. Suppression retains both explicit and effective flags. +[Autodesk configuration table API](https://help.autodesk.com/cloudhelp/ENU/Fusion-360-API/files/fusion_ConfigurationTopTable.htm) + +`configuration.complete:false` reports unsupported columns, theme tables, preview hierarchies, +inconsistent test-row membership, unreadable fields or externally inserted configurations. Each +unreadable source or cell field is recorded separately while remaining cells are returned. The +observed native unsaved/offline configured fixture cannot read its property-cell values, and therefore +remains incomplete even though row and property-owner identities are available. Such diagnostics cannot serve +as a recovery witness or authorize a typed model mutation. Even fully read configured designs remain +read-only for model edits until native configuration Abort and archive gates pass; this includes +parameter/feature edits that could implicitly modify an active-row cell. No configuration conversion, +activation, cell edit or row/column lifecycle operation is advertised. Standard unconfigured designs +retain the existing modeling workflow. Legacy script endpoints retain their own independent contract. + +Generated single-configuration documents (`Design.isConfiguration`) are reported with their native row ID, but remain incomplete and unsupported for model writes, complete BOMs and exports. Their source-table identity and durable lifecycle have not been verified. Missing or contradictory configuration flags/row IDs also refuse strict operations; row identity participates in stale-target checks. + +This development reader checks preview-only custom/test-row and hierarchy getters to establish +that no deferred data is hidden. Some hierarchy getters were introduced in July 2026 and +`ConfigurationTopTable.testRow` in September 2026. If an installed build lacks these getters, +inspection still returns stable ordinary rows, columns and cells, with field-specific read errors; +it does not infer that preview state is absent or claim a complete witness. These preview guard +dependencies must be reevaluated before a production release. +[Autodesk test-row API status](https://help.autodesk.com/cloudhelp/ENU/Fusion-360-API/files/fusion_ConfigurationTopTable_testRow.htm) + +Supported configuration graphs, including inactive and reserved test cells and exact controlled-source links, are part +of revision and recovery witnesses. Changing an inactive expression invalidates a previously inspected +target even if current geometry is identical. Incomplete graphs refuse recovery comparison rather than +comparing equal unknown placeholders. + +`bom` reports provenance for the currently active row: `tableId`, `rowId`, `rowName`, and its normal +document/revision target. Both flat and structured CSV data rows include that same provenance. +Configuration suppression affects represented instances; visibility alone never removes quantity. +BOM generation refuses incomplete configuration scope. Export remains an external file effect and +does not prove that F3D preserved a full configuration table; inspect the reopened archive before +claiming persistence. Multi-row activation/export is outside this slice. + +See [CP7 native acceptance](mechanical-verification/CP7-ACCEPTANCE.md) for the finite readback gate. + +## Build a parametric part + +Create a sketch with `sketch_create` on `xy`, `xz`, `yz`, a construction plane or a planar face. Use `sketch_geometry` for points, lines, rectangles, circles, arcs, polygons, slots and fitted splines. Geometry coordinates are sketch-space mm; arc sweeps and polygon angles are degrees. Use `sketch_constraint` and `sketch_dimension` to express design intent, then inspect the sketch for fully-constrained state, curves, points, dimension parameters and profile references. + +Pass current profile references to `feature_create`. The schema enumerates extrusion, revolve, fillet, chamfer, shell, hole, loft, sweep, combine, split, move, mirror, rectangular/circular pattern, draft and thread inputs. Creation targets a native component definition. Assembly-context proxy geometry is for inspection; obtain native references when authoring a definition. Cut/intersect operations require explicit participant bodies. Join requires an unambiguous single body; use combine for explicit multiple-body selection. + +Use `feature_edit` to rename or suppress a feature or change the expressions of parameters owned by that feature. `entity_delete` handles supported native deletable entities. `parameter_batch` groups user-parameter creation/deletion and user/model-parameter expression edits in one measured model transaction. Generic mixed operations with file/cloud rollback are outside this contract. + +## Select, measure and edit everyday geometry + +Development checkpoint 5 adds six typed operations. Their exact schemas are returned by +`fusion_mech_capabilities`; [the native acceptance protocol](mechanical-verification/CP5-ACCEPTANCE.md) +separates implemented behavior from live verification. + +`geometry_query` searches one body's faces, edges or vertices. Supply `space:"root"` or +`space:"component"`. Filters can specify an exact native geometry type, inclusive area/length/radius +ranges, a nearby point and tolerance, or an oriented planar normal/linear tangent. Units are mm and +mm²; angular tolerance is degrees. `selection:"one"` is the default and refuses zero or multiple +matches. `selection:"many"` explicitly permits an empty result; exceeding the result budget fails +instead of truncating. Component-space filters use the definition's coordinates, while returned +references retain the exact input body's occurrence context. Root-space queries require that context +for geometry in a child component. No “first face” ordering is promised. + +`measure` uses native minimum distance or angle calculations in `space:"root"`, returning a value in +mm/degrees and witness positions in root-space mm. It accepts two distance entities, two linear/planar +angle entities, or three points with the second point as the apex. Refuse a native child-definition +reference and use its occurrence-context reference. Missing or inconsistent witness positions fail. +For valid parallel line/plane angles, Fusion can return three zero witness points. These are preserved; +`witnessValidation` identifies the native geometry directions used to verify parallelism instead. +Degenerate three-point angles still fail. [Autodesk measurement API](https://help.autodesk.com/cloudhelp/ENU/Fusion-360-API/files/core_MeasureResults.htm) + +`sketch_curve_edit` accepts `action:"trim"|"extend"|"split"|"break"`, a native sketch/curve reference +and a sketch-space mm `pick`. Split requires an interior point on the curve; extend chooses the nearer +endpoint. Trim can replace/delete the original, so consume the returned replacement references after +every edit. An in-place extension can return the original curve with a fresh reference, after its +geometry change survives recompute. Removing a whole curve requires `allowDelete:true`, which applies +only to trim. Empty break/split and unchanged edits fail and abort. Linked/derived or deferred-compute sketches are +refused. [Autodesk curve-edit API](https://help.autodesk.com/cloudhelp/ENU/Fusion-360-API/files/fusion_SketchCurve.htm) + +`sketch_offset` accepts an ordered, connected chain of native 2D sketch `curves` and a nonzero signed +`distance` number or expression. Curve order is preserved. Positive offsets go right relative to +chain flow; negative offsets go left. For a single circle or ellipse, positive means outward. +`topologyMatched` defaults to true. The operation uses `createOffsetInput`/`addOffset2`, returns the +controlling parameter and child curves, and checks the requested signed value after recompute. +[Autodesk signed offset input](https://help.autodesk.com/cloudhelp/ENU/Fusion-360-API/files/fusion_GeometricConstraints_createOffsetInput.htm) + +`construction_axis` accepts `type:"edge"|"circular_face"|"two_planes"|"two_points"` and exact +`entities`. Native references in the target component need no context. Referencing another component +requires root-context geometry; a non-root target also requires `creationOccurrence` identifying the +specific target instance. The result reports actual parametric state, native geometry and health. +It does not use direct-only infinite-line or preview snap-point APIs. +[Autodesk axis input](https://help.autodesk.com/cloudhelp/ENU/Fusion-360-API/files/fusion_ConstructionAxisInput.htm) + +`sketch_diagnostics` reads sketch health, constraint flags and driving dimensions. It reports native +fully-constrained/fixed/linked/reference observations; it does not invent solver degrees of freedom or +a conflicting-constraint explanation, and it does not recompute or alter the sketch. + +Model transaction recovery records sketch constraint and dimension links into their native curve/point +inventory, including offset relationships and curve endpoint links. Unsupported relationship classes, +foreign links or unreadable graph properties fail closed before mutation. A reordered inventory can +conservatively leave recovery unverified; equal current coordinates alone never establish equal design +intent. Readback does not compare entity-token strings to establish these relationships. + +## Retry and recovery + +Give each typed write a unique `operationId`. After a transport timeout, query `fusion_mech_operation_status` with that ID and its recorded session ID. Repeating the exact canonical request in the same session returns the recorded terminal result or a pending/reconciliation response; it never dispatches a duplicate. Changed arguments under the same ID refuse. Bridge-owned `caller` and `_callerBudgetMs` metadata are excluded from the canonical digest. + +Development checkpoint 8 records admission in an app-owned SQLite journal before scheduling native work. Main-thread document observation captures the exact live document identity, with creation/data-file IDs as additional evidence; native handlers still enforce the original target/revision guards. History retains request digest, operation, source version, declared target, observed document, timestamps and the original terminal result. The `dispatched` phase means a dispatch generation was durably reserved before CustomEvent delivery, not that Fusion accepted or began it. `executing` is committed before invoking the typed handler. Terminal completion is acknowledged only after its result commits to the journal. + +A lost HTTP waiter cannot cancel an already-started operation. At timeout, a durable compare-and-set records `not_executed` only if native execution has not claimed the operation; a queued or late event then cannot start it. An executing or completed operation retains its pending or terminal evidence. A bare admission without a durable dispatch claim requires reconciliation; it is never automatically dispatched after an acknowledgement failure. Delivery errors cannot overwrite a faster native completion. A persistence failure after execution returns `operation_reconciliation_required`, `outcome:unknown`, and available observed-result evidence with `terminalRecorded:false`; it makes no rollback claim. `nativeExecutionStarted` refers to handler execution, not proof that a requested model mutation succeeded. Current-session observed result payloads are capped at 64 KiB each and 4 MiB in aggregate; omitted payloads are explicitly labeled, with a digest when canonicalization within the record limit succeeds. + +After restart, old session IDs can query history but cannot execute or rebind its entity references. Historical nonterminal records are `interrupted` with unknown outcome. A recorded historical terminal result describes its original document/session, not the current model. Inspect current native state before deciding whether a new operation is appropriate. Missing or unreadable history never proves that an operation did not run. Status uses a direct observational HTTP route outside lifecycle repair, window etiquette and the CAD queue. It never starts/restarts Fusion, handles dialogs, or changes preferences. Cold/unavailable add-ins return `operation_status_unavailable` with unknown outcome; querying history does not reopen Fusion. The Python GIL and bounded SQLite lock waits can still delay it. + +Storage is fixed to `%LOCALAPPDATA%/Adom/FusionBridge/mechanical/operations-v1.sqlite3` on Windows, `~/Library/Application Support/Adom/FusionBridge/mechanical/operations-v1.sqlite3` on macOS, or `~/.local/state/adom/fusion-bridge/mechanical/operations-v1.sqlite3` on Linux. Public verbs accept no storage path. Storage version 1 uses FULL synchronous commits, a maximum 10,000 retained operations, 64 MiB database, 4 MiB per record and 1 MiB canonical request. SQLite rollback files may require another database-sized allocation. Corruption, unsupported storage versions, missing SQLite, unsafe paths, permission failures and capacity exhaustion refuse admission; history is never silently reset, migrated or evicted. Looking up absent history creates no files. Opening existing history permits SQLite to recover a hot rollback journal after a process crash. + +These guarantees apply to the typed synchronous mechanical dispatcher. They do not add asynchronous jobs, cancellation, automatic replay, rendering, or durable recovery to legacy script verbs. File publication and native transaction recovery retain their existing independent boundaries. + +A failed transactional typed model operation reports its abort result and a semantic recovery witness. Standalone `parameter_set` and `parameter_create` instead use measured compensating recovery and report `transactional:false`. `recovery.verified:false` means restoration was not proved. Successful publication followed by temporary-file cleanup failure remains a success with a warning. An operation committed successfully but failing subsequent inspection returns `refreshRequired`; read the model again before continuing. + +## Export and save + +`export` writes to a temporary sibling location, verifies nonempty output, then publishes to the requested path. Overwrite is explicit. Runtime format support is checked; unsupported requests preserve existing destination files. Results include byte count and SHA-256. STL coordinates are mm. Native F3D preserves Fusion data more fully than neutral geometry formats; never assume supplier attributes survive a STEP roundtrip. + +`fusion_save_to_cloud` handles an explicit cloud project/folder/name. The typed export operation creates a local archive or exchange file and does not itself create a Fusion Team version. + +## Coverage and verification + +See [build handoff](build-2026-10-02.md) for the verified checkpoint and live host. See the [feature matrix](mechanical-comparison/FEATURE-MATRIX.md) for each surveyed external capability, its bridge route and remaining gaps. A discoverable native API or arbitrary Python execution does not establish that a dedicated bridge operation is implemented or live verified. + +## Assemble parts + +`component_create` creates an internal definition and its first occurrence. These operations require a Hybrid design; they never silently change the intent of an existing Part or Assembly design. `occurrence_insert` reuses a definition, or uses `independentCopy:true` to create a separate definition. `parent` is a component-definition reference, so adding children to it affects every instance of that definition. + +Create/insert transforms use parent-component coordinates. Creation, insertion and CAD import apply the requested pose to every currently placed parent instance, capture pending positions, and verify the resulting root-context matrices after recompute; `affectedOccurrences` lists these instances and `placementScope` is `all_current_parent_instances`. The definition-local base transform can differ from the computed position after a capture. Occurrence updates use the selected instance's assembly context. The transform input uses `position:[x,y,z]` in mm and `axis:[x,y,z]`, `angle` in degrees; it specifies an absolute pose. `occurrence_update` can ground/unground explicitly and captures changed positions in the timeline. Constrained or grounded placements refuse changes that cannot be applied and measured. + +`joint_create` creates an **as-built** rigid, revolute, slider, cylindrical, pin-slot, planar or ball joint. First place the occurrences, then supply exact occurrence references and joint geometry for non-rigid motion. Joint axes are in joint coordinates. Read the returned unit metadata: joint-geometry transform matrices retain explicitly labeled native cm translations, while motion values use mm/degrees. `joint_edit` reads/sets enabled limits, rest positions and motion values in mm/degrees, and rejects out-of-limit driving. Rigid groups have separate create/edit/delete operations. Joint and group suppression changes capture pending native state before recompute; responses report `positionCaptured`. Full motion-study authoring remains a comparison gap. + +`interference` analyzes selected occurrence instances or root-context body proxies and returns overlap volumes without adding interference bodies to the model. `assembly_properties` reads whole-assembly, component, occurrence or body mass, volume, area and center of mass with explicit units and coordinate scope. Material values reflect Fusion's assigned/default materials; they are not a supplier certification. + +## McMaster and purchased CAD + +`mcmaster_discover` reports availability of Fusion's native Insert McMaster-Carr command. `mcmaster_open` launches its interactive supplier picker in the targeted Hybrid design. Its recorded result is a launch request, not proof that a part was inserted. Inspect again after insertion or cancellation. This operation does not place orders. + +For an already downloaded model, use `cad_import` with an absolute host-local STEP/SAT filename, optional expected `sha256`, and provenance: + +```json +{ + "supplier": "McMaster-Carr", + "supplierPartNumber": "3597N17", + "sourceUrl": "https://www.mcmaster.com/3597N17/", + "description": "Supplier CAD downloaded for this part" +} +``` + +The importer hashes the actual source file, imports into a new wrapper component, and stores supplier/part number/URL/source path/checksum in native attributes. Caller-supplied supplier labels do not authenticate the download; retain the original supplier file/receipt. Imports are treated as purchased subassemblies by default. Use `component_metadata` for part number, description and BOM policy; `material_list` and `material_assign` use loaded library/material IDs. + +## Generate a mechanical BOM + +`bom` returns grouped purchasing parts plus a structured instance tree. It distinguishes component definitions from repeated occurrences, includes root-owned bodies, and keeps distinct same-named components separate. A purchased subassembly counts as one purchased item unless `explodePurchased:true`. `excludeFromBom` removes that purchasing subtree; physical totals still include its unsuppressed bodies. Typed BOM JSON and CSV publication responses declare `calculationAccuracy:"high"`, matching the high-accuracy physical-property workflow. + +`bom_export` writes `mode:"flat"` or `mode:"structured"` CSV to an absolute `filename`, with explicit overwrite and staged publication. Rows retain supplier provenance. Quantities do not depend on component display names. Save an F3D archive to retain Fusion metadata; STEP metadata persistence is not promised. Traversal budgets fail explicitly rather than presenting a truncated BOM as complete. + +## Regular assemblies (checkpoint 6) + +`regular_joint_create` solves geometry alignment between two guarded occurrence-context attachments or joint origins. `regular_joint_edit` redefines root-owned joint geometry, motion, offset, angle, flip or lock and restores the timeline marker even on error. Existing `joint_edit` and `joint_delete` remain the drive/limits/suppression and delete interfaces for both joint families. Rigid joints have implicit `locked:false`: requests for `locked:true` refuse before mutation, and the unsupported native rigid lock setter is never called. Redefining a locked moving joint as rigid clears its native lock; the resulting false value is verified after recompute. + +`joint_origin_create/edit/delete` manage native component-definition origins. Child-instance references appear as `jointOriginRefs` on occurrence inspection. Origins accept analytical face/edge/point attachments and optional straight orientation references in the same definition. + +Native origin `transform` is component-local; an occurrence-context origin returns its root-space proxy transform. The nested `geometry` frame is separately labeled `native_joint_geometry_api`: Fusion can expose an origin's defining geometry through a placed-instance proxy even for a native origin. Definition edits verify the exact native source and owner; regular-joint attachments additionally require the exact selected occurrence. + +A moving-to-rigid redefinition can make an existing Capture Position snapshot empty and unhealthy in Fusion. Such an edit refuses with `causeCode: unhealthy_model`; the response reports whether `PTransaction.Abort` verified restoration. Existing snapshot history is retained. Successful family changes and geometry reattachments require healthy resulting history; the bridge does not retarget or discard snapshots automatically. + +`motion_link_create/edit/delete` use the native November 2025+ API, with explicit joint degrees of freedom and positive mm/deg ratio magnitudes. `reversed` controls opposite motion. Missing native support fails explicitly. Motion link membership changes require recreating the link. + +Coupling-ratio edits run with the timeline at its end, where the link remains evaluated; they do not roll before the link as regular-joint redefinition does. Changing a ratio or reversal can re-solve member positions. Inspect the resulting assembly poses and base subsequent drive deltas on that post-edit state; an edit does not promise to preserve either member's prior position. A same-joint link can expose either null or the first joint again in native `jointTwo`. Readback preserves that native field and adds `sameJoint`; identity validation accepts only those equivalent representations, never an unrelated second joint. + +The [CP6 native acceptance cases](mechanical-verification/CP6-ACCEPTANCE.md) define solver/frame, repeated-instance, grounding, timeline, motion-link and F3D persistence gates. Inferred joints, multi-plane/profile/tangent attachments, custom joint motion axes and definition-owned nested joint redefinition are outside this checkpoint. Public origins and regular joints carry attachment and parameter readback; solver placement reports root-space occurrence transforms in mm. Native joint API frame matrices are labeled separately from root-space placement evidence. + +Regular-joint creation refuses pre-existing pending positions. Initial solve checks native joint-transform anchor distance against offset magnitude, offset axiality, and zero-angle Z-axis collinearity before applying drives. Native joint matrices identify their API source and root-space anchor origins; their axes differ from transient `JointGeometry` axes and do not independently prove physical angle/flip sense. The native acceptance campaign verifies that sense using independently known occurrence geometry and poses. Attachment source references are captured before solving and resolved afresh afterward: recompute can invalidate saved faces and transient geometry even when `JointGeometry.isValid` remains true. Driven `joint_edit` also verifies that active root motion links preserve their settings and propagate the specified motion ratio. diff --git a/docs/TESTING.md b/docs/TESTING.md new file mode 100644 index 0000000..ddc923e --- /dev/null +++ b/docs/TESTING.md @@ -0,0 +1,168 @@ +# Bridge verification + +The bridge has two different test surfaces: offline source/build gates and live +application workflows. A passing unit test, catalog row or availability probe +does not certify a native user workflow. Reports preserve failures, skipped +coverage, raw replies and the identity of the source being exercised. + +## Offline gates + +Use Python 3.11+ in a POSIX build environment with the Rust toolchain installed: + +```sh +python3 -m pip install -r requirements-test.txt +python3 tools/check-bridge.py +python3 tools/check-bridge.py --group python --group rust +python3 tools/check-bridge.py --group windows +``` + +The default runs Python regressions, Rust workspace tests, dashboard compilation +and tests if present, dependency portability and local-import shadow checks. +The Windows group is an explicit cross-build and needs the Rust Windows GNU +target plus its linker. `--cargo` selects a toolchain executable. Every run gets +a new report directory, separate group logs and hashes of source, test fixtures +and embedded assets. Source changes during the run invalidate the gate. Missing +tools, missing tests and timeouts do not become successful coverage. Dashboard +compilation with zero tests is identified as build-only with tests NOT RUN. +The portability gate inspects all targets and dependency paths. It records the +existing `ureq -> rustls -> ring` Windows TLS bindings as an explicit exception; +the same bindings through another path still fail. This is an architecture +check, not proof that every dependency runs on every platform. Three unexpanded +Rust Python templates are excluded from Python parsing and named in the report; +their bytes remain hashed, while rendered variants need separate execution tests. + +For a native checkpoint package, build the frozen source in its own fresh Rust +target directory. Do not symlink another snapshot's target cache: a copied +checkout can reuse an executable whose embedded catalog is stale even when Cargo +reports success. `package-mechanical-dev.py` checks that the executable contains +the exact current `contracts/describe.json` bytes before creating an archive. +This catalog check supplements the isolated build and source hashes; it does not +prove that every compiled function matches the source. Verify the installed +package hashes and live native catalog again before behavioral testing. + +The transport regressions use real local HTTP sockets and simulated Fusion +event delivery. They exercise immediate callbacks, simultaneous requests, +duplicate/late callbacks, status while a CAD operation is busy, malformed +requests, bounded input, slow clients and complete large responses. These are +transport tests, not native kernel tests. + +## Verb inventory and unattended discovery + +```sh +python3 tests/run_verb_tests.py --target MACHINE --dry-run +python3 tests/run_verb_tests.py --target MACHINE --tier daily +python3 tests/run_verb_tests.py --target MACHINE --only fusion_describe +``` + +The inventory retains every public verb and legacy test case. Execution uses a +separately reviewed allowlist with exact arguments. Manual and fixture cases +remain visible and cannot be enabled by `--only` or `--tier all`. Unknown case +names, catalog drift and zero executed cases fail instead of appearing green. +The dry-run mode only records the selection and does not contact the target. + +Some nominally read-only Fusion verbs run an automatic launch/repair gate, or +resolve licensing/update dialogs and write preferences. They require an explicit +owned campaign and are excluded from unattended discovery. The old shared +`BQ25792` fixture and theme mutation cannot execute in this runner. + +Replies retain their original transport and native envelopes. Native failure +outranks outer success. A negative test requires its intended refusal; a broken +connection is an infrastructure failure. Timed-out mutations are not replayed. +Typed mechanical calls may reconcile the original operation ID through status; +unresolved outcomes remain ambiguous. Foreground behavior stays UNVERIFIED +without an attributable transition record. + +The cron wrapper requires `FUSION_TEST_TARGET`, resolves this checkout relative +to itself and writes unique logs. It does not file an issue unless the scheduler +explicitly sets `FUSION_TEST_FILE_ISSUE=1`. + +## Live campaigns + +Maintained native campaigns use isolated scratch documents and public bridge +calls. Capture the starting runtime, document identities and preferences. Record +requests and native geometry independently; export and reopen archives; bind +visual review to exact image hashes. Close only documents owned by that run and +verify cleanup in `finally`. An unresolved mutation or changed/unrecognized +document must be preserved for investigation rather than deleted. + +Mechanical campaign commands and the exact independently verified subset are +recorded in the checkpoint handoff. Broader electronics, library, APS/cloud, +dashboard/wiki, rendering and animation workflows need their own native gates. +Windows coverage does not establish macOS coverage. + +```sh +python3 tests/ralph_mechanical.py plan +python3 tests/ralph_mechanical.py baseline --target MACHINE --host-name HOST --evidence NEW_DIRECTORY +python3 tests/ralph_mechanical.py run --target MACHINE --host-name HOST --evidence NEW_DIRECTORY \ + --expect-bridge 1.12.0-dev.4 --expect-addin 1.12.0-dev.4 \ + --iterations 2 --fixtures bracket assembly +python3 tests/ralph_mechanical.py recover --target MACHINE --host-name HOST --evidence EXISTING_DIRECTORY +``` + +The native campaign requires an already running, idle Fusion with an existing +Design document. It creates its own fixtures. Bracket coverage includes dimensions, +parameter edits, deliberate batch failure and archive reopening. Assembly coverage +includes repeated and independent component instances, placement, measured physical +properties and CSV BOM readback. Optional supplier coverage requires an explicit +local CAD path and expected SHA-256. Injected failures with `--inject-failure` +exercise cleanup; their expected nonzero exit remains recorded as a failed run, +with the cleanup result separately assessed. Checkpoints and evidence files are +retained intentionally. + +Use structured model state, API results, logs and archive hashes as the default +evidence. Continuous capture feeds and automatic Shotlog draining are disabled +for these campaigns. Do not add a Shotlog channel to the standard test commands. +For a meaningful visual checkpoint or a visually diagnosable failure, take one +useful frame and review it locally. Check exact hashes and perceptual similarity +before selecting any image for upload; discard empty, obscured or repetitive +frames from the upload selection. Retain original local failure evidence. + +The existing campaign's final viewport can support its separate `review` mode, +which binds annotations to exact image hashes. Native success alone does not +establish visual acceptance. State-only diagnostics explicitly disable capture +and report visual review as not requested. Background foreground-transition +evidence is separate and must not be inferred from screenshots. See the +[capture policy](mechanical-verification/CAPTURE-POLICY.md). + +The assembly campaign adds seven regular-joint families, known physical placement +and flip cases, root/nested/repeated contexts, joint origins, motion links, existing +as-built compatibility and full attachment-graph archive/recovery checks: + +```sh +python3 tests/ralph_assemblies.py plan +python3 tests/ralph_assemblies.py run --target MACHINE --host-name HOST \ + --expect-bridge 1.12.0-dev.6 --expect-addin 1.12.0-dev.6 \ + --groups regular_rigid --evidence NEW_DIRECTORY +python3 tests/ralph_assemblies.py recover --target MACHINE --host-name HOST --evidence EXISTING_DIRECTORY +python3 tests/ralph_assemblies.py review --evidence EXISTING_DIRECTORY \ + --reviewer REVIEWER --annotations EXACT_VIEWPORT_HASH_ANNOTATIONS.json +``` + +Omit `--groups` for all 15 groups, or select a finite subset during diagnosis. +Use the exact installed development version in both expected-version arguments. +This campaign covers representative fixtures, not every solver option, cloud-linked +replacement or animation workflow. Physical component matrices are distinct from +Fusion's internal joint-frame conventions. Expected refusals must preserve the +complete independently observed attachment graph; a successful `computeAll` alone +never establishes healthy downstream features. + +Add-in lifecycle regression tests use two independently loaded Python modules and +real loopback sockets to verify exclusive listener ownership, unique event IDs, +quiet address-in-use stand-down, failed registration/thread startup cleanup and +idempotent start/stop. Run `python3 -m unittest discover -s tests -p +'test_addin_lifecycle.py' -v`. Accepted connections retain their original listener +identity: late headers/bodies cannot borrow a successor event or admit new work +after retirement. Already admitted requests use the existing nonexecution CAS, +which preserves completed or executing native work. Windows uses `SO_EXCLUSIVEADDRUSE` before binding and +disables address/port reuse; other bind failures remain startup failures. A port +may remain temporarily unavailable while previous accepted connections drain. +Do not re-enable address reuse to force a restart. These offline tests do not +establish native CustomEvent delivery: installed-copy inspection and an actual +main-thread round trip remain required. Fusion 2705.1.30 has delivered a callback +after `fireCustomEvent` returned false, contrary to its documented acknowledgement +contract. The transport retains the waiter until callback or bounded expiry; +the return value alone proves neither completion nor nonexecution. Timeout CAS +still prevents an unclaimed late event from starting and preserves already claimed +execution for operation-ID reconciliation. Event-call exceptions keep their +existing delivery-failure handling. This does not prove persistent add-in delivery +works on every runtime; native acceptance remains separate. diff --git a/docs/build-2026-10-02-2.md b/docs/build-2026-10-02-2.md new file mode 100644 index 0000000..4d3e37b --- /dev/null +++ b/docs/build-2026-10-02-2.md @@ -0,0 +1,115 @@ +# three-musketeers build — Fusion roadmap — 2026-10-02 + +## What was built + +Checkpoint 4 (`1.12.0-dev.4`) adds a maintained background Ralph campaign and +hardens the bridge's test runner and HTTP transport. The campaign builds isolated +parts and assemblies, imports local supplier CAD, checks numerical geometry and +BOMs, exports/reopens native archives, reviews exact captured pixels and cleans +up only its own documents. Independent acceptance on Windows Fusion 2705.1.11 +passed 104 native assertions across two cycles, 12 document cleanup callbacks, +and six viewport files with three unique reviewed image hashes. Both intentional +interruption diagnostics preserved their failure results and passed cleanup. +The orchestrator's final offline run passed 194 Python tests and 56 Rust tests; +the Windows release build passed. The dashboard compiled but has no tests. +The real daily discovery run passed all 18 eligible read-only/refusal cases; +69 manual cases, 51 fixture-dependent skips, 47 unselected cases and one +unverified foreground check remain visible. The ten-minute background feed +transferred 1,043 main/child-window frames for observation, without treating +those transfers as 1,043 visual approvals. +[Commands and limits](TESTING.md), [verification data](mechanical-verification/cp4-verification.json) +and [review findings](mechanical-verification/CP4-ADVERSARY.md) accompany this checkpoint. + +The approved [implementation sequence](implementation-roadmap.md) continues with +everyday modeling. That next development candidate is separate from this accepted +checkpoint and still requires source review and native acceptance. The earlier +39-operation mechanical build, McMaster import/BOM behavior, comparison inventory +and Autodesk Compute research remain recorded in [the first handoff](build-2026-10-02.md). + +## Decisions for you + +No decision is needed to continue the approved development sequence. Changes are +published on `mechanical-design-2026-10-02` / PR #10; production promotion remains +a separate release decision. The test host stays development-pinned during the +next checkpoint, with a verified original-runtime backup retained for restoration. + +This checkpoint does not certify all bridge workflows. Electronics, library and +cloud flows need separate owned fixtures. macOS native behavior, photorealistic +rendering, native animation and foreground invariance have not passed this gate. +Background screenshots alone cannot prove that no foreground transition occurred. + +## Key decisions made during the build + +- Test discovery uses exact read-only arguments. Lifecycle-gated queries, + production fixtures and preference changes cannot be enabled through a broad + tier or selector. An integer that resembles a boolean does not satisfy the + reviewed argument contract. +- Native failure outranks outer transport success. Missing or ambiguous mutation + replies cannot trigger retries. Recovery queries only the original session and + operation, and requires a coherent terminal result. +- Cleanup requires runtime document identity, run ownership and a verified + checkpoint. Saved, changed, foreign or unresolved documents are preserved. + Historical cleanup failures remain recorded even if later recovery succeeds. +- The installed host's bridge inventory retained an old version string after + deployment. The campaign now binds a fresh loopback health read to the expected + host, discovered port, executable path, PID and process creation time. It rejects + sidecar-attributed versions and records the stale inventory value separately. +- HTTP request framing and body size are bounded; headers and body share an + absolute read deadline. Response writes receive their own budget. Duplicate + callbacks cannot replace terminal results, and a watchdog cannot release another + request's main-thread lock. +- Captures are decoded as images and identified by content hash. The feed helper + detects capture replacement and never stops shared capture state. A transferred + image remains unreviewed until an actual pixel review is attached. +- The all-target dependency gate explicitly records the existing portable TLS + stack's Windows bindings. The same bindings through other dependency paths fail. + Three unexpanded Rust Python templates remain hashed but are excluded from raw + Python parsing; rendered variants need their own tests. + +--- + +## Process appendix + +**Task:** implement the approved Fusion roadmap in verified checkpoints. +**Stage:** checkpoint 4 accepted; checkpoint 5 under review. +**Models:** independent Codex Builder, Adversary and Verifier roles; orchestrator +performs its own gates, deployment, image review and publication. + +### Adversary findings resolved + +| Finding | Area | Severity | Resolution | +|---|---|---|---| +| Read-only tests could enter lifecycle/repair paths | Runner selection | High | Exact reviewed allowlist; manual and owned-fixture cases remain explicit. | +| Numeric boolean equality admitted a real uninstall | Argument comparison | High | Type-preserving canonical JSON comparison. | +| Reused argument dictionaries changed recorded operation identity | Transport evidence | High | Immutable wire request and independently decoded history. | +| Failed ownership verification still permitted cleanup | Resource lifecycle | High | Semantic success required before close/delete adapters. | +| Resume lost preference restoration obligations | Recovery | Medium | Persist obligations and require rebound restoration adapters. | +| Incorrect terminal or uncoded timeout results could be accepted | Result interpretation | Medium | Exact identity, infrastructure classification and state/result coherence. | +| Slow input could exceed timeout; short remaining timeout truncated output | HTTP | Medium | Absolute read deadline and independent write timeout; real socket regressions. | +| Feed replacement, invalid PNGs and late frame work passed transfer checks | Capture evidence | Medium | Post-fetch epoch binding, pixel decode, per-call deadline. | +| Sidecar health could be mistaken for executable version | Native preflight | Medium | Refuse sidecar responses in the native campaign. | + +### Behavioral verification + +| Flow exercised | What the verifier did | Result | Notes | +|---|---|---|---| +| Bracket, twice | Constraints, parameter edit, failed-batch rollback, measured solid and archive reopening | PASS | 21 assertions per cycle. | +| Assembly, twice | Shared and independent copies, placement, physical totals, flat/structured BOM and archives | PASS | 18 assertions per cycle. | +| Supplier, twice | Hash-checked McMaster STEP, repeated instances, provenance, quantities and archives | PASS | 11 assertions per cycle; local supplied CAD, not automated retrieval. | +| Final state | Original document and preferences, runtime/pin preservation, owned cleanup | PASS | Four additional assertions; 12 exact close callbacks. | +| Viewport appearance | Exact image-file decoding, transfer and independent pixel review | PASS | Six captures, three unique hashes; no photorealistic render claim. | +| Interruption after document creation | Deliberately fail and run final cleanup | Expected failure / cleanup PASS | Five checkpoint/state assertions. | +| Interruption after modeling | Deliberately fail with built geometry and run final cleanup | Expected failure / cleanup PASS | 25 checkpoint/state assertions. | +| Public discovery | Call the real `fusion_describe` through the revised runner | PASS | Original large-catalog false failure retained with regression. | +| Daily read-only discovery | Public discovery, APS reads, status/config reads and exact negative guards | PASS | 18 eligible cases; skipped/manual coverage remains explicit. | +| Wrong runtime preflight | Request a bridge version that is not running | Expected refusal | Four observation calls; no CAD pass-through. | + +### Convergence + +Source review, private fault injection and the orchestrator's real-socket tests +converged with no unresolved serious finding. The adversary's additional final +pass cleared result interpretation, capture, dependency inspection and runtime +attestation. Initial startup polling, catalog-budget and static-gate failures were +preserved before corrections. The accepted full native campaign and explicit +failure-cleanup diagnostics remain separate reports; their checks are assertions, +not counts of unique end-to-end workflows. diff --git a/docs/build-2026-10-02.md b/docs/build-2026-10-02.md new file mode 100644 index 0000000..ffa1557 --- /dev/null +++ b/docs/build-2026-10-02.md @@ -0,0 +1,92 @@ +# three-musketeers build — Fusion mechanical design — 2026-10-02 + +## What was built + +The development build adds 39 typed mechanical operations to the existing Fusion bridge: guarded document/entity targeting, recoverable edits and verified exports; constrained sketches and 16 solid-feature families; internal assemblies, seven as-built joint motions, rigid groups, interference, material/physical properties, supplier CAD and mechanical BOM workflows. All three checkpoints passed independent live verification on Windows Fusion 2705.1.11. The final build is `1.12.0-dev.3` on development branch `mechanical-design-2026-10-02`. Root independently ran 71 mechanical Python tests; unchanged hygiene/routing suites add 30 passing tests, and the Rust workspace has 56 passing tests. Checkpoint 3 retains 815 exact native checks: 408 behavioral assertions and 407 availability/inspection checks, with no retained failure. These are assertions, not 815 distinct workflows. [Checkpoint reports](mechanical-verification/CP3-VERIFICATION.md) explain scope and evidence provenance. + +McMaster support has two explicit paths: launch Fusion's native interactive supplier picker, or import an actual downloaded STEP/SAT file with checksum and native supplier attributes. BOM operations group definitions and count repeated/nested instances, support purchased-subassembly collapse/explode/exclusion, and export flat or structured CSV. Physical totals remain independent of purchasing exclusions. + +The [comparison evidence](mechanical-comparison/README.md) records 695 source entries from sixteen repositories, 122 grouped action variants, 64 resource/prompt/protocol entries, the live official desktop MCP schema and published Data/Compute capabilities. Every inventoried entry has a gap flag. The [remaining scope](mechanical-comparison/REMAINING-GAPS.md) includes regular geometry-moving joints, advanced sketch operations, configurations, drawings, surface/form/mesh, sheet metal, CAM and simulation. Source registrations, working external implementations and our live-tested behavior are distinguished. + +The [Compute assessment](mechanical-comparison/autodesk-compute.md) records access prerequisites, client support and optional cloud-job opportunities. We have not authenticated this account to the new service. + +## Decisions for you + +The implementation and verification are complete for the agreed first three milestones. The remaining release decision is to review and merge the development PR, then use the normal release process. This build does not merge the default branch or publish a production registry version. The original host bridge and update preference have been restored. Recommend a separate follow-on for regular joints and the advanced sketch/inspection gaps before expanding into drawings, manufacturing or cloud execution. The existing as-built joint and interactive supplier scope is explicit in the guide; Autodesk Compute entitlement and behavior were not authenticated with this account. + +## Key decisions made during the build + +- Group CAD operations under discoverable schemas instead of adding a top-level verb for every API method. Keep the existing Rust bridge and in-process Python add-in. +- Treat native entity tokens as references requiring document/context validation, not globally unique immutable IDs. Track unsaved duplicate documents within the current add-in session. +- Separate the HTTP request waiter from the operation ledger. A timed-out caller can retrieve the recorded outcome; a changed session rejects an old retry instead of silently repeating it. +- Keep newly created scratch documents unnamed. Requested names are metadata until a save workflow can avoid Fusion's named-unsaved-document prompts. +- Treat filesystem publication as its own commit point. After publication, cleanup errors are warnings; model recovery cannot undo a written file. +- Compare source implementations and live results separately. Several external drawing/sheet-metal commands are placeholders or call mismatched APIs; those are flagged individually. +- Read back positions and joint settings after Fusion recomputes. A successful API assignment can leave a pending position that recompute discards; capture it within the transaction and verify each affected assembly context before committing. +- Keep BOM purchasing rules separate from physical accounting. Excluding or collapsing purchased assemblies changes rows, while mass and volume still count the modeled physical instances at explicit high calculation accuracy. + +--- + +## Process appendix + +**Task:** Build audit milestones 1–3, include McMaster and BOM, record MCP coverage and assess Autodesk Compute. **Stage:** development. **Models:** independent Codex Builder, Adversary and Verifier; the skill's default Opus/Sonnet were unavailable in this session. + +### Checkpoints + +| Checkpoint | Version | State | Evidence | +|---|---|---|---| +| Foundation | 1.12.0-dev.1 | Published development branch, commit 910e4c8f4ca7 | 21 mechanical + 6 document-hygiene + 24 routing Python tests; 29 Rust tests; 59 live behavioral and 38 inspection checks | +| Parametric parts | 1.12.0-dev.2 | Published development branch, commit f6c3af18ecb4 | 39 mechanical Python tests; Rust workspace tests; Windows release build; all 16 solid-feature families exercised live; 200 behavioral + 298 inspection checks | +| Assemblies / McMaster / BOM | 1.12.0-dev.3 | Accepted development checkpoint | 71 mechanical + 6 hygiene + 24 routing Python tests; 56 Rust workspace tests; Windows release build. 408 native behavioral + 407 availability/inspection checks, including 12 mechanical fixture groups and the native picker. | + +### Adversary findings resolved + +| Finding | Location | Severity | Resolution | +|---|---|---|---| +| Fast main-thread callback precedes HTTP waiter registration | addin/AdomBridge/http_server.py | High | Register waiter before event scheduling; independent immediate-callback reproducer passes. | +| Export reports preserved destination after replacement followed by cleanup failure | commands/mechanical/exporting.py | Medium | Record publication separately; report cleanup warning. Independent temporary-directory and hardlink cleanup probes pass. | +| BOM CSV cleanup can hide completed publication | commands/assembly_bom.py | Medium | Same publication boundary; independent probe passes. | + +### Independent baseline verification + +On `adom_drew2`, Fusion 2705.1.11: native PTransaction abort restored both a parameter and dependent solid volume; commit preserved the change; F3D export/reopen preserved it. This baseline proved the host mechanisms and is separate from certification of the new typed operations. The original pristine document was preserved during the probe; task scratch documents were closed. + +Original host installation was Fusion bridge 1.11.9, add-in 1.0.20, without a development pin. After final acceptance, the original cache files were restored from hash-checked backups, task-added runtime source files were removed, Fusion restarted, and the development pin was removed. Readiness confirms bridge 1.11.9 / add-in 1.0.20 with no stale add-in; bridge_list confirms one running instance and devPinned=false. Only one pristine, unmodified Untitled document remains. Tested development payloads, backups and evidence are retained; other bridge pins and user configuration were not changed. + +### Checkpoint 1 live gate + +Fusion 2705.1.11 passed 37 core behavioral assertions and 22 assembly assertions, plus 38 inspection/oracle checks. These cover parameter-driven geometry, failed-edit restoration, retry identity, document/revision rejection, non-destructive exports, native archive roundtrip, occurrence/body/face context and repeated/nested BOM quantities. A literal duplicate-name case could not be constructed because Fusion automatically renamed the second internal component; it is explicitly unverified live and covered by offline identity tests. One initial harness expectation was corrected from unsupported_format to the stricter invalid_input response; the refusal and unchanged sentinel both passed. + +Live testing found and resolved two additional defects before publication: bridge-injected caller metadata was rejected by strict validation; and Occurrence exposes suppression through timelineObject, not a direct isSuppressed property. Both have regressions and independent review. The final tested runtime archive SHA-256 is 6309c21bf46decc4768f28fe27ec7b1938355ea3226ad26a373c8e52c7391400. Scratch documents were closed; the original pristine document was preserved. + +### Checkpoint 2 pre-live review + +The reviewed source has 19 typed mechanical operations and 16 solid-feature variants. Five defects were fixed before deployment: profile inspection returned a reference that could not be resolved again; semantic recovery omitted healthy feature names and standalone-point fixedness; symmetric extrusion ignored taper; and polygon/slot generation dropped nonzero Z. Independent focused reproducers now pass. Operation status and capability queries bypass a held main-thread lock without touching the Fusion API. Kernel behavior remains subject to the live gate. + +### Checkpoint 2 live gate + +All 16 advertised solid-feature families now have independent real-geometry evidence on Fusion 2705.1.11, including modeled M6 threads. The constrained plate workflow also passed dimension-driven edits, feature rename/radius changes/suppress/unsuppress/delete, projection, stale-reference refusal, failed atomic parameter-batch restoration and status retrieval during a busy main CAD thread. Native archives reopened with matching geometry, parameters and timeline. + +Two live defects were fixed before publication: ConstructionPlane/ConstructionAxis use component for ownership; and rectangular patterns must explicitly disable Fusion's implicit second direction. One-axis count 3 now yields exactly 3 bodies / 72 mm³; explicit second direction yields 6 bodies / 144 mm³. Incomplete second-direction arguments refuse and restore unchanged state. The final runtime archive SHA-256 is f389e394253198cb2af69fcd155a62043c09b8934920cd1fe98d9f4d7591958e. Test-owned documents were exported/reopened and closed; the original pristine document remains. + +### Checkpoint 3 source review + +Three medium findings were fixed before the live gate: nested body/face/edge references needed exact occurrence-path resolution; material readback had to compare material IDs rather than display names; and interference traversal needed a shared body budget across all selected subassemblies. The independent final source pass rejected 22,000 physical instances before kernel invocation. + +The documentation review also corrected regular-versus-as-built joint coverage, missing inertia/mesh-analysis capabilities, transaction-probe prerequisites and material readback wording. These are reflected in the per-feature gap flags rather than counted as complete parity. + +### Checkpoint 3 live corrections + +Native tests exposed assumptions that offline API stand-ins could not certify. A newly created nested occurrence must be converted to its root assembly contexts before requesting a token. Actual supplier STEP geometry includes degenerate edges and grouped imported features without a readable timeline index; these now retain explicit topology/availability information in inspection and recovery witnesses. Joint driving requires a captured position before recompute; the native probe proved this preserves configured rest values and allows transaction abort to restore the original witness. + +The next native pass confirmed supplier import, repeated purchasing quantities and CSV/archive provenance, and all six moving-joint poses. It also exposed duplicate API-equal token results and suppression that becomes pending snapshot state. Strict joint/group edit readback now verifies names, suppression, numeric limits/rest values and enabled flags, including the implicit enabling of supplied limits. Typed BOM physical totals use explicit high calculation accuracy to match assembly properties; the initial default-accuracy mismatch remains in the verification evidence. Final acceptance is recorded in the checkpoint report. + +### Checkpoint 3 final acceptance + +The last live defect was an independent copy returning success at the source position instead of its requested position. A separate native probe proved that pending positions need capture and nested placement must use root-context proxies. The corrected create/insert/import path applies each requested parent-space pose in every current parent context, captures once, and checks all computed matrices before commit. The final native regression covers root copies, rotated/repeated subassemblies, later third/fourth parent reuse, source parameter edits, independent geometry and F3D reopen. A definition-local base transform is not substituted for an instance's computed position. + +The final exact evidence set is 244 checks from focused run `cp3-123613-f8a50491`, 542 checks from nine complete unaffected groups in `cp3-121116-6159c012`, and 29 native McMaster picker checks from `cp3-114558-46db347b`. The earlier copy-position failure remains in the raw evidence and is linked to its passing regression; it was not relabeled or omitted silently. Root independently matched every retained case/status/detail against those raw runs and verified 11 artifact hashes. Native results include all seven joint motions, limits/rollback and suppression, rigid-group lifecycle, interference, material/physical totals, genuine McMaster STEP geometry, repeated purchased quantities, CSV output and native provenance persistence. + +Final runtime archive SHA-256: `5662cea028729195f23395f58343a4a7a28ff224722a473ff5aa8806be11cc6f`. Native executable SHA-256: `72c94ce3d1f0ed1a7fc463a399c693570bc3c46316898ababd2d432a4b453be8`. All 51 runtime manifest files were verified; loaded module hashes also matched. The final adversary pass found no remaining actionable source defect and independently passed nine root-proxy placement fault checks. Earlier review findings and native failures are preserved in [the source review](mechanical-verification/CP3-SOURCE-REVIEW.md) and [live report](mechanical-verification/CP3-VERIFICATION.md). + +Evidence roots in the shared workspace are `research/fusion-mechanical-build-2026-10-02/` and `research/fusion-mechanical-verification-2026-10-02/`. They contain exact source snapshots, deployment/restoration receipts, raw cases, native archives, CSVs and the final adjudication. The comparison record was independently checked for all 695 source rows, 122 selector rows, 64 protocol rows and 75 official rows; every row has an explicit gap flag. External servers were audited from pinned source; their native behavior was not certified, and no external implementation code was copied. diff --git a/docs/build-2026-10-05-assemblies.md b/docs/build-2026-10-05-assemblies.md new file mode 100644 index 0000000..5537c3b --- /dev/null +++ b/docs/build-2026-10-05-assemblies.md @@ -0,0 +1,75 @@ +# Fusion regular assemblies — development checkpoint 6 + +`1.12.0-dev.6` adds eight typed operations for regular joints, joint origins and +motion links, bringing the mechanical surface to 53 operations under 167 public +verbs. Regular joints solve attachment geometry; the existing as-built joint +operations retain their placement-preserving behavior. Seven joint families, +nested and repeated instances, root attachments, geometry redefinition, origin +frames and physical motion coupling have native acceptance evidence on Windows +Fusion 2705.1.11. + +The accepted set is an explicit composite of 15 complete groups on the same +installed payload, session and process: 415 passing assertions, 32 exact owned +document closures and 34 byte-verified artifacts. Assertions include expected +refusals and cleanup checks. Separate image review covers all viewport artifacts. +Original failures remain recorded; no partial failed group contributes to the +accepted set. The existing bracket and BOM/assembly regressions also pass: 43 +additional checks, four exact closures and six artifacts, including both flat and +structured CSV exports. [Verification data](mechanical-verification/cp6-verification.json) +records the exact source and evidence relationships. + +The source gate passes 340 Python tests. The native binary reuses the independently +built and tested checkpoint 6 binary after proving all 150 native inputs unchanged: +56 Rust tests and a fresh isolated Windows build passed. The installed package's +203 file-copy hashes and exact live 167-verb catalog were independently checked. +Dashboard coverage is compilation only. + +## Native findings and resulting behavior + +- Fusion can invalidate retained attachment wrappers during recompute. The bridge + captures attachment identity before solving, resolves fresh native entities and + checks API equality afterward. Native joint frames already use assembly space; + proxy joint-origin frames require their own explicit local/root interpretation. +- Rigid joints reject their native lock setter even for false. The bridge refuses + `locked:true`, skips that unsupported setter for false, and verifies the native + result. Moving joint locks remain explicitly checked. +- Converting a moving joint to rigid can empty an existing Capture Position and + make the model unhealthy. That combination refuses with verified rollback; + history is preserved. The accepted replay separately proves this exact refusal, + locked same-family reattachment and unlocked moving-to-rigid conversion without + downstream position history. Locking a fresh moving joint itself creates a + snapshot, which is why the positive fixture has an explicit unlocked prerequisite. +- A motion-link ratio edit can re-solve its members. Propagation checks use the + post-edit physical poses, check both rotation and attachment-center preservation, + and handle Fusion's native representation of two motions on a single joint. +- Deleting a referenced joint origin can report successful compute while damaging + a dependent joint. Full health and attachment-graph checks reject and restore it. + +The controlled close-refusal exercise separately passes its recovery acceptance: +29 retained assertions, two verified artifacts and two exact closures. Its native +report intentionally stays FAIL. This proves same-session recovery of owned test +documents, not process-restart recovery. Original user documents, preferences and +the development pin were preserved. Passive capture and a bounded 45-second +foreground observation have their own qualifications; they do not establish +campaign-wide focus behavior. + +## Scope and next work + +This checkpoint does not complete all assembly workflows. Linked external parts, +controlled replacement, contact and swept-clearance studies remain open. Native +configuration inspection, durable operation history, supplier retrieval/cache and +procurement BOMs are separate checkpoints. Still rendering and animation remain +forthcoming; viewport images do not count as native render output. + +The pinned October 2 external/SolidWorks inventories remain intact. The +[checkpoint coverage overlay](mechanical-comparison/CHECKPOINT-STATUS.md) records +the newly accepted subset and remaining gaps. Development source publication goes +to `mechanical-design-2026-10-02` / PR #10; production promotion is separate. The +PR's stored diff has previously lagged its source branch, so review the exact +checkpoint commit before any future merge. + +Builder and Adversary supplied independent implementation and review. Root ran the +independently authored native harness after the original Verifier became +unavailable, then checked artifacts, installation identity and final accounting. +macOS, electronics, cloud, wiki UI and later feature families do not inherit this +mechanical acceptance result. diff --git a/docs/build-2026-10-05-jobs.md b/docs/build-2026-10-05-jobs.md new file mode 100644 index 0000000..b94d3e7 --- /dev/null +++ b/docs/build-2026-10-05-jobs.md @@ -0,0 +1,99 @@ +# Mechanical job history, development checkpoint 8 + +Typed mechanical mutations now record admission before native dispatch and retain +their exact terminal result in a bounded, app-owned SQLite journal. Repeating a +completed mutation in the same session returns the original result. Reusing its ID with a +different payload refuses that submitted request without changing the original +record. Status reads use an observational route that does not launch Fusion, +repair a timeout, change foreground state or replay a mutation. + +This source remains a development candidate on `mechanical-design-2026-10-02`. +It adds durable records for synchronous typed operations. Rendering, cancellation +and animation jobs are not implemented by this checkpoint. + +## Accepted evidence + +The installed bridge and add-in are `1.12.0-dev.8` on Windows, Fusion +`2705.1.30`, Python `3.14.0` and SQLite `3.50.4`. All 215 installed file copies +matched the package and the development pin remained set. The package SHA-256 +is `74cd3a10f12374c764621001ebb06144047760c4cb477ccc16337a30892bdf56`. + +- The ordinary native campaign passed 35 checks: exact creation replay, changed + payload refusal, parameter edits, transport-metadata equivalence, failed-batch + rollback and replay, immutable terminal history, native archive roundtrip and + exact document cleanup. The original pristine document and preferences were + restored. Its one final viewport was reviewed locally; no Shotlog upload was + requested. +- A single eight-second native main-thread hold completed normally. All three + complete status results, before/during/after, were identical. Exact Windows + performance-counter evidence places the status completion upper bound + 5.7555424 seconds before the hold ended. The hold lasted 8.0042803 seconds. + The original checker failure is retained: one parsed wall-clock float differed + by one ULP although the complete marker bytes, hashes and lengths matched. + Separate independent and maintained offline checks qualify those saved bytes; + no native rerun was used. This covers a GIL-releasing main-thread sleep, not a + native kernel or GIL wedge. +- The combined publication source passed 454 Python tests, plus 38 historical + acceptance-checker and 21 busy-observation/checker tests. It preserves the + previously accepted assembly harness corrections. Portability passed after + putting the already installed Cargo on PATH; the initial environment failure + is retained. The earlier 60 Rust tests, dashboard compilation and Windows + binary build apply through 151 unchanged build-input hashes. A new native + build is not claimed for the Python-only corrections. + +A controlled graceful Fusion restart changed the native process and add-in +session. All three complete historical records (creation, edit and failed batch) +matched exactly except for the documented current-session/historical fields. +The original failed postcheck harness attempt is preserved separately from the +successful read-only final-state qualification; no second restart was needed. +The new baseline is pristine, preferences unchanged and development pin retained. +An old-session parameter request refused with `session_mismatch`; the entire +current-model inspection and original historical record stayed unchanged. +Native crash interruption and disk/commit fault injection remain unqualified. +Offline tests cover those boundaries but do not certify their native-app behavior. + +## Native findings fixed + +Two installed add-in copies previously could share a listener and event ID. +Listener ownership is now exclusive, each event is unique, and a losing copy +cannot tear down the winner. Fusion `2705.1.30` also delivered a callback after +`fireCustomEvent` returned false; the bounded callback/deadline arbitration now +decides delivery. A false return alone cannot establish nonexecution. + +Windows rejected `fsync` on the read-only staged-database handle. Opening that +existing staged file as `r+b` permits the required flush without truncation. +Storage failures still refuse admission, and uncertain outcomes are retained. + +The admission-conflict correction distinguishes the rejected new payload from +the existing operation. Only proof of a different existing digest produces +`not_executed` for the submitted request. Neither the original result nor its +history is overwritten. + +## Configuration and remaining coverage + +Checkpoint 7 contributes read-only configuration inspection, including the +reserved test row and per-field failures. Its native conversion survived an +acknowledged Abort, so configuration writes remain disabled. Partial unsaved or +offline configuration cells do not produce a purportedly complete BOM. Full +configured archive recovery remains unverified. + +The pinned external MCP inventory remains a dated comparison, with later live +Autodesk observations recorded separately. Local McMaster STEP/SAT ingestion, +interactive picker handling and flat/structured physical-quantity BOM exports +remain supported; automated supplier retrieval, price/cache/procurement and +complete configured BOM workflows remain gaps. Native rendering and animation +are still separate roadmap work. + +A separate read-only scene diagnostic passed 12 native checks and exact archive +roundtrip/cleanup with no images. The installed build exposes RenderManager, +Rendering, SceneSettings, RenderFuture and 14 environment entries. Its +viewport camera was unavailable, so observation remains partial. No render ran, +no scene setting changed, and complete-scene/global-queue/settings-ownership +claims remain unverified. This is capability evidence for the next checkpoint, +not a render implementation. + +Follow the [capture policy](mechanical-verification/CAPTURE-POLICY.md). Use +structured results and native artifact hashes for routine checks. A screenshot +requires a concrete visual purpose, and an upload requires review and duplicate +screening. A passing mechanical workflow does not certify electronics, cloud, +other platforms or the entire bridge. diff --git a/docs/build-2026-10-05.md b/docs/build-2026-10-05.md new file mode 100644 index 0000000..e47f3bc --- /dev/null +++ b/docs/build-2026-10-05.md @@ -0,0 +1,79 @@ +# three-musketeers build — Fusion everyday modeling — 2026-10-05 + +## What was built + +`1.12.0-dev.5` adds six typed operations: current-geometry selection, engineering +measurement, sketch trim/extend/split/break, signed constrained offsets, +construction axes and native constraint diagnostics. The bridge now has 45 typed +mechanical operations under the existing 167 public verbs. The maintained everyday +Ralph runner drives owned native fixtures through public bridge calls, checks +independent geometry and relationship graphs, archives/reopens designs and verifies +exact cleanup. Root independently observed 230 Python tests and 56 Rust tests pass; +a fresh isolated Windows build and exact installed-file/catalog checks also passed. +The corrected native replay passed all 16 groups: 254 assertions, 32 exact owned +document closures and 34 artifacts verified by byte count and hash. All 16 viewport +captures passed separate image review, representing 15 unique hashes. A controlled +close-refusal recovery exercise passed its separate acceptance checks while retaining +the intentional native failure, adding two verified artifacts and two unique closures. +[Verification data](mechanical-verification/cp5-verification.json), +[source review](mechanical-verification/CP5-SOURCE-REVIEW.md) and +[runner review](mechanical-verification/CP5-RUNNER-REVIEW.md) record the evidence. + +## Decisions for you + +No decision is needed for the authorized development branch. Production promotion +remains separate. Regular joints, configurations, supplier retrieval/cache, +procurement BOMs, native rendering and animation remain subsequent checkpoints. +The original comparison inventories remain intact, with a separate later-checkpoint +coverage overlay identifying what is still missing. +Source checkpoints are published on `mechanical-design-2026-10-02`. PR #10 currently +displays an older stored diff; its checkpoint comment identifies the exact accepted +source commit. Refresh the review diff before any future production merge. + +## Key decisions made during the build + +- Child-component B-Rep cannot always be measured directly by Fusion. Component-space + picks use temporary geometry copies without changing the model; reversed native + distance witnesses are matched to the requested pick by coordinates. +- Valid parallel angles can return three zero native witness points. The bridge + preserves those points and reports a separate geometry-direction check; it never + invents nonzero witness geometry or accepts degenerate three-point angles. +- Extend can mutate its original line while returning no replacement collection. + A fresh reference is returned only when the selected curve itself changes and + that change survives recompute. Unrelated sketch movement cannot satisfy the check. +- Recovery snapshots include constraint/dimension attachment relationships, spline + shape, offset links and axes. An unchanged visible shape cannot hide a changed + graph. The everyday runner's recovery command uses the same extended witness. +- A shared Cargo target cache produced a stale native catalog despite reporting a + successful build. Candidate packages now use fresh isolated output directories + and check the exact embedded catalog before packaging. Installed bytes and the + catalog actually served by the native bridge are checked again. +- The recovery diagnostic found that aggregate counts omitted later restoration + checks. Recovery now counts the complete retained check list and preserves prior + failures; a regression and an idempotent native recovery pass verify both behaviors. + +## Process appendix + +**Task:** Fusion full mechanical roadmap · **Stage:** everyday modeling · +**Roles:** independent Builder, Adversary and Verifier, with root source/test/package +checks · **Target:** `adom_drew2`, Fusion 2705.1.11, behind an existing development pin. + +The first live sweep deliberately remains visible: 15 runs, 162 passing assertions, +2 failed assertions and 6 failed native reports, with all 15 cleanup checks passing. +These include 3 product defects, 2 expected-error-code mismatches in the new runner +and 1 transient offline metadata getter. No failed partial group is counted as a +complete pass. Controlled probes established the native behavior before each fix. + +The complete corrected replay covers 14 new modeling groups plus bracket and +assembly regressions. An independent audit checked 1,025 recorded CLI calls and +64 immutable harness source copies. The controlled recovery exercise records 15 +passing checks across its initial and corrected recovery passes, preserves two +historical refused-close rows, and restores the original pristine document. These +are assertion counts, not counts of distinct end-to-end workflows. The main replay +used the recorded earlier harness; the later support-only count correction has its +own source identity and recovery evidence. No product or installed payload changed. +Source review's final additional pass found no further substantive issue; actual +native failures were corrected and their whole groups replayed successfully. +Dashboard coverage is compilation only; macOS, unattributed foreground transitions, +electronics/cloud/wiki UI campaigns and the rest of the roadmap do not inherit this +mechanical gate's result. diff --git a/docs/implementation-roadmap.md b/docs/implementation-roadmap.md new file mode 100644 index 0000000..392c4b1 --- /dev/null +++ b/docs/implementation-roadmap.md @@ -0,0 +1,82 @@ +# Fusion implementation roadmap + +Approved for implementation on October 2, 2026 after the mechanical audit and +the first three development checkpoints. Continue publishing verified changes +on `mechanical-design-2026-10-02` / PR #10. Production promotion remains a +separate action. The existing `1.12.0-dev.3` source and native evidence are the +baseline; do not overwrite their records. + +## Delivery order + +1. Harden the maintained test harness and integrate repeatable background + Fusion campaigns. Verify catalog and dispatch contracts, actual transport, + result interpretation, ownership, recovery and evidence quality. +2. Everyday part modeling: sketch editing, construction references, selection + queries, engineering measurements and constraint diagnostics. +3. Assemblies: regular joints, motion diagnostics/clearance, linked parts and + controlled replacements. +4. Configurations, supplier retrieval/cache and procurement BOMs. +5. Product rendering: appearances/scenes, native still-render jobs and exports. +6. Animation: deterministic pose/camera frames, video encoding and the native + Animation API where verified on the installed build. +7. Drawing and manufacturing deliverables, clearly separating stable and + preview APIs and verifying output files independently. +8. Advanced geometry, sheet metal, CAM and simulation through individually + verified capabilities. +9. Optional Autodesk Compute backend with separate cloud identity, inputs, + outputs and job lifecycle. Public beta access does not certify this + account's entitlement or authorize changing its sign-in configuration. + +Durable operation recovery, progress and cancellation accompany stages 3–5. +The detailed feature/test backlog and rendering audit remain in the workspace +research record until their implemented subsets have acceptance evidence. + +## First checkpoint: 1.12.0-dev.4 + +Make the harness fail closed on contradictory/nested errors, infrastructure +failures, invalid expected refusals and ambiguous mutation timeouts. Default +discovery tests must be read-only and must not open or mutate a production +fixture. Keep explicit skipped/manual cases visible. Use exact document IDs, +a unique run directory, raw responses and cleanup in `finally` for live +campaigns. Never count unavailable screenshots or unobserved foreground +transitions as a visual/background pass. + +Add an independently measured native bracket/assembly workflow through public +bridge calls, including export, reopen, negative guards and owned cleanup. +Exercise transport interleavings and catalog/dispatch drift offline. Broader +electronics/library/cloud/UI campaigns remain separately tracked and cannot +inherit a mechanical campaign's PASS. + +## Ownership and acceptance + +The builder owns the existing verb runner, reusable harness helpers, focused +regressions and version files. The verifier owns the new maintained Ralph +campaign and independent evidence, and exclusively controls `adom_drew2` +during live testing. The adversary is read-only with private scratch files. +The orchestrator owns transport/contract tests, documentation, deployment and +publication, and runs independent gates. + +Each checkpoint requires no unresolved serious findings, meaningful offline +tests, an applicable real user workflow, source/runtime identity and cleanup +verification. Preserve original failures with the passing regression. Native +API availability and preview status are verified before promising a feature. +Test counts describe assertions, not unique end-to-end workflows. + +## Accepted checkpoints — 2026-10-05 + +Development checkpoint4 completed the transport/harness slice. Checkpoint5 completes +the everyday-modeling slice with six typed operations, independent native geometry +and graph checks, archive reopening, visual review and controlled recovery. See +[the accepted handoff](build-2026-10-05.md). Checkpoint6 adds the accepted regular-joint, +origin and motion-link subset: [assembly handoff](build-2026-10-05-assemblies.md). +Linked/replacement workflows and durable operation lifecycle remain separate +assembly work, not implied by regular-joint coverage. Configuration inspection and +durable history candidates have separate source/native gates; rendering remains +research preparation until actual native output and lifecycle behavior pass. + +Checkpoint 8 implements durable synchronous mechanical records and an +observational status route. The accepted ordinary and busy-status evidence, +native findings, capture policy and remaining gates are recorded in the +[job-history handoff](build-2026-10-05-jobs.md). Configuration inspection is +read-only; native conversion recovery has not passed, so configuration writes +remain disabled. diff --git a/docs/integration-2026-10-06.md b/docs/integration-2026-10-06.md new file mode 100644 index 0000000..db141d3 --- /dev/null +++ b/docs/integration-2026-10-06.md @@ -0,0 +1,47 @@ +# Mechanical foundation integration with current master + +This candidate applies the mechanical branch's delta to master commit +`52221e25dcbe11ebccb2dfdaac98c81dec7b8931` (bridge `1.11.39`). The source branch +is `7be40e9021c13ad430c28e108a6bf544d3f9481c`; their common ancestor is +`a721e84613f6744bb837cfb7b828795abbc565f5`. The original development branch and +its native evidence remain unchanged. + +Integration uses a three-way file merge. It retains master's newer routing and +electronics handlers, schemas, native routes and verb inventory, along with the +54 typed mechanical operations. The regenerated catalog has 179 public verbs: +all 176 master verbs plus the three mechanical entry points. Existing master +schemas and summaries are preserved; the branch's documented MCP example remains. + +No release version is assigned here. Master's `BRIDGE_VERSION`, package and +dashboard/Rust versions remain `1.11.39`; the independently versioned Fusion +add-in manifest remains `1.0.20`. Health and mechanical operation history read +that actual add-in manifest version. The maintainer owns the future `1.12.0` +release. Source hashes, rather than a new release number, identify this candidate. +No installed runtime or development pin is changed by this integration. + +Historical checkpoint reports describe their exact earlier development sources +and Fusion builds. Their successful native checks do not certify this merged +tree. Offline integration results and source hashes must accompany review; +native acceptance of the merged source remains pending until it is separately +built, installed behind a development pin and tested on an explicitly owned host. + +The next animation priority is [exploded views and assembly instructions](EXPLODED-VIEWS.md). +It is a planned separate checkpoint, not part of this integration. Continuous +capture and automatic Shotlog uploads remain disabled. + +## Offline acceptance of this merged source + +The root run passes 455 Python tests, 62 Rust tests, 38 history helper tests and +21 busy-status helper tests. Eight independent coexistence tests pass, including +all 54 operation schemas, catalog/handler union, stale-board mutation refusal, +electronics BOM and schematic planning. Dashboard compilation (zero tests), +portability/import checks and a fresh Windows release build pass. The binary +contains the exact frozen catalog. Source hashes remain unchanged during testing; +all 1,204 candidate files matched the frozen manifest afterward. + +See [root acceptance](integration-2026-10-06-evidence/root-acceptance.json), +[offline gate report](integration-2026-10-06-evidence/offline-gates.json), and +[independent behavioral verification](integration-2026-10-06-evidence/independent-verification.md). +These records certify source/build behavior only. Merged native acceptance is +NOT RUN. The evidence files and this paragraph were added after the source gate; +no runtime, catalog, test or build input changed. diff --git a/docs/mechanical-build-plan.md b/docs/mechanical-build-plan.md new file mode 100644 index 0000000..43fe873 --- /dev/null +++ b/docs/mechanical-build-plan.md @@ -0,0 +1,29 @@ +# Mechanical bridge build — 2026-10-02 + +Scope: deliver the first three audit milestones in the existing Fusion bridge: trustworthy execution/readback, parametric parts, and assemblies including purchased McMaster components and BOM generation. Advanced drawing, sheet-metal, CAM and simulation authoring remain explicit comparison gaps. The baseline is 1.11.9 / a721e84613f6744bb837cfb7b828795abbc565f5. Audit: /home/adom/project/research/fusion-mech-audit-2026-09-30/AUDIT.md. + +## Checkpoints + +1. Foundation: document/revision targeting, explicit units and references, health/recompute, recoverable typed mutation, useful inspection, corrected physical properties/BOM identity and safe exports. Ship a usable create/inspect/parameter/export flow. +2. Parametric parts: constrained sketch primitives, dimensions/construction geometry, common solid features, feature/parameter edits and readback. Exercise real geometry and a failed edit with recovery. +3. Assemblies and purchased parts: component/occurrence placement, joints and limits, interference, material/mass, McMaster CAD ingestion/provenance, structured/flat CSV BOM with correct quantities. Exercise a small assembly with repeated purchased components. + +Publish each verified checkpoint as a unique development version on the wiki branch mechanical-design-2026-10-02; do not update main or the production registry. Final handoff docs/build-2026-10-02.md records actual coverage and any restrictions. The installed development build is tested on adom_drew2; preserve existing user files and preferences. + +## Responsibilities + +Builder owns production code, version/contract changes and focused offline tests. Adversary reviews read-only. Verifier owns only private verification artifacts and exercises real Fusion workflows after coordinating exclusive host access. Orchestrator owns research, external feature mappings, publishing, environment/build setup and final handoff. Use three independent fresh-context Codex agents; the requested skill's default Opus/Sonnet are unavailable. + +## Acceptance + +No unresolved critical/high defects. Distinguish static/offline/live evidence. Explicitly report unsupported operations; script reachability is not dedicated feature support. Prove document mismatch rejection, failed mutation recovery, parameter-driven volume change, feature health, export preservation/roundtrip, assembly transforms and quantities, and purchased metadata retention. Keep existing electronics compatibility. Full external tool inventory must identify each comparator feature and our dedicated/script-only/missing status. + +## Build notes + +The implementation may group typed operations under a discoverable mechanical dispatcher rather than mint a verb per CAD API call. API availability must be checked against installed Fusion; preview or absent APIs must be reported without pretending support. + +## Build notes — 2026-10-02 + +The implemented surface uses three top-level verbs and 39 typed operations. Separate schema variants cover the solid features and seven as-built joint motions. Regular joints that align/move components remain a documented follow-on; the delivered assembly workflow places occurrences explicitly before constraining them. Native McMaster launch is kept separate from verified local supplier-CAD ingestion and provenance, because launching a picker cannot certify a download or insertion. BOM purchasing policy and physical totals are separate so excluding a bought assembly from a purchasing list does not erase its modeled mass. + +Live Fusion testing required API-specific corrections to construction-plane ownership, rectangular-pattern second-direction defaults and nested occurrence token context. Recovery is proved on the current runtime, rather than assumed from transaction API presence. The final handoff records the gate results and any remaining limitations. diff --git a/install.sh b/install.sh index 2aa098e..f54ec57 100644 --- a/install.sh +++ b/install.sh @@ -9,6 +9,20 @@ SKILLS_DIR="$HOME/.claude/skills" rm -rf "$SKILLS_DIR/adom-desktop-fusion-bridge" mkdir -p "$SKILLS_DIR/fusion-bridge" [ -f "$PKG_DIR/SKILL.md" ] && cp "$PKG_DIR/SKILL.md" "$SKILLS_DIR/fusion-bridge/SKILL.md" +# Bundle the new mechanical guide and its comparison/verification evidence with +# the installed skill so its relative documentation links remain usable. +MECHANICAL_DOCS_DIR="$SKILLS_DIR/fusion-bridge/docs" +mkdir -p "$MECHANICAL_DOCS_DIR" +for guide in MECHANICAL.md build-2026-10-02.md; do + [ ! -f "$PKG_DIR/docs/$guide" ] || cp "$PKG_DIR/docs/$guide" "$MECHANICAL_DOCS_DIR/$guide" +done +for guide_dir in mechanical-comparison mechanical-verification; do + if [ -d "$PKG_DIR/docs/$guide_dir" ]; then + mkdir -p "$MECHANICAL_DOCS_DIR/$guide_dir" + cp -r "$PKG_DIR/docs/$guide_dir/." "$MECHANICAL_DOCS_DIR/$guide_dir/" + fi +done + for skill_dir in "$PKG_DIR/skills"/*/; do [ -d "$skill_dir" ] || continue name=$(basename "$skill_dir") diff --git a/package.json b/package.json index 08d9034..cadc7bd 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,9 @@ "pcb", "exports", "aps", - "modeling" + "modeling", + "mechanical", + "assemblies" ], "hero": { "path": "screenshots/hero.png" @@ -42,9 +44,13 @@ "aps not configured", "share aps client id across my org", "set up fusion cloud search for my company", - "org settings autodesk aps" + "org settings autodesk aps", + "build a parametric part in fusion", + "assemble mechanical parts in fusion", + "insert a McMaster part in fusion", + "generate a mechanical assembly BOM" ], - "discovery_pitch": "Use the Adom Bridge Fusion 360 bridge when the user wants to DRIVE Autodesk Fusion 360 from the cloud: open/search their team's Fusion cloud designs, lay out a PCB or do electronics, export STEP/IGES/STL/Gerbers/BOM, or build a component library with real rendering 3D. It runs Fusion on the user's own machine via Adom Bridge. NOT for web/browser/login/form-filling (that's the native-browser extension) and NOT for KiCad (that's the kicad bridge).", + "discovery_pitch": "Use the Adom Bridge Fusion 360 bridge when the user wants to DRIVE Autodesk Fusion 360 from the cloud: open/search their team's Fusion cloud designs, author parametric mechanical parts and internal assemblies, ingest McMaster CAD and generate mechanical BOMs (development 1.12), lay out a PCB or do electronics, export STEP/IGES/STL/Gerbers/BOM, or build a component library with real rendering 3D. It runs Fusion on the user's own machine via Adom Bridge. NOT for web/browser/login/form-filling (that's the native-browser extension) and NOT for KiCad (that's the kicad bridge).", "sample_prompts": [ { "label": "Find a design", diff --git a/page.json b/page.json index 58bd5b0..9d0570d 100644 --- a/page.json +++ b/page.json @@ -17,7 +17,9 @@ "pcb", "exports", "aps", - "modeling" + "modeling", + "mechanical", + "assemblies" ], "org": "adom", "title": "Fusion - the Fusion 360 Bridge", @@ -57,7 +59,11 @@ "skills/fusion-dark-mode/**", "skills/fusion-file-search/**", "skills/fusion-mcp-and-preferences/**", - "skills/fusion-web-export/**" + "skills/fusion-web-export/**", + "docs/MECHANICAL.md", + "docs/build-2026-10-02.md", + "docs/mechanical-comparison/**", + "docs/mechanical-verification/**" ], "discovery_triggers": [ "drive fusion 360", @@ -79,9 +85,13 @@ "aps not configured", "share aps client id across my org", "set up fusion cloud search for my company", - "org settings autodesk aps" + "org settings autodesk aps", + "build a parametric part in fusion", + "assemble mechanical parts in fusion", + "insert a McMaster part in fusion", + "generate a mechanical assembly BOM" ], - "discovery_pitch": "Use the Adom Bridge Fusion 360 bridge when the user wants to DRIVE Autodesk Fusion 360 from the cloud: open/search their team's Fusion cloud designs, lay out a PCB or do electronics, export STEP/IGES/STL/Gerbers/BOM, or build a component library with real rendering 3D. It runs Fusion on the user's own machine via Adom Bridge. NOT for web/browser/login/form-filling (that's the native-browser extension) and NOT for KiCad (that's the kicad bridge).", + "discovery_pitch": "Use the Adom Bridge Fusion 360 bridge when the user wants to DRIVE Autodesk Fusion 360 from the cloud: open/search their team's Fusion cloud designs, author parametric mechanical parts and internal assemblies, ingest McMaster CAD and generate mechanical BOMs (development 1.12), lay out a PCB or do electronics, export STEP/IGES/STL/Gerbers/BOM, or build a component library with real rendering 3D. It runs Fusion on the user's own machine via Adom Bridge. NOT for web/browser/login/form-filling (that's the native-browser extension) and NOT for KiCad (that's the kicad bridge).", "brief": "Drive Autodesk Fusion 360 from the cloud via Adom Bridge: component libraries, IPC package generation, board layout, exports (STEP/Gerbers/BOM/CPL), fast APS cloud search, and parametric modeling.", "sample_prompts": [ { @@ -132,4 +142,4 @@ "name": "John Lauer", "email": "john@adom.inc" } -} \ No newline at end of file +} diff --git a/requirements-test.txt b/requirements-test.txt new file mode 100644 index 0000000..0c064fd --- /dev/null +++ b/requirements-test.txt @@ -0,0 +1,3 @@ +# Container-side PNG validation for live_feed_drain.py and its evidence tests. +# Not required inside Fusion or by the add-in. +Pillow>=11.0 diff --git a/rust/bridge/src/addin.rs b/rust/bridge/src/addin.rs index cbb1bdd..8997c9f 100644 --- a/rust/bridge/src/addin.rs +++ b/rust/bridge/src/addin.rs @@ -15,6 +15,7 @@ use std::time::{Duration, Instant}; use std::sync::Mutex; pub const ADDIN_COMMANDS: &[&str] = &[ + "mechanical", "mech_capabilities", "mech_operation_status", "activity_banner", "assembly_bom", "physical_properties", "get_app_state", "document_info", "activate_document", "import_step", "export_step", "export_stl", "export_3mf", "export_f3d", "export_fbx", "export_usdz", "export_dxf", "export_dwg", "export_iges", "export_obj", "export_sat", "export_skp", "get_design_info", "get_parameters", "set_parameter", "take_screenshot", "electron_run", "execute_text_command", "electron_zoom", "electron_pan", "electron_select", @@ -30,8 +31,8 @@ pub const WINDOW_RAISING: &[&str] = &[ "show_symbol", "show_footprint", "show_3d_package", "make_test_project", ]; const LONG_RUNNING: &[&str] = &["walk_cloud_tree", "search_cloud_files"]; -const BUSY_EXEMPT: &[&str] = &["activity_banner"]; -const MUTATING: &[&str] = &["run_modeling_script", "execute_text_command", "electron_run", "close_document", "close_all_documents", "import_step", "set_parameter", "save_to_cloud", "delete_cloud_file"]; +const BUSY_EXEMPT: &[&str] = &["activity_banner", "mech_operation_status", "mech_capabilities"]; +const MUTATING: &[&str] = &["mechanical", "run_modeling_script", "execute_text_command", "electron_run", "close_document", "close_all_documents", "import_step", "set_parameter", "save_to_cloud", "delete_cloud_file"]; const ASSEMBLY_BOM_UNIT_DEFAULTS: &[&str] = &["with fasteners", "with fastener", "with bearings", "bearing", "idler pulley", "pulley", "idler"]; fn addin_name(cmd: &str) -> &str { if cmd == "import_step" { "import_file" } else { cmd } } @@ -41,7 +42,7 @@ pub fn timeout_secs(cmd: &str) -> u64 { timeout_secs_or(cmd, 30) } pub fn timeout_secs_or(cmd: &str, default: u64) -> u64 { match cmd { "show_symbol" | "show_footprint" => 180, "show_3d_package" | "make_test_project" => 300, "bind_library_3d" => 480, "show_library" | "show_project" => 240, "demo_progress" => 15, - "assembly_bom" | "physical_properties" | "export_step" | "export_iges" | "export_sat" | "export_stl" | "export_3mf" | "export_usdz" | "export_obj" | "export_f3d" | "export_fbx" | "export_skp" => 300, + "mechanical" | "assembly_bom" | "physical_properties" | "export_step" | "export_iges" | "export_sat" | "export_stl" | "export_3mf" | "export_usdz" | "export_obj" | "export_f3d" | "export_fbx" | "export_skp" => 300, "export_dxf" | "export_dwg" => 120, "export_gerbers" => 180, "export_bom" | "export_cpl" | "export_board_image" | "close_all_documents" => 60, "walk_cloud_tree" => 600, "search_cloud_files" => 180, "open_by_urn" => 240, "run_modeling_script" => 180, _ => default, } @@ -155,7 +156,7 @@ pub fn addin_http(cmd: &str, args: &Value, timeout: u64) -> Value { } fn call_addin(cmd: &str, args: &Value, timeout: u64, sidecar: &dyn Fn(&str, Value, u64) -> Value) -> Value { let mut args = args.clone(); - if cmd == "assembly_bom" && args.get("treatAsUnit").map(|v| v.is_null() || v.as_array().map(|a| a.is_empty()).unwrap_or(false) || v == &json!("")).unwrap_or(true) { + if cmd == "assembly_bom" && args.get("treatAsUnit").map(|v| v.is_null()).unwrap_or(true) { args["treatAsUnit"] = json!(ASSEMBLY_BOM_UNIT_DEFAULTS); } let body = json!({"command": addin_name(cmd), "args": args}); diff --git a/rust/bridge/src/main.rs b/rust/bridge/src/main.rs index a5b4e19..fcf814a 100644 --- a/rust/bridge/src/main.rs +++ b/rust/bridge/src/main.rs @@ -15,6 +15,7 @@ mod chains; mod small_verbs; mod addin; +mod operation_status; mod fusion_prefs; mod timings; mod etiquette; @@ -378,6 +379,9 @@ fn main() { let parsed: Value = serde_json::from_str(&raw).unwrap_or(json!({})); let verb = parsed.get("command").and_then(|c| c.as_str()).unwrap_or("").to_string(); let reply = match verb.as_str() { + // Reconciliation is observational even when Fusion is cold, + // blocked or an owner preference selects the legacy sidecar. + v if operation_status::owns(v) => operation_status::read(&parsed["args"]), // Phase 3 shadow: the sidecar still answers; the exe computes its own readiness // from real probes and rides beside it with a diff, until parity holds. "fusion_readiness" | "readiness" if readiness_owner_is_exe(&**h) => owned_readiness(&**h, &sidecar, &caller), diff --git a/rust/bridge/src/operation_status.rs b/rust/bridge/src/operation_status.rs new file mode 100644 index 0000000..fee8d73 --- /dev/null +++ b/rust/bridge/src/operation_status.rs @@ -0,0 +1,105 @@ +//! Durable history reads must never repair, launch, focus or restart Fusion. +//! This module has no HostProcess, etiquette, sidecar or lifecycle dependencies. +use serde_json::{json, Value}; +use std::io::Read; +use std::time::Duration; + +const MAX_REPLY_BYTES: u64 = 8 * 1024 * 1024; + +pub fn owns(verb: &str) -> bool { + matches!(verb, "fusion_mech_operation_status" | "mech_operation_status") +} + +fn unavailable(message: impl ToString) -> Value { + json!({"success": false, "errorCode": "operation_status_unavailable", + "error": message.to_string(), "outcome": "unknown", "reconciliationRequired": true, + "lifecycleActionTaken": false}) +} + +pub fn read(args: &Value) -> Value { + read_at(args, "http://127.0.0.1:8774/command", Duration::from_secs(8)) +} + +fn read_at(args: &Value, url: &str, timeout: Duration) -> Value { + let body = json!({"command": "mech_operation_status", "args": args}); + let agent = ureq::AgentBuilder::new().redirects(0).build(); + let response = match agent.post(url).timeout(timeout).set("Content-Type", "application/json") + .send_string(&body.to_string()) { + Ok(response) | Err(ureq::Error::Status(_, response)) => response, + Err(error) => return unavailable(format!("Durable history endpoint unavailable: {error}")), + }; + let status = response.status(); + let mut bytes = Vec::new(); + if let Err(error) = response.into_reader().take(MAX_REPLY_BYTES + 1).read_to_end(&mut bytes) { + return unavailable(format!("Durable history response could not be read: {error}")); + } + if bytes.len() as u64 > MAX_REPLY_BYTES { return unavailable("Durable history response exceeds its byte limit"); } + match serde_json::from_slice::(&bytes) { + Ok(value) if value.is_object() && value["success"].is_boolean() + && !(300..400).contains(&status) && (status < 400 || value["success"] == false) => value, + _ => unavailable("Durable history endpoint returned a malformed or contradictory response"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::TcpListener; + use std::thread; + use tiny_http::{Response, Server}; + + fn server(reply: &str, status: u16, delay_ms: u64) -> (String, thread::JoinHandle) { + let server = Server::http("127.0.0.1:0").unwrap(); + let url = format!("http://{}/command", server.server_addr()); + let reply = reply.to_string(); + let handle = thread::spawn(move || { + let mut request = server.recv().unwrap(); + assert_eq!(request.url(), "/command"); + let mut body = String::new(); request.as_reader().read_to_string(&mut body).unwrap(); + let value: Value = serde_json::from_str(&body).unwrap(); + thread::sleep(Duration::from_millis(delay_ms)); + let _ = request.respond(Response::from_string(reply).with_status_code(status)); + value + }); + (url, handle) + } + + #[test] + fn only_status_aliases_use_this_route() { + assert!(owns("fusion_mech_operation_status") && owns("mech_operation_status")); + for verb in ["mechanical", "mech_capabilities", "get_app_state", "start"] { assert!(!owns(verb)); } + let main = include_str!("main.rs"); + assert!(main.find("v if operation_status::owns(v)").unwrap() + < main.find("v if addin_owner_is_exe").unwrap()); + } + + #[test] + fn cold_endpoint_returns_unknown_without_host_or_repair_context() { + let socket = TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}/command", socket.local_addr().unwrap()); drop(socket); + let result = read_at(&json!({"sessionId":"old", "operationId":"op"}), &url, Duration::from_millis(50)); + assert_eq!(result["errorCode"], "operation_status_unavailable"); + assert_eq!(result["outcome"], "unknown"); assert_eq!(result["lifecycleActionTaken"], false); + } + + #[test] + fn busy_native_thread_does_not_block_cached_history_route() { + let expected = json!({"success":true,"sessionId":"s","operationId":"op","state":"running","phase":"executing"}); + let (url, worker) = server(&expected.to_string(), 200, 0); + let args = json!({"sessionId":"s","operationId":"op"}); + assert_eq!(read_at(&args, &url, Duration::from_secs(1)), expected); + assert_eq!(worker.join().unwrap(), json!({"command":"mech_operation_status","args":args})); + } + + #[test] + fn unavailable_and_malformed_responses_never_trigger_a_fallback() { + for (reply, status, delay) in [("not json",200,0), ("{\"success\":true}",503,0), + ("{\"success\":true}",200,100)] { + let (url, worker) = server(reply, status, delay); + let result = read_at(&json!({}), &url, Duration::from_millis(20)); + assert_eq!(result["errorCode"], "operation_status_unavailable"); + assert_eq!(result["lifecycleActionTaken"], false); + worker.join().unwrap(); + } + } +} diff --git a/server.py b/server.py index 825c47f..5514338 100644 --- a/server.py +++ b/server.py @@ -2968,6 +2968,7 @@ def _handle_aps_open(fusion_info: dict, args: dict) -> dict: # Commands that are proxied to the Fusion add-in (port 8774). # Keys are the CLI-facing names (after stripping "fusion_" prefix). ADDIN_COMMANDS = { + "mechanical", "mech_capabilities", "mech_operation_status", "activity_banner", # Mechanical BOM + physical properties (PR #22, Oliver / BOM Forge). These live in the # ADD-IN, so they must be listed here or the bridge never proxies them and the verb @@ -3038,6 +3039,7 @@ ADDIN_COMMAND_MAP = { # timeout in http_server.py should be the source of truth; these are # matched to that so urllib doesn't cut off before the add-in does. ADDIN_COMMAND_TIMEOUTS = { + "mechanical": 300, "show_symbol": 180, "show_footprint": 180, "show_3d_package": 300, "make_test_project": 300, "bind_library_3d": 480, "show_library": 240, "show_project": 240, "demo_progress": 15, # getPhysicalProperties runs on Fusion's MAIN thread per component, so a big assembly @@ -3096,7 +3098,7 @@ LONG_RUNNING_COMMANDS = {"walk_cloud_tree", "search_cloud_files"} # because a live run was watched with the palette registry open, not by trusting the # helper's silence. A banner update is a sub-millisecond htmlContent set that is safe to # queue behind a long command; it is the opposite of what the gate protects against. -BUSY_EXEMPT_COMMANDS = {"activity_banner"} +BUSY_EXEMPT_COMMANDS = {"activity_banner", "mech_operation_status", "mech_capabilities"} # Commands that change Fusion's state and can pop a modal dialog / owned popup the AI # must see (a save confirm, the Hub "are you sure you want to close?" data-loss prompt, @@ -3419,7 +3421,7 @@ def _proxy_to_addin(command: str, args: dict, timeout: int = 30) -> dict: - Add-in alive but main thread blocked (modal dialog) → distinct error - Add-in HTTP server crashed → connection error """ - if command == "assembly_bom" and not args.get("treatAsUnit"): + if command == "assembly_bom" and args.get("treatAsUnit") is None: args = dict(args) args["treatAsUnit"] = list(_ASSEMBLY_BOM_UNIT_DEFAULTS) @@ -15303,12 +15305,61 @@ def _window_etiquette_post(command: str, args: dict, res, _docs_before, _fg: boo return res +def _read_operation_history(args: dict, _timeout: float = 8) -> dict: + """One loopback hop with an absolute deadline; no lifecycle or fallback.""" + import http.client + import io + import socket + import time + try: + deadline = time.monotonic() + _timeout + body = json.dumps({"command": "mech_operation_status", "args": args}).encode("utf-8") + if len(body) > 1024 * 1024: + raise ValueError("Durable history request exceeds its byte limit") + class DeadlineReader(io.RawIOBase): + def readable(self): + return True + def readinto(self, buffer): + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError("Durable history response exceeded its deadline") + transport.settimeout(remaining) + return transport.recv_into(buffer) + class ResponseSocket: + def makefile(self, mode): + return io.BufferedReader(DeadlineReader()) + with socket.create_connection(("127.0.0.1", ADDIN_PORT), timeout=_timeout) as transport: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError("Durable history connection exceeded its deadline") + transport.settimeout(remaining) + headers = ("POST /command HTTP/1.1\r\nHost: 127.0.0.1:%d\r\n" + "Content-Type: application/json\r\nContent-Length: %d\r\nConnection: close\r\n\r\n" + % (ADDIN_PORT, len(body))).encode("ascii") + transport.sendall(headers + body) + with http.client.HTTPResponse(ResponseSocket()) as response: + response.begin() + raw = response.read(8 * 1024 * 1024 + 1) + if len(raw) > 8 * 1024 * 1024: + raise ValueError("Durable history response exceeds its byte limit") + result = json.loads(raw) + if (not isinstance(result, dict) or type(result.get("success")) is not bool + or 300 <= response.status < 400 or response.status >= 400 and result["success"]): + raise ValueError("Malformed or contradictory durable history response") + return result + except Exception as error: + return {"success": False, "errorCode": "operation_status_unavailable", "error": str(error), + "outcome": "unknown", "reconciliationRequired": True, "lifecycleActionTaken": False} + + def dispatch_command(command: str, args: dict, caller_identity: dict = None) -> dict: """Thin wrapper: run the verb, then guarantee the windowEtiquette annotation lands on EVERY backgrounded window-raising result - the inner dispatcher's closure only covered direct-handler and add-in paths, so orchestrated verbs (show_*, make_test_project, the tour, ...) returned unannotated (the calling AI could not know no focus was taken). Idempotent with the inner annotation (the note is only appended once).""" + if command in ("fusion_mech_operation_status", "mech_operation_status"): + return _read_operation_history(args) _fg = bool((args or {}).get("foreground")) # WHAT WE OPEN IS OURS TO CLEAN UP (see _register_docs_we_opened). Snapshot before, # diff after, so every document our verbs create is attributable to us - and only ours. @@ -15548,6 +15599,8 @@ def _dispatch_command_inner(command: str, args: dict, caller_identity: dict = No this request. Bound to THIS thread (issue #348) so any ab callback we make while carrying out the verb is attributed to the AI thread that asked for it. Pass None to leave the binding alone. """ + if command in ("fusion_mech_operation_status", "mech_operation_status"): + return _read_operation_history(args) if caller_identity is not None: _set_caller_identity(caller_identity) # Strip the "fusion_" prefix if present — handlers are registered without it (e.g. "aps_signin" diff --git a/tools/check-bridge.py b/tools/check-bridge.py new file mode 100644 index 0000000..889afed --- /dev/null +++ b/tools/check-bridge.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +"""Run offline bridge gates with separate logs, source hashes and truthful status. + +No desktop, cloud, installation, publication or issue-reporting calls are made. +Native Fusion behavior is verified separately by tests/ralph_mechanical.py. +Run this coordinator in a POSIX build environment; Windows is a cross-build target. +""" +import argparse +from datetime import datetime, timezone +import hashlib +import json +import os +from pathlib import Path +import re +import signal +import shutil +import subprocess +import sys +import time +import uuid + +ROOT = Path(__file__).resolve().parents[1] +GROUPS = ("python", "rust", "dashboard", "portability", "shadow", "windows") +PYTHON_TEMPLATES = { + 'rust/bridge/src/attach_template.py', + 'rust/bridge/src/make3d_template.py', + 'rust/bridge/src/epg_template.py', +} + + +def execute(command, cwd, log, timeout, environment): + process = subprocess.Popen(command, cwd=cwd, stdout=log, stderr=subprocess.STDOUT, + start_new_session=True, env=environment) + try: + process.wait(timeout=timeout) + return process.returncode + finally: + if process.poll() is None: + # Only the process group created for this gate, never another + # agent's compiler or an application's process tree. + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + process.wait() + + +def sources(): + result = set(ROOT.glob("*.py")) | set(ROOT.glob("*.json")) + for relative in ("addin", "handlers", "tests", "tools", "rust", "dashboard", "contracts"): + for path in (ROOT / relative).rglob("*"): + if not path.is_file() or any(part in ("target", "__pycache__", "reports", "node_modules", ".git") for part in path.parts): + continue + result.add(path) + # Rust embeds assets outside its own directory (SVG, audio, overlays). + # Include literal embedded dependencies so asset drift also invalidates a run. + for path in list(result): + if path.suffix == ".rs": + for relative in re.findall(r'include_(?:str|bytes)!\(\s*"([^"\n]+)"', path.read_text()): + included = (path.parent / relative).resolve() + if not included.is_relative_to(ROOT): + raise ValueError("Embedded input escapes checkout: " + str(included)) + result.add(included) + result.update(ROOT / p for p in ("BRIDGE_VERSION", "bridge.json", "adom-bridge-fusion-manifest.json", "requirements-test.txt")) + return sorted(result) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--group", action="append", choices=GROUPS, + help="Repeat to select groups; default is every group except Windows cross-build") + parser.add_argument("--report-dir", type=Path, help="New directory; existing paths are refused") + parser.add_argument("--timeout", type=int, default=1200, help="Per-group deadline in seconds") + parser.add_argument("--cargo", help="Cargo executable; defaults to PATH or the current user's Rust installation") + args = parser.parse_args() + if os.name != "posix": + parser.error("Run this coordinator in a POSIX build environment; Windows is a cross-build target") + if args.timeout <= 0: + parser.error("--timeout must be positive") + groups = list(dict.fromkeys(args.group or GROUPS[:-1])) + cargo = args.cargo or shutil.which("cargo") or str(Path.home() / ".cargo/bin/cargo") + cargo = shutil.which(cargo) or str(Path(cargo).expanduser().absolute()) + environment = dict(os.environ) + environment["PATH"] = str(Path(cargo).parent) + os.pathsep + environment.get("PATH", "") + run_id = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + "-" + uuid.uuid4().hex[:10] + directory = (args.report_dir or ROOT / "tests/reports" / ("offline-" + run_id)).resolve() + directory.mkdir(parents=True, exist_ok=False) + paths = sources() + hashes = {p.relative_to(ROOT).as_posix(): hashlib.sha256(p.read_bytes()).hexdigest() for p in paths} + commands = { + "python": ([sys.executable, "-m", "unittest", "discover", "-s", "tests", "-p", "test_*.py"], ROOT), + "rust": ([cargo, "test", "--locked", "--workspace"], ROOT / "rust"), + "dashboard": ([cargo, "test", "--locked"], ROOT / "dashboard"), + "portability": (["bash", "tools/check-portable.sh"], ROOT), + "shadow": ([sys.executable, "tools/shadow_check.py"] + [str(p) for p in paths if p.suffix == ".py" and p.relative_to(ROOT).as_posix() not in PYTHON_TEMPLATES], ROOT), + "windows": ([cargo, "build", "--locked", "--release", "--target", "x86_64-pc-windows-gnu", "--package", "adom-fusion-bridge"], ROOT / "rust"), + } + report = {"runId": run_id, "version": (ROOT / "BRIDGE_VERSION").read_text().strip(), + "coverage": "offline only; no native Fusion workflow certified", "sourceSha256": hashes, + "selectedGroups": groups, "groups": [], "status": "RUNNING"} + + def save(): + temp = directory / "report.json.tmp" + temp.write_text(json.dumps(report, indent=2) + "\n") + temp.replace(directory / "report.json") + + save() + try: + for group in groups: + command, cwd = commands[group] + row = {"group": group, "command": command, "cwd": str(cwd), "status": "RUNNING", + "coverageKind": "tests" if group in ("python", "rust", "dashboard") else "build" if group == "windows" else "static"} + report["groups"].append(row) + if group == 'shadow': + row['excludedTemplates'] = sorted(PYTHON_TEMPLATES) + row['limitation'] = 'Unexpanded Rust string templates are hashed but cannot be parsed as Python source; rendered variants need separate tests.' + save() + started = time.monotonic() + try: + with (directory / (group + ".log")).open("wb") as log: + code = execute(command, cwd, log, args.timeout, environment) + row.update(returncode=code, status="PASS" if code == 0 else "FAIL") + if group == "python": + output = (directory / (group + ".log")).read_text(errors="replace") + match = re.search(r"Ran (\d+) tests? in", output) + row["testCount"] = int(match.group(1)) if match else 0 + if row["testCount"] == 0: + row.update(status="FAIL", reason="No Python tests were executed") + elif group in ("rust", "dashboard"): + output = (directory / (group + ".log")).read_text(errors="replace") + row["testCount"] = sum(int(n) for n in re.findall(r"test result: ok\. (\d+) passed", output)) + if row["testCount"] == 0: + row.update(testStatus="NOT_RUN", coverageKind="build_only") + if group == "rust": + row.update(status="FAIL", reason="No Rust workspace tests were executed") + else: + row["reason"] = "Dashboard compiled; no dashboard tests were executed" + except FileNotFoundError as error: + row.update(status="BLOCKED", reason=str(error)) + except subprocess.TimeoutExpired: + row.update(status="FAIL", reason="Group exceeded its deadline") + row["seconds"] = round(time.monotonic() - started, 3) + save() + print(json.dumps({k: v for k, v in row.items() if k not in ("command", "cwd")}), flush=True) + finally: + after = {p.relative_to(ROOT).as_posix(): hashlib.sha256(p.read_bytes()).hexdigest() for p in sources()} + report["sourceChanged"] = after != hashes + report["status"] = ("PASS" if len(report["groups"]) == len(groups) + and all(row["status"] == "PASS" for row in report["groups"]) + and not report["sourceChanged"] else "FAIL") + save() + print(str(directory / "report.json"), flush=True) + return 0 if report["status"] == "PASS" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/check-portable.sh b/tools/check-portable.sh index 94b61df..7a40243 100644 --- a/tools/check-portable.sh +++ b/tools/check-portable.sh @@ -1,10 +1,10 @@ #!/usr/bin/env bash -# fusion-core must never depend on an OS crate. Run in CI and before every release. +# fusion-core must not acquire OS bindings outside its reviewed portable TLS +# dependency. Check every target and every path, including duplicated branches. set -euo pipefail cd "$(dirname "$0")/../rust" -bad=$(cargo tree -p fusion-core -e normal 2>/dev/null | grep -iE "^\s*[│├└─ ]*(windows|windows-sys|winapi|cocoa|objc|core-foundation)\b" || true) -if [ -n "$bad" ]; then echo "PORTABILITY DRIFT: fusion-core depends on an OS crate:"; echo "$bad"; exit 1; fi -echo "fusion-core is OS-blind" +tree=$(cargo tree --locked --target all -p fusion-core -e normal --prefix depth --no-dedupe --format '{p}') +printf '%s\n' "$tree" | python3 ../tools/check_portable_tree.py # No `todo!()` may ship in a crate the Windows exe links: one killed the shell on its first # /status request. (os-mac is Kyle's crate and may still carry stubs until his port lands.) todo=$(grep -rn "todo!(" core/src lbr/src os-win/src bridge/src | grep -vE ':[0-9]+:\s*//' || true) diff --git a/tools/check_portable_tree.py b/tools/check_portable_tree.py new file mode 100644 index 0000000..c0f9a31 --- /dev/null +++ b/tools/check_portable_tree.py @@ -0,0 +1,65 @@ +"""Reject platform bindings except exact reviewed portable TLS dependency paths. + +The core's existing ureq -> rustls -> ring stack uses windows-sys for target +specific TLS primitives. That portable library implementation is allowed; the +same package through any other path is a portability finding. Input comes from +cargo tree --target all --prefix depth --no-dedupe, with Cargo.lock enforced. +""" +import re +import sys + +PLATFORM = re.compile(r'^(?:windows(?:[-_].*)?|winapi(?:[-_].*)?|cocoa(?:[-_].*)?|objc\d*(?:[-_].*)?|core-foundation(?:[-_].*)?)$') +TLS_ROOTS = ( + ('fusion-core', 'ureq', 'rustls', 'ring'), + ('fusion-core', 'ureq', 'rustls', 'rustls-webpki', 'ring'), +) + + +def approved_tls(path): + for root in TLS_ROOTS: + if tuple(path[:len(root)]) != root: + continue + tail = path[len(root):] + if tail in (['windows-sys'], ['windows-sys', 'windows-targets']): + return True + if len(tail) == 3 and tail[:2] == ['windows-sys', 'windows-targets'] and re.fullmatch( + r'windows_(?:aarch64|i686|x86_64)_(?:gnu|gnullvm|msvc)', tail[2]): + return True + return False + + +def inspect(text): + stack, failures, exceptions = [], [], set() + for line in text.splitlines(): + match = re.match(r'^(\d+)([A-Za-z0-9_-]+) v\S+(?: .*)?$', line) + if not match: + raise ValueError('Unrecognized dependency row: ' + line) + depth, package = int(match[1]), match[2] + if depth > len(stack) or depth == 0 and stack: + raise ValueError('Invalid dependency tree depth') + stack = stack[:depth] + [package] + if stack[0] != 'fusion-core': + raise ValueError('Unexpected dependency root') + if PLATFORM.fullmatch(package): + chain = ' -> '.join(stack) + if approved_tls(stack): + exceptions.add(chain) + else: + failures.append(chain) + if not stack: + raise ValueError('Empty dependency tree') + return failures, sorted(exceptions) + + +if __name__ == '__main__': + try: + failures, exceptions = inspect(sys.stdin.read()) + for chain in exceptions: + print('Reviewed portable TLS dependency: ' + chain) + for chain in failures: + print('PORTABILITY DRIFT: ' + chain) + print('No unreviewed platform bindings' if not failures else 'Platform dependency gate failed') + raise SystemExit(1 if failures else 0) + except ValueError as error: + print('Dependency inspection failed: ' + str(error)) + raise SystemExit(1) diff --git a/tools/install-mechanical-dev.ps1 b/tools/install-mechanical-dev.ps1 new file mode 100644 index 0000000..7abc343 --- /dev/null +++ b/tools/install-mechanical-dev.ps1 @@ -0,0 +1,57 @@ +param( + [Parameter(Mandatory=$true)][string]$Cache, + [Parameter(Mandatory=$true)][string]$Staged, + [Parameter(Mandatory=$true)][string]$Version +) +# Invoked only after bridge_dev_mode(on:true), bridge_pause and staged hash checks. +# The bridge's normal startup installs the bundled add-in into Fusion's scan paths. +$ErrorActionPreference = 'Stop' +if (!(Test-Path -LiteralPath (Join-Path $Cache '.dev-pin'))) { throw 'Fusion bridge is not dev-pinned' } +$manifestPath = Join-Path $Staged 'mechanical-dev-manifest.json' +$payload = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json +if ($payload.version -ne $Version) { throw 'Staged version mismatch' } +$cacheRoot = [IO.Path]::GetFullPath($Cache).TrimEnd('\') + '\' +$stagedRoot = [IO.Path]::GetFullPath($Staged).TrimEnd('\') + '\' +foreach ($file in $payload.files) { + $source = [IO.Path]::GetFullPath((Join-Path $Staged $file.path)) + $destination = [IO.Path]::GetFullPath((Join-Path $Cache $file.path)) + if (!$source.StartsWith($stagedRoot, [StringComparison]::OrdinalIgnoreCase) -or !$destination.StartsWith($cacheRoot, [StringComparison]::OrdinalIgnoreCase)) { throw 'Payload path escapes its directory' } + if ((Get-FileHash -LiteralPath $source -Algorithm SHA256).Hash -ne $file.sha256) { throw "Staged hash mismatch: $($file.path)" } +} +$backup = Join-Path $Cache ('mechanical-backup-' + (Get-Date -Format 'yyyyMMdd-HHmmss-fff')) +New-Item -ItemType Directory -Path $backup | Out-Null +$created = @() +foreach ($file in $payload.files) { + $destination = Join-Path $Cache $file.path + if (Test-Path -LiteralPath $destination) { + $saved = Join-Path $backup $file.path + New-Item -ItemType Directory -Force -Path (Split-Path $saved) | Out-Null + Copy-Item -LiteralPath $destination -Destination $saved + } else { $created += $file.path } +} +$created | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $backup 'new-paths.json') -Encoding UTF8 +# Only this bridge executable; never stop Fusion or other bridges here. +$exe = Join-Path $Cache 'adom-fusion-bridge.exe' +$stopped = @() +Get-CimInstance Win32_Process | Where-Object { $_.ExecutablePath -and $_.ExecutablePath -ieq $exe } | ForEach-Object { + $stopped += $_.ProcessId + Stop-Process -Id $_.ProcessId -Force -ErrorAction Stop +} +try { + foreach ($file in $payload.files) { + $destination = Join-Path $Cache $file.path + New-Item -ItemType Directory -Force -Path (Split-Path $destination) | Out-Null + Copy-Item -LiteralPath (Join-Path $Staged $file.path) -Destination $destination -Force + if ((Get-FileHash -LiteralPath $destination -Algorithm SHA256).Hash -ne $file.sha256) { throw "Installed hash mismatch: $($file.path)" } + } +} catch { + $failure = $_ + foreach ($file in $payload.files) { + $saved = Join-Path $backup $file.path + $destination = Join-Path $Cache $file.path + if (Test-Path -LiteralPath $saved) { Copy-Item -LiteralPath $saved -Destination $destination -Force } + elseif ($created -contains $file.path) { Remove-Item -LiteralPath $destination -Force -ErrorAction SilentlyContinue } + } + throw $failure +} +@{success=$true;version=$Version;backup=$backup;fileCount=$payload.files.Count;stoppedPids=$stopped;binarySha256=(Get-FileHash -LiteralPath $exe -Algorithm SHA256).Hash} | ConvertTo-Json diff --git a/tools/live_feed_drain.py b/tools/live_feed_drain.py index 4fa1028..bb09731 100644 --- a/tools/live_feed_drain.py +++ b/tools/live_feed_drain.py @@ -1,62 +1,210 @@ #!/usr/bin/env python3 -"""Drain fusion_live_feed's box-side ring buffer into shotlog, from the container. +"""Capture Fusion's main/child feed into per-run evidence and shotlog. - python3 tools/live_feed_drain.py --target AdomLapper --channel fusion-live [--interval 1.0] - -Publishes each frame with a kind tag so a viewer never confuses them: - [main #n] the Fusion window each tick (the ~1 fps video) - [popup #n.m] each owned dialog/panel over it, tied to its tick -Proof frames are NOT here; they come from fusion_prove_result evidence[] (kind:proof). +The feed is shared application state. An existing feed is observed without +restarting it. A new feed has a bounded native lifetime; this drainer never +sends stop, so it cannot stop a replacement or someone else's capture. +Successful transfer is not a visual or CAD-workflow verdict. """ -print = __import__("functools").partial(print, flush=True) -import argparse, json, subprocess, sys, time, os, base64 +import argparse +import base64 +import hashlib +import io +import json +from pathlib import Path +import subprocess +import sys +import time + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / 'tests')) +from fusion_harness import BridgeClient, data_payload, evaluate, run_id + +MIN_WORK_SECONDS = 1.0 + + +class CaptureDeadline(Exception): + """Expected bounded stop, distinct from an issued command timing out.""" + + +def png_dimensions(content): + # Pillow is a capture-tool prerequisite, not an add-in dependency. Decode + # pixels as well as checking PNG chunk integrity; a header is not evidence. + from PIL import Image + with Image.open(io.BytesIO(content)) as picture: + if picture.format != 'PNG': + raise ValueError('Capture is not a PNG') + picture.verify() + with Image.open(io.BytesIO(content)) as picture: + picture.load() + return picture.size + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--target', required=True) + parser.add_argument('--channel', required=True) + parser.add_argument('--interval', type=float, default=1.0) + parser.add_argument('--max-sec', type=float, default=600, + help='New producer duration, or observation allowance for an existing shared feed') + parser.add_argument('--drain-sec', type=float, default=30, + help='Additional bounded time to deliver the native feed tail (1–120 seconds)') + parser.add_argument('--evidence', type=Path) + args = parser.parse_args(argv) + if not 0.5 <= args.interval <= 30 or not 1 <= args.max_sec <= 3600 or not 1 <= args.drain_sec <= 120: + parser.error('interval must be 0.5–30, max-sec 1–3600 and drain-sec 1–120') + identity = run_id() + directory = args.evidence or ROOT / 'tests/reports' / ('feed-' + identity) + directory.mkdir(parents=True, exist_ok=False) + client = BridgeClient(args.target, identity, ai_thread='Fusion background feed', evidence_dir=directory / 'calls') + report = {'runId': identity, 'target': args.target, 'channel': args.channel, + 'status': 'RUNNING', 'frames': [], 'errors': [], 'ownsStart': False, 'transferred': 0, + 'captureComplete': False, 'feedEnded': False, 'incompleteReasons': [], + 'observationAllowanceSec': args.max_sec, 'nativeDurationSec': None, 'drainTailSec': args.drain_sec, + 'visualVerdict': 'NOT_REVIEWED', 'cadWorkflowVerdict': 'NOT_TESTED'} + deadline = time.monotonic() + min(30, args.max_sec + args.drain_sec) + + def remaining(minimum=0): + budget = deadline - time.monotonic() + if budget <= 0 or budget < minimum: + raise CaptureDeadline('Drain deadline has insufficient budget; no further work issued') + return budget + + def save(): + temp = directory / 'report.json.tmp' + temp.write_text(json.dumps(report, indent=2) + '\n') + temp.replace(directory / 'report.json') + + def call(verb, values): + result = client.call(verb, values, timeout=min(30, remaining(MIN_WORK_SECONDS))) + errors = evaluate(result.raw, {'ok': True}) + if errors: + raise RuntimeError(verb + ': ' + '; '.join(errors)) + return data_payload(result.observation) -def ab(target, verb, args): - p = subprocess.run(["adom-bridge", "--ai-thread", "fusion-live-feed", "--target", target, verb, json.dumps(args)], - capture_output=True, text=True, timeout=90) try: - d = json.loads(p.stdout); x = d.get("data") or d - if isinstance(x.get("output"), str): - try: x = json.loads(x["output"]) - except Exception: pass - return x - except Exception: - return {} - -def main(): - a = argparse.ArgumentParser(); a.add_argument("--target", required=True); a.add_argument("--channel", default="fusion-live") - a.add_argument("--interval", type=float, default=1.0); a.add_argument("--max-sec", type=float, default=600) - o = a.parse_args() - st = ab(o.target, "fusion_live_feed", {"action": "start", "intervalSec": o.interval, "maxSec": o.max_sec}) - print("feed:", "started" if st.get("started") else ("already running" if st.get("alreadyRunning") else st)) - since, t0, pushed, spins = 0, time.time(), 0, 0 - os.makedirs("/tmp/live_feed", exist_ok=True) - while time.time() - t0 < o.max_sec: - r = ab(o.target, "fusion_live_feed", {"action": "frames", "since": since}) - if r.get("error") or r.get("errorCode"): - print("feed call failed:", r.get("error") or r.get("errorCode"), (r.get("_hint") or "")[:120]) - # HONESTY: the box tells us when it is spinning without capturing. Say it, do not hide it. - if r.get("healthy") is False: - spins += 1 - if spins in (1, 10) or spins % 60 == 0: - print("FEED NOT LIVE (tick %s, captured %s): %s" % (r.get("tick"), r.get("captured"), r.get("_honesty"))) - for f in r.get("frames") or []: - since = max(since, f["tick"]) - rf = ab(o.target, "read_file", {"path": f["path"], "encoding": "base64"}) - c = rf.get("content") or "" - if not c: continue - local = "/tmp/live_feed/%s_%d_%s.png" % (f["kind"], f["tick"], f.get("index", 0)) - try: open(local, "wb").write(base64.b64decode(c)) - except Exception: continue - desc = ("[main #%d] %s | %d popup(s)" % (f["tick"], f.get("title", ""), f.get("popups", 0)) if f["kind"] == "main" - else "[popup #%d.%d] %s" % (f["tick"], f.get("index", 0), f.get("title", ""))) - subprocess.run(["adom-shotlog", "inject", "-c", o.channel, "-d", desc, "-s", "desktop_screenshot_window", "--native", local], - capture_output=True, text=True, timeout=30) - pushed += 1 - if pushed in (1, 10) or pushed % 100 == 0: - print("pushed %d frame(s); latest tick %d" % (pushed, since)) - if not r.get("running") and since: print("feed ended; pushed", pushed); break - time.sleep(o.interval) - ab(o.target, "fusion_live_feed", {"action": "stop"}) - -if __name__ == "__main__": main() + # Check the optional dependency before starting a shared native feed. + from PIL import Image + status = call('fusion_live_feed', {'action': 'status'}) + report['baseline'] = status + if not status.get('running'): + start = call('fusion_live_feed', {'action': 'start', 'intervalSec': args.interval, 'maxSec': args.max_sec}) + report['ownsStart'] = start.get('started') is True + if report['ownsStart']: + report['nativeDurationSec'] = args.max_sec + status = call('fusion_live_feed', {'action': 'status'}) + epoch = status.get('startedAt') + if not status.get('running') or not isinstance(epoch, (int, float)) or epoch <= 0: + raise RuntimeError('No active capture epoch') + report['startedAt'] = epoch + # The native producer gets maxSec; local delivery has its own tail. + # Setup latency must not consume that delivery allowance. + deadline = time.monotonic() + args.max_sec + args.drain_sec + save() + print(json.dumps({'startedAt': epoch, 'ownsStart': report['ownsStart'], 'evidence': str(directory)}), flush=True) + since, captured, seen = 0, 0, set() + while time.monotonic() < deadline: + status = call('fusion_live_feed', {'action': 'status'}) + if status.get('startedAt') != epoch: + raise RuntimeError('Feed was replaced; no stop or restart issued') + frames = call('fusion_live_feed', {'action': 'frames', 'since': since}) + # A replacement may happen between status and frames. The frame + # batch is immutable once fetched; bind it before any transfer. + after = call('fusion_live_feed', {'action': 'status'}) + if after.get('startedAt') != epoch: + raise RuntimeError('Feed changed during frame fetch; batch discarded') + if frames.get('healthy') is False and frames.get('tick', 0) >= 5: + raise RuntimeError(frames.get('_honesty', 'Capture feed is not healthy')) + batch = frames.get('frames') + if not isinstance(batch, list) or type(frames.get('running')) is not bool or type(frames.get('tick')) is not int: + raise RuntimeError('Native feed omitted frame batch or completion state') + if frames.get('count', len(batch)) > len(batch): + report['incompleteReasons'].append('Native frame batch was truncated before delivery') + by_tick = {} + for frame in batch: + if not isinstance(frame, dict) or frame.get('kind') not in ('main', 'popup') or type(frame.get('tick')) is not int or type(frame.get('index', 0)) is not int: + raise RuntimeError('Invalid frame identity') + by_tick.setdefault(frame['tick'], []).append(frame) + expected_tick = since + 1 + for tick, members in sorted(by_tick.items()): + if tick != expected_tick: + report['incompleteReasons'].append('Missing capture ticks before %s (expected %s)' % (tick, expected_tick)) + expected_tick = tick + 1 + mains = [f for f in members if f['kind'] == 'main'] + count = mains[0].get('popups') if len(mains) == 1 else None + popup_indices = sorted(f.get('index', 0) for f in members if f['kind'] == 'popup') + if type(count) is not int or count < 0 or len(popup_indices) != count or any(n != i + 1 for i, n in enumerate(popup_indices)): + report['incompleteReasons'].append('Incomplete main/popup capture group at tick %s' % tick) + records = [{**frame, 'status': 'PENDING'} for frame in batch] + report['frames'].extend(records) + save() + for frame, record in zip(batch, records): + key = (frame.get('kind'), frame.get('tick'), frame.get('index', 0)) + try: + remaining(MIN_WORK_SECONDS) + if key in seen: + raise RuntimeError('Duplicate frame identity') + seen.add(key) + raw = call('read_file', {'path': frame['path'], 'encoding': 'base64'}) + content = base64.b64decode(raw.get('content', ''), validate=True) + width, height = png_dimensions(content) + if width <= 0 or height <= 0: + raise RuntimeError('Capture dimensions are invalid') + path = directory / ('%s-%s-%s.png' % key) + with path.open('xb') as output: + output.write(content) + record.update(localPath=str(path.resolve()), sha256=hashlib.sha256(content).hexdigest(), width=width, height=height) + description = '[%s #%s.%s] %s' % (*key, frame.get('title', 'Fusion')) + pushed = subprocess.run(['adom-shotlog', 'inject', '-c', args.channel, '-d', description, + '-s', 'desktop_screenshot_window', '--native', str(path)], capture_output=True, text=True, timeout=min(30, remaining(MIN_WORK_SECONDS))) + record['shotlog'] = {'returncode': pushed.returncode, 'stdout': pushed.stdout, 'stderr': pushed.stderr} + if pushed.returncode: + raise RuntimeError('Shotlog injection failed') + record['status'] = 'TRANSFERRED' + captured += 1 + report['transferred'] = captured + except CaptureDeadline as error: + record.update(status='NOT_TRANSFERRED', reason=str(error)) + raise + except Exception as error: + record.update(status='FAIL', error=str(error)) + report['errors'].append(str(error)) + if isinstance(error, (TimeoutError, subprocess.TimeoutExpired)): + raise + since = max(since, frame.get('tick', 0)) + save() + report['lastStatus'] = after + report['transferred'] = captured + save() + if not frames.get('running'): + report['feedEnded'] = True + if frames.get('tick', since) != since: + report['incompleteReasons'].append('Producer ended with capture ticks not represented in delivered frames') + break + time.sleep(min(args.interval, max(0, deadline - time.monotonic()))) + if not report['feedEnded']: + report['incompleteReasons'].append('Bounded drain ended without observing producer completion') + report['captureComplete'] = bool(captured and report['feedEnded'] and not report['errors'] and not report['incompleteReasons']) + report['status'] = ('FAIL' if report['errors'] or not captured else + 'CAPTURED_REVIEW_REQUIRED' if report['captureComplete'] else 'PARTIAL_CAPTURE') + except CaptureDeadline as error: + report['incompleteReasons'].append(str(error)) + report['status'] = 'FAIL' if report['errors'] else 'PARTIAL_CAPTURE' + except BaseException as error: + report['errors'].append(type(error).__name__ + ': ' + str(error)) + report['status'] = 'FAIL' + finally: + # Never stop shared state. A feed this process started expires at the + # native maxSec deadline even if this process is interrupted. + report['stopIssued'] = False + for frame in report['frames']: + if frame['status'] == 'PENDING': + frame.update(status='NOT_TRANSFERRED', reason='Capture delivery ended before this fetched frame was processed') + save() + print(json.dumps({'status': report['status'], 'frames': report.get('transferred', 0), + 'report': str(directory / 'report.json')}), flush=True) + return 0 if report['status'] == 'CAPTURED_REVIEW_REQUIRED' else 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tools/package-mechanical-dev.py b/tools/package-mechanical-dev.py new file mode 100644 index 0000000..5d25a40 --- /dev/null +++ b/tools/package-mechanical-dev.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +"""Package a built mechanical development checkpoint for a pinned test host.""" +import argparse +import hashlib +import json +from pathlib import Path +import zipfile + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", required=True, type=Path) + args = parser.parse_args() + root = Path(__file__).resolve().parents[1] + version = (root / "BRIDGE_VERSION").read_text().strip() + if "dev" not in version: + raise SystemExit("This helper packages development versions only") + binary = root / "rust/target/x86_64-pc-windows-gnu/release/adom-fusion-bridge.exe" + # Cargo's shared target cache can retain an artifact from another copied + # checkout. The native shell embeds this exact catalog with include_str!; + # file existence and a successful cached build do not establish identity. + if (root / "contracts/describe.json").read_bytes() not in binary.read_bytes(): + raise SystemExit("Native executable embeds a stale/missing verb catalog; rebuild in an isolated target directory") + sources = {"adom-fusion-bridge.exe": binary} + for name in ("BRIDGE_VERSION", "bridge.json", "package.json", "describe.py", "server.py", "contracts/describe.json"): + sources[name] = root / name + for source in (root / "addin").rglob("*"): + if source.is_file() and "__pycache__" not in source.parts and source.suffix != ".pyc": + sources[source.relative_to(root).as_posix()] = source + records = [] + args.output.parent.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(args.output, "x", zipfile.ZIP_DEFLATED) as archive: + for name, source in sorted(sources.items()): + data = source.read_bytes() + records.append({"path": name, "sha256": hashlib.sha256(data).hexdigest(), "bytes": len(data)}) + archive.writestr(name, data) + manifest = {"version": version, "files": records} + archive.writestr("mechanical-dev-manifest.json", json.dumps(manifest, indent=2)) + args.output.with_suffix(".manifest.json").write_text(json.dumps(manifest, indent=2) + "\n") + print(json.dumps({"version": version, "path": str(args.output), "fileCount": len(records), "bytes": args.output.stat().st_size, "sha256": hashlib.sha256(args.output.read_bytes()).hexdigest()})) + + +if __name__ == "__main__": + main()